d817d5d012
致命修复:
- leaderboard: 日榜日期比较改用 dateOnly() 替代严格全等
- plan: 计划天数计算日期比较改用 dateOnly()
- cloud: pullAll 添加 orderBy('updatedAt','desc') 确保取最新文档避免数据丢失
严重修复:
- cloud: 新增 cancelPendingPush() 防止清除数据后被延迟推送复活
- settings: _doClearData 补充 user_profile 清除, 先取消推送再清除
- app: 云端同步改为时间戳比对策略, 支持跨设备拉取
- app: _restoreFromCloud 增加空数据保护和 profile 恢复
- storage: 记录ID改用时间戳+自增计数器确保唯一性
中等修复:
- cloud: init/_fetchOpenid 日志升级, 新增 syncStatus getter
- leaderboard云函数: 添加90天活跃过滤减少全表扫描
- storage: validateStreak 增加lastDate无记录时的通用修复
- leaderboard页面: 云函数失败时Toast提示用户而非静默降级
轻微修复:
- timer: onUnload 添加 voice.destroy() 释放音频上下文
- records: onLongPressDelete 委托给 onDeleteRecord 消除重复代码
Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
5.9 KiB
JavaScript
169 lines
5.9 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
|
|
|
|
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()
|
|
let skip = 0
|
|
|
|
// Defense-in-depth: if a user somehow ends up with multiple docs in the
|
|
// collection (e.g. before the client-side fix to _doPush landed, or via
|
|
// a buggy data migration), only the doc with the latest `updatedAt`
|
|
// counts for each openid. Otherwise we'd sum the same records N times
|
|
// and inflate the board.
|
|
//
|
|
// Why "latest updatedAt" instead of "first one we see": paginated reads
|
|
// come back in `_id` ascending order, so "first one" is the OLDEST doc.
|
|
// Records content is identical across duplicates (both came from the
|
|
// same local state), so duration/sessions are unaffected — but the
|
|
// oldest doc carries the OLDEST profile.nickname. Picking the latest
|
|
// doc gives the freshest nickname even before admin-dedupe runs.
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
while (true) {
|
|
const res = await db.collection(COLLECTION)
|
|
.where(_.or([
|
|
{ updatedAt: _.gte(ninetyDaysAgo) },
|
|
{ updatedAt: _.exists(false) }
|
|
]))
|
|
.skip(skip)
|
|
.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
|
|
skip += PAGE_SIZE
|
|
}
|
|
|
|
// 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)
|
|
} |