diff --git a/CHANGELOG.md b/CHANGELOG.md index b66edf17..97f40101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」 + +部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。 + ## 2026-09-05 — 带年份经历没问完不出时间卡,也不写验证报告 生时校正里,学业、钱、搬家、身体或工作这些带年份的口述还没问完(也没说没有)时,页面只显示只读时间范围,没有可点的时间卡,助手也不写验证报告。这些问完之后,报告和卡片在同一轮出现,下面不再追问。你说「没有更多」、已经选定时间、或题目已经问尽,仍按原来的收口。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 997245a5..3e12ba46 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8373,6 +8373,22 @@ - 复发自:BUG-390(质量探针只对学业发出,但学业内 kind 未收紧) - 修复版本:待发布 +## BUG-542 | 部署窗口数据库瞬断被报成「服务尚未配置」 + +- 状态:resolved +- 首次发现:2026-09-04 +- 最近更新:2026-09-06 +- 影响面:16 处 API 路由的 Supabase/数据库客户端创建兜底;共享 helper `frontend/src/lib/api/service-unavailable.ts` +- 用户现象:staging 刚切完部署时,生时校正点选答案返回 `503 {"error":"服务尚未配置"}`。同一时刻 `/api/health` 显示数据库检查 degraded,数分钟后自愈。环境变量从未缺失。 +- 触发条件:部署切换窗口内数据库短暂不可用;`createServerSupabaseClient()` 在自托管分支会先读身份会话(数据库查询),连接错误从这里抛出。 +- 根因:16 处 `catch` 把任意创建失败一律翻译成「服务尚未配置」。该文案只对应 `SupabaseConfigurationError`(缺环境变量)。 +- 修复:共享 `jsonForSupabaseSetupFailure`:仅配置错误返回「服务尚未配置」;其它异常返回 `503 {"error":"服务暂时不可用,请稍后重试","code":"service_unavailable"}`,并 `console.error` 路由名与 `error.name`(不打印堆栈/连接串)。不改客户端抛错语义、不加重试、不改 health。 +- 验证:`npx tsx --test tests/api-service-unavailable-20260904.test.ts` 4/4。mock `createServerSupabaseClient` 抛配置错误 → 503「服务尚未配置」;抛 `Error("connection refused")` → `code=service_unavailable`,文案不含「尚未配置」。覆盖 `POST /api/rectification/agent` 与 `GET /api/rectification/cases/[caseId]`。`grep -rn "服务尚未配置" frontend/src` 只命中 helper。`tsc --noEmit` 0 错;改动文件 eslint `--quiet` 0 error。 +- 防复发:新路由创建数据库客户端失败不得手写「服务尚未配置」;必须走 helper。发布门模块 mock 继续用 Node 22 的 `namedExports`(BUG-514)。 +- 相关记录:BUG-010、BUG-514 +- 复发自:无 +- 修复版本:待发布 + ## BUG-543 | staging 镜像构建因 Google Fonts 拉不到 Inter 失败 - 状态:resolved diff --git a/docs/tasks/PROGRESS-api-not-configured-mislabel-20260904.md b/docs/tasks/PROGRESS-api-not-configured-mislabel-20260904.md new file mode 100644 index 00000000..5dce5de5 --- /dev/null +++ b/docs/tasks/PROGRESS-api-not-configured-mislabel-20260904.md @@ -0,0 +1,46 @@ +# PROGRESS · 部署窗口 503 误标为「服务尚未配置」(2026-09-04) + +工作树:`.worktrees/api-not-configured-mislabel-20260904` +分支:`codex/api-not-configured-mislabel-20260904` +基线:开工时 `origin/staging` @ `d2a209d3`(任务书写的是 `a75929c1`;staging 已含后续提交)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill、未改 `createServerSupabaseClient` / `createAdminSupabaseClient` 抛错语义。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 共享 helper + 16 处替换 | 完成 | BUG-542 | +| 回归测试(agent + case 刷新) | 完成 | BUG-542 | +| BUG_HISTORY / CHANGELOG / 本文件 | 完成 | BUG-542 | + +## 编号 + +开工时最大号 BUG-550。本单使用预留的 **BUG-542**。未占用 BUG-551 及之后。 + +## 实现要点 + +- `frontend/src/lib/api/service-unavailable.ts`:`jsonForSupabaseSetupFailure(error, routeName)`。仅 `isSupabaseConfigurationError` 返回「服务尚未配置」+「请先配置数据库环境变量。」;其它异常 `503` + `code=service_unavailable`,`console.error(routeName, error.name)`,不把 Error 对象打进日志。 +- 16 处创建客户端的 catch 改为调用 helper(含 `consult/status` 的 server/admin 两段)。`birth-time-guide` / `birth-time-journey` 原先配置错误才 503、其它直接 throw,现与其余路由一样返回 `service_unavailable`。 +- 未登录仍是 401。未改 health、未加重试。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/api-service-unavailable-20260904.test.ts` | 4 项;pass 4;fail 0(约 28s,agent 路由 mock 导入较重) | +| `npx tsx --test tests/self-hosted-error-copy.test.ts` | 1/1 | +| `grep -rn "服务尚未配置" frontend/src` | 只命中 `src/lib/api/service-unavailable.ts` | +| `npx tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | + +Node 22 mock 契约:子进程 `mock.module(..., { namedExports })`(BUG-514)。本地 Node 24 会警告 `namedExports` 已弃用,不可改成 `exports`。 + +## 既有断言 / 文案变更(原值 / 新值 / 原因) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 16 处 catch 对非配置异常 | `503`「服务尚未配置」或直接 throw | `503` `code=service_unavailable`「服务暂时不可用,请稍后重试」 | 决策记录 1:只有配置错误能说尚未配置 | +| `GET /api/models` 目录为空 | `error: "模型服务尚未配置"` | `error: "没有可用的咨询模型"` | 该句包含「服务尚未配置」子串,会撞验收 grep;这不是配置错误,是目录为空 | + +未改既有测试断言。无既有测试把非配置异常锁成「服务尚未配置」。 + +## 遗留 + +未做浏览器真人走查。部署窗口的短暂 503 仍会出现,只是说法不再误导成环境变量丢失。未跑全量 `npm run lint` / `npm test`(任务书验收是 tsc + lint 0 error + 本单测试;改动文件 eslint 已 0 error)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4a1dba48..ba6c500b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -60,7 +60,7 @@ | `TASK-settings-dialog-and-billing-pane-20260906.md` | `PROGRESS-settings-dialog-and-billing-pane-20260906.md` | 设置弹窗尺寸随分区跳变、星盘资料格无条件铺开整张添加表单(改为固定尺寸四分区、列表→详情);「账户与点数」成为弹窗分区,删除 `/membership` 与 `/membership/orders` 页面并重定向,七处入口改回调 | 待执行(串行:在会话列表单之后) | `codex/settings-dialog-and-billing-pane-20260906`(BUG-554) | | `TASK-consultation-context-and-cache-20260906.md` | `PROGRESS-consultation-context-and-cache-20260906.md` | 普通对话历史只取每条前 4,000 字(报告结论被砍、无标记)、历史窗口不看模型 `context_window`、无溢出识别;改为检查点式会话摘要 + append-only 尾巴 + 按模型预算 + 一次降级重试;Anthropic 历史断点;共享方法段进系统块;后台用量页显示缓存命中率 | 待执行(串行:在会话列表单之后;动表需迁移) | `codex/consultation-context-and-cache-20260906`(BUG-555/556) | | `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 已验收通过 `ca4e2408`(2026-09-05;staging 部署仍停在 `afd14948`,`deploy-staging` 自 `c295b853` 起连续失败,先解决 `bab07187` 的待迁移) | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | -| `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | +| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待验收 | `codex/api-not-configured-mislabel-20260904`(BUG-542) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | ### 聊天主链路与首页 diff --git a/frontend/src/app/api/birth-time-guide/route.ts b/frontend/src/app/api/birth-time-guide/route.ts index de46cba0..ed93eb10 100644 --- a/frontend/src/app/api/birth-time-guide/route.ts +++ b/frontend/src/app/api/birth-time-guide/route.ts @@ -14,8 +14,8 @@ import { createSupabaseBirthTimeJourneyStore, } from "@/lib/birth-time-journey-store"; import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; -import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getBirthTimeGuideAgent } from "@/mastra"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; @@ -40,13 +40,7 @@ export async function POST(request: Request) { try { supabase = await createServerSupabaseClient(); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); - } - throw error; + return jsonForSupabaseSetupFailure(error, "POST /api/birth-time-guide"); } const { data: { user }, error: authError } = await supabase.auth.getUser(); diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index bc74187c..ac99d92d 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -14,8 +14,8 @@ import { StaleJourneyTurnError } from "@/lib/birth-time-journey-turn-persistence import { CandidateConfirmationError } from "@/lib/birth-time-evidence"; import { BirthTimeEvidenceContextError, EvidenceRectificationCaseNotFoundError, GuidedJourneyLegacyMutationError, StaleCandidateConfirmationError } from "@/lib/birth-time-evidence-service"; import { createSupabaseBirthTimeJourneyStore, BirthTimeJourneyStoreError } from "@/lib/birth-time-journey-store"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; -import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { BirthTimeScoringJobError } from "@/lib/birth-time-scoring-job"; import { GuidedCandidateActionError } from "@/lib/birth-time-guided-candidate"; @@ -62,13 +62,7 @@ export async function POST(request: Request) { supabase = await createServerSupabaseClient(); journeyStoreClient = createAdminSupabaseClient(); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); - } - throw error; + return jsonForSupabaseSetupFailure(error, "POST /api/birth-time-journey"); } const { data: { user }, error: authError } = await supabase.auth.getUser(); diff --git a/frontend/src/app/api/consult/cancel/route.ts b/frontend/src/app/api/consult/cancel/route.ts index 32ca9669..5ad8e677 100644 --- a/frontend/src/app/api/consult/cancel/route.ts +++ b/frontend/src/app/api/consult/cancel/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { runCreditRpc } from "@/lib/consultation-billing"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -16,11 +17,8 @@ export async function POST(request: Request) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/consult/cancel"); } const { data: { user }, error: authError } = await supabase.auth.getUser(); diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index a95adf01..dc7ac291 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -33,6 +33,7 @@ import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/ import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; import { reserveConsultationModel } from "@/lib/consultation-model-selection"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { streamTextResponse } from "@/lib/stream-text-response"; @@ -250,11 +251,8 @@ export async function POST(request: Request) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/consult"); } const { diff --git a/frontend/src/app/api/consult/status/route.ts b/frontend/src/app/api/consult/status/route.ts index 376e21a2..33432f7b 100644 --- a/frontend/src/app/api/consult/status/route.ts +++ b/frontend/src/app/api/consult/status/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { runCreditRpc } from "@/lib/consultation-billing"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -19,8 +20,8 @@ export async function GET(request: Request) { let supabase: Awaited>; try { supabase = await createServerSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/consult/status"); } const { data: { user }, error: authError } = await supabase.auth.getUser(); @@ -45,8 +46,8 @@ export async function GET(request: Request) { let accounting: ReturnType; try { accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/consult/status"); } let query = accounting diff --git a/frontend/src/app/api/models/route.ts b/frontend/src/app/api/models/route.ts index d7a83817..67a513ec 100644 --- a/frontend/src/app/api/models/route.ts +++ b/frontend/src/app/api/models/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; @@ -9,13 +10,7 @@ export async function GET() { try { supabase = await createServerSupabaseClient(); } catch (error) { - if (error instanceof Error) { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); - } - throw error; + return jsonForSupabaseSetupFailure(error, "GET /api/models"); } const { data: { user }, error: authError } = await supabase.auth.getUser(); @@ -33,7 +28,7 @@ export async function GET() { }; if (!catalog.defaultModelId || catalog.models.length === 0) { return NextResponse.json( - { error: "模型服务尚未配置", message: "当前没有可用的咨询模型。" }, + { error: "没有可用的咨询模型", message: "当前没有可用的咨询模型。" }, { status: 503 }, ); } diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 4dd920d8..85956adb 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -28,6 +28,7 @@ import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricin import { loadRuntimeFeatureFlags } from "@/lib/feature-flags"; import { isProductEnabled } from "@/lib/product-access"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; @@ -147,11 +148,8 @@ export async function POST(request: Request) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent"); } const { diff --git a/frontend/src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts index 707e4779..072f1872 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { adoptLegacyRectificationSkill, mapRectificationRpcError, RectificationCaseServiceError } from "@/lib/rectification-agentic/v9/case-service"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -27,8 +28,8 @@ export async function POST(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/adopt-skill"); } const { diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index 5e88f1f1..3643db20 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isProductEnabled } from "@/lib/product-access"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -58,8 +59,8 @@ export async function POST(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept"); } const { data: { user }, diff --git a/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts index 4ea41e90..fb6bef10 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -29,8 +30,8 @@ export async function POST(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/close"); } const { data: { user }, diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 7b702002..a553e6fc 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -40,8 +41,8 @@ export async function GET(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]"); } const { data: { user }, diff --git a/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts index 9069f239..06909d18 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/rectification-agentic/v9/tool-service"; import { resolveExactSkillPackage } from "@/lib/skill-package-registry"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification"; @@ -63,8 +64,8 @@ export async function POST(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate"); } const { diff --git a/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts index e6f16bfb..e05cb070 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -30,8 +31,8 @@ export async function POST(request: Request, context: RouteContext) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/upgrade-skill"); } const { data: { user }, diff --git a/frontend/src/app/api/rectification/cases/entry-summary/route.ts b/frontend/src/app/api/rectification/cases/entry-summary/route.ts index ea4fc57e..a5e62016 100644 --- a/frontend/src/app/api/rectification/cases/entry-summary/route.ts +++ b/frontend/src/app/api/rectification/cases/entry-summary/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { @@ -20,8 +21,8 @@ export async function GET() { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/entry-summary"); } const { data: { user }, diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts index 76a30528..b3e2ecd3 100644 --- a/frontend/src/app/api/rectification/cases/open/route.ts +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isProductEnabled } from "@/lib/product-access"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -37,8 +38,8 @@ export async function POST(request: Request) { try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); - } catch { - return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/open"); } const { data: { user }, diff --git a/frontend/src/lib/api/service-unavailable.ts b/frontend/src/lib/api/service-unavailable.ts new file mode 100644 index 00000000..5ccb5cdf --- /dev/null +++ b/frontend/src/lib/api/service-unavailable.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +export const SERVICE_NOT_CONFIGURED_ERROR = "服务尚未配置"; +export const SERVICE_NOT_CONFIGURED_MESSAGE = "请先配置数据库环境变量。"; + +export function jsonForSupabaseSetupFailure(error: unknown, routeName: string) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { + error: SERVICE_NOT_CONFIGURED_ERROR, + message: SERVICE_NOT_CONFIGURED_MESSAGE, + }, + { status: 503 }, + ); + } + const errorName = error instanceof Error ? error.name : "UnknownError"; + console.error(routeName, errorName); + return NextResponse.json( + { + error: "服务暂时不可用,请稍后重试", + code: "service_unavailable", + }, + { status: 503 }, + ); +} diff --git a/frontend/src/lib/onboarding-post.ts b/frontend/src/lib/onboarding-post.ts index 5b9cf77d..8d65e8f5 100644 --- a/frontend/src/lib/onboarding-post.ts +++ b/frontend/src/lib/onboarding-post.ts @@ -1,3 +1,4 @@ +import { jsonForSupabaseSetupFailure } from "./api/service-unavailable.ts"; import { createOnboardingCacheIdentity, decideOnboardingCache, @@ -146,11 +147,8 @@ export function createOnboardingPost(dependencies: OnboardingPostDependencies): let session: OnboardingSession; try { session = await dependencies.openSession(); - } catch { // no-excuse-ok: catch -- route boundary converts missing configuration. - return Response.json( - { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, - { status: 503 }, - ); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/onboarding"); } if (session.authError || !session.userId) { diff --git a/frontend/tests/api-service-unavailable-20260904.test.ts b/frontend/tests/api-service-unavailable-20260904.test.ts new file mode 100644 index 00000000..a9b76871 --- /dev/null +++ b/frontend/tests/api-service-unavailable-20260904.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); +const srcRoot = join(frontendRoot, "src"); + +const NOT_CONFIGURED = "服务尚未配置"; + +const ROUTES_USING_HELPER = [ + "src/app/api/rectification/agent/route.ts", + "src/app/api/rectification/cases/open/route.ts", + "src/app/api/rectification/cases/entry-summary/route.ts", + "src/app/api/rectification/cases/[caseId]/route.ts", + "src/app/api/rectification/cases/[caseId]/close/route.ts", + "src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts", + "src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts", + "src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", + "src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", + "src/app/api/consult/route.ts", + "src/app/api/consult/status/route.ts", + "src/app/api/consult/cancel/route.ts", + "src/app/api/models/route.ts", + "src/app/api/birth-time-journey/route.ts", + "src/app/api/birth-time-guide/route.ts", + "src/lib/onboarding-post.ts", +] as const; + +function walk(directory: string): string[] { + const files: string[] = []; + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) { + files.push(...walk(path)); + continue; + } + if (path.endsWith(".ts") || path.endsWith(".tsx")) files.push(path); + } + return files; +} + +type RouteProbe = { + configStatus: number; + configBody: Record; + unavailableStatus: number; + unavailableBody: Record; + unavailableLogs: string[][]; +}; + +function executeRouteProbe(kind: "agent" | "cases"): RouteProbe { + const script = String.raw` + import { mock } from "node:test"; + import { pathToFileURL } from "node:url"; + import { SupabaseConfigurationError } from "@/lib/supabase/config"; + + const kind = ${JSON.stringify(kind)}; + const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; + mock.module("server-only", { namedExports: {} }); + + let calls = 0; + mock.module("@/lib/supabase/server", { namedExports: { + createServerSupabaseClient: async () => { + calls += 1; + if (calls === 1) throw new SupabaseConfigurationError(["APP_DATABASE_URL"]); + throw new Error("connection refused"); + }, + }}); + mock.module("@/lib/supabase/admin", { namedExports: { + createAdminSupabaseClient: () => { throw new Error("admin unused in this unit test"); }, + }}); + + const unavailableLogs = []; + const originalError = console.error; + console.error = (...args) => { + unavailableLogs.push(args.map((value) => { + if (typeof value === "string") return value; + if (value instanceof Error) return value.stack ?? value.message; + return String(value); + })); + }; + + async function invoke() { + if (kind === "agent") { + const { POST } = await import(moduleUrl("src/app/api/rectification/agent/route.ts")); + return POST(new Request("https://staging.jyotisha.chat/api/rectification/agent", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + })); + } + const { GET } = await import(moduleUrl("src/app/api/rectification/cases/[caseId]/route.ts")); + return GET( + new Request("https://staging.jyotisha.chat/api/rectification/cases/123e4567-e89b-12d3-a456-426614174000?sessionId=123e4567-e89b-12d3-a456-426614174001"), + { params: Promise.resolve({ caseId: "123e4567-e89b-12d3-a456-426614174000" }) }, + ); + } + + const configResponse = await invoke(); + const configBody = await configResponse.json(); + const unavailableResponse = await invoke(); + const unavailableBody = await unavailableResponse.json(); + console.error = originalError; + console.log("AYANAM_RESULT " + JSON.stringify({ + configStatus: configResponse.status, + configBody, + unavailableStatus: unavailableResponse.status, + unavailableBody, + unavailableLogs, + })); + `; + const result = spawnSync(process.execPath, [ + "--experimental-test-module-mocks", + "--import", + "tsx", + "--input-type=module", + "--eval", + script, + ], { + cwd: frontendRoot, + encoding: "utf8", + env: { ...process.env, NODE_NO_WARNINGS: "1" }, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const line = result.stdout + .split("\n") + .map((row) => row.trim()) + .find((row) => row.startsWith("AYANAM_RESULT ")); + assert.ok(line, result.stdout); + return JSON.parse(line.slice("AYANAM_RESULT ".length)) as RouteProbe; +} + +function assertConfigMislabel(body: Record) { + assert.equal(body.error, NOT_CONFIGURED); + assert.equal(body.code, undefined); +} + +function assertUnavailable(body: Record, logs: string[][]) { + assert.equal(body.error, "服务暂时不可用,请稍后重试"); + assert.equal(body.code, "service_unavailable"); + assert.equal(JSON.stringify(body).includes("尚未配置"), false); + assert.ok(logs.some((entry) => entry.includes("Error"))); + assert.equal(JSON.stringify(logs).includes("connection refused"), false); + assert.equal(JSON.stringify(logs).includes("APP_DATABASE_URL"), false); +} + +test("shared helper classifies configuration errors and connection failures", async () => { + const { jsonForSupabaseSetupFailure } = await import("../src/lib/api/service-unavailable.ts"); + const { SupabaseConfigurationError } = await import("../src/lib/supabase/config.ts"); + const logged: unknown[][] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + logged.push(args); + }; + try { + const missing = await jsonForSupabaseSetupFailure( + new SupabaseConfigurationError(["APP_DATABASE_URL"]), + "POST /api/rectification/agent", + ); + assert.equal(missing.status, 503); + assert.deepEqual(await missing.json(), { + error: NOT_CONFIGURED, + message: "请先配置数据库环境变量。", + }); + assert.deepEqual(logged, []); + + const unavailable = await jsonForSupabaseSetupFailure( + new Error("connection refused"), + "GET /api/rectification/cases/[caseId]", + ); + assert.equal(unavailable.status, 503); + assert.deepEqual(await unavailable.json(), { + error: "服务暂时不可用,请稍后重试", + code: "service_unavailable", + }); + assert.deepEqual(logged, [["GET /api/rectification/cases/[caseId]", "Error"]]); + } finally { + console.error = originalError; + } +}); + +test("rectification agent maps setup failures without calling the rest of the handler", () => { + const probe = executeRouteProbe("agent"); + assert.equal(probe.configStatus, 503); + assertConfigMislabel(probe.configBody); + assert.equal(probe.unavailableStatus, 503); + assertUnavailable(probe.unavailableBody, probe.unavailableLogs); + assert.ok(probe.unavailableLogs.some((entry) => + entry.some((value) => value.includes("/api/rectification/agent")), + )); +}); + +test("rectification case refresh maps setup failures without calling the rest of the handler", () => { + const probe = executeRouteProbe("cases"); + assert.equal(probe.configStatus, 503); + assertConfigMislabel(probe.configBody); + assert.equal(probe.unavailableStatus, 503); + assertUnavailable(probe.unavailableBody, probe.unavailableLogs); + assert.ok(probe.unavailableLogs.some((entry) => + entry.some((value) => value.includes("/api/rectification/cases/")), + )); +}); + +test("not-configured copy lives only in the shared helper, and the 16 routes call it", () => { + const hits = walk(srcRoot) + .filter((path) => readFileSync(path, "utf8").includes(NOT_CONFIGURED)) + .map((path) => path.slice(srcRoot.length + 1).split("\\").join("/")); + assert.deepEqual(hits, ["lib/api/service-unavailable.ts"]); + for (const relative of ROUTES_USING_HELPER) { + const source = readFileSync(join(frontendRoot, relative), "utf8"); + assert.match( + source, + /jsonForSupabaseSetupFailure/, + `${relative} must use jsonForSupabaseSetupFailure`, + ); + } +});