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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user