const DB_COLLECTION = 'plank_data' const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a' 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 } catch (e) { _enabled = false } } 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, not by limit(1) which can pick someone // else's doc when the custom rule is in effect. const mine = await db.collection(DB_COLLECTION) .where({ _openid: _openid }) .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 } catch (e) { /* function not deployed, etc. */ } 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 _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') 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 }) .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)') 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 { console.log('[cloud] _doPush no openid available, aborting (avoid duplicate add)') return } // Reached only when we've positively confirmed no doc exists for our // openid in the cloud — safe to add() the first document. 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) } catch (e) { console.log('[cloud] _doPush error:', e.message || e) } } const clearAll = async () => { const db = getDb() if (!db) return // 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') return } // Always locate by _openid (never by cached _docId alone) so we're // guaranteed to target OUR doc under the custom permission rule. const mine = await db.collection(DB_COLLECTION) .where({ _openid: _openid }) .limit(1) .get() if (mine && mine.data && mine.data.length > 0) { const myDocId = mine.data[0]._id try { await db.collection(DB_COLLECTION).doc(myDocId).remove() console.log('[cloud] clearAll: removed doc', myDocId) } catch (e) { console.log('[cloud] clearAll: remove failed:', e.message || e) } } else { console.log('[cloud] clearAll: no doc found for openid') } // Always invalidate cached ids so the next _doPush starts fresh // instead of trying to update a now-deleted or foreign doc. _docId = null } module.exports = { init, pullAll, pushAll, clearAll, get enabled() { return _enabled } }