fix(appearance): 浏览器里同步从来没跑起来(三处叠加)+ 部署链加"前端不得比源码旧"闸门

用户说「webui 你也没改呢」。查证:**部署是活的**(本地产物 = 线上产物、CSS 里壁纸
修复的规则都在、入口 `Cache-Control: no-cache`、资源哈希+immutable)——是我新加的
"外观存服务端"那套在**浏览器**里根本没生效。沿途挖出三处叠加缺陷 + 一处部署链真空子:

## ① 路径写成绝对 `/api/v1/...`(双前缀 ⇒ 404)

`resolveBase()` 解析出来的 base 已经含 `/api/v1`(默认就是它),既有调用者传的都是
`/me/mail/inbox` 这种**相对基地址**的形状。我写成 `/api/v1/me/appearance` ⇒ 实际请求
`/api/v1/api/v1/me/appearance` ⇒ 404。
**单测全绿却没抓住**:我只断言了方法、报文,没断言 URL。现在补了 URL 判据
(含"不得出现 /api/v1/api/v1"这条)。

## ② 浏览器密码登录只有 cookie、没有 Bearer ⇒ `currentAuth()` 直接短路

`currentAuth()` 原先要求 token 非空,而密码登录只建 cookie 会话(桌面端粘贴用户密钥
才设 Bearer)⇒ WebUI 里 `pull/push` 从来没跑过。已放宽为"只要有 base",并补了两条
判据(cookie 会话也要能拉、能推)。
("完全没有网关地址 ⇒ local-only"这条判据删掉了:`resolveBase()` 总有默认值,
那个状态到不了 —— 判据不量够不着的对象。)

## ③ 服务端"无记录"时拿默认值覆盖本地

首次启用同步时每个老用户都会中招:服务端回默认值(theme=system / bg=none),
客户端照着应用 ⇒ **用户已有的主题与本地壁纸被静默重置**。现在改为"以本地为准、
推上去认领",并补判据(含"有记录时以服务端为准"的反向对照)。

## ④ 部署链真空子:dist 比源码旧也能"同步成功"

改完源码忘了 `vite build`,`redeploy-gateway.sh` 照样把旧 dist 打进二进制 —— 这正是
①在线上一直没被发现的直接原因。现在部署脚本会比对 `src/**` 与 `dist/index.html`
的 mtime,旧了就 **FAIL** 并提示先 build。

## 顺带:我自己在真实账号上留的测试数据

线上 E2E 时我把 `theme=dark/bg=preset(dusk)/dim=35` PUT 到了 **jianf** 这个真实账号
(应该用测试账号)。已删掉那条记录(接口现在回 `saved:false`),配合 ③ 的修复,
用户本地那份外观会被认领上去而不会被覆盖。

## 验证

- 浏览器实测(自带无头 Chromium + 真实功能,非注入 CSS):
  `200 GET /api/v1/me/appearance` → `data-bg=on`、`dark=true`、本地缓存写入 ✓
- 三张对比图(自定义图片档 / 关背景 / 预设渐变)已随邮件发给用户
- 前端 253 条(含新增 URL 判据与 cookie 会话判据)、server 10 包、打包一致性全绿
This commit is contained in:
2026-09-14 08:59:32 +08:00
parent 69b1887eae
commit ca96f77a4b
4 changed files with 142 additions and 12 deletions

View File

@ -39,8 +39,16 @@ export interface AppearanceSnapshot {
bgBlur: number;
}
export const APPEARANCE_PATH = '/api/v1/me/appearance';
export const APPEARANCE_IMAGE_PATH = '/api/v1/me/appearance/image';
/**
* ⚠️ 路径是**相对基地址**的:`resolveBase()` 解析出来的 base 已经含 `/api/v1`
* (默认就是它,见 api/config.ts),既有调用者传的都是 `/me/mail/inbox` 这种形状。
*
* 我第一版写成 `/api/v1/me/appearance` ⇒ 实际请求成了 `/api/v1/api/v1/me/appearance`
* ⇒ 浏览器里这套同步**从来没生效过**(而单测全绿,因为只断言了方法与报文、没断言 URL)。
* 现在测试里专门有一条断言请求路径。
*/
export const APPEARANCE_PATH = '/me/appearance';
export const APPEARANCE_IMAGE_PATH = '/me/appearance/image';
const THEMES = new Set(['light', 'dark', 'system']);
const KINDS = new Set(['none', 'preset', 'image']);
@ -102,8 +110,10 @@ export interface AppearanceAuth {
export async function pullAppearance(
auth: AppearanceAuth,
fetchImpl: typeof fetchWithAuth = fetchWithAuth
): Promise<{ snapshot: AppearanceSnapshot; imageDataUrl: string } | null> {
if (!auth?.base || !auth?.token) return null;
): Promise<{ snapshot: AppearanceSnapshot; imageDataUrl: string; saved: boolean } | null> {
// 只要求 base:**浏览器密码登录只建 cookie 会话、没有 Bearer 令牌**,
// 拿 token 当硬门槛会让这条路直接短路(实测:登录成功但外观不生效、且控制台 401)。
if (!auth?.base) return null;
try {
const res = await fetchImpl(auth, APPEARANCE_PATH);
if (!res.ok) return null;
@ -119,7 +129,10 @@ export async function pullAppearance(
imageDataUrl = await blobToDataUrl(blob);
}
}
return { snapshot, imageDataUrl };
// `saved` 必须回传:服务端"没有记录"时给的是默认值,而拿默认值去覆盖本地
// 等于把用户已有的外观(尤其是本地缓存的壁纸)抹掉 —— 首次启用这套同步时
// 每个老用户都会中招。调用方据此改走"以本地为准、推上去"。
return { snapshot, imageDataUrl, saved: body?.saved === true };
} catch {
return null;
}
@ -131,7 +144,7 @@ export async function pushAppearance(
payload: Record<string, unknown>,
fetchImpl: typeof fetchWithAuth = fetchWithAuth
): Promise<boolean> {
if (!auth?.base || !auth?.token) return false;
if (!auth?.base) return false;
try {
const res = await fetchImpl(auth, APPEARANCE_PATH, {
method: 'PUT',
@ -150,7 +163,7 @@ export async function uploadAppearanceImage(
imageDataUrl: string,
fetchImpl: typeof fetchWithAuth = fetchWithAuth
): Promise<boolean> {
if (!auth?.base || !auth?.token || !imageDataUrl) return false;
if (!auth?.base || !imageDataUrl) return false;
try {
const blob = dataUrlToBlob(imageDataUrl);
const form = new FormData();