fix(sync): 数据同步合并化 + 排行榜缓存与游标分页
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -67,14 +67,17 @@ App({
|
|||||||
|
|
||||||
if (!hasLocal && hasCloud) {
|
if (!hasLocal && hasCloud) {
|
||||||
// Case 1: Fresh install on a new device — restore from cloud
|
// Case 1: Fresh install on a new device — restore from cloud
|
||||||
this._restoreFromCloud(cloudData)
|
this._restoreFromCloud(cloudData, cloudTs)
|
||||||
wx.setStorageSync('_cloud_sync_at', cloudTs)
|
wx.setStorageSync('_cloud_sync_at', cloudTs)
|
||||||
} else if (hasLocal && hasCloud) {
|
} else if (hasLocal && hasCloud) {
|
||||||
// Case 2: Both sides have data — compare timestamps
|
// Case 2: Both sides have data — compare timestamps
|
||||||
if (cloudTs > localSyncAt) {
|
if (cloudTs > localSyncAt) {
|
||||||
// Cloud is newer (another device synced after our last push)
|
// Cloud is newer (another device synced after our last push).
|
||||||
this._restoreFromCloud(cloudData)
|
// 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)
|
wx.setStorageSync('_cloud_sync_at', cloudTs)
|
||||||
|
cloud.pushAll()
|
||||||
} else {
|
} else {
|
||||||
// Local is at least as current — push
|
// Local is at least as current — push
|
||||||
cloud.pushAll()
|
cloud.pushAll()
|
||||||
@@ -90,26 +93,33 @@ App({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_restoreFromCloud(cloudData) {
|
_restoreFromCloud(cloudData, cloudTs) {
|
||||||
if (!cloudData) return
|
if (!cloudData) return
|
||||||
try {
|
try {
|
||||||
if (cloudData.records && Object.keys(cloudData.records).length > 0) {
|
// Records: MERGE instead of overwrite. A training saved during the
|
||||||
// Guard: only restore if at least one month has actual records.
|
// async pullAll (or edits from another device) would otherwise be
|
||||||
// Pre-6d19928 duplicates could leave docs with month keys but
|
// wiped by the cloud's older snapshot.
|
||||||
// empty arrays, which would wipe local data on restore.
|
if (cloudData.records) {
|
||||||
const hasAny = Object.values(cloudData.records).some(
|
const merged = storage.mergeRecords(cloudData.records)
|
||||||
|
const hasAny = Object.values(merged).some(
|
||||||
arr => Array.isArray(arr) && arr.length > 0
|
arr => Array.isArray(arr) && arr.length > 0
|
||||||
)
|
)
|
||||||
if (hasAny) {
|
if (hasAny) {
|
||||||
wx.setStorageSync('training_records', cloudData.records)
|
wx.setStorageSync('training_records', merged)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (cloudData.settings) {
|
||||||
wx.setStorageSync('user_settings', cloudData.settings)
|
wx.setStorageSync('user_settings', cloudData.settings)
|
||||||
}
|
}
|
||||||
if (cloudData.streak) {
|
|
||||||
wx.setStorageSync('current_streak', cloudData.streak)
|
|
||||||
}
|
|
||||||
if (cloudData.customPlans && typeof cloudData.customPlans === 'object') {
|
if (cloudData.customPlans && typeof cloudData.customPlans === 'object') {
|
||||||
wx.setStorageSync('custom_plans', cloudData.customPlans)
|
wx.setStorageSync('custom_plans', cloudData.customPlans)
|
||||||
}
|
}
|
||||||
@@ -119,6 +129,11 @@ App({
|
|||||||
if (cloudData.themeId) {
|
if (cloudData.themeId) {
|
||||||
themeMod.setTheme(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) {
|
} catch (e) {
|
||||||
console.error('Cloud restore failed:', e)
|
console.error('Cloud restore failed:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,59 @@ const pad = (n) => String(n).padStart(2, '0')
|
|||||||
// other timezones, switch back to a client-provided date.
|
// other timezones, switch back to a client-provided date.
|
||||||
const TZ_OFFSET_MS = 8 * 60 * 60 * 1000
|
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) => {
|
exports.main = async (event) => {
|
||||||
const { period, maxRank } = event || {}
|
const { period, maxRank } = event || {}
|
||||||
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
|
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' }
|
else return { err: 'invalid period' }
|
||||||
|
|
||||||
const userMap = new Map()
|
const userMap = new Map()
|
||||||
let skip = 0
|
|
||||||
|
|
||||||
// Defense-in-depth: if a user somehow ends up with multiple docs in the
|
// Latest doc per openid (cached + cursor-paginated in _fetchLatestByOpenid).
|
||||||
// collection (e.g. before the client-side fix to _doPush landed, or via
|
// Dedup rationale: a user may have multiple docs from the pre-fix cold-
|
||||||
// a buggy data migration), only the doc with the latest `updatedAt`
|
// start add() bug; only the latest updatedAt counts, else we'd sum the
|
||||||
// counts for each openid. Otherwise we'd sum the same records N times
|
// same records N times and inflate the board. Latest (not first) also
|
||||||
// and inflate the board.
|
// gives the freshest profile.nickname before admin-dedupe runs.
|
||||||
//
|
const latestByOpenid = await _fetchLatestByOpenid()
|
||||||
// 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
|
// Second pass: each openid appears exactly once, so the accumulation
|
||||||
// logic doesn't need any dedup guards.
|
// logic doesn't need any dedup guards.
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ const PROFILE_KEY = 'user_profile'
|
|||||||
|
|
||||||
let _idSeq = 0
|
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.
|
* Backfill an `id` for any record that doesn't have one.
|
||||||
* Older records (e.g. data restored from cloud or migrated from a build
|
* Older records (e.g. data restored from cloud or migrated from a build
|
||||||
@@ -90,6 +101,49 @@ const deleteRecord = (recordId) => {
|
|||||||
cloud.pushAll()
|
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 getRecordsByMonth = (month) => {
|
||||||
const records = getRecords()
|
const records = getRecords()
|
||||||
return records[month] || []
|
return records[month] || []
|
||||||
@@ -118,6 +172,7 @@ const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || {
|
|||||||
|
|
||||||
const saveSettings = (settings) => {
|
const saveSettings = (settings) => {
|
||||||
wx.setStorageSync(SETTINGS_KEY, settings)
|
wx.setStorageSync(SETTINGS_KEY, settings)
|
||||||
|
_markLocalChanged()
|
||||||
cloud.pushAll()
|
cloud.pushAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +200,7 @@ const saveProfile = (profile) => {
|
|||||||
clean.avatarUrl = profile.avatarUrl
|
clean.avatarUrl = profile.avatarUrl
|
||||||
}
|
}
|
||||||
wx.setStorageSync(PROFILE_KEY, clean)
|
wx.setStorageSync(PROFILE_KEY, clean)
|
||||||
|
_markLocalChanged()
|
||||||
cloud.pushAll()
|
cloud.pushAll()
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
@@ -170,6 +226,7 @@ const saveCustomPlan = (planId, planData) => {
|
|||||||
const plans = getCustomPlans()
|
const plans = getCustomPlans()
|
||||||
plans[planId] = planData
|
plans[planId] = planData
|
||||||
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
||||||
|
_markLocalChanged()
|
||||||
cloud.pushAll()
|
cloud.pushAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +236,7 @@ const resetCustomPlan = (planId) => {
|
|||||||
if (!(planId in plans)) return
|
if (!(planId in plans)) return
|
||||||
delete plans[planId]
|
delete plans[planId]
|
||||||
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
|
||||||
|
_markLocalChanged()
|
||||||
cloud.pushAll()
|
cloud.pushAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +310,41 @@ const validateStreak = () => {
|
|||||||
return streak
|
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 updateStreak = (date) => {
|
||||||
const streak = getStreak()
|
const streak = getStreak()
|
||||||
const todayFull = date || getToday()
|
const todayFull = date || getToday()
|
||||||
@@ -310,6 +403,7 @@ module.exports = {
|
|||||||
getRecords,
|
getRecords,
|
||||||
saveRecord,
|
saveRecord,
|
||||||
deleteRecord,
|
deleteRecord,
|
||||||
|
mergeRecords,
|
||||||
getRecordsByMonth,
|
getRecordsByMonth,
|
||||||
getTotalStats,
|
getTotalStats,
|
||||||
getSettings,
|
getSettings,
|
||||||
@@ -321,6 +415,7 @@ module.exports = {
|
|||||||
saveProfile,
|
saveProfile,
|
||||||
getStreak,
|
getStreak,
|
||||||
validateStreak,
|
validateStreak,
|
||||||
|
recomputeStreak,
|
||||||
updateStreak,
|
updateStreak,
|
||||||
getToday,
|
getToday,
|
||||||
getDateOffset,
|
getDateOffset,
|
||||||
|
|||||||
Reference in New Issue
Block a user