diff --git a/.github/workflows/apply-production-rectification-migrations.yml b/.github/workflows/apply-production-rectification-migrations.yml index af391a3f..0ed56f33 100644 --- a/.github/workflows/apply-production-rectification-migrations.yml +++ b/.github/workflows/apply-production-rectification-migrations.yml @@ -68,6 +68,7 @@ jobs: frontend/supabase/migrations/20260724030000_allow_assistant_only_rectification_regenerate.sql \ frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql \ frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql \ + frontend/supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_DIR/" - name: Check or apply reviewed migrations @@ -120,7 +121,8 @@ jobs: "$REMOTE_DIR/20260724020000_align_global_birthplace_rectification_contract.sql" \ "$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" \ "$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" \ - "$REMOTE_DIR/20260725020000_repair_structured_conversational_date_validator.sql" + "$REMOTE_DIR/20260725020000_repair_structured_conversational_date_validator.sql" \ + "$REMOTE_DIR/20260726010000_backfill_reported_birth_time_status.sql" do filename="$(basename "$sql_file")" checksum="$(sha256sum "$sql_file" | awk '{print $1}')" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c532b44d..3993d75a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1413,3 +1413,19 @@ - 防复发:代码不得要求每轮必须提问,也不得把隐藏 prompt 自动拼接到 Agent 正文;没有可见短答问题时 `evidenceRequest` 应为 null,用户叙述节奏由 Agent 与用户共同决定,代码只维护记录、抽取、评分、收敛与确认边界。 - 相关记录:BUG-070、BUG-072、BUG-074 - 修复版本:待提交(本地可测) + +## BUG-076 | 完整出生资料已保存但权威档案状态为空 + +- 状态:resolved +- 首次发现:2026-07-26 +- 最近更新:2026-07-26 +- 影响面:账户出生资料保存、星盘库同步、`POST /api/consult` 服务端资料校验 +- 用户现象:出生日期、家人确认的具体时间、地点、坐标和时区都已填写,星盘库副本也显示 `birthTimeStatus: reported`,但咨询仍返回“暂时无法核对完整出生资料”。 +- 触发条件:首次创建 `profiles` 行,或既有合法出生声明的 `birth_time_status` 为空且用户原样重新保存。 +- 根因:分钟校正主链合并时把 BUG-018 的首次档案派生状态分支退回为 `{}`;共享 helper 又只在声明字段发生变化时写 `reported`,导致空状态行原样重存也无法自愈。`chart_profiles.profile` 只是星盘库 JSON 副本,不是咨询接口采用的权威出生资料。 +- 修复:账户路由统一调用共享状态派生 helper;新档案和合法声明空状态均原子写入 `reported`,确认分钟继续禁止被普通资料编辑覆盖;增加前向迁移,仅回填没有 active/candidate/case 应用结果且声明满足来源规则的空状态行。 +- 验证:账户 helper 回归覆盖新档案、空状态原样重存、候选失效和 confirmed 保护;迁移契约锁定安全过滤条件,并要求生产迁移工作流上传和应用各一次。 +- 防复发:服务端权威 `profiles` 的声明和状态必须同写;星盘库 JSON 不得作为咨询真值,历史空状态只能通过受约束前向迁移修复。 +- 相关记录:BUG-018、BUG-073 +- 复发自:BUG-018 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index f962c1a5..9f151eb9 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -163,9 +163,7 @@ export async function PATCH(request: Request) { if (currentProfileError) { return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 }); } - const applicationPatch = currentProfile - ? resolveAccountBirthTimeApplicationPatch(currentProfile, payload) - : {}; + const applicationPatch = resolveAccountBirthTimeApplicationPatch(currentProfile, payload); const baseProfile = { id: userId, ...(payload.name !== undefined ? { name: payload.name } : {}), diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 7db463ac..6d5a9049 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -235,17 +235,31 @@ export type AccountBirthTimeApplicationPatch = Readonly<{ }>; export function resolveAccountBirthTimeApplicationPatch( - current: AccountBirthTimeState, + current: AccountBirthTimeState | null, patch: AccountProfilePatch, ): AccountBirthTimeApplicationPatch { - const declarationChanged = declarationFields.some((field) => ( - patch[field] !== undefined && patch[field] !== current[field] - )); - if (!declarationChanged) return {}; + if (!current) { + return patch.birth_time_source ? { + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + } : {}; + } const confirmed = current.birth_time_status === "confirmed" || (current.birth_time_status === null && isBirthClockTime(current.birth_time ?? "")); if (confirmed) return {}; + + const declarationChanged = declarationFields.some((field) => ( + patch[field] !== undefined && patch[field] !== current[field] + )); + const repairsMissingStatus = current.birth_time_status === null + && current.active_birth_time === null + && current.birth_time === null + && current.rectification_case_id === null + && Boolean(patch.birth_time_source); + if (!declarationChanged && !repairsMissingStatus) return {}; return { active_birth_time: null, birth_time: null, diff --git a/frontend/supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql b/frontend/supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql new file mode 100644 index 00000000..82b6376c --- /dev/null +++ b/frontend/supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql @@ -0,0 +1,58 @@ +begin; + +-- Repair declarations saved while the account route failed to derive the +-- server-owned status. Applied/candidate/confirmed minutes remain untouched. +update public.profiles +set birth_time_status = 'reported', + updated_at = pg_catalog.now() +where birth_time_status is null + and active_birth_time is null + and birth_time is null + and rectification_case_id is null + and birth_date is not null + and ( + ( + birth_time_source = 'hospital_record' + and reported_birth_time is not null + and birth_time_period is null + and uncertainty_before_minutes = 2 + and uncertainty_after_minutes = 2 + ) + or ( + birth_time_source = 'family_exact' + and reported_birth_time is not null + and birth_time_period is null + and uncertainty_before_minutes in (5, 10, 15) + and uncertainty_after_minutes = uncertainty_before_minutes + ) + or ( + birth_time_source = 'approximate' + and reported_birth_time is not null + and birth_time_period is null + and uncertainty_before_minutes in (15, 30, 60) + and uncertainty_after_minutes = uncertainty_before_minutes + ) + or ( + birth_time_source = 'period_only' + and reported_birth_time is null + and birth_time_period in ('early_morning', 'morning', 'afternoon', 'evening', 'late_night') + and uncertainty_before_minutes is null + and uncertainty_after_minutes is null + ) + or ( + birth_time_source = 'unknown' + and reported_birth_time is null + and birth_time_period is null + and uncertainty_before_minutes is null + and uncertainty_after_minutes is null + ) + or ( + birth_time_source = 'legacy_import' + and reported_birth_time is not null + and birth_time_period is null + and uncertainty_before_minutes is null + and uncertainty_after_minutes is null + ) + ); + +commit; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 9c34f234..794ae944 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -12,6 +12,14 @@ import { const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); const patchSource = readFileSync(new URL("../src/lib/account-profile-patch.ts", import.meta.url), "utf8"); const caseServiceSource = readFileSync(new URL("../src/lib/account-rectification-case.ts", import.meta.url), "utf8"); +const reportedStatusMigration = readFileSync( + new URL("../supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql", import.meta.url), + "utf8", +); +const productionMigrationWorkflow = readFileSync( + new URL("../../.github/workflows/apply-production-rectification-migrations.yml", import.meta.url), + "utf8", +); test("account API reads and returns the server-configured rectification price", () => { assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/); @@ -435,6 +443,47 @@ test("ordinary declaration edits clear stale candidate application but never ove birth_time_status: "reported", rectification_case_id: null, }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...candidate, + active_birth_time: null, + birth_time: null, + birth_time_status: null, + rectification_case_id: null, + reported_birth_time: edited.reported_birth_time, + }, edited), { + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, edited), { + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...candidate, + birth_time_status: "confirmed", + }, edited), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...candidate, + active_birth_time: null, + birth_time: null, + birth_time_status: null, + reported_birth_time: edited.reported_birth_time, + }, edited), {}); +}); + +test("reported birth-time status repair is forward-only and wired into production migration flow", () => { + assert.match(reportedStatusMigration, /birth_time_status is null/); + assert.match(reportedStatusMigration, /birth_time_status = 'reported'/); + assert.match(reportedStatusMigration, /active_birth_time is null/); + assert.match(reportedStatusMigration, /rectification_case_id is null/); + assert.equal( + productionMigrationWorkflow.match(/20260726010000_backfill_reported_birth_time_status\.sql/g)?.length, + 2, + ); }); test("account PATCH uses the shared validator and never writes client birth_time over account truth", () => {