From ca96f77a4b33ac2df1ce0b286ad23c3a5e9836d6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 08:59:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(appearance):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E9=87=8C=E5=90=8C=E6=AD=A5=E4=BB=8E=E6=9D=A5=E6=B2=A1=E8=B7=91?= =?UTF-8?q?=E8=B5=B7=E6=9D=A5=EF=BC=88=E4=B8=89=E5=A4=84=E5=8F=A0=E5=8A=A0?= =?UTF-8?q?=EF=BC=89+=20=E9=83=A8=E7=BD=B2=E9=93=BE=E5=8A=A0"=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=8D=E5=BE=97=E6=AF=94=E6=BA=90=E7=A0=81=E6=97=A7?= =?UTF-8?q?"=E9=97=B8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户说「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 包、打包一致性全绿 --- client/electron/src/lib/appearance.ts | 27 +++-- client/electron/src/stores/appearanceSync.ts | 16 ++- .../test/stores/appearanceSync.test.ts | 98 ++++++++++++++++++- deploy/redeploy-gateway.sh | 13 +++ 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/client/electron/src/lib/appearance.ts b/client/electron/src/lib/appearance.ts index 7fb7772..3b5edd6 100644 --- a/client/electron/src/lib/appearance.ts +++ b/client/electron/src/lib/appearance.ts @@ -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, fetchImpl: typeof fetchWithAuth = fetchWithAuth ): Promise { - 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 { - if (!auth?.base || !auth?.token || !imageDataUrl) return false; + if (!auth?.base || !imageDataUrl) return false; try { const blob = dataUrlToBlob(imageDataUrl); const form = new FormData(); diff --git a/client/electron/src/stores/appearanceSync.ts b/client/electron/src/stores/appearanceSync.ts index d588181..6c1ee9d 100644 --- a/client/electron/src/stores/appearanceSync.ts +++ b/client/electron/src/stores/appearanceSync.ts @@ -42,8 +42,11 @@ let pushTimer: ReturnType | 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((set) => ({ @@ -63,6 +66,15 @@ export const useAppearanceSync = create((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); diff --git a/client/electron/test/stores/appearanceSync.test.ts b/client/electron/test/stores/appearanceSync.test.ts index 30ac298..d0b60d5 100644 --- a/client/electron/test/stores/appearanceSync.test.ts +++ b/client/electron/test/stores/appearanceSync.test.ts @@ -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); + }); +}); diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index 2e0016d..ccda0ed 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -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