## 一、`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 条)。
105 lines
4.7 KiB
Go
105 lines
4.7 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
)
|
||
|
||
// 用户外观(主题 + 壁纸)的读写。
|
||
//
|
||
// 为什么放服务端:原先主题与壁纸只存在浏览器 localStorage 里,换设备/换浏览器就没了,
|
||
// 而且**多账号共用一份**(键是全局常量)—— 同一台机器换账号背景不跟着走。
|
||
// 语义是**账号级**(跟账号走,不跟设备走)。
|
||
|
||
/*
|
||
* ⚠️⚠️ **本文件的 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
|
||
err := db.DB.QueryRowContext(ctx, `
|
||
SELECT theme, bg_kind, bg_preset_id, bg_dim, bg_blur,
|
||
image_sha256, image_type, image_bytes, CAST(updated_at AS TEXT)
|
||
FROM user_appearance WHERE user_id = $1`, userID).
|
||
Scan(&a.Theme, &a.BgKind, &a.BgPresetID, &a.BgDim, &a.BgBlur,
|
||
&a.ImageSHA256, &a.ImageType, &a.ImageBytes, &updated)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return models.DefaultAppearance(), false, nil
|
||
}
|
||
if err != nil {
|
||
return models.Appearance{}, false, err
|
||
}
|
||
a.UpdatedAt = updated.String
|
||
return a, true, nil
|
||
}
|
||
|
||
// UpsertAppearance 写入主题与背景档(不含图片本身,图片见 SetAppearanceImage)。
|
||
func UpsertAppearance(ctx context.Context, userID string, a models.Appearance) error {
|
||
_, err := db.DB.ExecContext(ctx, `
|
||
INSERT INTO user_appearance (user_id, theme, bg_kind, bg_preset_id, bg_dim, bg_blur, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||
ON CONFLICT (user_id) DO UPDATE SET
|
||
theme = $2, bg_kind = $3, bg_preset_id = $4, bg_dim = $5, bg_blur = $6, updated_at = NOW()`,
|
||
userID, a.Theme, a.BgKind, a.BgPresetID, a.BgDim, a.BgBlur)
|
||
return err
|
||
}
|
||
|
||
// SetAppearanceImage 记下这张壁纸(文件已落 blob 存储)。
|
||
func SetAppearanceImage(ctx context.Context, userID, sha256, contentType string, sizeBytes int64) error {
|
||
_, err := db.DB.ExecContext(ctx, `
|
||
INSERT INTO user_appearance (user_id, image_sha256, image_type, image_bytes, updated_at)
|
||
VALUES ($1, $2, $3, $4, NOW())
|
||
ON CONFLICT (user_id) DO UPDATE SET
|
||
image_sha256 = $2, image_type = $3, image_bytes = $4, updated_at = NOW()`,
|
||
userID, sha256, contentType, sizeBytes)
|
||
return err
|
||
}
|
||
|
||
// ClearAppearanceImage 清掉壁纸记录。
|
||
//
|
||
// **不删 blob 文件**:内容寻址意味着同一张图可能被别的记录引用,而且删除是不可逆的
|
||
// —— 交给 SweepUnreferencedBlobs 在确认无人引用后再收(它会读这张表)。
|
||
func ClearAppearanceImage(ctx context.Context, userID string) error {
|
||
_, err := db.DB.ExecContext(ctx, `
|
||
UPDATE user_appearance SET image_sha256 = '', image_type = '', image_bytes = 0, updated_at = NOW()
|
||
WHERE user_id = $1`, userID)
|
||
return err
|
||
}
|