fix: 修复排行榜数据自增长(冷启动重复创建云端文档)

每次小程序冷启动都会为同一用户创建新的云端文档,
导致 leaderboard 把同一份数据累加 N 次。

改动:
- app.js: onLaunch 改为 async,每次启动先 pullAll 填充 _openid / _docId 缓存
- utils/cloud.js: 引入 _ensureOpenid() 单次解析;_doPush 总是先按 openid
  定位现有文档;update 失败不再回退到 add()(避免创建重复文档)
- utils/storage.js: getTodayRecord 按日期部分比较,修复一天第二次训练
This commit is contained in:
liucheng
2026-06-21 22:56:51 +08:00
parent 47f718a129
commit 6d1992869e
6 changed files with 259 additions and 57 deletions
+93
View File
@@ -0,0 +1,93 @@
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const COLLECTION = 'plank_data'
const PAGE_SIZE = 100
/**
* One-shot admin tool: deduplicate plank_data docs by _openid.
*
* Background: before the client-side fix in utils/cloud.js (always locate
* by _openid before add()), every cold start of the mini program created
* a brand-new cloud doc for the same user. The leaderboard cloud function
* then summed duration across N docs for the same _openid, inflating the
* board. This function walks the whole collection, groups by _openid, and
* deletes every doc except the most-recently-updated one for each user.
*
* DEPLOYMENT NOTES — read before invoking:
* 1. Upload + deploy this function from the WeChat DevTools or `wx-cli`.
* 2. The default collection security rule only allows reading docs whose
* `doc._openid == auth.openid`, which prevents this function from
* seeing other users' docs. Temporarily flip the collection's read
* permission to "所有用户可读" in the CloudBase console, OR configure
* the function's role to a privileged account that bypasses the
* custom rule.
* 3. Invoke the function once from the CloudBase console test panel.
* 4. **Delete or disable this function immediately afterwards** so it
* can't be re-run by a user. It's destructive.
*/
exports.main = async () => {
// 1. Page through every doc in the collection.
const docs = []
let skip = 0
while (true) {
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
if (!res.data || res.data.length === 0) break
docs.push(...res.data)
if (res.data.length < PAGE_SIZE) break
skip += PAGE_SIZE
}
// 2. Sort by updatedAt descending so the freshest doc per openid is
// first; that one is the one we keep.
const tsOf = (d) => {
if (!d.updatedAt) return 0
// CloudBase sometimes returns Date objects, sometimes ISO strings
// depending on the SDK version — normalize both.
if (d.updatedAt instanceof Date) return d.updatedAt.getTime()
const t = new Date(d.updatedAt).getTime()
return Number.isFinite(t) ? t : 0
}
docs.sort((a, b) => tsOf(b) - tsOf(a))
// 3. Walk the sorted list, marking all but the first occurrence of
// each openid for deletion.
const seen = new Set()
const toDelete = []
const keptByOpenid = {}
for (const doc of docs) {
const openid = doc._openid || 'unknown'
if (seen.has(openid)) {
toDelete.push({ id: doc._id, openid })
} else {
seen.add(openid)
keptByOpenid[openid] = doc._id
}
}
// 4. Delete. Remove one at a time so a single permission/permission
// failure doesn't abort the whole cleanup.
let deleted = 0
const errors = []
for (const { id, openid } of toDelete) {
try {
await db.collection(COLLECTION).doc(id).remove()
deleted++
} catch (e) {
errors.push({ id, openid, msg: (e && e.message) || String(e) })
}
}
return {
scanned: docs.length,
uniqueOpenids: seen.size,
duplicatesFound: toDelete.length,
duplicatesDeleted: deleted,
duplicatesFailed: errors.length,
errors,
sampleKept: Object.fromEntries(
Object.entries(keptByOpenid).slice(0, 5)
)
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "admin-dedupe",
"version": "1.0.0",
"description": "一次性管理工具:按 _openid 清理 plank_data 集合中的重复文档。每个 openid 只保留 updatedAt 最新的一份,其余删除。修复前每次小程序冷启动都会为同一用户创建新的云端文档,导致 leaderboard 把同一份数据累加 N 次。请部署后调用一次,然后立刻删除或禁用本函数。",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}
+61 -36
View File
@@ -38,47 +38,38 @@ exports.main = async (event) => {
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()
while (true) {
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
if (!res.data || res.data.length === 0) break
for (const doc of res.data) {
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 openid = doc._openid || 'unknown'
const entry = userMap.get(openid)
const profile = doc.profile || {}
if (entry) {
entry.duration += duration
entry.sessions += sessions
// Refresh nickname in case the user updated it since last write
if (profile.nickname) entry.nickname = profile.nickname
} else {
userMap.set(openid, {
openid,
duration,
sessions,
nickname: profile.nickname || ''
})
}
const openid = doc._openid || 'unknown'
const current = latestByOpenid.get(openid)
if (!current || tsOf(doc) > tsOf(current)) {
latestByOpenid.set(openid, doc)
}
}
@@ -86,6 +77,40 @@ exports.main = async (event) => {
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)