From d817d5d0122c9a054e504da237f4d13c5803d23e Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 24 Jun 2026 12:22:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=2013=E9=A1=B9=E6=95=B0=E6=8D=AE=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E4=B8=8E=E6=8E=92=E8=A1=8C=E6=A6=9C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 致命修复: - leaderboard: 日榜日期比较改用 dateOnly() 替代严格全等 - plan: 计划天数计算日期比较改用 dateOnly() - cloud: pullAll 添加 orderBy('updatedAt','desc') 确保取最新文档避免数据丢失 严重修复: - cloud: 新增 cancelPendingPush() 防止清除数据后被延迟推送复活 - settings: _doClearData 补充 user_profile 清除, 先取消推送再清除 - app: 云端同步改为时间戳比对策略, 支持跨设备拉取 - app: _restoreFromCloud 增加空数据保护和 profile 恢复 - storage: 记录ID改用时间戳+自增计数器确保唯一性 中等修复: - cloud: init/_fetchOpenid 日志升级, 新增 syncStatus getter - leaderboard云函数: 添加90天活跃过滤减少全表扫描 - storage: validateStreak 增加lastDate无记录时的通用修复 - leaderboard页面: 云函数失败时Toast提示用户而非静默降级 轻微修复: - timer: onUnload 添加 voice.destroy() 释放音频上下文 - records: onLongPressDelete 委托给 onDeleteRecord 消除重复代码 Co-Authored-By: Claude --- app.js | 50 +++++++++++++++++++++++++---- cloudfunctions/leaderboard/index.js | 13 +++++++- config.js | 2 +- pages/leaderboard/leaderboard.js | 9 ++++-- pages/records/records.js | 17 +--------- pages/settings/settings.js | 21 ++++++++++-- pages/timer/timer.js | 1 + utils/cloud.js | 38 +++++++++++++++++++--- utils/plan.js | 4 +-- utils/storage.js | 37 ++++++++++++++++++--- 10 files changed, 152 insertions(+), 40 deletions(-) diff --git a/app.js b/app.js index 9f39200..5654775 100644 --- a/app.js +++ b/app.js @@ -39,15 +39,40 @@ App({ if (cloud.enabled) { try { const cloudData = await cloud.pullAll() - if (!records || Object.keys(records).length === 0) { - // Case 1: Fresh install (or user cleared local) — restore from cloud + const hasLocal = records && Object.keys(records).length > 0 + const hasCloud = cloudData && cloudData.records + && Object.keys(cloudData.records).length > 0 + && Object.values(cloudData.records).some(arr => Array.isArray(arr) && arr.length > 0) + + // Parse cloud updatedAt into a comparable timestamp + let cloudTs = 0 + if (cloudData && cloudData.updatedAt) { + const d = cloudData.updatedAt instanceof Date + ? cloudData.updatedAt + : new Date(cloudData.updatedAt) + cloudTs = d.getTime() || 0 + } + const localSyncAt = wx.getStorageSync('_cloud_sync_at') || 0 + + if (!hasLocal && hasCloud) { + // Case 1: Fresh install on a new device — restore from cloud 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. + 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) + wx.setStorageSync('_cloud_sync_at', cloudTs) + } else { + // Local is at least as current — push + cloud.pushAll() + } + } else if (hasLocal && !hasCloud) { + // Case 3: Only local data (first-ever sync) — push cloud.pushAll() } + // else: no data anywhere — nothing to sync } catch (e) { console.error('Cloud sync on launch failed:', e) } @@ -58,7 +83,15 @@ App({ if (!cloudData) return try { if (cloudData.records && Object.keys(cloudData.records).length > 0) { - wx.setStorageSync('training_records', cloudData.records) + // 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( + arr => Array.isArray(arr) && arr.length > 0 + ) + if (hasAny) { + wx.setStorageSync('training_records', cloudData.records) + } } if (cloudData.settings) { wx.setStorageSync('user_settings', cloudData.settings) @@ -69,6 +102,9 @@ App({ 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) } diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 1118b2e..b103430 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -61,8 +61,19 @@ exports.main = async (event) => { 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).skip(skip).limit(PAGE_SIZE).get() + 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) { diff --git a/config.js b/config.js index 6179239..adbc7ff 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ module.exports = { version: 'v1.5', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-06-11', + updatedAt: '2026-06-24', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '刘承', diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index 7ff1811..e7061b8 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -64,8 +64,11 @@ Page({ }).then(res => { this._applyResult(res.result || {}) if (cb) cb() - }).catch(() => { - // Cloud function not deployed — fall back to local data + }).catch((err) => { + // Cloud function not deployed or network error — fall back to + // local data but let the user know the leaderboard is degraded. + console.warn('[leaderboard] cloud function failed, using local fallback:', err) + wx.showToast({ title: '云端榜单暂不可用', icon: 'none', duration: 2000 }) this._applyLocal() if (cb) cb() }) @@ -102,7 +105,7 @@ Page({ let duration = 0 let sessions = 0 allRecords.forEach(r => { - if (period === 'day' && r.date === today) { duration += r.duration; sessions++ } + if (period === 'day' && util.dateOnly(r.date) === util.dateOnly(today)) { duration += r.duration; sessions++ } if (period === 'month' && r.date.startsWith(thisMonth)) { duration += r.duration; sessions++ } if (period === 'year' && r.date.startsWith(thisYear)) { duration += r.duration; sessions++ } }) diff --git a/pages/records/records.js b/pages/records/records.js index b1b11f7..3ce74b0 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -126,22 +126,7 @@ Page({ }, onLongPressDelete(e) { - const id = e.currentTarget.dataset.id - if (!id && id !== 0) { - wx.showToast({ title: '记录数据异常', icon: 'none', duration: 1200 }) - return - } - wx.showModal({ - title: '删除记录', - content: '确定要删除这条训练记录吗?', - success: (res) => { - if (res.confirm) { - storage.deleteRecord(id) - this.refresh() - wx.showToast({ title: '已删除', icon: 'none', duration: 1200 }) - } - } - }) + this.onDeleteRecord(e) }, onDeleteRecord(e) { diff --git a/pages/settings/settings.js b/pages/settings/settings.js index 510d55d..335773d 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -202,12 +202,25 @@ Page({ }, async _doClearData() { + // 1. Cancel any pending push BEFORE clearing, to prevent the + // scheduled _doPush from reading the freshly-reset defaults + // and recreating the cloud doc via add(). + cloud.cancelPendingPush() + + // 2. Clear all local keys (including user_profile which was + // previously missed) wx.removeStorageSync('training_records') wx.removeStorageSync('current_streak') wx.removeStorageSync('user_settings') wx.removeStorageSync('app_theme') wx.removeStorageSync('custom_plans') + wx.removeStorageSync('user_profile') + + // 3. Clear cloud doc (awaited — order matters: clear cloud + // after cancelling push, before resetting defaults) try { await cloud.clearAll() } catch (e) {} + + // 4. Reinstate default settings and streak wx.setStorageSync('user_settings', { planId: 'beginner', voiceGuide: true, @@ -215,15 +228,19 @@ Page({ planStartDate: storage.getToday() }) wx.setStorageSync('current_streak', { count: 0, lastDate: '' }) + + // 5. Reset theme and UI (include nickName/avatarUrl to clear + // profile state now that user_profile is gone) themeMod.setTheme('orange') this.setData({ currentPlanId: 'beginner', voiceGuide: true, vibrate: true, - currentThemeId: 'orange' + currentThemeId: 'orange', + nickName: '', + avatarUrl: '' }) themeMod.applyThemeToPage(this) - // Reset plans list — custom_plans are gone this.setData({ plans: buildPlansList(storage.getCustomPlans()) }) wx.showToast({ title: '已清除', icon: 'success', duration: 1500 }) }, diff --git a/pages/timer/timer.js b/pages/timer/timer.js index bbf91fc..7edbd32 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -150,6 +150,7 @@ Page({ onUnload() { voice.stop() + voice.destroy() if (this._timer) { this._timer.stop() this._timer = null diff --git a/utils/cloud.js b/utils/cloud.js index c62abcb..d65896a 100644 --- a/utils/cloud.js +++ b/utils/cloud.js @@ -24,8 +24,10 @@ const init = () => { try { wx.cloud.init({ env: ENV_ID, traceUser: true }) _enabled = true + console.log('[cloud] init ok, env:', ENV_ID) } catch (e) { _enabled = false + console.warn('[cloud] init failed — cloud sync disabled:', e.message || e) } } @@ -40,10 +42,13 @@ const pullAll = async () => { } if (!_openid) return null // cloud function call failed; bail - // Locate our doc by _openid, not by limit(1) which can pick someone - // else's doc when the custom rule is in effect. + // Locate our doc by _openid, ordered by updatedAt descending so + // we always get the MOST RECENT doc. Without orderBy, limit(1) + // returns the OLDEST doc (_id ascending), which causes data loss + // when duplicate docs exist from the old cold-start-add() bug. const mine = await db.collection(DB_COLLECTION) .where({ _openid: _openid }) + .orderBy('updatedAt', 'desc') .limit(1) .get() if (mine && mine.data && mine.data.length > 0) { @@ -67,7 +72,10 @@ const _fetchOpenid = async () => { try { const res = await wx.cloud.callFunction({ name: 'getOpenid' }) if (res && res.result && res.result.openid) return res.result.openid - } catch (e) { /* function not deployed, etc. */ } + console.warn('[cloud] getOpenid returned no openid:', JSON.stringify(res)) + } catch (e) { + console.warn('[cloud] getOpenid call failed — cloud functions may not be deployed:', e.message || e) + } return null } @@ -111,6 +119,17 @@ const pushAll = () => { }) } +const cancelPendingPush = () => { + if (_pushTimer) { + clearTimeout(_pushTimer) + _pushTimer = null + } +} + +const _markSynced = () => { + try { wx.setStorageSync('_cloud_sync_at', Date.now()) } catch (e) {} +} + const _doPush = async () => { console.log('[cloud] _doPush starting...') const db = getDb() @@ -135,6 +154,7 @@ const _doPush = async () => { try { await db.collection(DB_COLLECTION).doc(_docId).update({ data }) console.log('[cloud] _doPush update ok') + _markSynced() return } catch (e) { // Most commonly: doc was deleted out from under us (user cleared @@ -163,6 +183,7 @@ const _doPush = async () => { try { await db.collection(DB_COLLECTION).doc(_docId).update({ data }) console.log('[cloud] _doPush update ok (re-located by _openid)') + _markSynced() return } catch (e) { // If even the freshly-located doc rejects our update (custom @@ -185,6 +206,7 @@ const _doPush = async () => { if (res && res._id) _docId = res._id if (res && res._openid) _openid = res._openid console.log('[cloud] _doPush add ok, docId=', _docId) + _markSynced() } catch (e) { console.log('[cloud] _doPush error:', e.message || e) } @@ -227,4 +249,12 @@ const clearAll = async () => { _docId = null } -module.exports = { init, pullAll, pushAll, clearAll, get enabled() { return _enabled } } +module.exports = { + init, pullAll, pushAll, clearAll, cancelPendingPush, + get enabled() { return _enabled }, + get syncStatus() { + if (!_enabled) return 'disabled' + if (!_openid) return 'unauthenticated' + return 'ok' + } +} diff --git a/utils/plan.js b/utils/plan.js index caaff8e..772dc30 100644 --- a/utils/plan.js +++ b/utils/plan.js @@ -1,4 +1,4 @@ -const { formatDate } = require('./util') +const { formatDate, dateOnly } = require('./util') /** * Build the per-day target array for a plan from its formula fields. @@ -90,7 +90,7 @@ const getPlanDay = (planId, records, planStartDate) => { // Filter records: same plan, after plan start date, and not today const eligibleRecords = records.filter(r => { if (r.planId !== planId) return false - if (r.date === today) return false + if (dateOnly(r.date) === dateOnly(today)) return false if (planStartDate && r.date < planStartDate) return false return true }) diff --git a/utils/storage.js b/utils/storage.js index 53eeb45..7f5a401 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -7,6 +7,8 @@ const STREAK_KEY = 'current_streak' const CUSTOM_PLANS_KEY = 'custom_plans' const PROFILE_KEY = 'user_profile' +let _idSeq = 0 + /** * Backfill an `id` for any record that doesn't have one. * Older records (e.g. data restored from cloud or migrated from a build @@ -46,7 +48,7 @@ const getRecords = () => { } const saveRecord = (record) => { - record.id = String(Date.now()) + record.id = String(Date.now()) + '_' + (++_idSeq) const records = getRecords() const month = record.date.substring(0, 7) if (!records[month]) records[month] = [] @@ -190,8 +192,10 @@ const validateStreak = () => { monthRecs.forEach(r => allDates.add(dateOnly(r.date))) }) + const lastOnly = dateOnly(streak.lastDate) + // If lastDate is today, check today still has records - if (dateOnly(streak.lastDate) === todayOnly) { + if (lastOnly === todayOnly) { if (!allDates.has(todayOnly)) { // Today's records were deleted if (allDates.has(yesterdayOnly)) { @@ -206,8 +210,33 @@ const validateStreak = () => { return streak } - // If lastDate is not today and not yesterday, streak is already broken - // (updateStreak handles reset on next training), no action needed here + // 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 }