diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index aea7e4df..dc6c5da7 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -9,6 +9,7 @@ services: - ${APP_ENV_FILE:-../.env.production} environment: PORT: 5200 + GITHUB_SHA: ${GITHUB_SHA} JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api expose: - "5200" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3633fed8..435943fe 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8918,4 +8918,20 @@ - 复发自:无 - 修复版本:`1ded6bb0` +## BUG-576 | MD-only 长报告引擎 200 后附录 upsert 未带冲突键,staging 三次重试仍 failed + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`generatePersonalReportLongform`、`personal_report_longform_appendices`、自托管 `createLocalPostgresDataClient` upsert、报告详情失败态 +- 用户现象:personal_full MD-only 首次真实运行在引擎调用阶段失败(`calculation_unavailable`,`progressPercent=30`)。直连引擎同形虚构盘 HTTP 200。 +- 触发条件:staging 自托管 web worker 调用 `/api/professional_report_reference` 成功后写入 longform 附录。 +- 根因:本地 postgres 客户端的 `upsert` 必填 `onConflict`;附录写入只调用 `.upsert(row)`。请求在写库前抛错,附录表 0 行。Job 三次尝试均打到引擎(三次 HTTP 200,约 26–32s,低于 180s 超时)。不是 api 容器滞后,也不是引擎超时。web 容器 docker logs 几乎只有启动行,附录错误码此前也不在详情 API 中。 +- 修复:附录 upsert 显式 `onConflict: "report_id"`;本地客户端缺省冲突键回落到主键;详情失败态透出 `appendixLastErrorCode` 与可读原因;引擎调用记录 `http_status`/`duration_ms`;api 容器与 `/api/health` 暴露构建 SHA。 +- 验证:定向 frontend 单测、附录 `test:db` upsert 无 `onConflict`、health/compose 源合同。未改 `.gitea/workflows/**`,不提升 main。 +- 防复发:自托管 upsert 必须能在缺 `onConflict` 时按主键执行;附录写入必须带 PK 冲突键;失败详情必须带附录错误码。 +- 相关记录:BUG-574 +- 复发自:无 +- 修复版本:待提交 + diff --git a/docs/tasks/PROGRESS-report-longform-e2e-fail-20260907.md b/docs/tasks/PROGRESS-report-longform-e2e-fail-20260907.md new file mode 100644 index 00000000..b5a53ec3 --- /dev/null +++ b/docs/tasks/PROGRESS-report-longform-e2e-fail-20260907.md @@ -0,0 +1,54 @@ +# PROGRESS · MD-only 首跑失败取证与修复(2026-09-07) + +工作树:`.worktrees/report-longform-e2e-fail-20260907` +分支:`codex/report-longform-e2e-fail-20260907` +任务书:`TASK-report-longform-e2e-fail-20260907.md` +基线:任务书 `58081a2d`;开工后 `origin/staging` 已含 `ed0cd087`(BUG-575 校时 UI)。事故当时健康检查 SHA 为已部署的 `7cf7705a`。 + +## 任务 0 定案 + +**定案 C:附录 persist 在自托管 upsert 上失败(不是 A,也不是 B)。** + +| 项 | 证据 | +|---|---| +| 附录行 | `request_id=d17c27ef-…` 0 行;全表 `personal_report_longform_appendices` 也是 0 行 | +| Job | `failed / calculation_unavailable`,`attempt_count=3/3`,`progressPercent=30`,`05:14:15Z`–`05:15:48Z` | +| API 访问日志 | 三次 `POST /api/professional_report_reference` 皆 **200**:`05:14:42`、`05:15:14`、`05:15:48`(与 job 三次尝试对齐) | +| 容器版本 | web/api 同批 recreate `05:02Z`;web `GITHUB_SHA=7cf7705a`;api 镜像含 `format=markdown` / `packs`;含 gaps2 `e4d16b75` 祖先 | +| 任务 0.4 | 从 web 容器对 `api:5200` 虚构盘 POST:`http_status=200 duration_ms=26148 bytes=263471 format=markdown markdown_len=250452` | +| web 日志 | docker `LogPath` 空,近乎只有 Next 启动行;`[personal-report]` 不可见 | + +排除: + +- **A api 容器滞后**:端点存在且返回 markdown;web/api 同批部署。 +- **B 180s 超时**:单次 26–32s,三次合计约 93s,均低于 `AbortSignal.timeout(180s)`。 + +根因:`persistLongformAppendix` 调用 `.upsert(row)`,自托管 `LocalPostgresQueryBuilder.upsert` 读取 `options.onConflict` 时同步抛错,引擎 200 的 markdown 从未入表。 + +未读取或写入真实用户正文/出生资料。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +|---|---|---| +| 0 取证定案 | 完成 | 定案 C | +| 1 按定案修复 | 完成 | PK upsert + 本地客户端缺省主键;未调大 180s | +| 2 观测补口 | 完成 | 详情透出 `appendixLastErrorCode`;引擎 `http_status`/`duration_ms`;health `apiGitCommit` | +| 3 收官 smoke | 未部署 | 代码修复待 push/deploy 后按 `docs/operations/personal-report-staging.md` 重跑 | + +## 实测耗时 + +| 路径 | HTTP | duration_ms | 备注 | +|---|---|---|---| +| 事故三次引擎调用 | 200 | ~32s / 次 | 与 job 三次尝试对齐 | +| 任务 0.4 虚构盘 | 200 | 26148 | markdown 250452 字符 | + +未启用 `defer_optional_external_evidence`:真实耗时已在预算内。 + +## 质量门 + +定向 frontend / Python 源合同与附录 db 测(见提交说明)。未改 `.gitea/workflows/**`,不提升 main。 + +## BUG + +`docs/BUG_HISTORY.md` **BUG-576**(远端 staging 已占用 BUG-575 给校时 UI)。 diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index 78c5054e..e3e61034 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -26,7 +26,7 @@ function envCheck(names: string[]): Check { : { status: "ok" }; } -async function jyotishApiCheck(): Promise { +async function jyotishApiCheck(): Promise<{ check: Check; gitCommit: string }> { const started = Date.now(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); @@ -35,16 +35,28 @@ async function jyotishApiCheck(): Promise { cache: "no-store", signal: controller.signal, }); + const body = response.ok + ? await response.json().catch(() => null) as { git_commit?: unknown } | null + : null; + const gitCommit = typeof body?.git_commit === "string" && body.git_commit.trim() + ? body.git_commit.trim() + : "unknown"; return { - status: response.ok ? "ok" : "blocked", - message: response.ok ? undefined : `http:${response.status}`, - latencyMs: Date.now() - started, + check: { + status: response.ok ? "ok" : "blocked", + message: response.ok ? undefined : `http:${response.status}`, + latencyMs: Date.now() - started, + }, + gitCommit, }; } catch (error) { return { - status: "blocked", - message: error instanceof Error ? error.name : "jyotish_api_unavailable", - latencyMs: Date.now() - started, + check: { + status: "blocked", + message: error instanceof Error ? error.name : "jyotish_api_unavailable", + latencyMs: Date.now() - started, + }, + gitCommit: "unknown", }; } finally { clearTimeout(timeout); @@ -148,7 +160,7 @@ export async function GET() { ...databaseChecks, modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]), modelCatalog, - jyotishApi, + jyotishApi: jyotishApi.check, rectificationMigrations: migrations.check, researchTruthSource: { status: truthSourceIdentity.status, @@ -162,6 +174,7 @@ export async function GET() { timestamp: new Date().toISOString(), deployment: { gitCommit, + apiGitCommit: jyotishApi.gitCommit, }, database: { latestMigration: migrations.database.latestMigration, diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index ae950b1c..cb2bbc8a 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -48,7 +48,7 @@ async function resolvePersistenceForUser() { persistence: null as PersonalReportService | null, jobs: null, listSections: undefined, - loadLongformMarkdown: undefined, + loadLongformAppendix: undefined, }; } const persistence = createSupabasePersonalReportService(supabase); @@ -57,11 +57,11 @@ async function resolvePersistenceForUser() { userId: user.id, persistence, jobs: createSupabasePersonalReportJobService(supabase), - listSections: async (ownerId: string, requestId: string) => { + listSections: async (ownerId: string, requestId: string) => { const rows = await sections.list(ownerId, requestId); return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode })); }, - loadLongformMarkdown: async (input: Readonly<{ userId: string; reportId: string }>) => { + loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => { const appendixRead = await supabase .from(LONGFORM_APPENDIX_TABLE) .select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code") @@ -70,14 +70,19 @@ async function resolvePersistenceForUser() { .maybeSingle(); if (appendixRead.error) return null; const row = parseLongformAppendixRow(appendixRead.data); - return row?.status === "ready" && row.markdown ? row.markdown : null; + if (!row) return null; + return { + status: row.status, + lastErrorCode: row.lastErrorCode, + markdown: row.markdown, + }; }, }; } export async function GET(request: Request, context: RouteContext) { try { - const { userId, persistence, jobs, listSections, loadLongformMarkdown } = await resolvePersistenceForUser(); + const { userId, persistence, jobs, listSections, loadLongformAppendix } = await resolvePersistenceForUser(); const { reportId } = await context.params; if (!uuidPattern.test(reportId)) { return NextResponse.json( @@ -103,7 +108,7 @@ export async function GET(request: Request, context: RouteContext) { // a substitute. jobs: jobs ?? undefined, listSections, - loadLongformMarkdown, + loadLongformAppendix, validateReadyDocument: (document) => { const parsed = safeParseServerReportDocument(document); return parsed.ok diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index bc08019a..24a08560 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -32,7 +32,7 @@ export type ReportLoadState = | { phase: "not-found" } | { phase: "generating"; progressPercent?: number; progressPhase?: string } | { phase: "timed-out" } - | { phase: "failed"; failureCode: string | null; failureSummary?: string | null } + | { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null } | { phase: "invalid"; message: string } | { phase: "network-error" } | { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string } @@ -47,6 +47,7 @@ export interface ReportEnvelopeView { status: string; failureCode: string | null; failureSummary?: string | null; + appendixLastErrorCode?: string | null; createdAt: string; completedAt: string | null; progressPercent?: number; @@ -108,10 +109,15 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor const summary = typeof view.failureSummary === "string" && view.failureSummary.length > 0 ? view.failureSummary : null; + const appendixLastErrorCode = typeof view.appendixLastErrorCode === "string" + && view.appendixLastErrorCode.length > 0 + ? view.appendixLastErrorCode + : null; return { phase: "failed", - failureCode: code, + failureCode: appendixLastErrorCode ?? code, ...(summary ? { failureSummary: summary } : {}), + ...(appendixLastErrorCode ? { appendixLastErrorCode } : {}), }; } default: diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index 0ff6cf71..0a61028c 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -39,6 +39,29 @@ function identifier(value: string): string { return `"${normalized}"`; } +export function upsertConflictColumns(options?: { onConflict?: string }): string[] { + return (options?.onConflict ?? "") + .split(",") + .map((column) => column.trim()) + .filter(Boolean); +} + +async function primaryKeyColumns(client: PoolClient, table: string): Promise { + identifier(table); + const result = await client.query<{ column_name: string }>( + ` + select a.attname as column_name + from pg_index i + join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey) + where i.indrelid = format('%I.%I', 'public', $1::text)::regclass + and i.indisprimary + order by array_position(i.indkey, a.attnum) + `, + [table], + ); + return result.rows.map((row) => row.column_name); +} + function queryError(error: unknown): QueryError { const value = error as PostgresError; return { @@ -197,11 +220,9 @@ class LocalPostgresQueryBuilder implements PromiseLike { upsert( value: Record | readonly Record[], - options: { onConflict: string }, + options?: { onConflict?: string }, ) { - const conflict = options.onConflict - .split(",") - .map((column) => column.trim()); + const conflict = upsertConflictColumns(options); conflict.forEach(identifier); this.mutation = { kind: "upsert", rows: records(value), conflict }; return this; @@ -402,13 +423,15 @@ class LocalPostgresQueryBuilder implements PromiseLike { ); sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`; if (this.mutation.kind === "upsert") { - const updates = columns.filter( - (column) => - !this.mutation || - this.mutation.kind !== "upsert" || - !this.mutation.conflict.includes(column), - ); - sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${ + let conflict = this.mutation.conflict; + if (conflict.length === 0) { + conflict = await primaryKeyColumns(client, this.table); + } + if (conflict.length === 0) { + throw new Error("upsert requires a conflict target"); + } + const updates = columns.filter((column) => !conflict.includes(column)); + sql += ` on conflict (${conflict.map(identifier).join(", ")}) do ${ updates.length === 0 ? "nothing" : `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}` diff --git a/frontend/src/lib/personal-report-failure-summary.ts b/frontend/src/lib/personal-report-failure-summary.ts index 69d7af07..ec7c37d7 100644 --- a/frontend/src/lib/personal-report-failure-summary.ts +++ b/frontend/src/lib/personal-report-failure-summary.ts @@ -13,6 +13,7 @@ export type PersonalReportFailureSummary = Readonly<{ summary: string; innerReason: string | null; lastErrorCodes: readonly string[]; + appendixLastErrorCode: string | null; }>; const SECTION_ERROR_LABELS: Readonly> = { @@ -23,6 +24,15 @@ const SECTION_ERROR_LABELS: Readonly> = { section_output_invalid: "输出未通过校验", }; +const APPENDIX_ERROR_LABELS: Readonly> = { + upstream_unavailable: "计算引擎暂时不可用,请稍后重试", + upstream_busy: "计算引擎繁忙,请稍后重试", + empty_markdown: "计算引擎返回了空文,请稍后重试", + generation_failed: "报告生成超时或中断,请稍后重试", + appendix_persist_failed: "报告正文未能保存,请稍后重试", + longform_appendix_persist_failed: "报告正文未能保存,请稍后重试", +}; + function uniqueLabels(codes: readonly string[]): string[] { const labels: string[] = []; for (const code of codes) { @@ -36,6 +46,7 @@ export function summarizePersonalReportFailure(input: Readonly<{ themeCount?: number; sections: readonly PersonalReportSectionFailureRow[]; failureCode?: string | null; + appendixLastErrorCode?: string | null; }>): PersonalReportFailureSummary { const sections = input.sections; const blocked = sections.filter((row) => row.status === "blocked"); @@ -44,6 +55,7 @@ export function summarizePersonalReportFailure(input: Readonly<{ const lastErrorCodes = blocked .map((row) => row.lastErrorCode) .filter((code): code is string => typeof code === "string" && code.length > 0); + const appendixLastErrorCode = textCode(input.appendixLastErrorCode); const total = input.themeCount && input.themeCount > 0 ? input.themeCount : sections.length; const labels = uniqueLabels(lastErrorCodes); const labelText = labels.length > 0 @@ -61,6 +73,8 @@ export function summarizePersonalReportFailure(input: Readonly<{ summary = `${ready.length} 个主题已写成,整份报告未完成装配`; } else if (input.failureCode === "report_schema_invalid") { summary = "报告未通过结构校验"; + } else if (appendixLastErrorCode && APPENDIX_ERROR_LABELS[appendixLastErrorCode]) { + summary = APPENDIX_ERROR_LABELS[appendixLastErrorCode]; } else { summary = "本次生成没有产出可用报告"; } @@ -72,5 +86,11 @@ export function summarizePersonalReportFailure(input: Readonly<{ innerReason = "section_generation_incomplete"; } - return { summary, innerReason, lastErrorCodes }; + return { summary, innerReason, lastErrorCodes, appendixLastErrorCode }; +} + +function textCode(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts index c201fa78..31de595b 100644 --- a/frontend/src/lib/personal-report-longform-generate.ts +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -36,7 +36,10 @@ export type AppendixClient = { select(columns: string): { eq(column: string, value: unknown): AppendixFilter; }; - upsert(row: JsonRecord): PromiseLike<{ error: { message?: string } | null }>; + upsert( + row: JsonRecord, + options?: { onConflict?: string }, + ): PromiseLike<{ error: { message?: string } | null }>; }; }; @@ -99,20 +102,25 @@ export async function persistLongformAppendix(input: Readonly<{ successMarkdown: input.successMarkdown, errorCode: input.errorCode, }); - const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({ - report_id: input.reportId, - user_id: input.userId, - request_id: input.requestId, - status: next.status, - markdown: next.markdown, - content_sha256: next.contentSha256, - attempt_count: next.attemptCount, - last_error_code: next.lastErrorCode, - generated_at: next.status === "ready" ? new Date().toISOString() : null, - updated_at: new Date().toISOString(), - }); - if (result.error) { - throw new LongformGenerateError("calculation_unavailable", true, "longform_appendix_persist_failed"); + try { + const result = await input.admin.from(LONGFORM_APPENDIX_TABLE).upsert({ + report_id: input.reportId, + user_id: input.userId, + request_id: input.requestId, + status: next.status, + markdown: next.markdown, + content_sha256: next.contentSha256, + attempt_count: next.attemptCount, + last_error_code: next.lastErrorCode, + generated_at: next.status === "ready" ? new Date().toISOString() : null, + updated_at: new Date().toISOString(), + }, { onConflict: "report_id" }); + if (result.error) { + throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed"); + } + } catch (error) { + if (error instanceof LongformGenerateError) throw error; + throw new LongformGenerateError("calculation_unavailable", true, "appendix_persist_failed"); } } @@ -126,25 +134,39 @@ async function fetchLongformMarkdown(input: Readonly<{ const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout; - const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify(input.payload), - cache: "no-store", - signal, - }); - if (!upstream.ok) { - throw new LongformGenerateError( - "calculation_unavailable", - true, - upstream.status === 429 ? "upstream_busy" : "upstream_unavailable", - ); + const started = Date.now(); + let httpStatus: number | null = null; + try { + const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(input.payload), + cache: "no-store", + signal, + }); + httpStatus = upstream.status; + if (!upstream.ok) { + throw new LongformGenerateError( + "calculation_unavailable", + true, + upstream.status === 429 ? "upstream_busy" : "upstream_unavailable", + ); + } + const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null; + if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) { + throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown"); + } + return result.markdown; + } catch (error) { + throw error; + } finally { + const durationMs = Date.now() - started; + if (httpStatus !== null) { + console.info(`[personal-report] engine http_status=${httpStatus} duration_ms=${durationMs}`); + } else { + console.info(`[personal-report] engine http_status=error duration_ms=${durationMs}`); + } } - const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null; - if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) { - throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown"); - } - return result.markdown; } export async function generatePersonalReportLongform( diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 95e9c38d..4f324c81 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -153,6 +153,7 @@ export function reportView( ...(failure?.summary ? { failureSummary: failure.summary } : {}), ...(failure?.innerReason ? { innerReason: failure.innerReason } : {}), ...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}), + ...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}), }; } @@ -655,6 +656,14 @@ export type ReportReadCoreDeps = Readonly<{ userId: string; reportId: string; }>) => Promise; + loadLongformAppendix?: (input: Readonly<{ + userId: string; + reportId: string; + }>) => Promise<{ + status: string; + lastErrorCode: string | null; + markdown: string | null; + } | null>; }>; export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { @@ -676,17 +685,25 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { +test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, async () => { const fixture = startPostgresFixture(); const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); @@ -144,6 +148,32 @@ test("longform appendices are owner-read, service-written, and never change repo `), "unavailable:upstream_unavailable", ); + + const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + try { + const persisted = await admin.from("personal_report_longform_appendices").upsert({ + report_id: REPORT_ID, + user_id: USER_A, + request_id: REQUEST_ID, + status: "unavailable", + markdown: null, + content_sha256: null, + attempt_count: 2, + last_error_code: "appendix_persist_failed", + }); + assert.equal(persisted.error, null, persisted.error?.message); + assert.equal( + fixture.psql(` + select last_error_code + from public.personal_report_longform_appendices + where report_id = '${REPORT_ID}' + `), + "appendix_persist_failed", + ); + } finally { + await closeLocalPostgresDataPool(serviceUrl); + } } finally { fixture.stop(); } diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 36c2ed53..c253f3b2 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -44,6 +44,7 @@ test("health endpoint exposes deployment identity for production verification", assert.match(source, /GITHUB_SHA/); assert.match(source, /VERCEL_GIT_COMMIT_SHA/); assert.match(source, /gitCommit/); + assert.match(source, /apiGitCommit/); assert.match(source, /latestMigration/); assert.match(source, /rectificationContractVersion/); assert.match(source, /rectificationMigrations/); @@ -409,10 +410,12 @@ test("production traffic waits for a healthy web container and retries short rep const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8"); const caddyfile = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8"); const web = serviceBlock(compose, "web"); + const api = serviceBlock(compose, "api"); const caddy = serviceBlock(compose, "caddy"); const healthcheck = webHealthcheckBlock(web); assert.match(web, /GITHUB_SHA: \$\{GITHUB_SHA\}/); + assert.match(api, /GITHUB_SHA: \$\{GITHUB_SHA\}/); assert.match(web, /healthcheck:\n\s+test: \["CMD", "node", "-e", "fetch\('http:\/\/127\.0\.0\.1:3000\/api\/health'\)\.then\(r=>\{if\(!r\.ok\)process\.exit\(1\)\}\)"\]/); assert.match(healthcheck, /^ interval: 30s$/m); assert.match(healthcheck, /^ timeout: 5s$/m); diff --git a/frontend/tests/local-postgres-query-value.test.ts b/frontend/tests/local-postgres-query-value.test.ts index f942540f..aaab09ec 100644 --- a/frontend/tests/local-postgres-query-value.test.ts +++ b/frontend/tests/local-postgres-query-value.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { queryValue } from "../src/lib/db/local-postgres-client-core.ts"; +import { queryValue, upsertConflictColumns } from "../src/lib/db/local-postgres-client-core.ts"; test("queryValue keeps calendar dates in local civil form", () => { assert.equal(queryValue("date", new Date(2026, 7, 22)), "2026-08-22"); @@ -21,3 +21,10 @@ test("queryValue leaves non-date values unchanged", () => { const leftover = new Date("2026-08-22T12:34:56.000Z"); assert.equal(queryValue("unknown", leftover), leftover); }); + +test("upsert without onConflict does not throw before execute and defaults to an empty conflict list", () => { + assert.deepEqual(upsertConflictColumns(), []); + assert.deepEqual(upsertConflictColumns({}), []); + assert.deepEqual(upsertConflictColumns({ onConflict: "report_id" }), ["report_id"]); + assert.deepEqual(upsertConflictColumns({ onConflict: "user_id, session_id" }), ["user_id", "session_id"]); +}); diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 4672557d..5266b068 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -1089,6 +1089,39 @@ test("core read: failed reports include a readable summary from existing section assert.deepEqual(report.sectionErrorCodes, ["section_refs_mismatch"]); }); +test("core read: failed longform reports expose appendix last_error_code without bodies", async () => { + const persistence = new MemoryPersistence(); + persistence.rows.set(REPORT_ID, { + ...seedRecord(), + status: "failed", + failureCode: "calculation_unavailable", + requestedThemes: ["career", "marriage", "wealth", "health", "spirituality"], + }); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + listSections: async () => [], + loadLongformAppendix: async () => ({ + status: "unavailable", + lastErrorCode: "appendix_persist_failed", + markdown: null, + }), + validateReadyDocument: () => ({ ok: false }), + }); + assert.equal(response.status, 200); + const report = response.body.report as { + failureSummary?: string; + appendixLastErrorCode?: string; + }; + assert.equal(report.appendixLastErrorCode, "appendix_persist_failed"); + assert.equal(report.failureSummary, "报告正文未能保存,请稍后重试"); + assert.equal("markdown" in response.body, false); +}); + test("core read: rejects a polluted stored ready document via canonical re-validation", async () => { const persistence = new MemoryPersistence(); persistence.rows.set(REPORT_ID, { @@ -1164,6 +1197,27 @@ test("core read: markdown loader returns longform and hides the five-chapter bod assert.equal("reportDocument" in missing.body, false); }); +test("core read: appendix loader supplies ready markdown without the markdown-only fallback", async () => { + const persistence = new MemoryPersistence(); + persistence.rows.set(REPORT_ID, seedRecord()); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + loadLongformAppendix: async () => ({ + status: "ready", + lastErrorCode: null, + markdown: "# 附录长报告\n\n### 摘要\n正文", + }), + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(response.status, 200); + assert.equal(response.body.longformMarkdown, "# 附录长报告\n\n### 摘要\n正文"); +}); + test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => { const persistence = new MemoryPersistence(); await createReadyRow(persistence); @@ -1264,7 +1318,7 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/); assert.match(itemRoute, /resolveReportRead/); assert.match(itemRoute, /resolveReportDelete/); - assert.match(itemRoute, /loadLongformMarkdown/); + assert.match(itemRoute, /loadLongformAppendix/); assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/); }); diff --git a/frontend/tests/personal-report-longform-appendix.test.ts b/frontend/tests/personal-report-longform-appendix.test.ts index 920d0374..073dcfcb 100644 --- a/frontend/tests/personal-report-longform-appendix.test.ts +++ b/frontend/tests/personal-report-longform-appendix.test.ts @@ -6,6 +6,7 @@ import { nextLongformAppendixState, parseLongformAppendixRow, } from "../src/lib/personal-report-longform-appendix.ts"; +import { summarizePersonalReportFailure } from "../src/lib/personal-report-failure-summary.ts"; test("hashLongformMarkdown is stable sha256", () => { assert.equal(hashLongformMarkdown("# a"), hashLongformMarkdown("# a")); @@ -55,3 +56,15 @@ test("nextLongformAppendixState marks unavailable after two failures and never s assert.equal(ready.contentSha256, hashLongformMarkdown("# Full")); assert.equal(ready.lastErrorCode, null); }); + +test("failed longform appendix codes become readable summaries without storing bodies", () => { + const summary = summarizePersonalReportFailure({ + themeCount: 5, + sections: [], + failureCode: "calculation_unavailable", + appendixLastErrorCode: "empty_markdown", + }); + assert.equal(summary.appendixLastErrorCode, "empty_markdown"); + assert.equal(summary.summary, "计算引擎返回了空文,请稍后重试"); + assert.deepEqual(summary.lastErrorCodes, []); +}); diff --git a/frontend/tests/personal-report-longform-md.test.ts b/frontend/tests/personal-report-longform-md.test.ts index 7367670b..2ee8c532 100644 --- a/frontend/tests/personal-report-longform-md.test.ts +++ b/frontend/tests/personal-report-longform-md.test.ts @@ -80,6 +80,17 @@ test("writer pipeline stays in the tree but is feature-off", () => { assert.match(PERSONAL_REPORT_GENERATING_COPY, /10–30 秒/); }); +test("longform generate persists through PK upsert and logs engine timing without bodies", () => { + const generateSource = readFileSync( + new URL("../src/lib/personal-report-longform-generate.ts", import.meta.url), + "utf8", + ); + assert.match(generateSource, /onConflict:\s*"report_id"/); + assert.match(generateSource, /\[personal-report\] engine http_status=/); + assert.match(generateSource, /duration_ms=/); + assert.doesNotMatch(generateSource, /console\.(?:info|error|log)\([^\n]*markdown/); +}); + test("outline lifts navigation and summary to the first screen", () => { const outline = buildLongformOutline(SAMPLE_MARKDOWN); assert.ok(outline.headings.some((heading) => heading.title === "成品阅读导航")); diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index dd7ab1f7..76e55e49 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -347,6 +347,21 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real failureSummary: "4 个主题中 4 个写作失败:输出被截断", }, ); + assert.deepEqual( + classifyReportEnvelope(200, { + report: { + ...view("failed", "calculation_unavailable"), + failureSummary: "报告正文未能保存,请稍后重试", + appendixLastErrorCode: "appendix_persist_failed", + }, + }), + { + phase: "failed", + failureCode: "appendix_persist_failed", + failureSummary: "报告正文未能保存,请稍后重试", + appendixLastErrorCode: "appendix_persist_failed", + }, + ); assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null }); // Server-side error envelopes carry a stable code at the top level. diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 07581eca..01d3e404 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -3400,6 +3400,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._json({ 'status': 'ok', 'version': '6.9.14', + 'git_commit': os.environ.get('GITHUB_SHA') or 'unknown', 'swisseph_available': swisseph_available, 'swisseph_version': swisseph_version, 'ayanamsa_default': DEFAULT_AYANAMSA_NAME, diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 87038a8a..9faa52e3 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -217,6 +217,7 @@ def test_health_endpoint_exposes_runtime_accuracy_metadata() -> None: assert payload['ayanamsa_default'] == 'raman' assert 'swisseph_available' in payload assert 'swisseph_version' in payload + assert 'git_commit' in payload assert payload['vedastro']['configured'] is False assert payload['vedastro']['network_enabled'] is False assert payload['vedastro']['has_api_key'] is False