修复云端数据一致性问题
This commit is contained in:
@@ -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 缓存后第二次调用命中服务端缓存。
|
||||
@@ -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. 新增回归测试能先复现并验证上述行为。
|
||||
Reference in New Issue
Block a user