Files
wx_pbzc/cloudfunctions/leaderboard/index.js
T
lc b42d080826 feat(leaderboard): 训练完强刷 + 下拉刷新 force
- 云函数 _fetchLatestByOpenid 支持 force 跳过实例缓存重新扫描
- 训练完成设 _lb_force_refresh 标志,下次打开排行榜强刷,新数据立即可见
- 下拉刷新带 force=true,拉取最新数据
- 平时常规打开仍走 5min 缓存 + 客户端乐观缓存,不增加无谓扫描

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 08:47:41 +08:00

238 lines
9.7 KiB
JavaScript

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 PAGE_SIZE = 100
const DEFAULT_MAX_RANK = 100 // fallback if client doesn't pass maxRank
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()
// 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.
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()
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 }
}
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' }
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())
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' }
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.
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 myEntry = userMap.get(myOpenid)
const myRank = myEntry ? 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 <image> 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(myEntry ? [myEntry.avatarUrl] : [])
const _resolved = await _resolveAvatars(_avatarUrls)
_resolved.forEach((url, i) => {
if (i < ranked.length) ranked[i].avatarUrl = url
else if (myEntry) myEntry.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()
}
}
/**
* 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)
}
function maskOpenid(openid) {
if (!openid || openid === 'unknown') return '未知用户'
if (openid.length <= 4) return '****' + openid
return '****' + openid.slice(-4)
}