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 }