fix(reports): accept exact family birth time
This commit is contained in:
@@ -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
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user