From 1422afd6f65a9183bc3f10c4e2e68589dd88c7ac Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 14:22:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20/health=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E8=A2=AB=E8=AF=AF=E5=8A=A0=20/api/v1=20=E5=89=8D=E7=BC=80,?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E7=8A=B6=E6=80=81=E6=B0=B8=E8=BF=9C=E3=80=8C?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=BC=82=E5=B8=B8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_url 对所有路径强制加 /api/v1,但后端 /health 与 /health/ready 挂在根路径(app.py,与 compose healthcheck 一致)→ 前端请求 /api/v1/health 404 → fetchHealth 吞错返回 status=unknown → 右上角/监控页永远「系统异常」。 - build_url 对 /health 与 /health/* 豁免前缀 - http.test.ts 补 URL 豁免用例(该测试此前因 ApiError 参数属性不被 strip-types 支持而完全无法运行,顺手改为显式赋值使基建可用) --- frontend/src/lib/http.test.ts | 20 +++++++++++++++++++- frontend/src/lib/http.ts | 18 +++++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/http.test.ts b/frontend/src/lib/http.test.ts index b9fb5e9..ce057ae 100644 --- a/frontend/src/lib/http.test.ts +++ b/frontend/src/lib/http.test.ts @@ -21,8 +21,10 @@ globalThis.window = { } // 捕获每次 fetch 的入参供断言 +let lastUrl = '' let lastInit: RequestInit | undefined -globalThis.fetch = async (_url: string, init?: RequestInit) => { +globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { + lastUrl = String(url) lastInit = init return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }) } @@ -63,3 +65,19 @@ test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => { assert.equal(lastInit?.body, undefined) assert.equal((lastInit?.headers as Record)?.['Content-Type'], undefined) }) + +// ── 健康检查豁免: /health* 挂在根路径(后端 app.py 不在 /api/v1 下), +// 加前缀会 404 → 管理后台右上角永远「系统异常」 ── +test('build_url: /health 与 /health/ready 不加 /api/v1 前缀', async () => { + await http.get('/health') + assert.equal(lastUrl, '/health') + await http.get('/health/ready') + assert.equal(lastUrl, '/health/ready') +}) + +test('build_url: 常规 API 路径仍统一加 /api/v1', async () => { + await http.get('/admin/stats') + assert.equal(lastUrl, '/api/v1/admin/stats') + await http.get('/matches') + assert.equal(lastUrl, '/api/v1/matches') +}) diff --git a/frontend/src/lib/http.ts b/frontend/src/lib/http.ts index 251fdbc..c600f8d 100644 --- a/frontend/src/lib/http.ts +++ b/frontend/src/lib/http.ts @@ -17,22 +17,30 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized' const API_BASE = '/api/v1' const DEFAULT_TIMEOUT = 30_000 +/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致); + * 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */ +const ROOT_ONLY_PREFIXES = ['/health'] + /** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */ function build_url(path: string): string { if (path.startsWith('http')) return path + if (ROOT_ONLY_PREFIXES.some(p => path === p || path.startsWith(`${p}/`))) return path if (path.startsWith(API_BASE)) return path if (path.startsWith('/')) return `${API_BASE}${path}` return `${API_BASE}/${path}` } export class ApiError extends Error { - constructor( - message: string, - public status: number, - public data?: unknown, - ) { + status: number + data?: unknown + + constructor(message: string, status: number, data?: unknown) { super(message) this.name = 'ApiError' + // 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者, + // 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败 + this.status = status + this.data = data } } -- 2.39.5