From fd415e1d1179557f6f4da841c148a7e4d9e4cf62 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 22:13:03 +0800 Subject: [PATCH] fix(account): answer profile writes with the derived birth-time truth A zero-uncertainty exact declaration is accepted server-side as the active minute, but the account write only answered {ok:true}. Every save path then kept the draft it submitted, so the first consultation after initialization asked for unverified_birth_time against an accepted profile and was rejected with mode_changed before billing. The account route now returns the status and active minute it derived, and every profile save adopts that result instead of its own local guess. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++++ frontend/src/app/api/account/route.ts | 6 +- frontend/src/app/page.tsx | 55 ++++++------ frontend/src/lib/account-profile-patch.ts | 27 ++++++ frontend/src/lib/birth-time-intake-model.ts | 25 ++++++ frontend/tests/account-api.test.ts | 85 +++++++++++++++++++ .../birth-time-consultation-consent.test.ts | 2 +- .../birth-time-guided-review-fixes.test.ts | 2 +- frontend/tests/birth-time-intake.test.ts | 45 ++++++++++ frontend/tests/profile-persistence.test.ts | 18 ++++ 10 files changed, 254 insertions(+), 27 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 058bfb68..f6cad7cd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3893,3 +3893,19 @@ - 防复发:滚动容器留了 `padding-bottom` 时,它的 `position: sticky` 子元素永远无法贴到容器可视底边——sticky 受包含块内容盒夹持,调 `bottom` 偏移不解决问题。悬浮在输入框上方的控件应挂在输入框容器上(`bottom: 100%`),而不是挂在滚动容器里,这样才不依赖任何预留高度常量。另:BUG-218 当时已写明“浏览器内的视觉位置未经人工目视确认”,本条正是那句话对应的实际后果——纯源码合同测试能固定 DOM 与属性,固定不了几何位置,涉及定位的改动必须实测。 - 相关记录:BUG-218(引入该按钮与锚定逻辑)、BUG-252(曾把它记作 BUG-217 新增,并留下焦点丢失的待跟进项,本轮未处理) - 修复版本:本地未提交候选 + +## BUG-264 | 初始化填报准确出生时间后,第一条咨询必定 409:客户端按提交的声明选路,服务端已把它升级为 accepted + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/` 初始化流程的出生时间与出生地点保存、`PATCH /api/account`、`POST /api/consult` 的出生真值校验。 +- 用户现象:新用户在初始化里选择“我知道准确出生时间”并填到分钟,走完地点一步进入首页,发出第一条咨询即被拒绝,提示“出生时间状态已经变化 / 请刷新后重新选择使用填报时间、一般咨询或先完成校正”。刷新页面后同一条问题可以正常发出。 +- 触发条件:声明为 `family_exact` 且误差为 0,经账户接口保存后不刷新页面直接发第一条咨询。 +- 根因:账户写入会在服务端派生出生真值——零误差的准确时间被直接采用为 `active_birth_time` 且状态升级为 `accepted`——但 `PATCH /api/account` 只回 `{ok:true}`,页面又用提交的草稿覆盖 `profile`,草稿里状态仍是 `reported`、`time` 为空。于是客户端按未确认分钟选 `unverified_birth_time`,而咨询接口的未确认分支明确拒绝 `accepted`,在扣点前抛出 `mode_changed` 返回 409。派生规则写在服务端、客户端却各自推断同一件事,是这次不一致的入口。 +- 修复:`PATCH /api/account` 随写入成功返回它派生的出生真值(状态与当前排盘分钟),新增 `resolveAppliedAccountBirthTime` 负责这一派生;`persistProfile` 返回按该真值对账后的档案,初始化出生时间、初始化地点、账户弹窗保存和默认星盘切换四条保存路径统一采用返回值,不再沿用本地草稿。服务端的真值校验保持严格,不为客户端的过期视图放宽。 +- 验证:`account-api.test.ts` 锁定派生结果(零误差准确时间→`accepted`+分钟;改为时段声明→回落 `reported`;已确认与 legacy 分钟不被覆盖;纯改名不产生状态);`birth-time-intake.test.ts` 锁定客户端对账(含 `HH:mm:ss`、未知状态和缺字段时不动草稿);`profile-persistence.test.ts` 断言每条保存路径都采用返回档案,禁止回退到 `setProfile(profileDraft)`。`npm test` 1710 项中 1709 通过,唯一失败是并发跑 `database-*` postgres fixture 的既有抖动,单独串行复跑通过;`tsc --noEmit` 与目标文件 ESLint 清洁。未做的验证:**没有在 staging 真实新用户流程里目视复跑一遍**,需要一个未初始化的账户。 +- 防复发:出生时间状态与当前排盘分钟由服务端唯一派生,客户端只能采用接口返回值;任何新增的档案保存路径都必须消费 `persistProfile` 的返回档案,源码合同测试会拦住用本地草稿覆盖 `profile` 的写法。咨询选路不得从未落库的草稿推导。 +- 相关记录:BUG-018(首次保存未写入档案状态,同一类真值不一致)、BUG-017(`mode_changed` 的另一入口) +- 复发自:无 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 61ae62bc..bbe86bdc 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -6,6 +6,7 @@ import { accountProfilePatchSchema, applyAccountProfileConcurrencyGuards, resolveAccountBirthTimeApplicationPatch, + resolveAppliedAccountBirthTime, } from "@/lib/account-profile-patch"; import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar"; import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; @@ -271,7 +272,10 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 }); } - return NextResponse.json({ ok: true }); + return NextResponse.json({ + ok: true, + birthTime: resolveAppliedAccountBirthTime(currentProfile, applicationPatch), + }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6a2c2aa9..bf9c4ee8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -43,6 +43,7 @@ import { import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { applyBirthTimeDraftPatch, + applyPersistedBirthTime, assistantIntentCopy, birthTimeDisplayState, birthTimePersistenceValues, @@ -2061,9 +2062,9 @@ export default function Home() { } } - async function persistProfile(nextProfile: Profile) { + async function persistProfile(nextProfile: Profile): Promise { if (!account) throw new Error("账户尚未加载完成"); - if (process.env.NODE_ENV === "development" && uiPreview.current) return; + if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile; const birthPlace = selectedBirthPlace(nextProfile); const response = await fetch("/api/account", { method: "PATCH", @@ -2088,11 +2089,16 @@ export default function Home() { timezone_source: nextProfile.timezoneSource || null, }), }); + const payload = await response.json().catch(() => null) as { + error?: string; + birthTime?: unknown; + } | null; if (!response.ok) { - const payload = await response.json().catch(() => null) as { error?: string } | null; throw new Error(payload?.error || "账户资料暂时无法保存。"); } - await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null); + const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime); + await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null); + return savedProfile; } async function saveOtherChart(event: FormEvent) { @@ -2154,9 +2160,9 @@ export default function Home() { setProfileSaving(true); setAccountError(""); try { - await persistProfile(record.profile); - setProfile(record.profile); - setProfileDraft(record.profile); + const savedProfile = await persistProfile(record.profile); + setProfile(savedProfile); + setProfileDraft(savedProfile); setProfileNotice("已设为当前默认星盘。"); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败")); @@ -2195,17 +2201,17 @@ export default function Home() { setAccountError(""); try { const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft); - await persistProfile(profileDraft); - setProfile(profileDraft); - setProfileDraft(profileDraft); + const savedProfile = await persistProfile(profileDraft); + setProfile(savedProfile); + setProfileDraft(savedProfile); setRectificationError(""); if (declarationChanged) { setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); void refreshAccount(); } - setProfileNotice(profileDraft.birthTimeStatus === "confirmed" + setProfileNotice(savedProfile.birthTimeStatus === "confirmed" ? "出生资料已保存到云端,可在同一账号的其他设备使用。" - : `出生资料已保存。${birthTimeConsultationOptionsCopy(profileDraft)}`); + : `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`); } catch (caught) { setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败")); } finally { @@ -2220,13 +2226,13 @@ export default function Home() { setProfileSaving(true); setAccountError(""); try { - await persistProfile(nextProfile); - setProfile(nextProfile); - setProfileDraft(nextProfile); - setStartGreeting(createStartGreeting(nextProfile.name)); + const savedProfile = await persistProfile(nextProfile); + setProfile(savedProfile); + setProfileDraft(savedProfile); + setStartGreeting(createStartGreeting(savedProfile.name)); setDraft(""); setPresetMessageLength(0); - const nextStep = missingProfileStep(nextProfile); + const nextStep = missingProfileStep(savedProfile); if (nextStep) setOnboardingStep(nextStep); else setOnboardingJustCompleted(false); } catch (caught) { @@ -2243,11 +2249,12 @@ export default function Home() { setBirthTimeAssessmentPhase("saving_profile"); setAccountError(""); try { - await persistProfile(profileDraft); + const savedProfile = await persistProfile(profileDraft); birthTimeRevisionPending.current = false; - setProfile(profileDraft); + setProfile(savedProfile); + setProfileDraft(savedProfile); setPresetMessageLength(0); - const nextStep = missingProfileStep(profileDraft); + const nextStep = missingProfileStep(savedProfile); if (nextStep) setOnboardingStep(nextStep); else setOnboardingJustCompleted(false); } catch (caught) { @@ -2272,10 +2279,10 @@ export default function Home() { setBirthTimeAssessmentPhase("entering_home"); setAccountError(""); try { - await persistProfile(profileDraft); - setProfile(profileDraft); - setProfileDraft(profileDraft); - setStartGreeting(createStartGreeting(profileDraft.name)); + const savedProfile = await persistProfile(profileDraft); + setProfile(savedProfile); + setProfileDraft(savedProfile); + setStartGreeting(createStartGreeting(savedProfile.name)); setPresetMessageLength(0); setOnboardingJustCompleted(false); } catch (caught) { diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 7da6ad03..af97c35d 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -304,3 +304,30 @@ export function resolveAccountBirthTimeApplicationPatch( rectification_case_id: null, }; } + +export type AppliedAccountBirthTime = Readonly<{ + status: string | null; + activeTime: string | null; +}>; + +/** + * The birth-time truth the account owns after a successful write. Status and + * active minute are derived server-side, so a caller that keeps the declaration + * it submitted would consult under a mode the server no longer accepts. + */ +export function resolveAppliedAccountBirthTime( + current: AccountBirthTimeState | null, + applicationPatch: AccountBirthTimeApplicationPatch, +): AppliedAccountBirthTime { + const activeTime = normalizeApplicableBirthClock( + applicationPatch.active_birth_time !== undefined + ? applicationPatch.active_birth_time + : current?.active_birth_time ?? current?.birth_time, + ); + return Object.freeze({ + status: applicationPatch.birth_time_status + ?? current?.birth_time_status + ?? (activeTime ? "confirmed" : null), + activeTime, + }); +} diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index 9abf79db..03a253ba 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -326,6 +326,31 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) { }; } +const persistedBirthTimeStatuses = [ + "reported", "assessing", "rectifying", "candidate", "accepted", "confirmed", +] as const satisfies readonly Exclude[]; + +/** + * Reconciles a submitted declaration with the birth-time truth the account write + * returned. The server decides whether a declaration is already usable as the + * active minute, so consultation mode must never be derived from the draft alone. + */ +export function applyPersistedBirthTime( + draft: T, + applied: unknown, +): T { + if (applied === null || typeof applied !== "object") return draft; + const { status, activeTime } = applied as { status?: unknown; activeTime?: unknown }; + const persistedStatus = persistedBirthTimeStatuses.find((candidate) => candidate === status); + if (!persistedStatus) return draft; + const clock = typeof activeTime === "string" ? activeTime.slice(0, 5) : ""; + return { + ...draft, + time: isBirthClockTime(clock) ? clock : "", + birthTimeStatus: persistedStatus, + }; +} + export function describeBirthTimeDraft(draft: BirthTimeDraft) { const [year, month, day] = draft.date.split("-").map(Number); const date = `${year}年${month}月${day}日`; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index ebfd9841..05221274 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { accountProfilePatchSchema, resolveAccountBirthTimeApplicationPatch, + resolveAppliedAccountBirthTime, } from "../src/lib/account-profile-patch.ts"; const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); @@ -288,6 +289,90 @@ test("zero-uncertainty family exact time becomes an accepted usable chart time", }), {}); }); +test("account PATCH answers with the birth-time truth it derived server-side", () => { + 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; + + // A client that keeps the declaration it submitted would consult as + // unverified_birth_time and be rejected by the consultation truth check. + assert.deepEqual( + resolveAppliedAccountBirthTime( + null, + resolveAccountBirthTimeApplicationPatch(null, exactDeclaration), + ), + { status: "accepted", activeTime: "05:00" }, + ); + assert.deepEqual( + resolveAppliedAccountBirthTime( + reportedExactProfile, + resolveAccountBirthTimeApplicationPatch(reportedExactProfile, exactDeclaration), + ), + { status: "accepted", activeTime: "05:00" }, + ); + const periodDeclaration = { + ...exactDeclaration, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "early_morning", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + } as const; + assert.deepEqual( + resolveAppliedAccountBirthTime( + reportedExactProfile, + resolveAccountBirthTimeApplicationPatch(reportedExactProfile, periodDeclaration), + ), + { status: "reported", activeTime: null }, + ); + + // An untouched application keeps the stored truth, including a legacy confirmed minute. + const confirmedProfile = { + ...reportedExactProfile, + active_birth_time: "05:18:00", + birth_time_status: "confirmed", + } as const; + assert.deepEqual( + resolveAppliedAccountBirthTime( + confirmedProfile, + resolveAccountBirthTimeApplicationPatch(confirmedProfile, { district_code: "130407" }), + ), + { status: "confirmed", activeTime: "05:18" }, + ); + const legacyProfile = { + ...reportedExactProfile, + birth_time: "05:18:00", + birth_time_status: null, + } as const; + assert.deepEqual( + resolveAppliedAccountBirthTime( + legacyProfile, + resolveAccountBirthTimeApplicationPatch(legacyProfile, { district_code: "130407" }), + ), + { status: "confirmed", activeTime: "05:18" }, + ); + assert.deepEqual( + resolveAppliedAccountBirthTime(null, resolveAccountBirthTimeApplicationPatch(null, { name: "岳辰" })), + { status: null, activeTime: null }, + ); + + assert.match(source, /birthTime: resolveAppliedAccountBirthTime\(currentProfile, applicationPatch\)/); +}); + test("only a strict family exact zero-uncertainty declaration is auto-accepted", () => { const base = { birth_date: "1997-08-08", diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index 58e48589..5967914e 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -225,7 +225,7 @@ test("homepage and profile result copy use the source-aware consultation options const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); - assert.match(page, /birthTimeConsultationOptionsCopy\(profileDraft\)/); + assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/); assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(profile\)/); assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/); }); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 5a8c7058..d253bd1e 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -150,7 +150,7 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/); - assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(profileDraft\)\}`/); + assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/); assert.match(pageSource, / { + const exactDeclaration = { + ...emptyDraft, + birthTimeSource: "family_exact", + reportedTime: "05:00", + uncertaintyBeforeMinutes: 0, + uncertaintyAfterMinutes: 0, + birthTimeStatus: "reported", + } as const satisfies BirthTimeDraft; + + // The server accepts a zero-uncertainty exact time as the active minute, so the + // submitted draft must not keep claiming the time is still only reported. + assert.deepEqual( + applyPersistedBirthTime(exactDeclaration, { status: "accepted", activeTime: "05:00" }), + { ...exactDeclaration, time: "05:00", birthTimeStatus: "accepted" }, + ); + assert.deepEqual( + applyPersistedBirthTime({ ...exactDeclaration, time: "05:00", birthTimeStatus: "accepted" }, { + status: "reported", + activeTime: null, + }), + exactDeclaration, + ); + assert.deepEqual( + applyPersistedBirthTime(exactDeclaration, { status: "accepted", activeTime: "05:00:00" }), + { ...exactDeclaration, time: "05:00", birthTimeStatus: "accepted" }, + ); + + // A response without usable birth-time truth must leave the draft untouched. + for (const applied of [ + undefined, + null, + {}, + { status: "unknown_status", activeTime: "05:00" }, + { status: 7, activeTime: "05:00" }, + ]) { + assert.deepEqual(applyPersistedBirthTime(exactDeclaration, applied), exactDeclaration); + } + assert.deepEqual( + applyPersistedBirthTime(exactDeclaration, { status: "accepted", activeTime: "5:0" }), + { ...exactDeclaration, time: "", birthTimeStatus: "accepted" }, + ); +}); diff --git a/frontend/tests/profile-persistence.test.ts b/frontend/tests/profile-persistence.test.ts index 06f2c471..f87abbfc 100644 --- a/frontend/tests/profile-persistence.test.ts +++ b/frontend/tests/profile-persistence.test.ts @@ -8,6 +8,24 @@ test("upserts a missing profile when saving account details", () => { assert.match(source, /credentials:\s*"same-origin"/); }); +test("every profile save adopts the birth-time truth the account write returned", () => { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + + // Given: the account write derives birth-time status and active minute. + assert.match(source, /const savedProfile = applyPersistedBirthTime\(nextProfile, payload\?\.birthTime\)/); + assert.match(source, /return savedProfile;/); + + // When: any save path resolves. + // Then: it must apply the returned profile, or the next consultation runs under a + // mode the server rejects with mode_changed. + const savePaths = source.match(/[^\n]*await persistProfile\([^\n]*/g) ?? []; + assert.equal(savePaths.length > 0, true); + for (const savePath of savePaths) { + assert.match(savePath, /const savedProfile = await persistProfile\(/); + } + assert.doesNotMatch(source, /setProfile\(profileDraft\)/); +}); + test("account route upserts profiles with the server admin client", () => { const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); assert.match(source, /createAdminSupabaseClient\(\)/);