fix: harden birth-time consultation modes

This commit is contained in:
Jesse_Chen
2026-07-21 07:25:02 +08:00
parent fc1e9f1085
commit 8be39d5ad6
21 changed files with 1516 additions and 206 deletions
+84 -69
View File
@@ -3,6 +3,10 @@ import {
parseRectificationPriceCredits,
type AccountRectificationCaseState,
} from "@/lib/birth-time-consultation-consent";
import {
accountProfilePatchSchema,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
@@ -11,45 +15,8 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
type ProfilePatchPayload = {
name?: unknown;
birth_date?: unknown;
birth_time?: unknown;
reported_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
country_code?: unknown;
province_code?: unknown;
city_code?: unknown;
district_code?: unknown;
latitude?: unknown;
longitude?: unknown;
timezone_offset?: unknown;
};
const birthTimeSources = ["hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"] as const;
const birthTimePeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const rectificationStatuses = ["starting", "active", "paused", "confirming", "completed", "abandoned"] as const;
function nullableString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function nullableNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function nullableInteger(value: unknown) {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function nullableChoice(value: unknown, choices: readonly string[]) {
return typeof value === "string" && choices.includes(value) ? value : null;
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
@@ -88,6 +55,7 @@ export async function GET() {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const rectificationPriceCredits = parseRectificationPriceCredits(
process.env.RECTIFICATION_PRICE_CREDITS,
@@ -95,7 +63,7 @@ export async function GET() {
const { data: profile, error } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status")
.eq("id", user.id)
.eq("id", userId)
.single();
if (error) {
@@ -141,52 +109,99 @@ export async function PATCH(request: Request) {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
}
const parsedPayload = accountProfilePatchSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsedPayload.success) return NextResponse.json({
error: "账户资料格式不正确",
details: parsedPayload.error.flatten(),
}, { status: 400 });
const payload = parsedPayload.data;
const admin = createAdminSupabaseClient();
const { data: currentProfile, error: currentProfileError } = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", userId)
.maybeSingle();
if (currentProfileError) {
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
}
const applicationPatch = currentProfile
? resolveAccountBirthTimeApplicationPatch(currentProfile, payload)
: {};
const baseProfile = {
id: user.id,
name: nullableString(payload.name),
birth_date: nullableString(payload.birth_date),
birth_time: nullableString(payload.birth_time),
reported_birth_time: nullableString(payload.reported_birth_time),
birth_time_source: nullableChoice(payload.birth_time_source, birthTimeSources),
birth_time_period: nullableChoice(payload.birth_time_period, birthTimePeriods),
birth_time_clue: nullableString(payload.birth_time_clue),
uncertainty_before_minutes: nullableInteger(payload.uncertainty_before_minutes),
uncertainty_after_minutes: nullableInteger(payload.uncertainty_after_minutes),
country_code: nullableString(payload.country_code),
province_code: nullableString(payload.province_code),
city_code: nullableString(payload.city_code),
district_code: nullableString(payload.district_code),
id: userId,
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.birth_date !== undefined ? { birth_date: payload.birth_date } : {}),
...(payload.reported_birth_time !== undefined
? { reported_birth_time: payload.reported_birth_time }
: {}),
...(payload.birth_time_source !== undefined
? { birth_time_source: payload.birth_time_source }
: {}),
...(payload.birth_time_period !== undefined
? { birth_time_period: payload.birth_time_period }
: {}),
...(payload.birth_time_clue !== undefined
? { birth_time_clue: payload.birth_time_clue }
: {}),
...(payload.uncertainty_before_minutes !== undefined
? { uncertainty_before_minutes: payload.uncertainty_before_minutes }
: {}),
...(payload.uncertainty_after_minutes !== undefined
? { uncertainty_after_minutes: payload.uncertainty_after_minutes }
: {}),
...(payload.country_code !== undefined ? { country_code: payload.country_code } : {}),
...(payload.province_code !== undefined ? { province_code: payload.province_code } : {}),
...(payload.city_code !== undefined ? { city_code: payload.city_code } : {}),
...(payload.district_code !== undefined ? { district_code: payload.district_code } : {}),
...applicationPatch,
updated_at: new Date().toISOString(),
};
const withCoordinates = {
...baseProfile,
latitude: nullableNumber(payload.latitude),
longitude: nullableNumber(payload.longitude),
timezone_offset: nullableNumber(payload.timezone_offset),
...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}),
...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}),
...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}),
};
const withoutCoordinates = baseProfile;
let { data, error } = await admin
.from("profiles")
.upsert(withCoordinates, { onConflict: "id" })
.select("id")
.single();
const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0;
async function writeProfile(values: Record<string, unknown>) {
if (!currentProfile) {
return admin
.from("profiles")
.upsert(values, { onConflict: "id" })
.select("id")
.maybeSingle();
}
let query = admin.from("profiles").update(values).eq("id", userId);
if (invalidatesUnconfirmedApplication) {
query = currentProfile.birth_time_status === null
? query.is("birth_time_status", null)
: query.eq("birth_time_status", currentProfile.birth_time_status);
query = currentProfile.active_birth_time === null
? query.is("active_birth_time", null)
: query.eq("active_birth_time", currentProfile.active_birth_time);
}
return query.select("id").maybeSingle();
}
let { data, error } = await writeProfile(withCoordinates);
if (error && isMissingProfileColumn(error)) {
const fallback = await admin
.from("profiles")
.upsert(withoutCoordinates, { onConflict: "id" })
.select("id")
.single();
const fallback = await writeProfile(withoutCoordinates);
data = fallback.data;
error = fallback.error;
}
if (!error && !data && invalidatesUnconfirmedApplication) {
return NextResponse.json({
error: "出生时间状态已经变化",
message: "最新确认结果已保留,请刷新后重新编辑。",
}, { status: 409 });
}
if (error || !data) {
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
}