diff --git a/app.js b/app.js index 37322be..9f39200 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ const cloud = require('./utils/cloud') const storage = require('./utils/storage') App({ - onLaunch() { + async onLaunch() { // Init cloud storage (no-op if not configured) cloud.init() @@ -28,14 +28,29 @@ App({ this.globalData.theme = themeMod.getCurrentTheme() - // Sync with cloud: pull on empty local, push on non-empty local + // Sync with cloud: always locate our doc first (pullAll caches + // _openid / _docId), then either restore or push. The previous flow + // only pulled when local was empty, so on a cold start with non-empty + // local it pushed without locating — and because the module-level + // _openid / _docId caches had been reset, pushAll fell through to + // add() and created a duplicate cloud doc every cold start. The + // leaderboard then summed duration across N docs for the same user. const records = wx.getStorageSync('training_records') - if (!records || Object.keys(records).length === 0) { - // Case 1: Fresh install — restore from cloud - cloud.pullAll().then(cloudData => this._restoreFromCloud(cloudData)) - } else { - // Case 2: Has local data — push to cloud (local is authoritative) - cloud.pushAll() + if (cloud.enabled) { + try { + const cloudData = await cloud.pullAll() + if (!records || Object.keys(records).length === 0) { + // Case 1: Fresh install (or user cleared local) — restore from cloud + this._restoreFromCloud(cloudData) + } else { + // Case 2: Has local data — push to cloud (local is authoritative) + // pullAll already populated _openid / _docId, so pushAll will + // update the existing doc instead of creating a duplicate. + cloud.pushAll() + } + } catch (e) { + console.error('Cloud sync on launch failed:', e) + } } }, diff --git a/cloudfunctions/admin-dedupe/index.js b/cloudfunctions/admin-dedupe/index.js new file mode 100644 index 0000000..5e34445 --- /dev/null +++ b/cloudfunctions/admin-dedupe/index.js @@ -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) + ) + } +} \ No newline at end of file diff --git a/cloudfunctions/admin-dedupe/package.json b/cloudfunctions/admin-dedupe/package.json new file mode 100644 index 0000000..7043722 --- /dev/null +++ b/cloudfunctions/admin-dedupe/package.json @@ -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" + } +} \ No newline at end of file diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 0a1d7fa..1118b2e 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -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) diff --git a/utils/cloud.js b/utils/cloud.js index 1576c89..c62abcb 100644 --- a/utils/cloud.js +++ b/utils/cloud.js @@ -8,6 +8,9 @@ let _docId = null // cache doc id after first successful push let _openid = null // cache openid from first successful add, used to // re-locate our doc when _docId goes stale // (e.g. user manually deleted the doc in the console) +let _openidPromise = null // in-flight _fetchOpenid promise, prevents + // concurrent pushAll calls from racing the + // openid lookup (and each issuing an add()) const getDb = () => { if (!_enabled) return null @@ -68,11 +71,44 @@ const _fetchOpenid = async () => { return null } +/** + * Resolve and cache the user's openid exactly once per app session, + * sharing a single in-flight promise across concurrent callers. + * + * Why this exists: pushAll() schedules _doPush() 2s later. Between the + * schedule and the actual run, the module-level caches (_openid, _docId) + * are null on every cold start. The original code would let _doPush fall + * straight through to add() and create a brand-new cloud doc on every + * cold start — which leaderboard then summed across N docs for the same + * _openid. Pre-resolving openid here lets _doPush always locate the + * existing doc (or skip the write if lookup fails) before considering + * add(). + */ +const _ensureOpenid = async () => { + if (_openid) return _openid + if (!_openidPromise) { + _openidPromise = _fetchOpenid().then(o => { + _openid = o + _openidPromise = null + return o + }).catch(e => { + _openidPromise = null + return null + }) + } + return _openidPromise +} + const pushAll = () => { if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return } - if (_pushTimer) clearTimeout(_pushTimer) console.log('[cloud] pushAll scheduled') - _pushTimer = setTimeout(() => _doPush(), 2000) + // Pre-warm openid so _doPush never sees _openid=null on a cold start + // (which used to bypass the locate-by-openid branch and call add(), + // creating a duplicate cloud doc every cold start). + _ensureOpenid().finally(() => { + if (_pushTimer) clearTimeout(_pushTimer) + _pushTimer = setTimeout(() => _doPush(), 2000) + }) } const _doPush = async () => { @@ -94,7 +130,7 @@ const _doPush = async () => { } console.log('[cloud] _doPush records keys:', Object.keys(data.records || {})) - // Use cached doc id if available + // Fast path: cached _docId update if (_docId) { try { await db.collection(DB_COLLECTION).doc(_docId).update({ data }) @@ -105,28 +141,46 @@ const _doPush = async () => { // the collection in the console). The custom write rule evaluates // `doc._openid == auth.openid` → `undefined == openid` → false, // so this surfaces as -502003 rather than "not found". Either way, - // fall through and re-locate the doc via cached _openid (or add). + // fall through and re-locate the doc via openid. console.log('[cloud] _doPush cached _docId stale, clearing:', e.message) _docId = null } } - // Fallback: locate OUR doc by _openid (cached from a previous add) and - // update it. If we have no cached openid yet (first ever push) just add. - if (_openid) { + // Required path: always try to locate OUR doc by _openid before any + // add(). pushAll() pre-warms _openid via _ensureOpenid, so by the time + // we reach this branch on a cold start _openid is populated and we'll + // hit the update branch instead of falling through to add(). This is + // the fix for the "leaderboard data increases on its own" bug. + const openid = await _ensureOpenid() + if (openid) { const existing = await db.collection(DB_COLLECTION) - .where({ _openid: _openid }) + .where({ _openid: openid }) .limit(1) .get() if (existing && existing.data && existing.data.length > 0) { _docId = existing.data[0]._id - await db.collection(DB_COLLECTION).doc(_docId).update({ data }) - console.log('[cloud] _doPush update ok (re-located by _openid)') - return + try { + await db.collection(DB_COLLECTION).doc(_docId).update({ data }) + console.log('[cloud] _doPush update ok (re-located by _openid)') + return + } catch (e) { + // If even the freshly-located doc rejects our update (custom + // permission rule, race with another tab, etc.), DO NOT fall + // through to add() — that would silently create a duplicate + // cloud doc for the same user. Bail and let the next push retry. + console.log('[cloud] _doPush update-by-openid failed, aborting:', e.message) + _docId = null + return + } } + } else { + console.log('[cloud] _doPush no openid available, aborting (avoid duplicate add)') + return } - // First push ever, or our doc was deleted and no other doc exists. + // Reached only when we've positively confirmed no doc exists for our + // openid in the cloud — safe to add() the first document. const res = await db.collection(DB_COLLECTION).add({ data }) if (res && res._id) _docId = res._id if (res && res._openid) _openid = res._openid diff --git a/utils/storage.js b/utils/storage.js index 3cfd1d9..53eeb45 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -245,12 +245,18 @@ const getDateOffset = (dateStr, offset) => { const getTodayRecord = () => { const today = getToday() + // Record `date` is "YYYY-MM-DD HH:MM:SS" (formatted at saveRecord time), + // so a strict === check against the freshly-formatted `today` only matches + // a record saved in the exact same second. Compare date-only portions + // instead, otherwise the second training of the day reads as zero and + // the home page flips "今日已完成" back off. + const todayOnly = dateOnly(today) const records = getRecords() let totalDuration = 0 let hasRecord = false Object.keys(records).forEach((month) => { records[month].forEach((r) => { - if (r.date === today) { + if (dateOnly(r.date) === todayOnly) { totalDuration += r.duration hasRecord = true }