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

224 lines
7.5 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 (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/agentmail/gateway/internal/blob"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
/*
用户外观(主题 + 壁纸)—— /api/v1/me/appearance
# 为什么要有这套端点
2026-09-13 用户的质问:「为什么背景是保存在本地而不是服务器!」
当时的实情:主题与壁纸只写浏览器 localStorage于是换设备/换浏览器就没了,
更糟的是**多账号共用一份**(存储键是全局常量)—— 同一台机器换账号,背景不跟着走。
而 localStorage 只有 ~5MB 配额,客户端不得不把手机照片压到 2.4MB 以内(那套限制
本身就是"为本地存储而设计"的痕迹)。
放服务端之后:账号级、跟设备无关、多账号各自一份;客户端保留本地缓存用于秒开与离线。
# 图片为什么不塞进 JSON
图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256 —— 这样
`repo.SweepUnreferencedBlobs` 能一眼看出这张图还有没有人用(它读的就是这张表)。
塞进 JSON 的话 GC 就得在 SQL 里解析 JSON而两种方言写法还不一样。
# 鉴权
与其余 /me/* 一样要求登录cookie 或 Bearer。图片 GET 也要求 —— 客户端用
带认证的 fetch 取回来再转 object URL**不接受 `?token=`**(那会进日志与历史记录)。
*/
// GET /api/v1/me/appearance
func GetAppearance(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
a, exists, err := repo.GetAppearance(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load appearance")
return
}
JSON(w, http.StatusOK, appearanceResponse(a, exists))
}
func appearanceResponse(a models.Appearance, exists bool) map[string]any {
resp := map[string]any{
"theme": a.Theme,
"bg_kind": a.BgKind,
"bg_preset_id": a.BgPresetID,
"bg_dim": a.BgDim,
"bg_blur": a.BgBlur,
"has_image": a.ImageSHA256 != "",
"image_bytes": a.ImageBytes,
"saved": exists,
}
if a.UpdatedAt != "" {
resp["updated_at"] = a.UpdatedAt
}
if a.ImageSHA256 != "" {
// 给 URL 而不是把图塞进 JSON壁纸最大几 MB塞进去每次读设置都要传一遍。
resp["image_url"] = "/api/v1/me/appearance/image"
}
return resp
}
// PUT /api/v1/me/appearance —— 主题与背景档(不含图片)
func PutAppearance(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req models.Appearance
if !DecodeBody(w, r, &req) {
return
}
a := models.NormalizeAppearance(req)
if err := repo.UpsertAppearance(r.Context(), user.Username, a); err != nil {
Error(w, http.StatusInternalServerError, "Failed to save appearance")
return
}
saved, _, err := repo.GetAppearance(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to reload appearance")
return
}
JSON(w, http.StatusOK, appearanceResponse(saved, true))
}
// POST /api/v1/me/appearance/image —— 上传壁纸multipart字段名 file
func UploadAppearanceImage(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
max := appearanceMaxBytes()
// 双层限制:外层卡整个请求体(含 multipart 边界blob.Put 卡文件内容本身。
// 少了外层,超大 multipart 头就能把内存拖满(与附件上传同一套做法)。
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
return
}
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusBadRequest, "缺少 file 字段")
return
}
defer file.Close()
name := sanitizeFilename(header.Filename)
ctype := detectContentType(header.Header.Get("Content-Type"), name)
// 只收图片:这个端点不是通用文件柜,而浏览器会把非图片当壁纸渲染成空白,
// 用户只会看到"设置了却什么也没变"。
if !isImageContentType(ctype) {
Error(w, http.StatusUnsupportedMediaType,
"壁纸必须是图片image/png、image/jpeg、image/webp、image/gif")
return
}
// 先落盘再入库(顺序不能反,否则会出现"库里有记录、磁盘没文件"的 404
sum, size, err := Blobs.Put(file, max)
if errors.Is(err, blob.ErrTooLarge) {
Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("壁纸超过上限 %.1f MB客户端会先压缩这个上限是兜底", float64(max)/(1<<20)))
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "保存壁纸失败")
return
}
if err := repo.SetAppearanceImage(r.Context(), user.Username, sum, ctype, size); err != nil {
Error(w, http.StatusInternalServerError, "登记壁纸失败")
return
}
a, _, err := repo.GetAppearance(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to reload appearance")
return
}
JSON(w, http.StatusOK, appearanceResponse(a, true))
}
// GET /api/v1/me/appearance/image —— 取壁纸本体
func GetAppearanceImage(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
a, _, err := repo.GetAppearance(r.Context(), user.Username)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to load appearance")
return
}
if a.ImageSHA256 == "" {
Error(w, http.StatusNotFound, "尚未设置壁纸")
return
}
f, err := Blobs.Open(a.ImageSHA256)
if err != nil {
// 库里说有条目、磁盘上却没有这不该发生GC 会把这张表当引用源)。
// 明确报错而不是回空图,否则客户端只会显示"设置了但没效果"。
Error(w, http.StatusNotFound, "壁纸文件缺失")
return
}
defer f.Close()
w.Header().Set("Content-Type", a.ImageType)
w.Header().Set("Content-Length", strconv.FormatInt(a.ImageBytes, 10))
// 内容寻址 ⇒ 同一 sha256 的内容永不改变,可以长缓存;换图会换 URL 语义
// (客户端拿到的 image_url 不变,所以这里只做短缓存,避免缓存穿透到"旧图")。
w.Header().Set("Cache-Control", "private, max-age=60")
_, _ = f.WriteTo(w)
}
// DELETE /api/v1/me/appearance/image
func DeleteAppearanceImage(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := repo.ClearAppearanceImage(r.Context(), user.Username); err != nil {
Error(w, http.StatusInternalServerError, "Failed to clear image")
return
}
// blob 文件不在这里删:内容寻址可能被别的记录引用,交给 SweepUnreferencedBlobs。
JSON(w, http.StatusOK, map[string]any{"has_image": false})
}
func appearanceMaxBytes() int64 {
if config.C != nil && config.C.MaxAppearanceBytes > 0 {
return config.C.MaxAppearanceBytes
}
return 4 << 20
}
func isImageContentType(ct string) bool {
switch ct {
case "image/png", "image/jpeg", "image/webp", "image/gif":
return true
}
return false
}