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:
@@ -3,7 +3,7 @@ const cloud = require('./utils/cloud')
|
|||||||
const storage = require('./utils/storage')
|
const storage = require('./utils/storage')
|
||||||
|
|
||||||
App({
|
App({
|
||||||
onLaunch() {
|
async onLaunch() {
|
||||||
// Init cloud storage (no-op if not configured)
|
// Init cloud storage (no-op if not configured)
|
||||||
cloud.init()
|
cloud.init()
|
||||||
|
|
||||||
@@ -28,14 +28,29 @@ App({
|
|||||||
|
|
||||||
this.globalData.theme = themeMod.getCurrentTheme()
|
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')
|
const records = wx.getStorageSync('training_records')
|
||||||
if (!records || Object.keys(records).length === 0) {
|
if (cloud.enabled) {
|
||||||
// Case 1: Fresh install — restore from cloud
|
try {
|
||||||
cloud.pullAll().then(cloudData => this._restoreFromCloud(cloudData))
|
const cloudData = await cloud.pullAll()
|
||||||
} else {
|
if (!records || Object.keys(records).length === 0) {
|
||||||
// Case 2: Has local data — push to cloud (local is authoritative)
|
// Case 1: Fresh install (or user cleared local) — restore from cloud
|
||||||
cloud.pushAll()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,47 +38,38 @@ exports.main = async (event) => {
|
|||||||
const userMap = new Map()
|
const userMap = new Map()
|
||||||
let skip = 0
|
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) {
|
while (true) {
|
||||||
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
|
const res = await db.collection(COLLECTION).skip(skip).limit(PAGE_SIZE).get()
|
||||||
if (!res.data || res.data.length === 0) break
|
if (!res.data || res.data.length === 0) break
|
||||||
|
|
||||||
for (const doc of res.data) {
|
for (const doc of res.data) {
|
||||||
const records = doc.records || {}
|
const openid = doc._openid || 'unknown'
|
||||||
let duration = 0
|
const current = latestByOpenid.get(openid)
|
||||||
let sessions = 0
|
if (!current || tsOf(doc) > tsOf(current)) {
|
||||||
|
latestByOpenid.set(openid, doc)
|
||||||
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 || ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +77,40 @@ exports.main = async (event) => {
|
|||||||
skip += PAGE_SIZE
|
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)
|
// Full sorted list (for ranking)
|
||||||
const allSorted = Array.from(userMap.values())
|
const allSorted = Array.from(userMap.values())
|
||||||
.sort((a, b) => b.duration - a.duration)
|
.sort((a, b) => b.duration - a.duration)
|
||||||
|
|||||||
+66
-12
@@ -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
|
let _openid = null // cache openid from first successful add, used to
|
||||||
// re-locate our doc when _docId goes stale
|
// re-locate our doc when _docId goes stale
|
||||||
// (e.g. user manually deleted the doc in the console)
|
// (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 = () => {
|
const getDb = () => {
|
||||||
if (!_enabled) return null
|
if (!_enabled) return null
|
||||||
@@ -68,11 +71,44 @@ const _fetchOpenid = async () => {
|
|||||||
return null
|
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 = () => {
|
const pushAll = () => {
|
||||||
if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return }
|
if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return }
|
||||||
if (_pushTimer) clearTimeout(_pushTimer)
|
|
||||||
console.log('[cloud] pushAll scheduled')
|
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 () => {
|
const _doPush = async () => {
|
||||||
@@ -94,7 +130,7 @@ const _doPush = async () => {
|
|||||||
}
|
}
|
||||||
console.log('[cloud] _doPush records keys:', Object.keys(data.records || {}))
|
console.log('[cloud] _doPush records keys:', Object.keys(data.records || {}))
|
||||||
|
|
||||||
// Use cached doc id if available
|
// Fast path: cached _docId update
|
||||||
if (_docId) {
|
if (_docId) {
|
||||||
try {
|
try {
|
||||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
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
|
// the collection in the console). The custom write rule evaluates
|
||||||
// `doc._openid == auth.openid` → `undefined == openid` → false,
|
// `doc._openid == auth.openid` → `undefined == openid` → false,
|
||||||
// so this surfaces as -502003 rather than "not found". Either way,
|
// 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)
|
console.log('[cloud] _doPush cached _docId stale, clearing:', e.message)
|
||||||
_docId = null
|
_docId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: locate OUR doc by _openid (cached from a previous add) and
|
// Required path: always try to locate OUR doc by _openid before any
|
||||||
// update it. If we have no cached openid yet (first ever push) just add.
|
// add(). pushAll() pre-warms _openid via _ensureOpenid, so by the time
|
||||||
if (_openid) {
|
// 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)
|
const existing = await db.collection(DB_COLLECTION)
|
||||||
.where({ _openid: _openid })
|
.where({ _openid: openid })
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
if (existing && existing.data && existing.data.length > 0) {
|
if (existing && existing.data && existing.data.length > 0) {
|
||||||
_docId = existing.data[0]._id
|
_docId = existing.data[0]._id
|
||||||
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
try {
|
||||||
console.log('[cloud] _doPush update ok (re-located by _openid)')
|
await db.collection(DB_COLLECTION).doc(_docId).update({ data })
|
||||||
return
|
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 })
|
const res = await db.collection(DB_COLLECTION).add({ data })
|
||||||
if (res && res._id) _docId = res._id
|
if (res && res._id) _docId = res._id
|
||||||
if (res && res._openid) _openid = res._openid
|
if (res && res._openid) _openid = res._openid
|
||||||
|
|||||||
+7
-1
@@ -245,12 +245,18 @@ const getDateOffset = (dateStr, offset) => {
|
|||||||
|
|
||||||
const getTodayRecord = () => {
|
const getTodayRecord = () => {
|
||||||
const today = getToday()
|
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()
|
const records = getRecords()
|
||||||
let totalDuration = 0
|
let totalDuration = 0
|
||||||
let hasRecord = false
|
let hasRecord = false
|
||||||
Object.keys(records).forEach((month) => {
|
Object.keys(records).forEach((month) => {
|
||||||
records[month].forEach((r) => {
|
records[month].forEach((r) => {
|
||||||
if (r.date === today) {
|
if (dateOnly(r.date) === todayOnly) {
|
||||||
totalDuration += r.duration
|
totalDuration += r.duration
|
||||||
hasRecord = true
|
hasRecord = true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user