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();

View File

@ -42,8 +42,11 @@ let pushTimer: ReturnType<typeof setTimeout> | null = null;
function currentAuth(): AppearanceAuth | null {
const a = activeAuth();
if (!a?.base || !a.token) return null;
return { base: a.base, token: a.token };
if (!a?.base) return null;
// 令牌可以为空:**浏览器密码登录走 cookie 会话**(桌面端粘贴用户密钥才设 Bearer
// 以前这里要求 token 非空,于是 WebUI 里这套同步从来不跑 —— 用户看到的就是
// "服务端存了外观、界面却毫无变化"。
return { base: a.base, token: a.token ?? '' };
}
export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
@ -63,6 +66,15 @@ export const useAppearanceSync = create<AppearanceSyncState>((set) => ({
set({ status: 'local-only' });
return;
}
if (!remote.saved) {
// 服务端还没这份记录(第一次用这套同步,或换了新账号):
// **不能拿默认值覆盖本地** —— 反过来把本地这份推上去,作为账号的初始外观。
// 漏了这一条,每个老用户升级后第一次登录都会发现主题/壁纸被重置。
set({ status: 'pending' });
await useAppearanceSync.getState().push();
return;
}
set({ applyingRemote: true });
try {
useThemeStore.getState().setPref(remote.snapshot.theme);

View File

@ -11,6 +11,13 @@
* 3. 本地改动会推上去payload 形状要对)
* 4. 推不上去要变成 pending未登录/不可达是 local-only —— **降级可见**
* 5. 壁纸只在换图时上传一次(几 MB 的东西不该每次 PUT 都跟着走)
* 6. ★ **浏览器密码登录(只有 cookie、没有 Bearer 令牌)也必须能同步** ——
* 这条是 2026-09-14 自查发现的:原先要求令牌非空,于是 WebUI 里这套同步
* 从来不跑(服务端存了外观、界面毫无变化,控制台还有 401
*
* 没有"完全没有网关地址 ⇒ local-only"这条判据:`resolveBase()` 总有默认值,
* 那个状态在现实里到不了(判据不量够不着的对象)。真正的降级触发点是
* 请求失败(见下)与服务端无记录。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
@ -37,6 +44,8 @@ const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const REMOTE = {
// saved: true = 服务端确实有这条记录(缺了它会被当成"首次启用",走"认领本地"那条路)
saved: true,
theme: 'dark',
bg_kind: 'preset',
bg_preset_id: 'dusk',
@ -83,6 +92,18 @@ describe('快照换算(纯函数)', () => {
});
describe('拉取与推送', () => {
it('★ 请求路径必须相对基地址(写错成 /api/v1/... 会变成双前缀)', async () => {
const calls = fakeFetch(call =>
call.url.endsWith('/me/appearance') ? json(REMOTE) : json({}, 404),
);
await useAppearanceSync.getState().pull();
expect(calls.map(c => c.url)).toContain('http://gw/me/appearance');
expect(
calls.some(c => c.url.includes('/api/v1/api/v1')),
'不得出现双前缀(基地址里已经有 /api/v1',
).toBe(false);
});
it('拉取以服务端为准(换设备能拿回来)', async () => {
fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
@ -109,16 +130,23 @@ describe('拉取与推送', () => {
const put = calls.find(c => c.method === 'PUT');
expect(put, '应当发出 PUT').toBeTruthy();
expect(put!.url).toBe('http://gw/me/appearance');
const body = JSON.parse(String(put!.body));
expect(body).toMatchObject({ theme: 'light', bg_kind: 'preset', bg_preset_id: 'mint' });
expect(useAppearanceSync.getState().status).toBe('synced');
});
it('未登录 → local-only降级必须可见', async () => {
setActiveAuth({ base: '', token: '' });
it('没有网关地址 → local-only降级必须可见', async () => {
// 注意 `setActiveAuth` 的语义base 为空时**保留**原来的 API_BASE它只在非空时覆盖
// 所以"完全没地址"这一态要在模块加载后的初始值上测 —— 这里用 import 时抓到的默认值。
// 用空 base 模拟"未登录"是错的(地址还在、只是没令牌 = 浏览器 cookie 会话,
// 那条路必须继续工作,见下面那组判据)。
setActiveAuth({ base: ' ', token: '' });
const before = useAppearanceSync.getState().status;
fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
expect(useAppearanceSync.getState().status).toBe('local-only');
// 有地址无令牌 ⇒ 走 cookie 会话那条路synced这正是我们要的
expect(useAppearanceSync.getState().status).not.toBe(before);
});
it('服务端不可达 → local-only不是"已同步"', async () => {
@ -146,3 +174,67 @@ describe('拉取与推送', () => {
expect(posts(), '没换图时不该重复上传').toBe(1);
});
});
/**
* ★ 服务端"没有记录"时**不能拿默认值覆盖本地**2026-09-14 自查发现的缺陷)。
*
* 首次启用这套同步时,服务端对每个老用户都是"无记录 ⇒ 回默认值"。若直接应用,
* 用户已有的主题与本地缓存的壁纸会被静默重置 —— 我自己的线上 E2E 就顺手在真实
* 账号上验证了这条路径(先写了一条记录,才没暴露;把记录删掉就会暴露)。
*
* 正确行为:以本地为准,把本地这份**推上去**作为账号的初始外观。
*/
describe('首次启用同步(服务端无记录)', () => {
it('★ 不覆盖本地,而是把本地推上去', async () => {
const calls = fakeFetch(call =>
call.method === 'GET'
? json({ theme: 'system', bg_kind: 'none', bg_dim: 12, bg_blur: 4, saved: false })
: json({ saved: true }),
);
useThemeStore.getState().setPref('dark');
useBackgroundStore.getState().setPreset('mint');
await useAppearanceSync.getState().pull();
// 本地没被默认值改写
expect(useThemeStore.getState().pref).toBe('dark');
expect(useBackgroundStore.getState().kind).toBe('preset');
expect(useBackgroundStore.getState().presetId).toBe('mint');
// 而且真的把本地推上去了
const put = calls.find(c => c.method === 'PUT');
expect(put, '无记录时必须发起一次 PUT 认领本地外观').toBeTruthy();
expect(JSON.parse(String(put!.body))).toMatchObject({ theme: 'dark', bg_preset_id: 'mint' });
});
it('反向对照:服务端有记录时必须以服务端为准', async () => {
fakeFetch(() => json(REMOTE)); // saved 缺省 = 有记录
useThemeStore.getState().setPref('light');
await useAppearanceSync.getState().pull();
expect(useThemeStore.getState().pref).toBe('dark');
});
});
/**
* ★ 浏览器密码登录(只有 cookie 会话、没有 Bearer 令牌)也必须能同步。
*
* 2026-09-14 自查发现:`currentAuth()` 原先要求 `token` 非空,而密码登录只建
* cookie 会话 ⇒ WebUI 里这套同步**从来不跑**(现象:服务端存了外观、界面毫无变化,
* 控制台还有一个 401。桌面端粘贴用户密钥才走 Bearer所以这个缺陷只在浏览器里显形。
*/
describe('cookie 会话(无 Bearer 令牌)', () => {
it('★ 无令牌也要能拉取并以服务端为准', async () => {
setActiveAuth({ base: 'http://gw', token: '' }); // 浏览器base 有值、令牌为空
const calls = fakeFetch(() => json(REMOTE));
await useAppearanceSync.getState().pull();
expect(calls.filter(c => c.method === 'GET').length, '必须真的发了请求').toBeGreaterThan(0);
expect(useThemeStore.getState().pref).toBe('dark');
expect(useAppearanceSync.getState().status).toBe('synced');
});
it('★ 无令牌也要能推送', async () => {
setActiveAuth({ base: 'http://gw', token: '' });
const calls = fakeFetch(() => json({ saved: true }));
await useAppearanceSync.getState().push();
expect(calls.some(c => c.method === 'PUT')).toBe(true);
});
});

View File

@ -87,6 +87,19 @@ if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
# /opt/agentmail/bin/service-failure-notify.mjs不是仓库路径。
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效2026-09-14 修的就是这个)。
# 前端产物必须比源码新,否则会把旧界面打进二进制。
# 2026-09-14 实测踩过:改了 src/lib/appearance.ts 的请求路径却没跑 vite build
# 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404
# /api/v1/api/v1/… 双前缀),而所有单测都是绿的。
if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then
newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -3)
if [ -n "$newer" ]; then
echo " [FAIL] 前端 dist 比源码旧先跑cd client/electron && npm run build"
echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')"
exit 1
fi
echo " [ OK ] 前端产物比源码新"
fi
install -d /opt/agentmail/bin
install -m 0755 "$REPO/deploy/service-failure-notify.mjs" /opt/agentmail/bin/service-failure-notify.mjs
else