const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) 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 const SNAPSHOT_TOP = 500 // 快照每周期存储上榜人数上限(= 函数 maxRank 上限),客户端请求 ≤ 此值即可直接切片 const SNAPSHOT_TTL_MS = 6 * 60 * 1000 // 快照过期阈值:略大于 4min 定时节奏,容忍一次漏跑;过期则回退实时重算 const pad = (n) => String(n).padStart(2, '0') // Fixed UTC+8 offset — the user base is China-only and `new Date()` in // the cloud function runs in UTC, so without this shift "today" rolls // over at 08:00 Beijing instead of 00:00. If you ever support users in // other timezones, switch back to a client-provided date. const TZ_OFFSET_MS = 8 * 60 * 60 * 1000 // In-instance cache of the latest doc per openid. The full-collection // scan + dedup is the expensive part of building the board; cache it for // a short TTL so repeated board opens (and day/month/year switches within // a session) reuse one scan. Cold starts just re-scan. Not persistent // across instances, which is fine - the board needn't be real-time. // 5min: the board is non-real-time, and the expensive part is the serial // full-collection scan (100/page). A longer TTL means far fewer re-scans // while the instance stays warm; the trade-off is a freshly-trained user // may not see themselves for up to 5min, acceptable for a motivational // board. The client also keeps its own display cache, so re-entry paints // instantly regardless of this TTL. const CACHE_TTL = 5 * 60 * 1000 let _docsCache = null // { latestByOpenid: Map, ts } let _docsCachePromise = null // in-flight 扫描 promise,防缓存击穿 const tsOf = (d) => { if (!d.updatedAt) return 0 // CloudBase returns Date objects in the SDK, but be defensive in case // a stringified ISO timestamp sneaks through (older SDKs / migrations). if (d.updatedAt instanceof Date) return d.updatedAt.getTime() const t = new Date(d.updatedAt).getTime() return Number.isFinite(t) ? t : 0 } // Page through the collection keeping only the most-recently-updated doc // per openid. Uses cursor pagination (_id > lastId) instead of skip(N): // skip is O(N) and degrades as the collection grows; a cursor is constant // cost per page, and default get() order (_id ascending) won't drop/dup. const _fetchLatestByOpenid = async (force) => { if (!force && _docsCache && (Date.now() - _docsCache.ts) < CACHE_TTL) { return _docsCache.latestByOpenid } // 缓存过期或强刷(force):复用 in-flight promise,避免并发请求各自全表扫描(缓存击穿) if (_docsCachePromise) return _docsCachePromise _docsCachePromise = (async () => { const latestByOpenid = new Map() // 只扫近 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([{ 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' const current = latestByOpenid.get(openid) if (!current || tsOf(doc) > tsOf(current)) { latestByOpenid.set(openid, doc) } } if (res.data.length < PAGE_SIZE) break lastId = res.data[res.data.length - 1]._id } _docsCache = { latestByOpenid, ts: Date.now() } return latestByOpenid })() try { return await _docsCachePromise } finally { _docsCachePromise = null } } 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())}` const thisMonth = today.substring(0, 7) // 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()) let prefix, exact if (period === 'day') { exact = today; prefix = null } else if (period === 'month') { prefix = thisMonth; exact = null } else { prefix = thisYear; exact = null } const userMap = new Map() // 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 || {} let duration = 0 let sessions = 0 for (const monthKey of Object.keys(records)) { if (prefix && !monthKey.startsWith(prefix)) continue const arr = records[monthKey] if (!Array.isArray(arr)) continue for (const r of arr) { // 防御脏数据:r.date 缺失/非字符串、duration 非数字时跳过, // 避免单条坏记录抛 TypeError 导致整个排行榜对所有人失败 if (!r || typeof r.date !== 'string') continue const dur = Number(r.duration) || 0 if (period === 'day') { // Use startsWith rather than === to tolerate date strings with // trailing time/zone info (e.g. "2026-06-11T09:11:24.587Z" if // some legacy path stored an ISO date) and to be timezone- // agnostic when client local date differs from server UTC date. if (r.date.startsWith(exact)) { duration += dur; sessions++ } } else { if (r.date.startsWith(prefix)) { duration += dur; sessions++ } } } } if (duration > 0) { const profile = doc.profile || {} userMap.set(openid, { openid, duration, sessions, nickname: profile.nickname || '', avatarUrl: profile.avatarUrl || '' }) } } // Full sorted list (for ranking) const allSorted = Array.from(userMap.values()) .sort((a, b) => b.duration - a.duration) // My entry always included, even if outside top N 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. const _displayName = (entry) => (entry && entry.nickname && entry.nickname.trim()) || maskOpenid(entry && entry.openid) // Top N for the board const ranked = allSorted.slice(0, limit).map((item, i) => ({ rank: i + 1, isMe: item.openid === myOpenid, nickname: item.nickname || '', name: _displayName(item), duration: item.duration, sessions: item.sessions, avatarUrl: item.avatarUrl || '' })) // Pre-resolve cloud:// avatar fileIDs into temporary HTTPS URLs so the // client doesn't pay a getTempFileURL round-trip at render time // (that lazy round-trip is what made avatars lag 1-2s behind the text). // 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(myEntryRaw ? [myEntryRaw.avatarUrl] : []) const _resolved = await _resolveAvatars(_avatarUrls) _resolved.forEach((url, i) => { if (i < ranked.length) ranked[i].avatarUrl = url else if (myEntryRaw) myEntryRaw.avatarUrl = url }) 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) } } /** * 读预计算快照(1 次 get ≈ 50ms)。返回与实时路径一致的响应结构。 * 快照缺失或过期(超过 SNAPSHOT_TTL_MS)返回 null,由调用方回退实时重算。 */ 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 } } /** * 定时触发器调用:一次性扫描,离线算出日/月/年三张榜并写回快照。 * 这样无论容器冷不冷、用户首开与否,客户端读榜都是 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 }) }