const DB_COLLECTION = 'plank_data' const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a' const { deleteAllPages } = require('./cloud-delete') let _db = null let _pushTimer = null let _enabled = false let _docId = null // cache doc id after first successful push let _openid = null // cache openid from first successful add, used to // re-locate our doc when _docId goes stale // (e.g. user manually deleted the doc in the console) let _openidPromise = null // in-flight _fetchOpenid promise, prevents // concurrent pushAll calls from racing the // openid lookup (and each issuing an add()) const getDb = () => { if (!_enabled) return null if (!_db) { try { _db = wx.cloud.database({ env: ENV_ID }) } catch (e) { _enabled = false } } return _db } 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) } } const pullAll = async () => { const db = getDb() if (!db) return null try { // Always need the openid to safely locate OUR doc under the custom // security rule. Cache it on first call so subsequent pulls are fast. if (!_openid) { _openid = await _fetchOpenid() } if (!_openid) return null // cloud function call failed; bail // 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) { _docId = mine.data[0]._id return mine.data[0] } // No doc for us — that's a fresh install on a new device with no // history, or the user cleared their cloud data. Either way, nothing // to restore. _docId = null return null } catch (e) { return null } } // One-shot helper: call the getOpenid cloud function and return the // openid string. Returns null on any failure (function not deployed, // network error, etc.) — pullAll then bails cleanly. const _fetchOpenid = async () => { try { const res = await wx.cloud.callFunction({ name: 'getOpenid' }) if (res && res.result && res.result.openid) return res.result.openid 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 } /** * Resolve and cache the user's openid exactly once per app session, * sharing a single in-flight promise across concurrent callers. * * Why this exists: pushAll() schedules _doPush() 2s later. Between the * schedule and the actual run, the module-level caches (_openid, _docId) * are null on every cold start. The original code would let _doPush fall * straight through to add() and create a brand-new cloud doc on every * cold start — which leaderboard then summed across N docs for the same * _openid. Pre-resolving openid here lets _doPush always locate the * existing doc (or skip the write if lookup fails) before considering * add(). */ const _ensureOpenid = async () => { if (_openid) return _openid if (!_openidPromise) { _openidPromise = _fetchOpenid().then(o => { _openid = o _openidPromise = null return o }).catch(e => { _openidPromise = null return null }) } return _openidPromise } const pushAll = () => { if (!_enabled) { console.log('[cloud] pushAll skipped (not enabled)'); return } console.log('[cloud] pushAll scheduled') // Pre-warm openid so _doPush never sees _openid=null on a cold start // (which used to bypass the locate-by-openid branch and call add(), // creating a duplicate cloud doc every cold start). _ensureOpenid().finally(() => { if (_pushTimer) clearTimeout(_pushTimer) _pushTimer = setTimeout(() => _doPush(), 2000) }) } 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() if (!db) { console.log('[cloud] _doPush aborted (no db)'); return } try { const storage = require('./storage') const themeMod = require('./theme') const data = { records: storage.getRecords(), settings: storage.getSettings(), streak: storage.getStreak(), customPlans: storage.getCustomPlans(), profile: storage.getProfile(), themeId: themeMod.getCurrentTheme().id, updatedAt: db.serverDate() } console.log('[cloud] _doPush records keys:', Object.keys(data.records || {})) // Fast path: cached _docId update if (_docId) { 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 // the collection in the console). The custom write rule evaluates // `doc._openid == auth.openid` → `undefined == openid` → false, // so this surfaces as -502003 rather than "not found". Either way, // fall through and re-locate the doc via openid. console.log('[cloud] _doPush cached _docId stale, clearing:', e.message) _docId = null } } // Required path: always try to locate OUR doc by _openid before any // add(). pushAll() pre-warms _openid via _ensureOpenid, so by the time // we reach this branch on a cold start _openid is populated and we'll // hit the update branch instead of falling through to add(). This is // the fix for the "leaderboard data increases on its own" bug. const openid = await _ensureOpenid() if (openid) { const existing = await db.collection(DB_COLLECTION) .where({ _openid: openid }) .orderBy('updatedAt', 'desc') // 与 pullAll 一致,避免命中最旧文档 .limit(1) .get() if (existing && existing.data && existing.data.length > 0) { _docId = existing.data[0]._id 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 // permission rule, race with another tab, etc.), DO NOT fall // through to add() — that would silently create a duplicate // cloud doc for the same user. Bail and let the next push retry. console.log('[cloud] _doPush update-by-openid failed, aborting:', e.message) _docId = null return } } } // else: openid is null — skip locate+update, go straight to add() // Reached when: (a) openid exists but no doc found, OR (b) openid is // null (getOpenid not deployed). In both cases add() is safe — CloudBase // auto-populates _openid on document creation. console.log('[cloud] _doPush adding new doc (openid=' + (openid || 'null') + ')') const res = await db.collection(DB_COLLECTION).add({ data }) 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) } } const clearAll = async () => { const db = getDb() if (!db) throw new Error('云端服务不可用') // Ensure _openid is cached before trying to delete — without it we // can't reliably locate the user's doc (add() only returns _id). if (!_openid) { _openid = await _fetchOpenid() } if (!_openid) { console.log('[cloud] clearAll: cannot fetch openid, giving up') throw new Error('无法识别当前用户') } // Repeatedly fetch the first page and delete it. This avoids skip()'s // shifting-offset bug and removes legacy duplicate docs as well. await deleteAllPages( async () => { const mine = await db.collection(DB_COLLECTION) .where({ _openid: _openid }) .limit(100) .get() return (mine && mine.data) || [] }, async (id) => { await db.collection(DB_COLLECTION).doc(id).remove() console.log('[cloud] clearAll: removed doc', id) } ) // Invalidate cached ids so the next _doPush starts fresh. _docId = null } module.exports = { init, pullAll, pushAll, clearAll, cancelPendingPush, get enabled() { return _enabled }, get syncStatus() { if (!_enabled) return 'disabled' if (!_openid) return 'unauthenticated' return 'ok' } }