Files
MailUI4Agents/server/internal/repo/appearance.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

105 lines
4.7 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 (
"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
}