fix(account): answer profile writes with the derived birth-time truth
Independent Staging Quality Gate / validate (push) Failing after 10m56s
Independent Staging Quality Gate / publish (push) Has been skipped

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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 22:13:03 +08:00
co-authored by Cursor
parent 6ce4e67186
commit fd415e1d11
10 changed files with 254 additions and 27 deletions
+5 -1
View File
@@ -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 });
+31 -24
View File
@@ -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<Profile> {
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<HTMLFormElement>) {
@@ -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) {
+27
View File
@@ -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,
});
}
@@ -326,6 +326,31 @@ export function birthTimePersistenceValues(draft: BirthTimeDraft) {
};
}
const persistedBirthTimeStatuses = [
"reported", "assessing", "rectifying", "candidate", "accepted", "confirmed",
] as const satisfies readonly Exclude<BirthTimeStatus, "">[];
/**
* 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<T extends BirthTimeDraft>(
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}`;
+85
View File
@@ -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",
@@ -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\)/);
});
@@ -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, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
+45
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
applyBirthTimeDraftPatch,
applyPersistedBirthTime,
assistantIntentCopy,
birthTimeDisplayState,
birthTimeDraftReadyHint,
@@ -350,3 +351,47 @@ test("fresh intake preserves exact, approximate-period, and unknown-time paths w
assert.match(source, /birthTimeStatus: "reported"/);
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
});
test("a saved declaration adopts the birth-time truth the account write returned", () => {
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" },
);
});
@@ -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\(\)/);