From a8279623aa2826481fad131e6134ff980287096d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 16 Aug 2026 16:55:09 +0800 Subject: [PATCH] fix(reports): accept exact family birth time --- docs/BUG_HISTORY.md | 14 ++ frontend/src/lib/account-profile-patch.ts | 52 +++++++- ...010000_accept_exact_family_birth_times.sql | 19 +++ frontend/tests/account-api.test.ts | 124 +++++++++++++++++- .../tests/database-local-business.test.ts | 58 ++++++++ 5 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 frontend/supabase/migrations/20260816010000_accept_exact_family_birth_times.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 795c5e69..92d9aa35 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3509,3 +3509,17 @@ - 防复发:服务器已经确定的 Skill 身份、指令和首个事实读取步骤不得再依赖模型自动选工具;所有 provider 调用前必须完成可审计的 Skill 绑定,首步工具面保持最小化,并继续以最终 `run.completed`、持久化 Turn 和计费不变量作为部署后验收标准。 - 相关记录:BUG-177、BUG-198、BUG-206 - 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 与健康检查验收为准) +## BUG-209 | 用户选择准确出生时间后仍停留 reported,个人报告固定返回 birth_time_not_usable + +- 状态:resolved(本地候选,待 staging 迁移、精确 SHA 发布与登录态报告验收) +- 首次发现:2026-08-16 +- 最近更新:2026-08-16 +- 影响面:初始化出生资料保存、账户资料编辑、`POST /api/reports` 出生时间可用性门槛、既有准确时间 Profile。 +- 用户现象:用户在初始资料明确选择“我知道准确出生时间”并填写具体分钟,资料与地点均完整,但生成个人报告仍返回 `422 birth_time_not_usable`。 +- 触发条件:Profile 保存为 `birth_time_source=family_exact`、前后误差均为 `0` 且有合法 `reported_birth_time`,但 `active_birth_time` 仍为空、`birth_time_status` 仍为 `reported`;报告接口正确要求 `accepted/confirmed + active_birth_time`,因此请求必然被拒绝。 +- 根因:账户资料写入逻辑把所有非引擎确认的出生时间声明统一降为 `reported + active null`,没有表达“用户明确采用自己提供的准确分钟”这一独立状态。初始化资料声明与报告事实门槛各自符合旧合同,但组合后准确时间永远无法成为报告可用时间。 +- 修复:账户资料应用层只对 `family_exact + 0/0 + 合法分钟` 写入 `active_birth_time=reported_birth_time` 与 `birth_time_status=accepted`,继续保留原始 `reported_birth_time`,绝不伪装为 `confirmed`;同一准确声明重新保存可修复既有 `reported`,修改已采用的准确分钟会同步新的 active time。带 10/15 分钟误差的 family 声明、approximate、period-only、unknown 仍保持 `reported + active null`,普通资料编辑仍不得覆盖 `confirmed`。新增 forward-only 业务迁移,仅回填无校正 Case、active 为空、状态为 reported 的严格 0/0 family-exact 记录;该迁移只进入 `frontend/supabase/migrations`,不污染 identity-only `frontend/db/migrations`。 +- 验证:账户回归覆盖新建、既有 reported 原样重存、已 accepted 分钟修改、confirmed/legacy confirmed 保护及所有非严格准确来源,13/13 通过;账户、出生时间 intake、报告 API 与报告入口聚焦测试 82/82 通过。PostgreSQL 全业务迁移测试实际执行新增 migration,验证严格 0/0 记录得到 `05:00:05:00:accepted`,10 分钟误差记录保持 `active null + reported`,1/1 通过;TypeScript `--noEmit`、目标 ESLint 与 `git diff --check` 通过。 +- 防复发:`reported` 表示用户声明但尚未采用,`accepted` 表示用户明确采用为当前排盘输入,`confirmed` 只表示引擎或校正流程确认;任何初始化来源语义变更必须同时覆盖 Profile 持久化、历史回填、报告服务端门槛和客户端入口,不得通过放宽报告接口读取未采用的 `reported_birth_time` 绕过事实边界。 +- 相关记录:BUG-125、BUG-196、BUG-197 +- 修复版本:本地未提交候选 diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 5af42620..7da6ad03 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -228,25 +228,55 @@ export function applyAccountProfileConcurrencyGuards< } export type AccountBirthTimeApplicationPatch = Readonly<{ - active_birth_time?: null; - birth_time_status?: "reported"; + active_birth_time?: string | null; + birth_time_status?: "accepted" | "reported"; rectification_case_id?: null; }>; +function normalizeApplicableBirthClock(value: string | null | undefined): string | null { + if (!value) return null; + if (isBirthClockTime(value)) return value; + return /^(?:[01]\d|2[0-3]):[0-5]\d:00(?:\.0+)?$/.test(value) + ? value.slice(0, 5) + : null; +} + +function resolveExactFamilyBirthTime( + current: AccountBirthTimeState | null, + patch: AccountProfilePatch, +): string | null { + const source = patch.birth_time_source !== undefined + ? patch.birth_time_source + : current?.birth_time_source; + const before = patch.uncertainty_before_minutes !== undefined + ? patch.uncertainty_before_minutes + : current?.uncertainty_before_minutes; + const after = patch.uncertainty_after_minutes !== undefined + ? patch.uncertainty_after_minutes + : current?.uncertainty_after_minutes; + const reportedTime = patch.reported_birth_time !== undefined + ? patch.reported_birth_time + : current?.reported_birth_time; + if (source !== "family_exact" || before !== 0 || after !== 0) return null; + return normalizeApplicableBirthClock(reportedTime); +} + export function resolveAccountBirthTimeApplicationPatch( current: AccountBirthTimeState | null, patch: AccountProfilePatch, ): AccountBirthTimeApplicationPatch { + const exactFamilyBirthTime = resolveExactFamilyBirthTime(current, patch); if (!current) { return patch.birth_time_source ? { - active_birth_time: null, - birth_time_status: "reported", + active_birth_time: exactFamilyBirthTime, + birth_time_status: exactFamilyBirthTime ? "accepted" : "reported", rectification_case_id: null, } : {}; } const confirmed = current.birth_time_status === "confirmed" - || (current.birth_time_status === null && isBirthClockTime(current.birth_time ?? "")); + || (current.birth_time_status === null + && normalizeApplicableBirthClock(current.birth_time) !== null); if (confirmed) return {}; const declarationChanged = declarationFields.some((field) => ( @@ -257,7 +287,17 @@ export function resolveAccountBirthTimeApplicationPatch( && current.birth_time === null && current.rectification_case_id === null && Boolean(patch.birth_time_source); - if (!declarationChanged && !repairsMissingStatus) return {}; + const repairsReportedExactTime = current.birth_time_status === "reported" + && current.active_birth_time === null + && current.rectification_case_id === null + && exactFamilyBirthTime !== null + && declarationFields.some((field) => patch[field] !== undefined); + if (!declarationChanged && !repairsMissingStatus && !repairsReportedExactTime) return {}; + if (exactFamilyBirthTime) return { + active_birth_time: exactFamilyBirthTime, + birth_time_status: "accepted", + rectification_case_id: null, + }; return { active_birth_time: null, birth_time_status: "reported", diff --git a/frontend/supabase/migrations/20260816010000_accept_exact_family_birth_times.sql b/frontend/supabase/migrations/20260816010000_accept_exact_family_birth_times.sql new file mode 100644 index 00000000..9ca454c6 --- /dev/null +++ b/frontend/supabase/migrations/20260816010000_accept_exact_family_birth_times.sql @@ -0,0 +1,19 @@ +begin; + +-- A zero-uncertainty family declaration is user-adopted chart input, not an +-- engine-confirmed minute. Repair only untouched reported profiles and keep the +-- original declaration in reported_birth_time. +update public.profiles +set active_birth_time = reported_birth_time, + birth_time_status = 'accepted', + updated_at = pg_catalog.now() +where birth_time_source = 'family_exact' + and uncertainty_before_minutes = 0 + and uncertainty_after_minutes = 0 + and reported_birth_time is not null + and extract(second from reported_birth_time) = 0 + and birth_time_status = 'reported' + and active_birth_time is null + and rectification_case_id is null; + +commit; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 8a7c7643..ebfd9841 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { accountProfilePatchSchema, @@ -12,6 +12,10 @@ const reportedStatusMigration = readFileSync( new URL("../supabase/migrations/20260726010000_backfill_reported_birth_time_status.sql", import.meta.url), "utf8", ); +const acceptedExactFamilyMigration = readFileSync( + new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url), + "utf8", +); const productionMigrationWorkflow = readFileSync( new URL("../../.github/workflows/apply-production-rectification-migrations.yml", import.meta.url), "utf8", @@ -224,6 +228,107 @@ test("ordinary declaration edits clear stale candidate application but never ove }, edited), {}); }); +test("zero-uncertainty family exact time becomes an accepted usable chart time", () => { + const exactDeclaration = { + birth_date: "1997-08-08", + reported_birth_time: "05:00", + birth_time_source: "family_exact", + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + } as const; + const reportedExactProfile = { + ...exactDeclaration, + reported_birth_time: "05:00:00", + active_birth_time: null, + birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + } as const; + + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, exactDeclaration), { + active_birth_time: "05:00", + birth_time_status: "accepted", + rectification_case_id: null, + }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(reportedExactProfile, exactDeclaration), { + active_birth_time: "05:00", + birth_time_status: "accepted", + rectification_case_id: null, + }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...reportedExactProfile, + active_birth_time: "05:00:00", + birth_time_status: "accepted", + }, { + ...exactDeclaration, + reported_birth_time: "05:40", + }), { + active_birth_time: "05:40", + birth_time_status: "accepted", + rectification_case_id: null, + }); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...reportedExactProfile, + active_birth_time: "05:00:00", + birth_time_status: "confirmed", + }, { + ...exactDeclaration, + reported_birth_time: "05:40", + }), {}); + assert.deepEqual(resolveAccountBirthTimeApplicationPatch({ + ...reportedExactProfile, + active_birth_time: "05:00:00", + birth_time: "05:00:00", + birth_time_status: null, + }, { + ...exactDeclaration, + reported_birth_time: "05:40", + }), {}); +}); + +test("only a strict family exact zero-uncertainty declaration is auto-accepted", () => { + const base = { + birth_date: "1997-08-08", + reported_birth_time: "05:00", + birth_time_source: "family_exact", + birth_time_period: null, + birth_time_clue: null, + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + } as const; + const declarations = [ + { ...base, uncertainty_before_minutes: 5, uncertainty_after_minutes: 5 }, + { ...base, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10 }, + { ...base, uncertainty_before_minutes: 15, uncertainty_after_minutes: 15 }, + { ...base, birth_time_source: "approximate", uncertainty_before_minutes: 30, uncertainty_after_minutes: 30 }, + { + ...base, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "morning", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + }, + { + ...base, + reported_birth_time: null, + birth_time_source: "unknown", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + }, + ] as const; + + for (const declaration of declarations) { + assert.deepEqual(resolveAccountBirthTimeApplicationPatch(null, declaration), { + active_birth_time: null, + birth_time_status: "reported", + rectification_case_id: null, + }); + } +}); + 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'/); @@ -235,6 +340,23 @@ test("reported birth-time status repair is forward-only and wired into productio ); }); +test("existing exact family declarations are forward-repaired without claiming confirmation", () => { + assert.match(acceptedExactFamilyMigration, /update public\.profiles/); + assert.match(acceptedExactFamilyMigration, /active_birth_time = reported_birth_time/); + assert.match(acceptedExactFamilyMigration, /birth_time_status = 'accepted'/); + assert.match(acceptedExactFamilyMigration, /birth_time_source = 'family_exact'/); + assert.match(acceptedExactFamilyMigration, /uncertainty_before_minutes = 0/); + assert.match(acceptedExactFamilyMigration, /uncertainty_after_minutes = 0/); + assert.match(acceptedExactFamilyMigration, /birth_time_status = 'reported'/); + assert.match(acceptedExactFamilyMigration, /active_birth_time is null/); + assert.match(acceptedExactFamilyMigration, /rectification_case_id is null/); + assert.doesNotMatch(acceptedExactFamilyMigration, /birth_time_status = 'confirmed'/); + assert.equal( + existsSync(new URL("../db/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url)), + false, + ); +}); + test("account PATCH uses the shared validator and never writes client birth_time over account truth", () => { assert.match(source, /accountProfilePatchSchema\.safeParse/); assert.match(source, /resolveAccountBirthTimeApplicationPatch/); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index d24d81e7..7b2ab7d1 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -13,6 +14,10 @@ import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath( new URL("../scripts/db-migrate.mjs", import.meta.url), ); +const acceptedExactFamilyMigration = readFileSync( + new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url), + "utf8", +); function rpcError(error: unknown): string { if (!error || typeof error !== "object") return ""; @@ -71,6 +76,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260814020000_rectification_v10_runtime\.sql/); assert.match(migration.stdout, /applied 20260814025000_personal_report_document_v2\.sql/); assert.match(migration.stdout, /applied 20260814040000_personal_report_jobs_v2\.sql/); + assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/); assert.equal( fixture.psql(` @@ -233,6 +239,58 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic `SET\n${userId}\nlocal-user@example.com`, ); fixture.psql(`update public.profiles set birth_date = '1997-08-08' where id = '${userId}'`); + fixture.psql(` + update public.profiles + set reported_birth_time = '05:00', + active_birth_time = null, + birth_time_source = 'family_exact', + uncertainty_before_minutes = 0, + uncertainty_after_minutes = 0, + birth_time_status = 'reported', + rectification_case_id = null + where id = '${userId}' + `); + fixture.psql(acceptedExactFamilyMigration); + assert.equal( + fixture.psql(` + select to_char(reported_birth_time, 'HH24:MI') || ':' || + to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status + from public.profiles + where id = '${userId}' + `), + "05:00:05:00:accepted", + ); + fixture.psql(` + update public.profiles + set reported_birth_time = '05:00', + active_birth_time = null, + birth_time_source = 'family_exact', + uncertainty_before_minutes = 10, + uncertainty_after_minutes = 10, + birth_time_status = 'reported', + rectification_case_id = null + where id = '${userId}' + `); + fixture.psql(acceptedExactFamilyMigration); + assert.equal( + fixture.psql(` + select active_birth_time is null || ':' || birth_time_status + from public.profiles + where id = '${userId}' + `), + "true:reported", + ); + fixture.psql(` + update public.profiles + set reported_birth_time = null, + active_birth_time = null, + birth_time_source = null, + uncertainty_before_minutes = null, + uncertainty_after_minutes = null, + birth_time_status = null, + rectification_case_id = null + where id = '${userId}' + `); const local = createLocalPostgresDataClient( fixture.connectionUrl("app_runtime", "app-runtime-test-password"),