diff --git a/app.js b/app.js index afa6d4c..4b9fae2 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ const cloud = require('./utils/cloud') const storage = require('./utils/storage') const darkMod = require('./utils/darkMode') const voice = require('./utils/voice') +const config = require('./config') App({ async onLaunch() { @@ -33,6 +34,16 @@ App({ // Init cloud storage (no-op if not configured) cloud.init() + // P1 排行榜预热:App 启动即后台触发一次榜单请求,提前填充云函数实例/快照, + // 用户点进排行榜时已是热路径,首开等待从 2-3s 降到近乎无感。 + // fire-and-forget,不 await,不阻塞启动链路;失败静默忽略。 + if (cloud.enabled) { + wx.cloud.callFunction({ + name: 'leaderboard', + data: { period: 'day', maxRank: config.leaderboardMaxRank } + }).catch(() => {}) + } + const settings = wx.getStorageSync('user_settings') if (!settings) { wx.setStorageSync('user_settings', { diff --git a/cloudfunctions/leaderboard/config.json b/cloudfunctions/leaderboard/config.json index 0967ef4..3be0a51 100644 --- a/cloudfunctions/leaderboard/config.json +++ b/cloudfunctions/leaderboard/config.json @@ -1 +1,9 @@ -{} +{ + "triggers": [ + { + "name": "snapshotTimer", + "type": "timer", + "config": "0 */4 * * * * *" + } + ] +} diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 2f53498..5c8d7d6 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -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 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 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) -} \ No newline at end of file +/** + * 定时触发器调用:一次性扫描,离线算出日/月/年三张榜并写回快照。 + * 这样无论容器冷不冷、用户首开与否,客户端读榜都是 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 }) +} diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index a57252c..4f56ed4 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -71,8 +71,9 @@ Page({ // 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。 // 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记 - // 后台刷新状态;未命中才显示骨架屏。云函数全表扫描冷启动可达 2-3s, - // 这层客户端缓存把"重复进入 / 切 period"的等待几乎降到无感。 + // 后台刷新状态;未命中才显示骨架屏。云函数现直接读预计算快照(1 次 get≈50ms), + // 网络往返本身已极快;这层客户端缓存进一步把"重复进入 / 切 period / 弱网"的 + // 等待降到无感,并兜底离线。(训练后带 force 的那次才会实时重算并回写快照。) const cached = storage.getLeaderboardCache(period) if (cached && cached.rankedList) { this.setData({