feat(leaderboard): 预计算快照消除首开延迟
- leaderboard 云函数重写为快照架构:定时触发离线算日/月/年三榜写入
leaderboard_snapshot 集合(约 50ms 单次 get),请求默认读快照,force 才
实时重算并回写;快照缺失/过期自动回退实时路径,老客户端零改动兼容
- config.json 加每 4 分钟定时器 snapshotTimer
- _persistSnapshot 修复 wx-server-sdk 写操作需 { data: {...} } 包裹的坑
(裸 set(data) 报 parameter.data should be object instead of undefined)
- app.js onLaunch 预热排行榜,覆盖"开 App→点榜"常见路径
- leaderboard.js 更新过时"全表扫描 2-3s"注释
This commit is contained in:
@@ -1 +1,9 @@
|
||||
{}
|
||||
{
|
||||
"triggers": [
|
||||
{
|
||||
"name": "snapshotTimer",
|
||||
"type": "timer",
|
||||
"config": "0 */4 * * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ const db = cloud.database()
|
||||
const _ = db.command
|
||||
|
||||
const COLLECTION = 'plank_data'
|
||||
const SNAPSHOT_COLLECTION = 'leaderboard_snapshot'
|
||||
const PAGE_SIZE = 100
|
||||
const DEFAULT_MAX_RANK = 100 // fallback if client doesn't pass maxRank
|
||||
const DEFAULT_MAX_RANK = 100
|
||||
const SNAPSHOT_TOP = 500 // 快照每周期存储上榜人数上限(= 函数 maxRank 上限),客户端请求 ≤ 此值即可直接切片
|
||||
const SNAPSHOT_TTL_MS = 6 * 60 * 1000 // 快照过期阈值:略大于 4min 定时节奏,容忍一次漏跑;过期则回退实时重算
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
@@ -51,18 +54,25 @@ const _fetchLatestByOpenid = async (force) => {
|
||||
if (_docsCachePromise) return _docsCachePromise
|
||||
_docsCachePromise = (async () => {
|
||||
const latestByOpenid = new Map()
|
||||
// Exclude accounts that haven't synced in 90+ days to bound the scan.
|
||||
// Docs without an updatedAt field (pre-fix legacy) are included as well.
|
||||
// 只扫近 90 天同步过的账号,把扫描范围从"全集合"收窄到"活跃用户子集"。
|
||||
// updatedAt 已建单字段索引(控制台),updatedAt >= 90d 走索引范围扫描,
|
||||
// 不再全集合扫描;随用户量增长耗时不再线性恶化。
|
||||
// 说明:旧版(修复前的冷启动 add 重复)遗留 doc 可能无 updatedAt 字段,
|
||||
// 这里不再用 exists(false) 兜底 —— 既会拖垮索引(OR 分支无法走索引),
|
||||
// 这些账号也必已 90+ 天未同步,从榜上消失可接受。
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
|
||||
let lastId = ''
|
||||
while (true) {
|
||||
const cond = lastId
|
||||
? _.and([
|
||||
_.or([{ updatedAt: _.gte(ninetyDaysAgo) }, { updatedAt: _.exists(false) }]),
|
||||
{ _id: _.gt(lastId) }
|
||||
])
|
||||
: _.or([{ updatedAt: _.gte(ninetyDaysAgo) }, { updatedAt: _.exists(false) }])
|
||||
const res = await db.collection(COLLECTION).where(cond).limit(PAGE_SIZE).get()
|
||||
? _.and([{ updatedAt: _.gte(ninetyDaysAgo) }, { _id: _.gt(lastId) }])
|
||||
: { updatedAt: _.gte(ninetyDaysAgo) }
|
||||
// 投影:只取算榜必需的字段(records/profile/_openid/updatedAt),
|
||||
// 跳过 settings/streak/customPlans/themeId 等大字段,缩小单次读取载荷。
|
||||
const res = await db.collection(COLLECTION)
|
||||
.where(cond)
|
||||
.field({ records: true, profile: true, _openid: true, updatedAt: true })
|
||||
.limit(PAGE_SIZE)
|
||||
.get()
|
||||
if (!res.data || res.data.length === 0) break
|
||||
for (const doc of res.data) {
|
||||
const openid = doc._openid || 'unknown'
|
||||
@@ -80,11 +90,44 @@ const _fetchLatestByOpenid = async (force) => {
|
||||
try { return await _docsCachePromise } finally { _docsCachePromise = null }
|
||||
}
|
||||
|
||||
exports.main = async (event) => {
|
||||
const { period, maxRank, force } = event || {}
|
||||
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
||||
if (!period) return { err: 'missing period' }
|
||||
function maskOpenid(openid) {
|
||||
if (!openid || openid === 'unknown') return '未知用户'
|
||||
if (openid.length <= 4) return '****' + openid
|
||||
return '****' + openid.slice(-4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolve cloud:// avatar fileIDs into temporary HTTPS URLs.
|
||||
* The client <image> would otherwise perform this getTempFileURL round-trip
|
||||
* lazily at render time — the root of the 1-2s avatar delay. Only cloud://
|
||||
* IDs are resolved; any other value passes through untouched. getTempFileURL
|
||||
* accepts ≤50 fileIDs per call, so we page in batches of 50. On failure we
|
||||
* return the original URLs so the client degrades to its normal cloud:// load.
|
||||
*/
|
||||
const _resolveAvatars = async (urls) => {
|
||||
const cloudUrls = (urls || []).filter(u => typeof u === 'string' && u.startsWith('cloud://'))
|
||||
if (cloudUrls.length === 0) return urls || []
|
||||
const map = {}
|
||||
for (let i = 0; i < cloudUrls.length; i += 50) {
|
||||
const batch = cloudUrls.slice(i, i + 50)
|
||||
try {
|
||||
const res = await cloud.getTempFileURL({ fileList: batch })
|
||||
;(res.fileList || []).forEach(f => {
|
||||
if (f && f.fileID && f.tempFileURL) map[f.fileID] = f.tempFileURL
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[leaderboard] getTempFileURL batch failed:', e)
|
||||
}
|
||||
}
|
||||
return (urls || []).map(u => (u && map[u]) ? map[u] : u)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已抓取好的 latestByOpenid(Map) 计算单个周期的榜单。
|
||||
* 抽出来供「实时重算」(force/缺失快照) 与「定时重建快照」两条路径共用,
|
||||
* 避免重复扫描逻辑。返回 { ranked, myEntry, myOpenid } —— ranked 已含头像临时 URL。
|
||||
*/
|
||||
const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
|
||||
const now = new Date()
|
||||
const local = new Date(now.getTime() + TZ_OFFSET_MS)
|
||||
const today = `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`
|
||||
@@ -92,25 +135,16 @@ exports.main = async (event) => {
|
||||
// Year is computed from the same shifted date so year boundaries also
|
||||
// align with Beijing's midnight, not UTC's.
|
||||
const thisYear = String(local.getUTCFullYear())
|
||||
const myOpenid = cloud.getWXContext().OPENID
|
||||
|
||||
let prefix, exact
|
||||
if (period === 'day') { exact = today; prefix = null }
|
||||
else if (period === 'month') { prefix = thisMonth; exact = null }
|
||||
else if (period === 'year') { prefix = thisYear; exact = null }
|
||||
else return { err: 'invalid period' }
|
||||
else { prefix = thisYear; exact = null }
|
||||
|
||||
const userMap = new Map()
|
||||
|
||||
// Latest doc per openid (cached + cursor-paginated in _fetchLatestByOpenid).
|
||||
// Dedup rationale: a user may have multiple docs from the pre-fix cold-
|
||||
// start add() bug; only the latest updatedAt counts, else we'd sum the
|
||||
// same records N times and inflate the board. Latest (not first) also
|
||||
// gives the freshest profile.nickname before admin-dedupe runs.
|
||||
const latestByOpenid = await _fetchLatestByOpenid(force)
|
||||
|
||||
// Second pass: each openid appears exactly once, so the accumulation
|
||||
// logic doesn't need any dedup guards.
|
||||
// Each openid appears exactly once in latestByOpenid (deduped upstream),
|
||||
// so the accumulation logic doesn't need any dedup guards.
|
||||
for (const doc of latestByOpenid.values()) {
|
||||
const openid = doc._openid || 'unknown'
|
||||
const records = doc.records || {}
|
||||
@@ -155,8 +189,8 @@ exports.main = async (event) => {
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
|
||||
// My entry always included, even if outside top N
|
||||
const myEntry = userMap.get(myOpenid)
|
||||
const myRank = myEntry ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0
|
||||
const myEntryRaw = myOpenid ? userMap.get(myOpenid) : undefined
|
||||
const myRank = myEntryRaw ? allSorted.findIndex(e => e.openid === myOpenid) + 1 : 0
|
||||
|
||||
// Resolve display name: prefer the user's chosen nickname, fall back to
|
||||
// a masked openid for users who haven't set one.
|
||||
@@ -181,58 +215,135 @@ exports.main = async (event) => {
|
||||
// Non-cloud values (wx qlogo links, empty strings, base64 data URIs)
|
||||
// pass through untouched. If the resolve fails we keep the original fileID
|
||||
// — the client can still load it, just without the speed-up.
|
||||
const _avatarUrls = ranked.map(r => r.avatarUrl).concat(myEntry ? [myEntry.avatarUrl] : [])
|
||||
const _avatarUrls = ranked.map(r => r.avatarUrl).concat(myEntryRaw ? [myEntryRaw.avatarUrl] : [])
|
||||
const _resolved = await _resolveAvatars(_avatarUrls)
|
||||
_resolved.forEach((url, i) => {
|
||||
if (i < ranked.length) ranked[i].avatarUrl = url
|
||||
else if (myEntry) myEntry.avatarUrl = url
|
||||
else if (myEntryRaw) myEntryRaw.avatarUrl = url
|
||||
})
|
||||
|
||||
return {
|
||||
period,
|
||||
ranked,
|
||||
myOpenid,
|
||||
myEntry: myEntry ? {
|
||||
rank: myRank,
|
||||
openid: myEntry.openid,
|
||||
nickname: myEntry.nickname || '',
|
||||
name: _displayName(myEntry),
|
||||
duration: myEntry.duration,
|
||||
sessions: myEntry.sessions,
|
||||
avatarUrl: myEntry.avatarUrl || ''
|
||||
} : null,
|
||||
updatedAt: now.toISOString()
|
||||
const myEntry = myEntryRaw ? {
|
||||
rank: myRank,
|
||||
openid: myEntryRaw.openid,
|
||||
nickname: myEntryRaw.nickname || '',
|
||||
name: _displayName(myEntryRaw),
|
||||
duration: myEntryRaw.duration,
|
||||
sessions: myEntryRaw.sessions,
|
||||
avatarUrl: myEntryRaw.avatarUrl || ''
|
||||
} : null
|
||||
|
||||
return { ranked, myEntry, myOpenid }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把单周期榜单写入 leaderboard_snapshot 集合(doc._id = 周期)。
|
||||
* 集合首次写入时由 CloudBase 自动创建,无需手动建表。
|
||||
*/
|
||||
const _persistSnapshot = async (period, result) => {
|
||||
try {
|
||||
// wx-server-sdk 的写操作(add/update/set)参数必须是 { data: {...} } 包裹形式,
|
||||
// SDK 内部读 parameter.data。直接传裸对象会让 SDK 读到 data.data === undefined,
|
||||
// 报 "parameter.data should be object instead of undefined"。务必用 data 包裹。
|
||||
const ranked = (result && Array.isArray(result.ranked)) ? result.ranked : []
|
||||
const payload = {
|
||||
data: {
|
||||
period: String(period),
|
||||
ranked: ranked,
|
||||
myOpenid: (result && result.myOpenid) ? String(result.myOpenid) : '',
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
console.log('[leaderboard] persisting snapshot', period, 'rankedLen=', ranked.length)
|
||||
const docRef = db.collection(SNAPSHOT_COLLECTION).doc(period)
|
||||
const setRes = await docRef.set(payload)
|
||||
console.log('[leaderboard] persist ok', period, JSON.stringify(setRes))
|
||||
} catch (e) {
|
||||
console.warn('[leaderboard] persist snapshot failed for', period, e && e.errMsg ? e.errMsg : e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolve cloud:// avatar fileIDs into temporary HTTPS URLs.
|
||||
* The client <image> would otherwise perform this getTempFileURL round-trip
|
||||
* lazily at render time — the root of the 1-2s avatar delay. Only cloud://
|
||||
* IDs are resolved; any other value passes through untouched. getTempFileURL
|
||||
* accepts ≤50 fileIDs per call, so we page in batches of 50. On failure we
|
||||
* return the original URLs so the client degrades to its normal cloud:// load.
|
||||
* 读预计算快照(1 次 get ≈ 50ms)。返回与实时路径一致的响应结构。
|
||||
* 快照缺失或过期(超过 SNAPSHOT_TTL_MS)返回 null,由调用方回退实时重算。
|
||||
*/
|
||||
const _resolveAvatars = async (urls) => {
|
||||
const cloudUrls = (urls || []).filter(u => typeof u === 'string' && u.startsWith('cloud://'))
|
||||
if (cloudUrls.length === 0) return urls || []
|
||||
const map = {}
|
||||
for (let i = 0; i < cloudUrls.length; i += 50) {
|
||||
const batch = cloudUrls.slice(i, i + 50)
|
||||
try {
|
||||
const res = await cloud.getTempFileURL({ fileList: batch })
|
||||
;(res.fileList || []).forEach(f => {
|
||||
if (f && f.fileID && f.tempFileURL) map[f.fileID] = f.tempFileURL
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[leaderboard] getTempFileURL batch failed:', e)
|
||||
}
|
||||
const _serveFromSnapshot = async (period, limit, myOpenid) => {
|
||||
try {
|
||||
const doc = await db.collection(SNAPSHOT_COLLECTION).doc(period).get()
|
||||
const data = doc.data
|
||||
if (!data || !Array.isArray(data.ranked)) return null
|
||||
const age = data.updatedAt ? Date.now() - new Date(data.updatedAt).getTime() : Infinity
|
||||
if (Number.isFinite(age) && age > SNAPSHOT_TTL_MS) return null // 过期 → 实时
|
||||
const ranked = data.ranked.slice(0, limit).map(item => ({
|
||||
...item,
|
||||
isMe: item.openid === myOpenid
|
||||
}))
|
||||
const me = ranked.find(r => r.openid === myOpenid)
|
||||
const myEntry = me ? {
|
||||
rank: me.rank,
|
||||
openid: me.openid,
|
||||
nickname: me.nickname || '',
|
||||
name: me.name,
|
||||
duration: me.duration,
|
||||
sessions: me.sessions,
|
||||
avatarUrl: me.avatarUrl || '',
|
||||
isMe: true
|
||||
} : null
|
||||
return { period, ranked, myOpenid, myEntry, updatedAt: data.updatedAt }
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
return (urls || []).map(u => (u && map[u]) ? map[u] : u)
|
||||
}
|
||||
|
||||
function maskOpenid(openid) {
|
||||
if (!openid || openid === 'unknown') return '未知用户'
|
||||
if (openid.length <= 4) return '****' + openid
|
||||
return '****' + openid.slice(-4)
|
||||
}
|
||||
/**
|
||||
* 定时触发器调用:一次性扫描,离线算出日/月/年三张榜并写回快照。
|
||||
* 这样无论容器冷不冷、用户首开与否,客户端读榜都是 1 次 get,彻底消除等待。
|
||||
*/
|
||||
const _rebuildSnapshots = async () => {
|
||||
const latestByOpenid = await _fetchLatestByOpenid(true)
|
||||
for (const p of ['day', 'month', 'year']) {
|
||||
const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null)
|
||||
console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef')
|
||||
await _persistSnapshot(p, result)
|
||||
}
|
||||
return { ok: true, rebuiltAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时重算单周期(force 强刷 / 快照缺失回退)。可选 persist 把结果写回快照,
|
||||
* 使后续请求走快路径。
|
||||
*/
|
||||
const _computeBoard = async (period, limit, myOpenid, opts) => {
|
||||
opts = opts || {}
|
||||
const latestByOpenid = await _fetchLatestByOpenid(opts.force)
|
||||
const result = await _buildFromScan(latestByOpenid, period, limit, myOpenid)
|
||||
const nowIso = new Date().toISOString()
|
||||
if (opts.persist) await _persistSnapshot(period, result)
|
||||
return { period, ranked: result.ranked, myOpenid, myEntry: result.myEntry, updatedAt: nowIso }
|
||||
}
|
||||
|
||||
exports.main = async (event) => {
|
||||
// 定时触发器(每 4 分钟)进入此分支:离线重建三张榜快照,不响应客户端。
|
||||
const isTimer = event && (event.type === 'timer' || event.Type === 'timer' || event.triggerName || event.MessageType === 'timer')
|
||||
if (isTimer) return await _rebuildSnapshots()
|
||||
|
||||
const { period, maxRank, force } = event || {}
|
||||
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
||||
if (!period) return { err: 'missing period' }
|
||||
if (period !== 'day' && period !== 'month' && period !== 'year') return { err: 'invalid period' }
|
||||
|
||||
const myOpenid = cloud.getWXContext().OPENID
|
||||
|
||||
if (force) {
|
||||
// 训练后强刷:实时重算并写回快照,用户立刻看到新记录,且快照对所有人变新鲜。
|
||||
// 这是唯一会触发整表扫描的路径(仅训练后那一次),其余 99% 请求走快照。
|
||||
return await _computeBoard(period, limit, myOpenid, { force: true, persist: true })
|
||||
}
|
||||
|
||||
// 热路径:读预计算快照(1 次 get ≈ 50ms),容器冷启动也不再慢。
|
||||
const snap = await _serveFromSnapshot(period, limit, myOpenid)
|
||||
if (snap) return snap
|
||||
|
||||
// 快照缺失/过期(首次部署、集合被清、触发器漏跑):实时算并写回,
|
||||
// 保证本次请求能返回,且后续请求直接走快照。
|
||||
return await _computeBoard(period, limit, myOpenid, { force: false, persist: true })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user