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:
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user