Files
MailUI4Agents/server/internal/handler/appearance_test.go
JianFeeeee 5b6fef764f feat(appearance): 主题与壁纸搬到服务端(账号级)—— 回答"为什么背景存在本地"
用户质问:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写
localStorage:换设备/换浏览器就没了,而且**多账号共用一份**(键是全局常量
`agentmail.background`)—— 同一台机器换账号背景不跟着走。而 localStorage 的 ~5MB
配额也解释了客户端那套"压到 2.4MB 以内"的限制本来就是为本地存储设计的。

现在:**服务端是权威(账号级),本地只是缓存**(首屏秒开、离线可用)。

## 服务端

- 新表 `user_appearance`(两种方言),用**列**而不是 JSON:blob GC 要一眼看出
  "这张图还有没有人用"。
- `/api/v1/me/appearance`:GET / PUT(主题+背景档)/ POST image(multipart)/
  GET image / DELETE image。鉴权同其余 /me/*(cookie 或 Bearer)。
- 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256;上限 4MB 兜底
  (客户端会先压到 ~2.4MB),只收图片类型(非图片 415 —— 浏览器会把非图片渲染成
  空白,用户只会看到"设置了却没变化"),超限 413 不静默截断。
- ★ **blob GC 的引用源加了这张表**:我在实现前先读了 `SweepUnreferencedBlobs`,
  它只认 attachments / calendar_attachments。漏了这一处,壁纸会在下次 GC 时被当
  孤儿删掉,而库里那行还在 —— 表现为"图 404、设置却显示已设置"。判据同时验了
  壁纸存活**与**孤儿确实被清(否则"还在"可能只是因为 GC 没跑)。

## 客户端

- `lib/appearance.ts`(纯函数:两侧形状换算、data URL→Blob)+ `stores/appearanceSync.ts`
  (pull / push / 去抖订阅 / 账号切换重新拉取)。
- 三条不变量都有判据:拉取以服务端为准;★ **拉取不会再推回去**(否则是自触发回环,
  一次拉取顺带一次 PUT,服务端 updated_at 被无意义刷新);本地改动会推上去。
- 壁纸**只在换图时上传一次**(几 MB 不该每次 PUT 都跟着走)。
- 降级**必须可见**:未登录/不可达 → `local-only`,推失败 → `pending`,背景设置里
  有徽标与说明("已同步 / 待同步 / 仅本机")。静默降级会让人以为已经同步,
  然后在另一台机器上发现没有 —— 正是这次的缺陷。
- 图片用**带认证的 fetch** 取回再转 data URL:`<img src>` 发不出 Bearer,而
  `?token=` 会把密钥写进历史记录与服务端日志(明确不做)。

## 判据

- Go 10 条:往返、★多账号隔离、非法值归一、上传/取回字节一致、非图片 415、
  超限 413、删除、未登录 401(五个端点)、★GC 存活 + 孤儿对照。
- 客户端 10 条:形状换算、image 无图退回 none、越界夹取、拉取生效、
  ★拉取不推送、推送 payload、未登录/500 → local-only、推失败 → pending、
  ★壁纸只上传一次。
- 全量:server 10 包全绿、客户端 249 通过(含打包一致性判据 —— 它先红后绿,
  因为前端改了必须重打安装包,这条护栏是先前特意留下的)。

## 线上验证与交付

- jianf 设置 → 回包 saved=true;**gui-lab 读到自己那份默认值**(隔离生效);
  gui-lab 上传 67B PNG → 取回 sha256 一致、`has_image=true`;DELETE 后 404。
- 网关已重打(WebUI 内嵌)并部署;Electron 安装包已重打(AppImage + deb)。

遗留:鸿蒙端还没有外观功能(数据已在服务端,将来可直接读);本地缓存仍在(离线可用)。
2026-09-14 08:32:22 +08:00

361 lines
13 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 handler
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/agentmail/gateway/internal/blob"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
/*
用户外观(主题 + 壁纸)落在服务端 —— 2026-09-14。
用户原话:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写
localStorage换设备就没了而且**多账号共用一份**(键是全局常量)。这组判据钉住
四件事:
1. 存下来了、读得回来(账号级);
2. ★ 两个账号互不干扰(就是上面那条缺陷);
3. 图片走 blob**换设备能拿到**GET image 真返回字节);
4. ★ blob GC 不会把壁纸当孤儿删掉 —— 我在实现前先查了 GC它只认
attachments / calendar_attachments漏了那张表就会表现为"图 404、设置却显示已设置"。
*/
// setupAppearanceDB 起一个带真实用户与 blob 目录的测试库。
func setupAppearanceDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
// 两个用户:多账号隔离是这组判据的重点
for _, u := range []string{"alice", "bob"} {
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', 'user')`, u); err != nil {
t.Fatalf("建用户 %s: %v", u, err)
}
}
// 真实 blob 目录(壁纸上传统统落到这里)
Blobs = mustBlobStore(t, filepath.Join(dir, "blobs"))
t.Cleanup(func() { db.Close() })
}
func mustBlobStore(t *testing.T, root string) *blob.Store {
t.Helper()
st, err := blob.New(root)
if err != nil {
t.Fatalf("blob store: %v", err)
}
return st
}
func asUser(r *http.Request, username string) *http.Request {
ctx := context.WithValue(r.Context(), middleware.UserKey, &models.User{Username: username})
return r.WithContext(ctx)
}
func putAppearance(t *testing.T, username string, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/v1/me/appearance", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
PutAppearance(resp, asUser(req, username))
return resp
}
func getAppearance(t *testing.T, username string) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance", nil)
resp := httptest.NewRecorder()
GetAppearance(resp, asUser(req, username))
if resp.Code != http.StatusOK {
t.Fatalf("GET appearance = %d%s", resp.Code, resp.Body.String())
}
var out map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
/** 一张最小的合法 PNG1×1 透明)。 */
func pngBytes() []byte {
return []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
}
func uploadAppearanceImage(t *testing.T, username string, content []byte, filename, ctype string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename)},
"Content-Type": {ctype},
})
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(content); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/me/appearance/image", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp := httptest.NewRecorder()
UploadAppearanceImage(resp, asUser(req, username))
return resp
}
func TestAppearanceRoundTrip(t *testing.T) {
setupAppearanceDB(t)
// 没设置过:默认值 + saved=false"从没设置过"不是错误,不该 404
fresh := getAppearance(t, "alice")
if fresh["saved"] != false {
t.Fatalf("没设置过时 saved 应为 false实际 %v", fresh["saved"])
}
if fresh["bg_kind"] != "none" {
t.Fatalf("默认背景档应为 none实际 %v", fresh["bg_kind"])
}
resp := putAppearance(t, "alice", map[string]any{
"theme": "dark", "bg_kind": "preset", "bg_preset_id": "aurora", "bg_dim": 30, "bg_blur": 6,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT = %d%s", resp.Code, resp.Body.String())
}
got := getAppearance(t, "alice")
if got["theme"] != "dark" || got["bg_kind"] != "preset" || got["bg_dim"].(float64) != 30 {
t.Fatalf("读回的值不对:%v", got)
}
}
// ★ 多账号各一份 —— 这是"放本地"时最直接的缺陷(同一台机器换账号背景不跟着走)。
func TestAppearanceIsPerAccount(t *testing.T) {
setupAppearanceDB(t)
putAppearance(t, "alice", map[string]any{"theme": "dark", "bg_kind": "preset", "bg_preset_id": "ocean"})
putAppearance(t, "bob", map[string]any{"theme": "light", "bg_kind": "none"})
a, b := getAppearance(t, "alice"), getAppearance(t, "bob")
if a["bg_preset_id"] != "ocean" || a["theme"] != "dark" {
t.Fatalf("alice 的外观被串了:%v", a)
}
if b["theme"] != "light" {
t.Fatalf("★ bob 的外观受 alice 影响:%v", b)
}
}
// 越界/非法值必须被夹住:这些值最终会写进 CSS 变量,越界会让界面不可读。
func TestAppearanceNormalizesBadInput(t *testing.T) {
setupAppearanceDB(t)
putAppearance(t, "alice", map[string]any{
"theme": "neon", "bg_kind": "video", "bg_dim": 999, "bg_blur": -5,
})
got := getAppearance(t, "alice")
if got["theme"] != "system" || got["bg_kind"] != "none" {
t.Fatalf("认不出的 theme/kind 应退回默认,实际 %v / %v", got["theme"], got["bg_kind"])
}
if got["bg_dim"].(float64) != 90 || got["bg_blur"].(float64) != 0 {
t.Fatalf("dim/blur 应被夹进范围,实际 %v / %v", got["bg_dim"], got["bg_blur"])
}
}
// 壁纸本体:上传后**换设备也能拿到同样的字节**(这就是"放服务器"的意义)。
func TestAppearanceImageUploadAndFetch(t *testing.T) {
setupAppearanceDB(t)
data := pngBytes()
resp := uploadAppearanceImage(t, "alice", data, "wall.png", "image/png")
if resp.Code != http.StatusOK {
t.Fatalf("上传 = %d%s", resp.Code, resp.Body.String())
}
got := getAppearance(t, "alice")
if got["has_image"] != true {
t.Fatalf("上传后 has_image 应为 true%v", got)
}
if got["image_url"] != "/api/v1/me/appearance/image" {
t.Fatalf("应给出 image_url实际 %v", got["image_url"])
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil)
rec := httptest.NewRecorder()
GetAppearanceImage(rec, asUser(req, "alice"))
if rec.Code != http.StatusOK {
t.Fatalf("取图 = %d", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), data) {
t.Fatalf("取回的字节与上传的不一致(%d vs %d 字节)", rec.Body.Len(), len(data))
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q", ct)
}
}
// 非图片要当场拒(浏览器会把非图片渲染成空白,用户只会看到"设置了却没变化")。
func TestAppearanceImageRejectsNonImage(t *testing.T) {
setupAppearanceDB(t)
resp := uploadAppearanceImage(t, "alice", []byte("#!/bin/sh\necho hi\n"), "evil.sh", "text/x-shellscript")
if resp.Code != http.StatusUnsupportedMediaType {
t.Fatalf("非图片应为 415实际 %d%s", resp.Code, resp.Body.String())
}
if getAppearance(t, "alice")["has_image"] != false {
t.Fatal("被拒的上传不该留下记录")
}
}
// 超过上限:明确 413不静默截断截断的图会以损坏文件的形式存下来
func TestAppearanceImageSizeLimit(t *testing.T) {
setupAppearanceDB(t)
// config.C 由 main 在启动时装载,单测里可能是 nil —— 先保证有一个可改的实例,
// 跑完再复原(改全局是有代价的,所以只在这条判据里、且立刻还原)。
if config.C == nil {
config.C = &config.Config{}
defer func() { config.C = nil }()
}
saved := config.C.MaxAppearanceBytes
config.C.MaxAppearanceBytes = 1024
defer func() { config.C.MaxAppearanceBytes = saved }()
big := append(pngBytes(), bytes.Repeat([]byte{0}, 4096)...)
resp := uploadAppearanceImage(t, "alice", big, "big.png", "image/png")
if resp.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("超限应为 413实际 %d%s", resp.Code, resp.Body.String())
}
}
// 删除壁纸:记录清空,且读图变 404。
func TestAppearanceImageDelete(t *testing.T) {
setupAppearanceDB(t)
if resp := uploadAppearanceImage(t, "alice", pngBytes(), "w.png", "image/png"); resp.Code != http.StatusOK {
t.Fatalf("上传 = %d", resp.Code)
}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/me/appearance/image", nil)
rec := httptest.NewRecorder()
DeleteAppearanceImage(rec, asUser(req, "alice"))
if rec.Code != http.StatusOK {
t.Fatalf("删除 = %d", rec.Code)
}
if getAppearance(t, "alice")["has_image"] != false {
t.Fatal("删除后 has_image 应为 false")
}
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil)
rec2 := httptest.NewRecorder()
GetAppearanceImage(rec2, asUser(req2, "alice"))
if rec2.Code != http.StatusNotFound {
t.Fatalf("删除后取图应为 404实际 %d", rec2.Code)
}
}
// 未登录一律 401壁纸是个人内容不能匿名读
func TestAppearanceRequiresAuth(t *testing.T) {
setupAppearanceDB(t)
for name, fn := range map[string]func(http.ResponseWriter, *http.Request){
"GET": GetAppearance, "PUT": PutAppearance,
"POST image": UploadAppearanceImage, "GET image": GetAppearanceImage,
"DELETE image": DeleteAppearanceImage,
} {
method := http.MethodGet
if name == "PUT" {
method = http.MethodPut
} else if name == "POST image" {
method = http.MethodPost
} else if name == "DELETE image" {
method = http.MethodDelete
}
rec := httptest.NewRecorder()
fn(rec, httptest.NewRequest(method, "/api/v1/me/appearance", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s 未登录应为 401实际 %d", name, rec.Code)
}
}
}
// ★ 壁纸不能被 blob GC 当成孤儿删掉。
//
// 实现前特意先读了 SweepUnreferencedBlobs它只认 attachments / calendar_attachments
// 两张引用表。漏掉 user_appearance 的话,壁纸文件会在下一次 GC 时消失,而库里那行
// 还在 —— 表现为"图 404、设置却显示已设置"。
func TestAppearanceImageSurvivesBlobGC(t *testing.T) {
setupAppearanceDB(t)
data := pngBytes()
if resp := uploadAppearanceImage(t, "alice", data, "wall.png", "image/png"); resp.Code != http.StatusOK {
t.Fatalf("上传 = %d", resp.Code)
}
sum := sha256.Sum256(data)
sha := hex.EncodeToString(sum[:])
if !Blobs.Exists(sha) {
t.Fatal("上传后 blob 应当存在")
}
// minAge=0让 GC 立刻把"无人引用"的文件当孤儿(正是要验的窗口)
if _, err := repo.SweepUnreferencedBlobs(context.Background(), Blobs, 0); err != nil {
t.Fatalf("GC: %v", err)
}
if !Blobs.Exists(sha) {
t.Fatal("★ 壁纸被 GC 删掉了 —— SweepUnreferencedBlobs 的引用源少了 user_appearance")
}
// 反向对照:一个谁都没引用的文件必须被清掉,否则说明 GC 其实没在干活
// (这条判据就没有分辨率 —— "壁纸还在"可能只是因为 GC 根本没跑)。
// 孤儿用 store 自己的 Put 造:手拼路径不合 blob 的布局,会得到一个假失败。
orphanSum, _, err := Blobs.Put(bytes.NewReader([]byte("nobody references me")), 1024)
if err != nil {
t.Fatal(err)
}
if !Blobs.Exists(orphanSum) {
t.Fatal("孤儿文件应当先被写进去")
}
if _, err := repo.SweepUnreferencedBlobs(context.Background(), Blobs, 0); err != nil {
t.Fatalf("GC(2): %v", err)
}
if Blobs.Exists(orphanSum) {
t.Fatal("孤儿文件应当被清掉否则这条判据无法区分「被保护」与「GC 没跑」)")
}
if !Blobs.Exists(sha) {
t.Fatal("第二轮 GC 之后壁纸仍必须存在")
}
}
// 读图端点必须原样吐字节(不能只回长度就完事)。
func TestAppearanceImageServesBytes(t *testing.T) {
setupAppearanceDB(t)
data := pngBytes()
uploadAppearanceImage(t, "alice", data, "w.png", "image/png")
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil)
rec := httptest.NewRecorder()
GetAppearanceImage(rec, asUser(req, "alice"))
body, _ := io.ReadAll(rec.Body)
if !bytes.Equal(body, data) {
t.Fatalf("取回 %d 字节,期望 %d", len(body), len(data))
}
}