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. const CACHE_TTL = 60 * 1000 let _docsCache = null // { latestByOpenid: Map, ts } 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 () => { if (_docsCache && (Date.now() - _docsCache.ts) < CACHE_TTL) { return _docsCache.latestByOpenid } 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 } exports.main = async (event) => { const { period, maxRank } = 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() // 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 for (const r of records[monthKey]) { 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 += r.duration; sessions++ } } else { if (r.date.startsWith(prefix)) { duration += r.duration; sessions++ } } } } if (duration > 0) { const profile = doc.profile || {} userMap.set(openid, { openid, duration, sessions, nickname: profile.nickname || '' }) } } // 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, openid: item.openid, nickname: item.nickname || '', name: _displayName(item), duration: item.duration, sessions: item.sessions })) return { period, ranked, myOpenid, myEntry: myEntry ? { rank: myRank, openid: myEntry.openid, nickname: myEntry.nickname || '', name: _displayName(myEntry), duration: myEntry.duration, sessions: myEntry.sessions } : null, updatedAt: now.toISOString() } } function maskOpenid(openid) { if (!openid || openid === 'unknown') return '未知用户' if (openid.length <= 4) return '****' + openid return '****' + openid.slice(-4) }