From 850f9192696f6346853e56fc48c3a05c7ef713cd Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Thu, 9 Jul 2026 09:06:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E6=95=B0=E6=8D=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=90=88=E5=B9=B6=E5=8C=96=20+=20=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C=E7=BC=93=E5=AD=98=E4=B8=8E=E6=B8=B8=E6=A0=87=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage: 新增 mergeRecords,云端记录与本地按 id 去重合并,restore 不再覆盖,解决训练中冷启动丢记录与多设备记录丢失 - storage: 新增 recomputeStreak,restore 后基于合并记录重算 streak, 不再信任云端 streak 值 - storage: 新增 _local_changed_at 标记,settings/profile/customPlans 在本地有未同步改动时保留本地,避免被云端旧值覆盖 - app: _restoreFromCloud 改合并 + 时间戳保护 + streak 重算;Case 2 合并后回推让两端收敛 - leaderboard: 模块级 60s 缓存 + 游标分页(_id > lastId)替代 skip, 全表扫描从每次打开降到 60s 内一次 Co-Authored-By: Claude Fable 5 --- app.js | 63 ++++++++++------ cloudfunctions/leaderboard/index.js | 109 +++++++++++++++------------- utils/storage.js | 95 ++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 74 deletions(-) diff --git a/app.js b/app.js index 3cb81a3..02758ac 100644 --- a/app.js +++ b/app.js @@ -67,14 +67,17 @@ App({ if (!hasLocal && hasCloud) { // Case 1: Fresh install on a new device — restore from cloud - this._restoreFromCloud(cloudData) + this._restoreFromCloud(cloudData, cloudTs) wx.setStorageSync('_cloud_sync_at', cloudTs) } else if (hasLocal && hasCloud) { // Case 2: Both sides have data — compare timestamps if (cloudTs > localSyncAt) { - // Cloud is newer (another device synced after our last push) - this._restoreFromCloud(cloudData) + // Cloud is newer (another device synced after our last push). + // Merge cloud into local, then push the merged result back so + // both sides converge (records union + local's unsynced edits). + this._restoreFromCloud(cloudData, cloudTs) wx.setStorageSync('_cloud_sync_at', cloudTs) + cloud.pushAll() } else { // Local is at least as current — push cloud.pushAll() @@ -90,35 +93,47 @@ App({ } }, - _restoreFromCloud(cloudData) { + _restoreFromCloud(cloudData, cloudTs) { if (!cloudData) return try { - if (cloudData.records && Object.keys(cloudData.records).length > 0) { - // Guard: only restore if at least one month has actual records. - // Pre-6d19928 duplicates could leave docs with month keys but - // empty arrays, which would wipe local data on restore. - const hasAny = Object.values(cloudData.records).some( + // Records: MERGE instead of overwrite. A training saved during the + // async pullAll (or edits from another device) would otherwise be + // wiped by the cloud's older snapshot. + if (cloudData.records) { + const merged = storage.mergeRecords(cloudData.records) + const hasAny = Object.values(merged).some( arr => Array.isArray(arr) && arr.length > 0 ) if (hasAny) { - wx.setStorageSync('training_records', cloudData.records) + wx.setStorageSync('training_records', merged) } } - if (cloudData.settings) { - wx.setStorageSync('user_settings', cloudData.settings) - } - if (cloudData.streak) { - wx.setStorageSync('current_streak', cloudData.streak) - } - if (cloudData.customPlans && typeof cloudData.customPlans === 'object') { - wx.setStorageSync('custom_plans', cloudData.customPlans) - } - if (cloudData.profile && typeof cloudData.profile === 'object') { - wx.setStorageSync('user_profile', cloudData.profile) - } - if (cloudData.themeId) { - themeMod.setTheme(cloudData.themeId) + + // Single-value fields: only adopt the cloud version if the local + // side hasn't changed since the cloud snapshot. Otherwise we'd + // clobber unsynced local edits (e.g. a setting flipped mid-sync). + const localChangedAt = wx.getStorageSync('_local_changed_at') || 0 + const keepLocal = cloudTs > 0 && localChangedAt > cloudTs + if (keepLocal) { + console.log('[cloud] restore: keeping local single-value fields (local changed after cloud snapshot)') + } else { + if (cloudData.settings) { + wx.setStorageSync('user_settings', cloudData.settings) + } + if (cloudData.customPlans && typeof cloudData.customPlans === 'object') { + wx.setStorageSync('custom_plans', cloudData.customPlans) + } + if (cloudData.profile && typeof cloudData.profile === 'object') { + wx.setStorageSync('user_profile', cloudData.profile) + } + if (cloudData.themeId) { + themeMod.setTheme(cloudData.themeId) + } } + + // Streak: never trust the cloud value - recompute from the merged + // records so it reflects actual history on this device. + storage.recomputeStreak() } catch (e) { console.error('Cloud restore failed:', e) } diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index b103430..240391b 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -15,6 +15,59 @@ const pad = (n) => String(n).padStart(2, '0') // 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)) @@ -36,57 +89,13 @@ exports.main = async (event) => { 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 - } + // 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. diff --git a/utils/storage.js b/utils/storage.js index 358bfcd..92da125 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -9,6 +9,17 @@ const PROFILE_KEY = 'user_profile' let _idSeq = 0 +/** + * Record the timestamp of the last local mutation to single-value fields + * (settings / profile / customPlans / theme). _restoreFromCloud compares + * this against the cloud doc's updatedAt: if local changed after the cloud + * snapshot, we keep local to avoid clobbering unsynced edits. Records are + * exempt - they go through mergeRecords() which never loses data. + */ +const _markLocalChanged = () => { + try { wx.setStorageSync('_local_changed_at', Date.now()) } catch (e) {} +} + /** * Backfill an `id` for any record that doesn't have one. * Older records (e.g. data restored from cloud or migrated from a build @@ -90,6 +101,49 @@ const deleteRecord = (recordId) => { cloud.pushAll() } +/** + * Merge cloud records into local records WITHOUT losing either side. + * + * Why this exists: _restoreFromCloud used to overwrite training_records + * with the cloud doc verbatim. If the user finished a training during the + * async pullAll on launch, saveRecord had already written locally, and the + * subsequent restore wiped that fresh record with the cloud's older + * snapshot. Same hazard for multi-device edits. + * + * Dedup: records with a real (non-legacy) id dedup by id; legacy/idless + * records dedup by `date|duration` so old migrated data doesn't double up. + * Result is re-bucketed by month and sorted newest-first. + */ +const mergeRecords = (cloudRecords) => { + const local = wx.getStorageSync(RECORDS_KEY) || {} + const cloud = (cloudRecords && typeof cloudRecords === 'object') ? cloudRecords : {} + const seen = new Set() + const out = [] + const push = (r) => { + if (!r) return + const id = r.id + const isRealId = id != null && id !== '' && !String(id).startsWith('legacy-') + const key = isRealId ? `id:${id}` : `c:${r.date}|${r.duration}` + if (seen.has(key)) return + seen.add(key) + out.push(r) + } + Object.keys(local).forEach((m) => (local[m] || []).forEach(push)) + Object.keys(cloud).forEach((m) => (cloud[m] || []).forEach(push)) + + const merged = {} + out.forEach((r) => { + const mo = (r.date && r.date.substring) ? r.date.substring(0, 7) : 'unknown' + if (!merged[mo]) merged[mo] = [] + merged[mo].push(r) + }) + Object.keys(merged).forEach((mo) => { + merged[mo].sort((a, b) => (b.date || '').localeCompare(a.date || '')) + }) + _ensureRecordIds(merged) + return merged +} + const getRecordsByMonth = (month) => { const records = getRecords() return records[month] || [] @@ -118,6 +172,7 @@ const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || { const saveSettings = (settings) => { wx.setStorageSync(SETTINGS_KEY, settings) + _markLocalChanged() cloud.pushAll() } @@ -145,6 +200,7 @@ const saveProfile = (profile) => { clean.avatarUrl = profile.avatarUrl } wx.setStorageSync(PROFILE_KEY, clean) + _markLocalChanged() cloud.pushAll() return clean } @@ -170,6 +226,7 @@ const saveCustomPlan = (planId, planData) => { const plans = getCustomPlans() plans[planId] = planData wx.setStorageSync(CUSTOM_PLANS_KEY, plans) + _markLocalChanged() cloud.pushAll() } @@ -179,6 +236,7 @@ const resetCustomPlan = (planId) => { if (!(planId in plans)) return delete plans[planId] wx.setStorageSync(CUSTOM_PLANS_KEY, plans) + _markLocalChanged() cloud.pushAll() } @@ -252,6 +310,41 @@ const validateStreak = () => { return streak } +/** + * Fully recompute the streak from actual training records. Used after a + * cloud restore/merge: the cloud doc's streak may be stale or belong to + * another device, so we trust the merged records over the cloud value. + */ +const recomputeStreak = () => { + const records = getRecords() + const allDates = new Set() + Object.values(records).forEach((monthRecs) => { + monthRecs.forEach((r) => allDates.add(dateOnly(r.date))) + }) + const sorted = Array.from(allDates).sort().reverse() + if (sorted.length === 0) { + const empty = { count: 0, lastDate: '' } + wx.setStorageSync(STREAK_KEY, empty) + return empty + } + let count = 1 + let prev = sorted[0] + for (let i = 1; i < sorted.length; i++) { + const prevDate = new Date(prev.replace(/-/g, '/')) + const curDate = new Date(sorted[i].replace(/-/g, '/')) + const diff = Math.round((prevDate - curDate) / (1000 * 60 * 60 * 24)) + if (diff === 1) { + count++ + prev = sorted[i] + } else { + break + } + } + const streak = { count, lastDate: sorted[0] } + wx.setStorageSync(STREAK_KEY, streak) + return streak +} + const updateStreak = (date) => { const streak = getStreak() const todayFull = date || getToday() @@ -310,6 +403,7 @@ module.exports = { getRecords, saveRecord, deleteRecord, + mergeRecords, getRecordsByMonth, getTotalStats, getSettings, @@ -321,6 +415,7 @@ module.exports = { saveProfile, getStreak, validateStreak, + recomputeStreak, updateStreak, getToday, getDateOffset,