fix(reports): accept exact family birth time
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-16 16:55:09 +08:00
parent c712787ec2
commit a8279623aa
5 changed files with 260 additions and 7 deletions
+14
View File
@@ -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
- 修复版本:本地未提交候选
+46 -6
View File
@@ -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",
@@ -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;
+123 -1
View File
@@ -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/);
@@ -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"),