fix(account): a new birth declaration drops a confirmed minute too (M2, BUG-264 product revision)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c61095b1eb
commit
5e97db4077
@@ -3932,6 +3932,7 @@
|
||||
- 相关记录:BUG-018(首次保存未写入档案状态,同一类真值不一致)、BUG-017(`mode_changed` 的另一入口)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
- 2026-09-29 产品修订(`TASK-mobile-chart-and-confirmed-edit-20260929` M2,不是本 Bug 复发):原约定「已确认与 legacy 分钟不被普通资料修改覆盖」改为——**只有声明字段(出生日期、时间与来源、时段 / 范围、地点代码与坐标、时区)真的变了**,已确认或 legacy 分钟才被丢掉,之后按新声明派生,与未确认状态完全一致(零误差家人时间 → `accepted` + 新分钟,否则 `reported`,`rectification_case_id` 清空);同时清掉图表会回落读取的遗留 `birth_time`,以及跨午夜采用三列 `active_birth_date` / `active_birth_timezone_offset` / `active_birth_provenance`(数据库触发器 `zz_guard_adopted_birth_date` 也会清)。只改称呼、岁差、性别、头像不动已确认分钟。影响:用户改出生资料会让已采用的生时校正结果作废,这是产品要的(「用户改生日肯定是得到信息了」)。同时把声明比较改成按值比较(库里的 `HH:MM:SS` 与表单提交的 `HH:MM`、数字与数字字符串、空串与 null 视为相同),否则 `/people` 每次整表重提都会被误判为新声明。`account-api.test.ts` 与 `chart-profile-update-consistency.test.ts` 里锁旧约定的断言按三栏改写,新增一条「confirmed 分钟只跟随新声明」测试。数据库侧:`guard_birth_time_journey`(最新版 20260804020000)只在状态为空且有分钟时补 `confirmed`,本修订总是写明确状态,不会被改回;`zz_guard_adopted_birth_date`、`agentic_rectification_profiles_rebaseline_guard`、`invalidate_agentic_rectification_results_on_profile_change` 在声明变化时照旧触发。本机无 Docker,未跑 `npm run test:db`,staging 实测见真机清单。
|
||||
|
||||
## BUG-265 | 首页“今日星语”号称个人化,实际是 4 张写死卡片轮换
|
||||
|
||||
|
||||
@@ -269,6 +269,12 @@ export type AccountBirthTimeApplicationPatch = Readonly<{
|
||||
active_birth_time?: string | null;
|
||||
birth_time_status?: "accepted" | "reported";
|
||||
rectification_case_id?: null;
|
||||
/** Only when a confirmed or legacy minute is dropped: the legacy column the chart falls back to. */
|
||||
birth_time?: null;
|
||||
/** Only when a confirmed adoption is dropped; `guard_adopted_birth_date` clears them too. */
|
||||
active_birth_date?: null;
|
||||
active_birth_timezone_offset?: null;
|
||||
active_birth_provenance?: null;
|
||||
}>;
|
||||
|
||||
function normalizeApplicableBirthClock(value: string | null | undefined): string | null {
|
||||
@@ -299,6 +305,29 @@ function resolveExactFamilyBirthTime(
|
||||
return normalizeApplicableBirthClock(reportedTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored and submitted declarations differ only in shape: Postgres returns a
|
||||
* `time` as `HH:MM:SS`, the form sends `HH:MM`; numbers may arrive as strings;
|
||||
* an empty string and null both mean "not given". The profile form resubmits
|
||||
* every birth field on each save, so a raw `!==` would read a name-only save
|
||||
* as a new declaration and drop a confirmed minute.
|
||||
*/
|
||||
function sameDeclarationValue(submitted: unknown, stored: unknown): boolean {
|
||||
const empty = (value: unknown) => value === null || value === undefined || value === "";
|
||||
if (empty(submitted) || empty(stored)) return empty(submitted) && empty(stored);
|
||||
if (typeof submitted === "string" && typeof stored === "string") {
|
||||
const clock = /^(\d{2}:\d{2})(?::00(?:\.0+)?)?$/;
|
||||
const a = clock.exec(submitted);
|
||||
const b = clock.exec(stored);
|
||||
if (a && b) return a[1] === b[1];
|
||||
return submitted === stored;
|
||||
}
|
||||
const a = Number(submitted);
|
||||
const b = Number(stored);
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) return a === b;
|
||||
return submitted === stored;
|
||||
}
|
||||
|
||||
export function resolveAccountBirthTimeApplicationPatch(
|
||||
current: AccountBirthTimeState | null,
|
||||
patch: AccountProfilePatch,
|
||||
@@ -315,11 +344,28 @@ export function resolveAccountBirthTimeApplicationPatch(
|
||||
const confirmed = current.birth_time_status === "confirmed"
|
||||
|| (current.birth_time_status === null
|
||||
&& normalizeApplicableBirthClock(current.birth_time) !== null);
|
||||
if (confirmed) return {};
|
||||
|
||||
const declarationChanged = declarationFields.some((field) => (
|
||||
patch[field] !== undefined && patch[field] !== current[field]
|
||||
patch[field] !== undefined && !sameDeclarationValue(patch[field], current[field])
|
||||
));
|
||||
// Product 2026-09-29 (TASK-mobile-chart-and-confirmed-edit M2, revising
|
||||
// BUG-264): a confirmed or adopted minute survives every edit except a new
|
||||
// declaration. Once the user changes the birth date, time or place, the chart
|
||||
// follows the new declaration exactly as an unconfirmed one would, and the
|
||||
// old minute is dropped everywhere the chart could still read it.
|
||||
if (confirmed) {
|
||||
if (!declarationChanged) return {};
|
||||
return {
|
||||
...(exactFamilyBirthTime
|
||||
? { active_birth_time: exactFamilyBirthTime, birth_time_status: "accepted" as const }
|
||||
: { active_birth_time: null, birth_time_status: "reported" as const }),
|
||||
rectification_case_id: null,
|
||||
...(current.birth_time != null ? { birth_time: null } : {}),
|
||||
...(current.active_birth_date != null || current.active_birth_timezone_offset != null
|
||||
? { active_birth_date: null, active_birth_timezone_offset: null, active_birth_provenance: null }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const repairsMissingStatus = current.birth_time_status === null
|
||||
&& current.active_birth_time === null
|
||||
&& current.birth_time === null
|
||||
|
||||
@@ -156,7 +156,10 @@ test("profile patch schema validates calendar, clock, source requirements, and l
|
||||
}).success, false);
|
||||
});
|
||||
|
||||
test("ordinary declaration edits clear stale candidate application but never overwrite confirmed active time", () => {
|
||||
test("ordinary declaration edits clear stale candidate application, and a new declaration drops a confirmed minute too", () => {
|
||||
// 原值(测试名):…but never overwrite confirmed active time
|
||||
// 新值:…and a new declaration drops a confirmed minute too
|
||||
// 原因:产品 2026-09-29 推翻 BUG-264 约定:改了出生日期 / 时间 / 地点就按新资料排盘,即使原来已确认(TASK-mobile-chart-and-confirmed-edit M2)
|
||||
const candidate = {
|
||||
birth_date: "1997-08-08",
|
||||
reported_birth_time: "05:30",
|
||||
@@ -214,10 +217,18 @@ test("ordinary declaration edits clear stale candidate application but never ove
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
});
|
||||
// 原值:confirmed + 新声明 → {}(已确认分钟不动)
|
||||
// 新值:confirmed + 新声明 → 回到 reported,并清掉遗留 birth_time(图表会回落读它)
|
||||
// 原因:产品 2026-09-29 M2;只改岁差仍然不动(下一条断言原样保留)
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...candidate,
|
||||
birth_time_status: "confirmed",
|
||||
}, edited), {});
|
||||
}, edited), {
|
||||
active_birth_time: null,
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
birth_time: null,
|
||||
});
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...candidate,
|
||||
birth_time_status: "confirmed",
|
||||
@@ -267,10 +278,16 @@ test("ordinary declaration edits clear stale candidate application but never ove
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
});
|
||||
// 原值 / 新值 / 原因:同上一处 confirmed 断言(产品 2026-09-29 M2)
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...candidate,
|
||||
birth_time_status: "confirmed",
|
||||
}, edited), {});
|
||||
}, edited), {
|
||||
active_birth_time: null,
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
birth_time: null,
|
||||
});
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...candidate,
|
||||
active_birth_time: null,
|
||||
@@ -320,6 +337,9 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time",
|
||||
birth_time_status: "accepted",
|
||||
rectification_case_id: null,
|
||||
});
|
||||
// 原值:confirmed / legacy 分钟 + 新的精确家人时间 → {}
|
||||
// 新值:→ accepted + 新分钟;legacy 行另清遗留 birth_time
|
||||
// 原因:产品 2026-09-29 M2,已确认分钟也跟随新声明
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...reportedExactProfile,
|
||||
active_birth_time: "05:00:00",
|
||||
@@ -327,7 +347,11 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time",
|
||||
}, {
|
||||
...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",
|
||||
@@ -336,7 +360,12 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time",
|
||||
}, {
|
||||
...exactDeclaration,
|
||||
reported_birth_time: "05:40",
|
||||
}), {});
|
||||
}), {
|
||||
active_birth_time: "05:40",
|
||||
birth_time_status: "accepted",
|
||||
rectification_case_id: null,
|
||||
birth_time: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("account PATCH answers with the birth-time truth it derived server-side", () => {
|
||||
@@ -391,6 +420,9 @@ test("account PATCH answers with the birth-time truth it derived server-side", (
|
||||
);
|
||||
|
||||
// An untouched application keeps the stored truth, including a legacy confirmed minute.
|
||||
// 原值:confirmed / legacy 分钟 + 改 district_code → 仍 confirmed 05:18
|
||||
// 新值:改地点属于新声明 → 按声明走(本夹具声明是零误差家人时间 05:00,于是 accepted 05:00);只改称呼 / 岁差仍保留 confirmed 05:18
|
||||
// 原因:产品 2026-09-29 M2(改日期、时间或地点就按新资料排盘)
|
||||
const confirmedProfile = {
|
||||
...reportedExactProfile,
|
||||
active_birth_time: "05:18:00",
|
||||
@@ -401,6 +433,13 @@ test("account PATCH answers with the birth-time truth it derived server-side", (
|
||||
confirmedProfile,
|
||||
resolveAccountBirthTimeApplicationPatch(confirmedProfile, { district_code: "130407" }),
|
||||
),
|
||||
{ status: "accepted", activeTime: "05:00" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveAppliedAccountBirthTime(
|
||||
confirmedProfile,
|
||||
resolveAccountBirthTimeApplicationPatch(confirmedProfile, { name: "示例", ayanamsa: "lahiri" }),
|
||||
),
|
||||
{ status: "confirmed", activeTime: "05:18" },
|
||||
);
|
||||
const legacyProfile = {
|
||||
@@ -413,6 +452,13 @@ test("account PATCH answers with the birth-time truth it derived server-side", (
|
||||
legacyProfile,
|
||||
resolveAccountBirthTimeApplicationPatch(legacyProfile, { district_code: "130407" }),
|
||||
),
|
||||
{ status: "accepted", activeTime: "05:00" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveAppliedAccountBirthTime(
|
||||
legacyProfile,
|
||||
resolveAccountBirthTimeApplicationPatch(legacyProfile, { name: "示例" }),
|
||||
),
|
||||
{ status: "confirmed", activeTime: "05:18" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -576,3 +622,86 @@ test("candidate invalidation compares coordinates and timezone in the conditiona
|
||||
assert.deepEqual(calls.find((call) => call[1] === "active_birth_time"), ["eq", "active_birth_time", "05:18"]);
|
||||
assert.deepEqual(calls.find((call) => call[1] === "birth_time_status"), ["eq", "birth_time_status", "candidate"]);
|
||||
});
|
||||
|
||||
// TASK-mobile-chart-and-confirmed-edit-20260929 M2 (product 2026-09-29): a new
|
||||
// declaration drops a confirmed or adopted minute; nothing else does.
|
||||
test("confirmed minute follows a new declaration and survives everything else", () => {
|
||||
const confirmed = {
|
||||
birth_date: "1994-05-18",
|
||||
reported_birth_time: "07:40:00",
|
||||
birth_time_source: "approximate",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
active_birth_time: "07:52:00",
|
||||
active_birth_date: "1994-05-18",
|
||||
active_birth_timezone_offset: 8,
|
||||
birth_time: null,
|
||||
birth_time_status: "confirmed",
|
||||
rectification_case_id: "11111111-1111-4111-8111-111111111111",
|
||||
country_code: "CN",
|
||||
province_code: "330000",
|
||||
city_code: "330100",
|
||||
district_code: "330106",
|
||||
latitude: 30.27,
|
||||
longitude: 120.15,
|
||||
timezone_offset: 8,
|
||||
} as const;
|
||||
// The form resubmits every birth field; stored `HH:MM:SS` vs submitted `HH:MM` is not a change.
|
||||
const resubmitted = {
|
||||
birth_date: "1994-05-18",
|
||||
reported_birth_time: "07:40",
|
||||
birth_time_source: "approximate",
|
||||
birth_time_period: null,
|
||||
birth_time_clue: null,
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
country_code: "CN",
|
||||
province_code: "330000",
|
||||
city_code: "330100",
|
||||
district_code: "330106",
|
||||
latitude: 30.27,
|
||||
longitude: 120.15,
|
||||
timezone_offset: 8,
|
||||
} as const;
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, name: "示例" }), {});
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ayanamsa: "lahiri" }), {});
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { gender: "female" }), {});
|
||||
|
||||
const cleared = { active_birth_date: null, active_birth_timezone_offset: null, active_birth_provenance: null };
|
||||
// New exact family time → accepted at the new minute.
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, {
|
||||
...resubmitted,
|
||||
reported_birth_time: "08:05",
|
||||
birth_time_source: "family_exact",
|
||||
uncertainty_before_minutes: 0,
|
||||
uncertainty_after_minutes: 0,
|
||||
}), { active_birth_time: "08:05", birth_time_status: "accepted", rectification_case_id: null, ...cleared });
|
||||
// Period only → reported, no minute.
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, {
|
||||
...resubmitted,
|
||||
reported_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_period: "morning",
|
||||
uncertainty_before_minutes: null,
|
||||
uncertainty_after_minutes: null,
|
||||
}), { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared });
|
||||
// A new date drops the adopted cross-midnight date, offset and provenance.
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, birth_date: "1994-05-19" }),
|
||||
{ active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared });
|
||||
// A new place (coordinates) is a new declaration too.
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, { ...resubmitted, latitude: 31.23, longitude: 121.47 }),
|
||||
{ active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, ...cleared });
|
||||
// Without adoption columns, nothing extra is written.
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch({
|
||||
...confirmed, active_birth_date: null, active_birth_timezone_offset: null,
|
||||
}, { ...resubmitted, birth_date: "1994-05-19" }),
|
||||
{ active_birth_time: null, birth_time_status: "reported", rectification_case_id: null });
|
||||
|
||||
// Legacy minute (status null, birth_time set): same rule, and the legacy column the chart falls back to is cleared.
|
||||
const legacy = { ...confirmed, active_birth_time: null, active_birth_date: null, active_birth_timezone_offset: null, birth_time: "07:52:00", birth_time_status: null };
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(legacy, { ...resubmitted, name: "示例" }), {});
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(legacy, { ...resubmitted, birth_date: "1994-05-19" }),
|
||||
{ active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, birth_time: null });
|
||||
});
|
||||
|
||||
@@ -101,7 +101,10 @@ test("account and chart-view share server-owned date, time, offset, and provenan
|
||||
assert.equal(adoptedBirth.failure, null);
|
||||
});
|
||||
|
||||
test("confirmed ordinary edits keep the active minute and clear a complete adopted tuple together", () => {
|
||||
// 原值(测试名与断言):confirmed ordinary edits keep the active minute…;application = {},排盘仍 confirmed 23:55(legacy)
|
||||
// 新值:confirmed declaration edits drop the minute…;application 回到 reported 并显式清空跨午夜三列,排盘按新声明 00:20(declared)
|
||||
// 原因:产品 2026-09-29 推翻 BUG-264 约定——改了出生日期 / 时间 / 地点就按新资料排盘(TASK-mobile-chart-and-confirmed-edit M2)
|
||||
test("confirmed declaration edits drop the minute and clear a complete adopted tuple together", () => {
|
||||
const confirmed = { ...crossMidnight, birth_time_status: "confirmed" as const };
|
||||
const patch = {
|
||||
birth_date: "2000-06-16",
|
||||
@@ -112,22 +115,31 @@ test("confirmed ordinary edits keep the active minute and clear a complete adopt
|
||||
uncertainty_before_minutes: 30,
|
||||
uncertainty_after_minutes: 30,
|
||||
};
|
||||
assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, patch), {});
|
||||
const stored = applyAdoptedBirthDateGuard(confirmed, { ...confirmed, ...patch });
|
||||
assert.equal(stored.active_birth_time, "23:55");
|
||||
const application = resolveAccountBirthTimeApplicationPatch(confirmed, patch);
|
||||
assert.deepEqual(application, {
|
||||
active_birth_time: null,
|
||||
birth_time_status: "reported",
|
||||
rectification_case_id: null,
|
||||
active_birth_date: null,
|
||||
active_birth_timezone_offset: null,
|
||||
active_birth_provenance: null,
|
||||
});
|
||||
const current = { ...confirmed } as Record<string, unknown>;
|
||||
const stored = applyAdoptedBirthDateGuard(current, { ...current, ...patch, ...application });
|
||||
assert.equal(stored.active_birth_time, null);
|
||||
assert.equal(stored.active_birth_date, null);
|
||||
assert.equal(stored.active_birth_timezone_offset, null);
|
||||
assert.equal(stored.active_birth_provenance, null);
|
||||
const birth = resolveServerOwnedChartBirth(stored);
|
||||
assert.equal(birth.status, "confirmed");
|
||||
assert.equal(birth.time, "23:55");
|
||||
assert.equal(birth.status, "reported");
|
||||
assert.equal(birth.time, "00:20");
|
||||
assert.equal(birth.date, "2000-06-16");
|
||||
assert.equal(birth.timezoneOffset, -4);
|
||||
assert.equal(birth.adoption, "legacy");
|
||||
assert.equal(birth.adoption, "declared");
|
||||
assert.equal(birth.failure, null);
|
||||
const echoed = chartBirthAfterAccountWrite({
|
||||
current: confirmed,
|
||||
written: { ...confirmed, ...patch },
|
||||
written: { ...confirmed, ...patch, ...application },
|
||||
returned: stored,
|
||||
});
|
||||
assert.equal(echoed.fingerprint, birth.fingerprint);
|
||||
|
||||
@@ -136,3 +136,19 @@ test("guard: the product keeps at least one edit entry for the owner's birth dat
|
||||
assert.match(onboardingSource, /await saveSelfProfile\(nextProfile\)/);
|
||||
assert.doesNotMatch(onboardingSource, /async function saveProfile/);
|
||||
});
|
||||
|
||||
// TASK-mobile-chart-and-confirmed-edit-20260929 M2: once the server drops a
|
||||
// confirmed minute, the saved profile carries the new truth, not the old one.
|
||||
test("saving a new time over a confirmed minute takes the server's new status and minute", async () => {
|
||||
const confirmedSelf: Profile = { ...selfProfile, time: "07:52", birthTimeStatus: "confirmed" };
|
||||
const accepted = await saveSelfProfile({ ...confirmedSelf, reportedTime: "08:05" }, fakeFetch(200, {
|
||||
birthTime: { status: "accepted", activeTime: "08:05:00", activeDate: null, activeTimezoneOffset: null },
|
||||
}, []));
|
||||
assert.equal(accepted.profile.birthTimeStatus, "accepted");
|
||||
assert.equal(accepted.profile.time, "08:05");
|
||||
const reported = await saveSelfProfile({ ...confirmedSelf, birthTimeSource: "period_only", reportedTime: "" }, fakeFetch(200, {
|
||||
birthTime: { status: "reported", activeTime: null, activeDate: null, activeTimezoneOffset: null },
|
||||
}, []));
|
||||
assert.equal(reported.profile.birthTimeStatus, "reported");
|
||||
assert.equal(reported.profile.time, "");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user