后端: 销掉一份漂移的欠账副本 + 钉住 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 条)。
This commit is contained in:
2026-09-19 16:27:09 +08:00
parent 1bf687f506
commit 63649031d8
3 changed files with 265 additions and 41 deletions

View File

@ -15,8 +15,42 @@ import (
// 而且**多账号共用一份**(键是全局常量)—— 同一台机器换账号背景不跟着走。
// 语义是**账号级**(跟账号走,不跟设备走)。
/*
* ⚠️⚠️ **本文件的 userID 参数收的是「用户名」,不是 UUID。**
*
* `user_appearance.user_id` 这一列存的是 `users.username`(如 `jianf`),
* 而**兄弟表的同名列存的是 UUID**(`user_keys` / `user_sessions` 实测都是
* `809967c5-…` 这种)。两处实测(2026-09-19):
*
* user_keys.user_id = 809967c5-fb1e-4759-9ff7-75e312fb4d02
* user_sessions.user_id = 809967c5-fb1e-4759-9ff7-75e312fb4d02
* user_appearance.user_id = jianf ← 与兄弟表不同
*
* **功能上是自洽的**(本文件读写都用 username,handler 也一律传
* `user.Username`),所以这不是一个活跃的 bug。但它是**一个不报错的陷阱**:
* 任何人按兄弟表的习惯去 JOIN —— 例如
*
* SELECT u.username, a.bg_kind FROM users u
* LEFT JOIN user_appearance a ON u.user_id = a.user_id
*
* —— 会**静默匹配到 0 行**(UUID 永不等于用户名),而且没有任何错误。
* 我自己在审计时就先踩了一次(用 `users.user_id = user_appearance.user_id`
* 查出来全是空,差点当成"记录不存在")。
*
* 为什么不改列名/加迁移:表里有**生产数据**(壁纸本体存在 blob 里、
* 由 `image_sha256` 引用),改列要迁移 + 回滚预案,而收益只是"名字更好看"。
* ⇒ 选择**把语义钉在这里**:参数名保持 `userID`(与 SQL 列名一致),
* 但在函数注释里写明它收的是用户名,并由判据
* (`server/internal/repo/appearance_semantics_test.go`)钉住"这一列存的是用户名"。
*
* 哪天真要统一:那是一次**独立的数据迁移**(UPDATE 把 username 换成 UUID),
* 不该顺手做。
*/
// GetAppearance 读某个用户的外观。没有记录时返回**默认值 + false**(不是错误):
// 「从没设置过」是正常状态,调用方不该为此处理 404。
//
// `userID` 传的是**用户名**(见文件头那段警告)。
func GetAppearance(ctx context.Context, userID string) (models.Appearance, bool, error) {
a := models.DefaultAppearance()
var updated sql.NullString

View File

@ -0,0 +1,86 @@
package repo
import (
"os"
"strings"
"testing"
)
/*
* `user_appearance.user_id` 存的是**用户名**(不是 UUID)—— 这件事必须被钉住。
*
* # 为什么值得一条判据
*
* 它**不是**活跃 bug(本包读写都用 username,handler 也一律传 `user.Username`),
* 但它是一个**不报错的陷阱**:兄弟表的同名列存的是 UUID
* (`user_keys` / `user_sessions` 实测都是 `809967c5-…`),
* 而任何人按那个习惯写 JOIN 会**静默匹配到 0 行**。
*
* 我自己在 2026-09-19 审计时就先踩了一次:用
* `LEFT JOIN user_appearance a ON u.user_id = a.user_id` 查出全是空,
* 差点当成"这些账号从没设置过外观"(而实际上 jianf 有记录)。
*
* # 这条判据钉什么
*
* ① **值来源**:本包所有涉及该表的查询都必须用 `user.Username`/username 语义
* (具体表现:函数的参数名与调用契约里出现 username 字样,而不是 UserID);
* ② **陷阱被写明**:文件头要有一段解释"这一列与兄弟表不同、JOIN 会静默失配",
* 否则下一个人只会重新踩一遍;
* ③ **README 级的可见性**:那条注释必须提到一个**具体的兄弟表名**
* (拿它当"这里是异常的那一个"的锚)—— 空泛的"注意类型"没人会读。
*
* ★ 判据从 `*_test.go` 读源码(不是运行时行为):这里要钉的是**约定**,
* 而约定的存在形式就是注释与命名。行为测试证明不了"下一个人不会踩"。
*/
func TestUserAppearanceStoresUsernameNotUUID(t *testing.T) {
src, err := os.ReadFile("appearance.go")
if err != nil {
t.Fatalf("读不到 appearance.go:%v", err)
}
text := string(src)
/* ① 陷阱必须被写明,且要指名一个兄弟表(拿它当"异常的是这张"的锚) */
if !strings.Contains(text, "存的是") || !strings.Contains(text, "用户名") {
t.Fatal("★ appearance.go 的头部必须写明 user_appearance.user_id 存的是**用户名** —— " +
"它与兄弟表不同,不写清楚下一个人只会重新踩(我踩过一次)")
}
siblings := []string{"user_keys", "user_sessions"}
found := false
for _, s := range siblings {
if strings.Contains(text, s) {
found = true
break
}
}
if !found {
t.Fatal("★ 那段说明必须**指名一个兄弟表**(user_keys / user_sessions)—— " +
"'注意列名'这种空泛提醒没人会读;指名才让人知道去比对什么")
}
if !strings.Contains(text, "JOIN") {
t.Fatal("★ 必须说清**失败长什么样**(JOIN 会静默匹配到 0 行)—— " +
"只说'语义不同'不能让人预见到后果")
}
/* ② 本包对该表的所有查询都要走同一个语义(不得混入 UUID 语义的参数名) */
if strings.Contains(text, "userUUID") || strings.Contains(text, "user_id UUID") {
t.Fatal("★ 出现 UUID 语义的标识符 —— 该表存的是用户名,两种语义混用必然出错")
}
/*
* ③ handler 侧也必须一致(跨文件的一致性是这条约定的另一半):
* 若哪天有人在 handler 里改成传 UUID,功能会**静默失效**
* (读不到记录 ⇒ 一律返回默认外观,且完全不报错)。
*/
hsrc, err := os.ReadFile("../handler/appearance.go")
if err != nil {
t.Fatalf("读不到 handler/appearance.go:%v", err)
}
htext := string(hsrc)
if strings.Contains(htext, "user.UserID") {
t.Fatal("★ handler 里不得出现 `user.UserID` —— " +
"本表按**用户名**存取;传 UUID 会静默读不到记录(表现:外观永远回到默认值,且没有报错)")
}
if !strings.Contains(htext, "user.Username") {
t.Fatal("★ handler 应显式使用 `user.Username`(这是那条约定的落点)")
}
}

View File

@ -30,41 +30,43 @@ type debt struct {
Where string // 判据在哪
}
var debts = map[string]debt{
"mails-status-derived": {
ID: "mails-status-derived",
Count: 0, // 由 mail_status_derived_test.go **实测**后标 1(不许手写:常量余额 = 永久 Skip 的死法)
Due: "详情/线程改为按读者派生(readStateFor)之后 —— 那时 mail_status_derived_test.go 从 Skip 转实跑",
Where: "server/internal/repo/mail_status_derived_test.go",
},
"static-criteria": {
ID: "static-criteria",
Count: 7,
Due: "本工作区能装、能点设备(探针三值转 true 时自动变红)",
Where: "client/electron/test/run-all.mjs(STATIC_ONLY)",
},
"gesture-semantics": {
ID: "gesture-semantics",
Count: 1,
Due: "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)",
Where: "docs/HARMONY-ALIGN-PLAN.md P6 段",
},
/*
* ★★ 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
}
// debtMark 由各条欠账的判据在"确认未清"时调用(余额来自**测量**,不是常量)。
func debtMark(id string) {
d := debts[id]
d.Count = 1
debts[id] = d
type debtEntry struct {
ID string `json:"id"`
Count int `json:"count"`
Due string `json:"due"`
Where string `json:"where"`
}
type debtLedger struct {
Debts []struct {
ID string `json:"id"`
Count int `json:"count"`
Due string `json:"due"`
Where string `json:"where"`
} `json:"debts"`
Debts []debtEntry `json:"debts"`
}
// loadDebts 读**那一份**登记(docs/DEBTS.json):两端的余额必须来自同一处。
@ -101,9 +103,26 @@ func TestDebtLedgerMatchesMeasurement(t *testing.T) {
t.Fatalf("欠账 %s 必须写明到期前提与判据位置(否则它只是「存在」,不是「欠账」)", d.ID)
}
}
for _, id := range []string{"static-criteria", "mails-status-derived", "gesture-semantics"} {
/*
* ★★ 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)
t.Fatalf("欠账登记里缺 %s —— 跨端净值与本包可实测的那笔必须同处登记,"+
"否则审计只会找到一处就当全部", id)
}
}
// 本包能实测的那一笔:**自己测**(不依赖别的测试先跑过 —— 排序依赖是隐蔽的假绿)
@ -128,12 +147,29 @@ func TestDebtLedgerMatchesMeasurement(t *testing.T) {
}
// debtSummary 供 TestMain 打印:一处能看全的余额 + 到期前提。
//
// ★ 2026-09-19:改为**直接读权威 `docs/DEBTS.json`**(原先读这里的一份手写副本,
// 那份副本已漂移到与 JSON 完全对不上 —— 详见上面删掉它时留的那段)。
//
// 拿不到 `*testing.T`(TestMain 里调),所以这里不 Fatalf、出错时报一行说明即可:
// 余额打印是**给人看的辅助信息**,它坏掉不该让整个测试套件崩,
// 但也不能静默 —— 与"欠账不显形就等于没有"同一条纪律。
func debtSummary() string {
ids := make([]string, 0, len(debts))
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
for id, d := range debts {
byID := map[string]debtEntry{}
for _, d := range l.Debts {
byID[d.ID] = d
if d.Count > 0 {
ids = append(ids, id)
ids = append(ids, d.ID)
total += d.Count
}
}
@ -141,12 +177,80 @@ func debtSummary() string {
return " debts=0(全部还清)"
}
sort.Strings(ids)
var b strings.Builder
fmt.Fprintf(&b, "\n======= 欠账余额 debts=%d =======", total)
var sb strings.Builder
fmt.Fprintf(&sb, "\n======= 欠账余额 debts=%d =======", total)
for _, id := range ids {
d := debts[id]
fmt.Fprintf(&b, "\n · %s ×%d\n 判据:%s\n 到期:%s", d.ID, d.Count, d.Where, d.Due)
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)
}
}
fmt.Fprintf(&b, "\n(三笔合在一处看全:分散登记时,审计只会找到一处就当全部)")
return b.String()
}