fix: repair missing reported birth-time status

This commit is contained in:
Jesse_Chen
2026-07-26 13:14:14 +08:00
parent 5f492be1b9
commit cdf7a76766
6 changed files with 146 additions and 9 deletions
@@ -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}')"
+16
View File
@@ -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
- 修复版本:待提交(本地可测)
+1 -3
View File
@@ -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 } : {}),
+19 -5
View File
@@ -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,
@@ -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;
+49
View File
@@ -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", () => {