From 63649031d822d7970edfba8022a6faf2b2431ddb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 16:27:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF:=20=E9=94=80=E6=8E=89?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=E6=BC=82=E7=A7=BB=E7=9A=84=E6=AC=A0=E8=B4=A6?= =?UTF-8?q?=E5=89=AF=E6=9C=AC=20+=20=E9=92=89=E4=BD=8F=20`user=5Fappearanc?= =?UTF-8?q?e.user=5Fid`=20=E7=9A=84=E8=AF=AD=E4=B9=89=E9=99=B7=E9=98=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、`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 条)。 --- server/internal/repo/appearance.go | 34 ++++ .../repo/appearance_semantics_test.go | 86 ++++++++ server/internal/repo/debt_registry_test.go | 186 ++++++++++++++---- 3 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 server/internal/repo/appearance_semantics_test.go diff --git a/server/internal/repo/appearance.go b/server/internal/repo/appearance.go index e2763fd..b1f19fa 100644 --- a/server/internal/repo/appearance.go +++ b/server/internal/repo/appearance.go @@ -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 diff --git a/server/internal/repo/appearance_semantics_test.go b/server/internal/repo/appearance_semantics_test.go new file mode 100644 index 0000000..d53abf3 --- /dev/null +++ b/server/internal/repo/appearance_semantics_test.go @@ -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`(这是那条约定的落点)") + } +} diff --git a/server/internal/repo/debt_registry_test.go b/server/internal/repo/debt_registry_test.go index 1567ca7..af19859 100644 --- a/server/internal/repo/debt_registry_test.go +++ b/server/internal/repo/debt_registry_test.go @@ -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() }