From 17727ab1ddb7253dea75e53bd844fde90c63870c Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Wed, 29 Jul 2026 11:03:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=91=E7=AB=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=80=E8=87=B4=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloudfunctions/leaderboard/data.js | 19 ++ cloudfunctions/leaderboard/index.js | 27 ++- cloudfunctions/tts/cache.js | 4 + cloudfunctions/tts/index.js | 4 +- config.js | 2 +- .../2026-07-29-cloud-data-correctness.md | 200 ++++++++++++++++++ ...026-07-29-cloud-data-correctness-design.md | 48 +++++ pages/leaderboard/leaderboard.wxml | 2 +- pages/settings/settings.js | 18 +- tests/cloud-data-correctness.test.js | 57 +++++ utils/cloud-delete.js | 11 + utils/cloud.js | 38 ++-- 12 files changed, 389 insertions(+), 41 deletions(-) create mode 100644 cloudfunctions/leaderboard/data.js create mode 100644 cloudfunctions/tts/cache.js create mode 100644 docs/superpowers/plans/2026-07-29-cloud-data-correctness.md create mode 100644 docs/superpowers/specs/2026-07-29-cloud-data-correctness-design.md create mode 100644 tests/cloud-data-correctness.test.js create mode 100644 utils/cloud-delete.js diff --git a/cloudfunctions/leaderboard/data.js b/cloudfunctions/leaderboard/data.js new file mode 100644 index 0000000..449c548 --- /dev/null +++ b/cloudfunctions/leaderboard/data.js @@ -0,0 +1,19 @@ +const crypto = require('crypto') + +const memberKey = (openid) => + crypto.createHash('sha256').update(String(openid || '')).digest('hex') + +const publicEntry = (entry) => { + if (!entry) return null + const { memberKey: _memberKey, openid: _openid, ...publicData } = entry + return publicData +} + +const publicRanked = (ranked) => (ranked || []).map(publicEntry) + +const findMyEntry = (ranked, key) => { + const item = (ranked || []).find(entry => entry.memberKey === key) + return item ? { ...publicEntry(item), isMe: true } : null +} + +module.exports = { memberKey, publicEntry, publicRanked, findMyEntry } diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 5c8d7d6..494a637 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -1,4 +1,5 @@ const cloud = require('wx-server-sdk') +const { memberKey, publicEntry, publicRanked, findMyEntry } = require('./data') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command @@ -201,6 +202,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { // Top N for the board const ranked = allSorted.slice(0, limit).map((item, i) => ({ rank: i + 1, + memberKey: memberKey(item.openid), isMe: item.openid === myOpenid, nickname: item.nickname || '', name: _displayName(item), @@ -273,21 +275,13 @@ const _serveFromSnapshot = async (period, limit, myOpenid) => { if (!data || !Array.isArray(data.ranked)) return null const age = data.updatedAt ? Date.now() - new Date(data.updatedAt).getTime() : Infinity if (Number.isFinite(age) && age > SNAPSHOT_TTL_MS) return null // 过期 → 实时 - const ranked = data.ranked.slice(0, limit).map(item => ({ + const key = memberKey(myOpenid) + const snapshotRanked = data.ranked.slice(0, limit) + const ranked = publicRanked(snapshotRanked).map((item, i) => ({ ...item, - isMe: item.openid === myOpenid + isMe: snapshotRanked[i].memberKey === key })) - const me = ranked.find(r => r.openid === myOpenid) - const myEntry = me ? { - rank: me.rank, - openid: me.openid, - nickname: me.nickname || '', - name: me.name, - duration: me.duration, - sessions: me.sessions, - avatarUrl: me.avatarUrl || '', - isMe: true - } : null + const myEntry = findMyEntry(data.ranked, key) return { period, ranked, myOpenid, myEntry, updatedAt: data.updatedAt } } catch (e) { return null @@ -318,7 +312,12 @@ const _computeBoard = async (period, limit, myOpenid, opts) => { const result = await _buildFromScan(latestByOpenid, period, limit, myOpenid) const nowIso = new Date().toISOString() if (opts.persist) await _persistSnapshot(period, result) - return { period, ranked: result.ranked, myOpenid, myEntry: result.myEntry, updatedAt: nowIso } + return { + period, + ranked: publicRanked(result.ranked), + myEntry: publicEntry(result.myEntry), + updatedAt: nowIso + } } exports.main = async (event) => { diff --git a/cloudfunctions/tts/cache.js b/cloudfunctions/tts/cache.js new file mode 100644 index 0000000..34b9bd3 --- /dev/null +++ b/cloudfunctions/tts/cache.js @@ -0,0 +1,4 @@ +const cachedFileId = (data) => + data && typeof data.fileID === 'string' ? data.fileID : null + +module.exports = { cachedFileId } diff --git a/cloudfunctions/tts/index.js b/cloudfunctions/tts/index.js index f6f741a..bc8789e 100644 --- a/cloudfunctions/tts/index.js +++ b/cloudfunctions/tts/index.js @@ -16,6 +16,7 @@ // those prefixes are reserved by Tencent Cloud SCF. Use the TTS_ prefix. const cloud = require('wx-server-sdk') +const { cachedFileId } = require('./cache') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // Polished, fixed prompts. @@ -90,7 +91,8 @@ exports.main = async (event) => { if (!resolvedFileID) { try { const r = await db.collection('tts_cache').doc(cacheKey).get() - if (r.data && r.data.length && r.data[0].fileID) resolvedFileID = r.data[0].fileID + const cached = cachedFileId(r.data) + if (cached) resolvedFileID = cached } catch (e) {} // 集合/文档不存在,走合成 } if (resolvedFileID) { diff --git a/config.js b/config.js index b156341..4f9b194 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ module.exports = { version: 'v3.02', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-07-28', + updatedAt: '2026-07-29', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '三口一瓶', diff --git a/docs/superpowers/plans/2026-07-29-cloud-data-correctness.md b/docs/superpowers/plans/2026-07-29-cloud-data-correctness.md new file mode 100644 index 0000000..d8f539c --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-cloud-data-correctness.md @@ -0,0 +1,200 @@ +# 云端数据正确性修复 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复排行榜快照的本人识别、云端数据全量清除、TTS 缓存读取及排行榜列表 key。 + +**Architecture:** 在云函数侧生成仅用于服务端匹配的 SHA-256 `memberKey`;快照保留该字段,但云函数响应会剥离它。将可独立验证的快照匹配和 TTS 缓存读取抽入纯帮助模块,并用 Node 内置测试验证;客户端云端清除按 openid 分页删除全部文档。 + +**Tech Stack:** 微信小程序原生 JavaScript、CloudBase `wx-server-sdk`、Node.js `node:test`、Node.js `crypto`。 + +--- + +## 文件结构 + +- 新建 `cloudfunctions/shared/cloud-data.js`:用户指纹、快照本人项查找、响应脱敏、TTS 单文档缓存读取。 +- 新建 `utils/cloud-delete.js`:可在小程序运行时和 Node 测试中复用的分页删除控制流。 +- 新建 `tests/cloud-data-correctness.test.js`:纯帮助模块的回归测试。 +- 修改 `cloudfunctions/leaderboard/index.js`:构建快照指纹,并在快照和实时响应返回前剥离内部字段。 +- 修改 `cloudfunctions/tts/index.js`:使用单文档缓存读取帮助函数。 +- 修改 `utils/cloud.js`:分页删除当前 openid 的全部文档,失败向调用方传播。 +- 修改 `pages/settings/settings.js`:只在云端删除成功后显示成功提示;失败时给出可理解的反馈。 +- 修改 `pages/leaderboard/leaderboard.wxml`:循环 key 使用 `rank`。 + +### Task 1: 纯数据帮助模块与回归测试 + +**Files:** +- Create: `cloudfunctions/shared/cloud-data.js` +- Create: `tests/cloud-data-correctness.test.js` + +- [ ] **Step 1: 写失败测试** + +```js +const test = require('node:test') +const assert = require('node:assert/strict') +const { memberKey, findMyEntry, publicEntry, cachedFileId } = require('../cloudfunctions/shared/cloud-data') + +test('findMyEntry matches a hashed snapshot member and strips its internal key', () => { + const key = memberKey('my-openid') + const entry = findMyEntry([{ rank: 2, memberKey: key, duration: 90 }], key) + assert.deepEqual(entry, { rank: 2, duration: 90, isMe: true }) +}) + +test('cachedFileId reads a CloudBase doc object rather than an array', () => { + assert.equal(cachedFileId({ fileID: 'cloud://env.tts-cache/start.mp3' }), 'cloud://env.tts-cache/start.mp3') +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node --test tests/cloud-data-correctness.test.js` + +Expected: FAIL,原因是 `cloudfunctions/shared/cloud-data.js` 尚不存在。 + +- [ ] **Step 3: 实现最小帮助模块** + +```js +const crypto = require('crypto') + +const memberKey = (openid) => crypto.createHash('sha256').update(String(openid || '')).digest('hex') +const publicEntry = (entry) => { + if (!entry) return null + const { memberKey: _memberKey, ...publicData } = entry + return publicData +} +const findMyEntry = (ranked, key) => { + const item = (ranked || []).find(entry => entry.memberKey === key) + return item ? { ...publicEntry(item), isMe: true } : null +} +const cachedFileId = (data) => data && typeof data.fileID === 'string' ? data.fileID : null + +module.exports = { memberKey, publicEntry, findMyEntry, cachedFileId } +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node --test tests/cloud-data-correctness.test.js` + +Expected: PASS,2 个测试均通过。 + +### Task 2: 排行榜快照匹配与脱敏 + +**Files:** +- Modify: `cloudfunctions/leaderboard/index.js:1-348` +- Test: `tests/cloud-data-correctness.test.js` + +- [ ] **Step 1: 扩充失败测试** + +```js +test('publicEntry never returns memberKey', () => { + assert.deepEqual(publicEntry({ rank: 1, memberKey: 'secret', name: '甲' }), { rank: 1, name: '甲' }) +}) + +test('publicRanked strips memberKey from every snapshot entry', () => { + assert.deepEqual(publicRanked([{ rank: 1, memberKey: 'secret' }]), [{ rank: 1 }]) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node --test tests/cloud-data-correctness.test.js` + +Expected: FAIL,原因是 `publicRanked` 尚未导出。 + +- [ ] **Step 3: 接入帮助模块** + +在帮助模块中增加 `publicRanked = (ranked) => (ranked || []).map(publicEntry)`。在排行榜云函数中 `require('../shared/cloud-data')`;创建榜单项时加入 `memberKey: memberKey(item.openid)`;构建 `myEntry` 与返回 `ranked` 时统一使用 `publicEntry`,快照读取时调用 `findMyEntry(data.ranked, memberKey(myOpenid))`,并对 `ranked` 调用 `publicRanked` 后返回客户端。 + +- [ ] **Step 4: 运行测试与语法检查** + +Run: `node --test tests/cloud-data-correctness.test.js && node --check cloudfunctions/leaderboard/index.js` + +Expected: PASS,且语法检查退出码为 0。 + +### Task 3: TTS 单文档缓存读取 + +**Files:** +- Modify: `cloudfunctions/tts/index.js:1-123` +- Test: `tests/cloud-data-correctness.test.js` + +- [ ] **Step 1: 扩充失败测试** + +```js +test('cachedFileId rejects missing or non-string file IDs', () => { + assert.equal(cachedFileId({}), null) + assert.equal(cachedFileId({ fileID: 1 }), null) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node --test tests/cloud-data-correctness.test.js` + +Expected: FAIL,直到帮助函数对无效数据返回 `null`。 + +- [ ] **Step 3: 替换错误的数组读取** + +将 `r.data.length` / `r.data[0]` 访问替换为: + +```js +const cached = cachedFileId(r.data) +if (cached) resolvedFileID = cached +``` + +- [ ] **Step 4: 验证** + +Run: `node --test tests/cloud-data-correctness.test.js && node --check cloudfunctions/tts/index.js` + +Expected: PASS,且语法检查退出码为 0。 + +### Task 4: 云端全量清除与界面反馈 + +**Files:** +- Modify: `utils/cloud.js:215-250` +- Modify: `pages/settings/settings.js:339-382` +- Create: `utils/cloud-delete.js` + +- [ ] **Step 1: 写失败测试** + +在 `tests/cloud-data-correctness.test.js` 中为独立的 `deleteAllPages(fetchPage, remove)` 辅助函数添加测试:两页共三条文档时,断言三个 id 都传给 `remove`;任一 `remove` 抛错时,断言 Promise reject。 + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node --test tests/cloud-data-correctness.test.js` + +Expected: FAIL,原因是 `utils/cloud-delete.js` 尚不存在。 + +- [ ] **Step 3: 实现与接入分页删除** + +在 `utils/cloud-delete.js` 增加 `deleteAllPages`:循环调用注入的 `fetchPage(cursor)`,逐条 await `remove(id)`,当本页数量小于页大小时结束。`utils/cloud.js` 使用 `_openid`、按 `_id` 游标查询并调用该帮助函数;查询或删除失败直接抛出。设置页捕获异常后显示“云端数据未清除,请重试”,仅成功路径显示“已清除”。 + +- [ ] **Step 4: 验证** + +Run: `node --test tests/cloud-data-correctness.test.js && node --check utils/cloud.js && node --check pages/settings/settings.js` + +Expected: PASS,且三个语法检查退出码为 0。 + +### Task 5: 修复列表 key 与完整验证 + +**Files:** +- Modify: `pages/leaderboard/leaderboard.wxml:81-97` + +- [ ] **Step 1: 修改循环 key** + +将 `wx:key="openid"` 改为 `wx:key="rank"`,因为返回给客户端的每个榜单项都包含稳定且唯一的 `rank`。 + +- [ ] **Step 2: 运行完整验证** + +Run: + +```bash +node --test tests/cloud-data-correctness.test.js +for f in app.js config.js utils/*.js pages/*/*.js components/*/*.js custom-tab-bar/index.js cloudfunctions/*/index.js cloudfunctions/shared/*.js; do node --check "$f"; done +git diff --check +``` + +Expected: 所有测试通过、所有语法检查退出码为 0、`git diff --check` 无输出。 + +- [ ] **Step 3: 人工验收清单** + +在微信开发者工具中验证:日/月/年榜快照命中时本人高亮;榜外但位于快照前 500 名时显示底部本人条目;设置页清除后重启不恢复训练数据;清空本地 TTS fileID 缓存后第二次调用命中服务端缓存。 diff --git a/docs/superpowers/specs/2026-07-29-cloud-data-correctness-design.md b/docs/superpowers/specs/2026-07-29-cloud-data-correctness-design.md new file mode 100644 index 0000000..5c81999 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-cloud-data-correctness-design.md @@ -0,0 +1,48 @@ +# 云端数据正确性修复设计 + +## 目标 + +修复排行榜快照无法显示本人信息、清除数据不彻底、TTS 服务端缓存失效及排行榜列表 key 无效四项问题。保持排行榜快照的低延迟特性,不向客户端公开其他用户的 openid。 + +## 范围 + +包含: + +1. 排行榜快照请求的本人排名与高亮。 +2. 云端训练数据的全量删除。 +3. TTS 缓存文档的正确读取。 +4. 排行榜 WXML 的稳定列表 key。 +5. Node 回归测试。 + +不包含:排行榜防作弊、数据模型重构、云函数部署配置文档。 + +## 设计 + +### 排行榜 + +定时任务继续写入不包含原始 openid 的公开快照。每个榜单项会保存仅供云函数匹配的 SHA-256 `memberKey`(由 openid 生成),用户请求命中快照时用当前调用者的 openid 生成同样的指纹,从而准确定位其榜单项并返回 `myEntry`。`memberKey` 在返回客户端前剥离,不在页面数据和本地缓存中保存。若用户未进入快照上限,返回空 `myEntry`,避免将完整榜单或其他用户标识暴露给客户端。 + +实时强刷路径保留现有全量计算逻辑,但返回数据与快照路径保持一致。榜单列表项以 `rank` 作为 WXML 的稳定 key。 + +### 清除数据 + +`clearAll()` 在已获取当前 openid 后,以游标或分页方式查询该 openid 的全部 `plank_data` 文档并逐一删除。任一单条删除失败会被记录并抛出,使调用页不显示“已清除”的错误成功状态;本地数据仍按现有流程清除。 + +### TTS 缓存 + +`doc(cacheKey).get()` 的 `data` 是单文档对象。缓存读取改为直接读取 `data.fileID`,并继续保留文件不存在时重新合成的降级路径。 + +## 错误处理 + +- 快照或本人文档不可用时,排行榜继续降级至现有实时计算或本地展示路径。 +- 无法获取 openid 时不尝试跨用户删除;清除操作返回失败。 +- 删除任一云端文档失败时,不伪造成功反馈。 +- TTS 缓存缺失或失效时才重新合成。 + +## 验收标准 + +1. 快照命中时,榜内用户可被高亮;榜外用户可获得其个人排名(前提是快照包含其名次)。 +2. 同一 openid 的多份云端文档均会被删除,之后重启不会恢复旧训练数据。 +3. 未提供客户端 fileID 时,已有 `tts_cache` 文档会返回缓存文件,而非重新合成。 +4. 排行榜循环不再引用不存在的 `openid` key。 +5. 新增回归测试能先复现并验证上述行为。 diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml index 7c5de7f..c403a64 100644 --- a/pages/leaderboard/leaderboard.wxml +++ b/pages/leaderboard/leaderboard.wxml @@ -80,7 +80,7 @@ { + const key = memberKey('my-openid') + const entry = findMyEntry([{ rank: 2, memberKey: key, duration: 90 }], key) + + assert.deepEqual(entry, { rank: 2, duration: 90, isMe: true }) +}) + +test('cachedFileId reads a CloudBase doc object rather than an array', () => { + assert.equal( + cachedFileId({ fileID: 'cloud://env.tts-cache/start.mp3' }), + 'cloud://env.tts-cache/start.mp3' + ) +}) + +test('publicRanked strips memberKey from every snapshot entry', () => { + assert.deepEqual( + publicRanked([{ rank: 1, memberKey: 'secret', name: '甲' }]), + [{ rank: 1, name: '甲' }] + ) +}) + +test('deleteAllPages removes every document across pages', async () => { + const pages = [ + [{ _id: 'a' }, { _id: 'b' }], + [{ _id: 'c' }], + [] + ] + const removed = [] + + await deleteAllPages( + async () => pages.shift(), + async (id) => { removed.push(id) } + ) + + assert.deepEqual(removed, ['a', 'b', 'c']) +}) + +test('deleteAllPages surfaces a failed deletion', async () => { + await assert.rejects( + deleteAllPages( + async () => [{ _id: 'a' }], + async () => { throw new Error('delete failed') } + ), + /delete failed/ + ) +}) diff --git a/utils/cloud-delete.js b/utils/cloud-delete.js new file mode 100644 index 0000000..20bdc73 --- /dev/null +++ b/utils/cloud-delete.js @@ -0,0 +1,11 @@ +const deleteAllPages = async (fetchPage, remove) => { + while (true) { + const page = await fetchPage() + if (!Array.isArray(page) || page.length === 0) return + for (const doc of page) { + await remove(doc._id) + } + } +} + +module.exports = { deleteAllPages } diff --git a/utils/cloud.js b/utils/cloud.js index d1e4e73..c86ef55 100644 --- a/utils/cloud.js +++ b/utils/cloud.js @@ -1,5 +1,6 @@ const DB_COLLECTION = 'plank_data' const ENV_ID = 'cloudbase-d1g56kl2q8f4f7d8a' +const { deleteAllPages } = require('./cloud-delete') let _db = null let _pushTimer = null @@ -214,7 +215,7 @@ const _doPush = async () => { const clearAll = async () => { const db = getDb() - if (!db) return + 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). @@ -223,29 +224,26 @@ const clearAll = async () => { } if (!_openid) { console.log('[cloud] clearAll: cannot fetch openid, giving up') - return + throw new Error('无法识别当前用户') } - // 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) + // 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) } - } 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. + // Invalidate cached ids so the next _doPush starts fresh. _docId = null }