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
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}`;