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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}日`;
|
||||
|
||||
Reference in New Issue
Block a user