fix(api): stop labeling database blips as unconfigured (BUG-542)

Deploy-window connection errors were translated into "服务尚未配置".
Only real configuration errors keep that copy; other 503s use service_unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 12:37:21 +08:00
parent d2a209d326
commit 5483649bc1
22 changed files with 360 additions and 65 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」
部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。
## 2026-09-05 — 带年份经历没问完不出时间卡,也不写验证报告
生时校正里,学业、钱、搬家、身体或工作这些带年份的口述还没问完(也没说没有)时,页面只显示只读时间范围,没有可点的时间卡,助手也不写验证报告。这些问完之后,报告和卡片在同一轮出现,下面不再追问。你说「没有更多」、已经选定时间、或题目已经问尽,仍按原来的收口。Skill 版本仍是 10.0.14。
+16
View File
@@ -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
@@ -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 4fail 0(约 28sagent 路由 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)。
+1 -1
View File
@@ -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-05staging 部署仍停在 `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-505509 |
### 聊天主链路与首页
@@ -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();
@@ -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();
+3 -5
View File
@@ -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();
+3 -5
View File
@@ -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 {
+5 -4
View File
@@ -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<ReturnType<typeof createServerSupabaseClient>>;
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<typeof createAdminSupabaseClient>;
try {
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json({ error: "服务尚未配置" }, { status: 503 });
} catch (error) {
return jsonForSupabaseSetupFailure(error, "GET /api/consult/status");
}
let query = accounting
+3 -8
View File
@@ -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 },
);
}
@@ -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 {
@@ -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 {
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 {
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
);
}
+3 -5
View File
@@ -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) {
@@ -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<string, unknown>;
unavailableStatus: number;
unavailableBody: Record<string, unknown>;
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<string, unknown>) {
assert.equal(body.error, NOT_CONFIGURED);
assert.equal(body.code, undefined);
}
function assertUnavailable(body: Record<string, unknown>, 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`,
);
}
});