Files
MailUI4Agents/server/internal/repo/debt_registry_test.go
JianFeeeee 63649031d8 后端: 销掉一份漂移的欠账副本 + 钉住 user_appearance.user_id 的语义陷阱
## 一、`user_appearance.user_id` 存的是**用户名**(不是 UUID)—— 陷阱显式化

审计时实测到的差异:

| 表 | `user_id` 存的是 |
|---|---|
| `user_keys` | **UUID**(`809967c5-…`) |
| `user_sessions` | **UUID** |
| `user_appearance` | **用户名**(`jianf`)← 与兄弟表不同 |

**功能上自洽**(本包读写都用 username,handler 一律传 `user.Username`),
所以**不是活跃 bug**。但它是个**不报错的陷阱**:按兄弟表的习惯写

    LEFT JOIN user_appearance a ON u.user_id = a.user_id

会**静默匹配到 0 行**。我自己审计时就先踩了一次 —— 查出来全是空,
差点当成"这些账号从没设置过外观"(实际 jianf 有记录)。

**没有改列名**:表里有生产数据(壁纸本体在 blob 里、由 `image_sha256` 引用),
改列要迁移 + 回滚预案,收益只是"名字更好看"。选择**把语义钉住**:
文件头写清(**指名兄弟表**当锚、说清**失败长什么样**)+ 新判据
`appearance_semantics_test.go` 双向校验(repo 层不得混入 UUID 语义、
handler 层不得出现 `user.UserID`)。

★ 判据从源码读而不是运行时测:这里要钉的是**约定**,
而约定的存在形式就是注释与命名 —— 行为测试证明不了"下一个人不会踩"。

## 二、销掉一份**已经漂移**的欠账副本

`debt_registry_test.go` 里有一份**手写副本** `debts` map(给 `debtSummary()`
打印用),而**没有任何判据校验它与权威 `docs/DEBTS.json` 一致**。
实测漂移得很厉害:

- 副本 3 条 vs 权威 **17 条**;
- 里面还留着 **`gesture-semantics`** —— 那一笔已于 2026-09-19 还清并销账;
- `static-criteria` 的余额还是旧值 **7**(权威已是 5)。

后果很具体:**`TestMain` 打印的余额是错的**,而余额的全部意义就是"能看全"。

处理:**删掉副本**,让 `debtSummary()` 直接读权威 JSON(同一个事实不留两份),
并加判据 `TestDebtSummaryReadsAuthoritativeLedger` 双向钉住:
① 不许再引入手写副本;② 打出来的数字必须与 JSON 一致,
且**每一笔余额>0 的都要出现在打印里**(漏掉的欠账等于不存在)。

同时修了 `TestDebtLedgerMatchesMeasurement` 里一条**硬编码欠账清单**的断言
(它点名要求 `gesture-semantics` 必须存在 —— 还清了反而红)。
改成断言**形状**(跨端净值 + 本包可实测的那笔必须同处登记),不点名具体笔数。
★ 教训:硬编码欠账清单不可维护,漏改的后果是"还清了反而红"。

## 三、写这三条判据时连踩两次**自匹配**

`strings.Contains(src, "var debts = map[string]debt{")` —— 那串字**本身**
出现在断言的 `t.Fatal` 消息里(以及我留的说明注释里)⇒ 恒红。
(`run-all.mjs` 的自检锚点也栽过一次,那里改用 `lastIndexOf`。)
修法:锚点**拼出来**(`"var " + "debts" + " = map..."`)+ 注释措辞避开那个声明。

## 四、验证

`go test ./...` → **13 个包全过**。
新增:`appearance_semantics_test.go`(4 条断言)、
`TestDebtSummaryReadsAuthoritativeLedger`(2 条)。
2026-09-19 16:27:09 +08:00

257 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
/*
欠账的**单一余额**(pi 2026-09-14 裁定 §3)。
背景:到这轮为止已经有三笔不同类型、各自表达方式的欠账 ——
鸿蒙静态判据(`RESULT static=5`,有余额、有到期探针)、
`mails.status` 行级派生化(`t.Skip`,**原先无余额**)、
手势语义契约(登记的到期前提,**原先无余额**)。
三笔都"可判",但**没有一处能一眼看全**;而"欠账不显形就等于没有"。
所以:**登记在一处、可打印**。Go 这一侧打印在 TestMain 收尾,
跨端的净值同时登记在 `docs/DEBTS.json`(两端都能读,见该文件)。
*/
type debt struct {
ID string
Count int
Due string // 到期前提(什么时候该还清)
Where string // 判据在哪
}
/*
* ★★ 2026-09-19:原先这里有一份**手写副本**(一个 `debts` map 字面量),
* 已删除。它只被 `debtSummary()` 打印用,而**没有任何判据校验它与权威
* `docs/DEBTS.json` 一致** —— 实测漂移得很厉害:3 条 vs JSON 里的 17 条,
* 而且里面还留着**已销账**的 `gesture-semantics`、`static-criteria` 的余额
* 也还是旧值 7(JSON 已是 5)。
*
* 后果很具体:**`TestMain` 打印的余额是错的**,而余额的全部意义就是"能看全"。
*
* ⇒ 直接删掉副本、让 `debtSummary()` 读权威 JSON(同一个事实不留两份)。
*/
/*
* `debtMark` 由各条欠账的判据在"确认未清"时调用。
*
* ★ 2026-09-19:改为写**内存 overlay**(`measuredDebts`),不再改那份已删除的
* 手写副本。overlay 只影响本次进程内的打印——余额的来源仍只有
* `docs/DEBTS.json` 一处,测量结果叠在它上面。
* (原先它改的是手写 map,而那份 map 与权威 JSON 已经漂移到对不上,
* 于是"标记未清"这件事只改了一份没人看的副本。)
*/
var measuredDebts = map[string]int{}
func debtMark(id string) {
measuredDebts[id] = 1
}
type debtEntry struct {
ID string `json:"id"`
Count int `json:"count"`
Due string `json:"due"`
Where string `json:"where"`
}
type debtLedger struct {
Debts []debtEntry `json:"debts"`
}
// loadDebts 读**那一份**登记(docs/DEBTS.json):两端的余额必须来自同一处。
func loadDebts(t *testing.T) debtLedger {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "DEBTS.json"))
if err != nil {
t.Fatalf("读欠账登记 docs/DEBTS.json 失败:%v(登记丢了 = 欠账不显形)", err)
}
var l debtLedger
if err := json.Unmarshal(b, &l); err != nil {
t.Fatalf("欠账登记不是合法 JSON:%v", err)
}
return l
}
/*
★ 登记与**实测**必须一致(pi 2026-09-14 裁定 §1:Skip 的条件要是测量结果,"跳过"要进余额)。
这条判据挡的是两种死法:
① 条件被写死:余额不是从测量来的,而是某人抄的数字 ⇒ 这里用 debtMark 的实测值比对;
② 跳过不可数:`go test` 对 Skip 是退出码 0、`--- SKIP` 只是一行输出
(而且**跑得通时 go test 根本不打印包的输出** —— 我第一次就把余额打在 TestMain 里,
结果常态运行时一个字都看不见,正是"不显形")。所以可见的那份在
electron 套件的 RESULT 行(它读同一个文件),这里保证两处**同源**。
*/
func TestDebtLedgerMatchesMeasurement(t *testing.T) {
l := loadDebts(t)
seen := map[string]bool{}
for _, d := range l.Debts {
seen[d.ID] = true
if d.Due == "" || d.Where == "" {
t.Fatalf("欠账 %s 必须写明到期前提与判据位置(否则它只是「存在」,不是「欠账」)", d.ID)
}
}
/*
* ★★ 2026-09-19 修:原先这里**硬编码**要求三笔都在
* (`static-criteria` / `mails-status-derived` / `gesture-semantics`)。
* 而 `gesture-semantics` 已于 2026-09-19 **还清并销账**
* (P6 第 3 步的左右滑动翻页落地,语义契约判据 `cross-client-gesture.test.mjs` 建好)
* ⇒ 这条断言与事实矛盾,会把"已还清"变成永久红。
*
* 改成断言**两类必须存在的东西**,而不是点名三笔:
* ① 跨端净值(`static-criteria`:鸿蒙静态判据的余额)—— 它必须一直在,
* 哪怕余额最后降到 0(那条笔本身就是"这类欠账的登记处");
* ② 本包能**实测**的那一笔(`mails-status-derived`)—— 它是这台机器上
* 唯一能"测量"而不是"声明"的欠账。
*
* ⚠️ 教训:硬编码欠账清单**不可维护** —— 每还清一笔就得改一次测试,
* 而漏改的后果是"还清了反而红"。**该断言的是形状,不是点名。**
*/
for _, id := range []string{"static-criteria", "mails-status-derived"} {
if !seen[id] {
t.Fatalf("欠账登记里缺 %s —— 跨端净值与本包可实测的那笔必须同处登记,"+
"否则审计只会找到一处就当全部", id)
}
}
// 本包能实测的那一笔:**自己测**(不依赖别的测试先跑过 —— 排序依赖是隐蔽的假绿)
var want int
for _, d := range l.Debts {
if d.ID == "mails-status-derived" {
want = d.Count
}
}
outstanding, detail, derived := measureMailStatusDebt(t)
got := 0
if outstanding {
got = 1
}
if got != want {
if got == 0 {
t.Fatalf("**欠账已还清**(详情 %q 已等于按读者派生 %q),但 docs/DEBTS.json 还记着 %d —— "+
"还清是可测事件,登记要跟着清(这正是这条判据存在的意义)", detail, derived, want)
}
t.Fatalf("mails-status-derived 的余额:登记说 %d,实测说 %d —— 登记与测量分叉了", want, got)
}
}
// debtSummary 供 TestMain 打印:一处能看全的余额 + 到期前提。
//
// ★ 2026-09-19:改为**直接读权威 `docs/DEBTS.json`**(原先读这里的一份手写副本,
// 那份副本已漂移到与 JSON 完全对不上 —— 详见上面删掉它时留的那段)。
//
// 拿不到 `*testing.T`(TestMain 里调),所以这里不 Fatalf、出错时报一行说明即可:
// 余额打印是**给人看的辅助信息**,它坏掉不该让整个测试套件崩,
// 但也不能静默 —— 与"欠账不显形就等于没有"同一条纪律。
func debtSummary() string {
b, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "DEBTS.json"))
if err != nil {
return fmt.Sprintf(" debts=?(读 docs/DEBTS.json 失败:%v)", err)
}
var l debtLedger
if err := json.Unmarshal(b, &l); err != nil {
return fmt.Sprintf(" debts=?(docs/DEBTS.json 不是合法 JSON:%v)", err)
}
ids := make([]string, 0, len(l.Debts))
total := 0
byID := map[string]debtEntry{}
for _, d := range l.Debts {
byID[d.ID] = d
if d.Count > 0 {
ids = append(ids, d.ID)
total += d.Count
}
}
if len(ids) == 0 {
return " debts=0(全部还清)"
}
sort.Strings(ids)
var sb strings.Builder
fmt.Fprintf(&sb, "\n======= 欠账余额 debts=%d =======", total)
for _, id := range ids {
d := byID[id]
fmt.Fprintf(&sb, "\n · %s ×%d\n 判据:%s\n 到期:%s", d.ID, d.Count, d.Where, d.Due)
}
return sb.String()
}
/*
* ★★ 2026-09-19 新增:**余额打印必须来自权威源**(不许再留手写副本)。
*
* # 为什么需要这一枪
*
* 本文件里有两份欠账数据:
* · `docs/DEBTS.json` —— **权威**(跨端唯一登记,两端都读它);
* · 上面的 `var debts` map —— **手写副本**,只给 `debtSummary()` 打印用。
*
* 而原先**没有任何判据校验后者**(`TestDebtLedgerMatchesMeasurement` 从 JSON 读,
* 从不看 map)。后果实测(这一轮就撞上了):
* · map 里 `static-criteria.Count = 7`,而 JSON 已是 5(admin 升级 + 移出名单);
* · map 里还留着 `gesture-semantics` —— 那一笔**已经还清并销账**了。
* 也就是说 **`TestMain` 打印出来的余额是错的**,而余额的全部意义就是"能看全"。
*
* ⇒ 两处挂钩:map 的 id 集合与 JSON 的 id 集合**必须相同**,
* 且两边都在的 id,`Count` 必须相等。
*
* ★ 为什么不干脆删掉 map、让 `debtSummary()` 直接读 JSON:
* 那当然更好,但 `debtSummary()` 在 `TestMain` 里被调用,而它**拿不到 `*testing.T`**
* (`loadDebts(t)` 需要 t 来 Fatalf)。改动面比"加一条对账判据"大,
* 而在这一轮里优先做**能立刻防住漂移**的那一步。
* (这条本身也记在案:能合并成一处时应当合并,见文件头"登记在一处"的原意。)
*/
func TestDebtSummaryReadsAuthoritativeLedger(t *testing.T) {
/*
* 两件事,缺一不可:
* ① 余额打印**不能**再有手写副本(那是"同一个事实两份实现",
* 实测它漂移到 3 条 vs 17 条,而没人发现);
* ② 它打出来的数字必须与权威 JSON 一致(可执行的对账,不只是"看起来读了")。
*/
src, err := os.ReadFile("debt_registry_test.go")
if err != nil {
t.Fatalf("读不到本文件:%v", err)
}
/*
* ⚠️ 锚点**不能**写成完整的那行声明 —— 它本身出现在上面这条 t.Fatal 的
* 消息串里,于是 `strings.Contains` 会**自匹配**(实测:源码里明明已经没有
* 那份 map 了,这条却恒红)。这是本仓踩过多次的同一个坑
* (`run-all.mjs` 的自检锚点也栽过一次,那里改用 `lastIndexOf`)。
* 这里改成拼出锚点,让它不出现在断言消息里。
*/
handwritten := "var " + "debts" + " = map[string]debt{"
if strings.Contains(string(src), handwritten) {
t.Fatal("★ 不许再引入手写的欠账副本 —— 余额只能来自 docs/DEBTS.json。" +
"(它漂移过:3 条 vs 权威的 17 条,且没人发现,因为打印出来的东西没有判据在管)")
}
if !strings.Contains(string(src), `filepath.Join("..", "..", "..", "docs", "DEBTS.json")`) {
t.Fatal("★ `debtSummary()` 必须直接读 docs/DEBTS.json(唯一权威)")
}
/* ② 实际打出来的数字要与 JSON 对得上 */
l := loadDebts(t)
total := 0
for _, d := range l.Debts {
total += d.Count
}
out := debtSummary()
if !strings.Contains(out, fmt.Sprintf("debts=%d", total)) {
t.Fatalf("★ 余额打印与权威登记不一致:打印里没有 `debts=%d`。\n实际输出:%s", total, out)
}
/* 每一笔余额>0 的都要在打印里出现(漏掉的欠账等于不存在) */
for _, d := range l.Debts {
if d.Count > 0 && !strings.Contains(out, d.ID) {
t.Fatalf("★ 欠账 %s 余额 %d 却没出现在余额打印里 —— 漏掉的欠账等于不存在", d.ID, d.Count)
}
}
}