修复云端数据一致性问题

This commit is contained in:
2026-07-29 11:03:44 +08:00
parent a3dd080b6f
commit 17727ab1dd
12 changed files with 389 additions and 41 deletions
+19
View File
@@ -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 }
+13 -14
View File
@@ -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) => {
+4
View File
@@ -0,0 +1,4 @@
const cachedFileId = (data) =>
data && typeof data.fileID === 'string' ? data.fileID : null
module.exports = { cachedFileId }
+3 -1
View File
@@ -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) {
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
version: 'v3.02',
/** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-07-28',
updatedAt: '2026-07-29',
/** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶',
@@ -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: PASS2 个测试均通过。
### 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. 新增回归测试能先复现并验证上述行为。
+1 -1
View File
@@ -80,7 +80,7 @@
<view class="rank-list">
<view
wx:for="{{rankedList}}"
wx:key="openid"
wx:key="rank"
wx:if="{{rankedList.length < 3 || index >= 3}}"
class="rank-item {{item.isMe ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
+14 -4
View File
@@ -351,9 +351,15 @@ Page({
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) {}
// 3. Clear every cloud doc for this openid. Local data is already gone
// even if this fails, so surface that partial failure honestly.
let cloudClearFailed = false
try {
await cloud.clearAll()
} catch (e) {
cloudClearFailed = true
console.warn('[settings] cloud clear failed:', e)
}
// 4. Reinstate default settings and streak
wx.setStorageSync('user_settings', {
@@ -379,7 +385,11 @@ Page({
})
themeMod.applyThemeToPage(this)
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
wx.showToast({
title: cloudClearFailed ? '本地已清除,云端未清除' : '已清除',
icon: cloudClearFailed ? 'none' : 'success',
duration: cloudClearFailed ? 2200 : 1500
})
},
// -------- Plan editor --------
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const {
memberKey,
findMyEntry,
publicRanked
} = require('../cloudfunctions/leaderboard/data')
const { cachedFileId } = require('../cloudfunctions/tts/cache')
const { deleteAllPages } = require('../utils/cloud-delete')
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'
)
})
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/
)
})
+11
View File
@@ -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 }
+15 -17
View File
@@ -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.
// 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(1)
.limit(100)
.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')
return (mine && mine.data) || []
},
async (id) => {
await db.collection(DB_COLLECTION).doc(id).remove()
console.log('[cloud] clearAll: removed doc', id)
}
)
// 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
}