Files
wx_pbzc/utils/storage.js
T
lc 850f919269 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>
2026-07-09 09:06:44 +08:00

424 lines
13 KiB
JavaScript

const { formatDate, dateOnly } = require('./util')
const cloud = require('./cloud')
const RECORDS_KEY = 'training_records'
const SETTINGS_KEY = 'user_settings'
const STREAK_KEY = 'current_streak'
const CUSTOM_PLANS_KEY = 'custom_plans'
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
* that didn't yet write the id field) come in without an id, which makes
* delete-by-id fail with "记录数据异常". Assign a stable, deterministic id
* derived from the record's position + payload so the user can still
* delete them.
*
* Returns true if any record was patched.
*/
const _ensureRecordIds = (records) => {
if (!records || typeof records !== 'object') return false
let changed = false
Object.keys(records).forEach((month) => {
const list = records[month]
if (!Array.isArray(list)) return
list.forEach((r, i) => {
if (r && (r.id == null || r.id === '')) {
// month is YYYY-MM; strip the dash so the id stays a safe token
const m = String(month).replace(/[^0-9]/g, '')
r.id = `legacy-${m}-${i}-${r.duration || 0}`
changed = true
}
})
})
return changed
}
const getRecords = () => {
const records = wx.getStorageSync(RECORDS_KEY) || {}
if (_ensureRecordIds(records)) {
// Persist the backfill so subsequent calls don't re-mutate; the cloud
// will resync on the next saveRecord/deleteRecord.
try { wx.setStorageSync(RECORDS_KEY, records) } catch (e) {}
}
return records
}
const saveRecord = (record) => {
record.id = String(Date.now()) + '_' + (++_idSeq)
// Defensive: reject records with a future date. Indicates the user has
// changed their phone clock, which would otherwise poison the cloud
// leaderboard (and their own stats) with impossible timestamps. Allow
// a 1-minute tolerance for normal clock skew.
const recordTime = new Date(record.date.replace(/-/g, '/')).getTime()
if (recordTime > Date.now() + 60 * 1000) {
console.warn('[storage] saveRecord rejected: future date', record.date)
wx.showToast({ title: '系统时间异常,请检查', icon: 'none' })
return
}
const records = getRecords()
const month = record.date.substring(0, 7)
if (!records[month]) records[month] = []
records[month].push(record)
records[month].sort((a, b) => b.date.localeCompare(a.date))
wx.setStorageSync(RECORDS_KEY, records)
console.log('[storage] saveRecord date=' + record.date + ' duration=' + record.duration)
cloud.pushAll()
}
const deleteRecord = (recordId) => {
if (!recordId && recordId !== 0) return // guard against undefined/null
const id = String(recordId)
if (!id || id === 'undefined' || id === 'null') return
const records = getRecords()
let found = false
Object.keys(records).forEach((month) => {
const before = records[month].length
records[month] = records[month].filter((r) => String(r.id) !== id)
if (records[month].length < before) found = true
if (records[month].length === 0) delete records[month]
})
if (!found) return // id not found, don't overwrite storage
wx.setStorageSync(RECORDS_KEY, records)
// Re-validate streak: deleting the last record for a day should break the streak
validateStreak()
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] || []
}
const getTotalStats = () => {
const records = getRecords()
let totalDuration = 0
let totalSessions = 0
let maxDuration = 0
Object.keys(records).forEach((key) => {
records[key].forEach((r) => {
totalDuration += r.duration
totalSessions++
if (r.duration > maxDuration) maxDuration = r.duration
})
})
return { totalDuration, totalSessions, maxDuration }
}
const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || {
planId: 'beginner',
voiceGuide: true,
vibrate: true
}
const saveSettings = (settings) => {
wx.setStorageSync(SETTINGS_KEY, settings)
_markLocalChanged()
cloud.pushAll()
}
/**
* User profile (nickname + avatar). Stored locally and pushed to the
* cloud alongside records/settings so the leaderboard can show real
* names instead of masked openids.
*
* Shape: { nickname: string, avatarUrl: string (wxfile://...) }
* Both fields are optional; absent profile returns `{}`.
*/
const getProfile = () => {
const raw = wx.getStorageSync(PROFILE_KEY)
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
return {}
}
const saveProfile = (profile) => {
// Strip empties so we don't push {nickname: '', avatarUrl: ''}
const clean = {}
if (profile && typeof profile.nickname === 'string' && profile.nickname.trim()) {
clean.nickname = profile.nickname.trim().slice(0, 16)
}
if (profile && typeof profile.avatarUrl === 'string' && profile.avatarUrl) {
clean.avatarUrl = profile.avatarUrl
}
wx.setStorageSync(PROFILE_KEY, clean)
_markLocalChanged()
cloud.pushAll()
return clean
}
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
/**
* User-customized training plans, keyed by planId (e.g. 'beginner').
* Each value is a full plan object built by `utils/plan.generatePlanDays`
* plus the editor's formula fields (startTarget / increment / cycleDays).
*
* Storage shape mirrors `getRecords()`: an object map so partial writes
* don't have to read+modify+write the whole collection.
*/
const getCustomPlans = () => {
const raw = wx.getStorageSync(CUSTOM_PLANS_KEY)
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw
return {}
}
const saveCustomPlan = (planId, planData) => {
if (!planId) return
const plans = getCustomPlans()
plans[planId] = planData
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
_markLocalChanged()
cloud.pushAll()
}
const resetCustomPlan = (planId) => {
if (!planId) return
const plans = getCustomPlans()
if (!(planId in plans)) return
delete plans[planId]
wx.setStorageSync(CUSTOM_PLANS_KEY, plans)
_markLocalChanged()
cloud.pushAll()
}
/**
* Validate streak against actual training records.
* Call on app launch to correct any inconsistencies
* (e.g. after deleting the only record for a day).
*
* Streak compares date-ONLY portions of `lastDate` so that training
* twice on the same day (with different seconds in `record.date`)
* doesn't accidentally reset the streak.
*/
const validateStreak = () => {
const streak = getStreak()
if (!streak.lastDate) return streak
const today = getToday()
const todayOnly = dateOnly(today)
const yesterdayOnly = dateOnly(getDateOffset(today, -1))
const records = getRecords()
const allDates = new Set()
Object.values(records).forEach(monthRecs => {
monthRecs.forEach(r => allDates.add(dateOnly(r.date)))
})
const lastOnly = dateOnly(streak.lastDate)
// If lastDate is today, check today still has records
if (lastOnly === todayOnly) {
if (!allDates.has(todayOnly)) {
// Today's records were deleted
if (allDates.has(yesterdayOnly)) {
streak.lastDate = yesterdayOnly
streak.count = Math.max(1, streak.count - 1)
} else {
streak.count = 0
streak.lastDate = ''
}
wx.setStorageSync(STREAK_KEY, streak)
}
return streak
}
// If lastDate points to a date that no longer has ANY records,
// recompute the streak from actual recorded dates.
if (!allDates.has(lastOnly)) {
const sorted = Array.from(allDates).sort().reverse()
if (sorted.length === 0) {
streak.count = 0
streak.lastDate = ''
} else {
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
}
}
streak.count = count
streak.lastDate = sorted[0]
}
wx.setStorageSync(STREAK_KEY, 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 streak = getStreak()
const todayFull = date || getToday()
const todayOnly = dateOnly(todayFull)
const yesterdayOnly = dateOnly(getDateOffset(todayFull, -1))
if (dateOnly(streak.lastDate) === todayOnly) return streak
if (dateOnly(streak.lastDate) === yesterdayOnly) {
streak.count += 1
} else {
streak.count = 1
}
// Store the date-ONLY portion so subsequent comparisons are stable
// across multiple same-day trainings with different seconds.
streak.lastDate = todayOnly
wx.setStorageSync(STREAK_KEY, streak)
cloud.pushAll()
return streak
}
const getToday = () => formatDate(new Date())
const getDateOffset = (dateStr, offset) => {
// Strip the time portion (formatDate now includes HH:MM:SS) and convert
// dashes to slashes — `new Date("YYYY/MM/DD")` is the only reliably-
// parsed form across both V8 (WeChat dev tools) and JSCore (iOS).
const d = new Date(dateOnly(dateStr).replace(/-/g, '/'))
d.setDate(d.getDate() + offset)
return formatDate(d)
}
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 (dateOnly(r.date) === todayOnly) {
totalDuration += r.duration
hasRecord = true
}
})
})
return hasRecord ? { date: today, duration: totalDuration } : null
}
module.exports = {
getRecords,
saveRecord,
deleteRecord,
mergeRecords,
getRecordsByMonth,
getTotalStats,
getSettings,
saveSettings,
getCustomPlans,
saveCustomPlan,
resetCustomPlan,
getProfile,
saveProfile,
getStreak,
validateStreak,
recomputeStreak,
updateStreak,
getToday,
getDateOffset,
getTodayRecord
}