fix: harden birth-time consultation modes
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import {
|
||||
consultationInputSchema,
|
||||
consultationWorkflowReceipt,
|
||||
getGeneralJyotishAgent,
|
||||
getJyotishAgent,
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
@@ -19,16 +20,22 @@ import { reserveConsultationModel } from "@/lib/consultation-model-selection";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { streamTextResponse } from "@/lib/stream-text-response";
|
||||
import { guardPreciseTimingOutput } from "@/lib/timing-output-guard";
|
||||
import {
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
consultationBirthTimeModeSchema,
|
||||
createBirthTimeModeOutputGuard,
|
||||
serverProfileAllowsBirthTimeMode,
|
||||
shouldRunBirthChartWorkflow,
|
||||
type ConsultationBirthTimeMode,
|
||||
} from "@/lib/consultation-birth-time-mode";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
const chatRequestSchema = consultationInputSchema.extend({
|
||||
const chatRequestMetadataSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
modelId: z.string().trim().min(1).max(64),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
name: z.string().trim().max(80).optional().default(""),
|
||||
history: z
|
||||
.array(
|
||||
@@ -41,6 +48,24 @@ const chatRequestSchema = consultationInputSchema.extend({
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const chartChatRequestSchema = consultationInputSchema.extend({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
|
||||
.optional()
|
||||
.default("verified_chart"),
|
||||
entrypoint: consultationEntrypointSchema.optional(),
|
||||
});
|
||||
|
||||
const generalChatRequestSchema = z.object({
|
||||
...chatRequestMetadataSchema.shape,
|
||||
consultationMode: z.literal("general_no_birth_time"),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
|
||||
entrypoint: z.undefined().optional(),
|
||||
}).strict();
|
||||
|
||||
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
|
||||
|
||||
function currentTimeContext(now = new Date()) {
|
||||
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
@@ -119,6 +144,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.entrypoint === "birth_time_rectification") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "旧版生时校正入口已停用",
|
||||
message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
@@ -136,6 +171,41 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const requestedConsultationMode = parsed.data.consultationMode;
|
||||
if (shouldRunBirthChartWorkflow(requestedConsultationMode)) {
|
||||
if (!("hour" in parsed.data) || !("minute" in parsed.data)) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生时间请求格式不正确" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const requestedTime = `${String(parsed.data.hour).padStart(2, "0")}:${String(parsed.data.minute).padStart(2, "0")}`;
|
||||
const { data: birthTimeProfile, error: birthTimeProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("active_birth_time,reported_birth_time,birth_time_source,birth_time_status")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
if (birthTimeProfileError || !birthTimeProfile) {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法核对出生时间状态", message: "请稍后重试,本次不会扣点。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!serverProfileAllowsBirthTimeMode(
|
||||
birthTimeProfile,
|
||||
requestedConsultationMode,
|
||||
requestedTime,
|
||||
)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "出生时间状态已经变化",
|
||||
message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requestTime = new Date();
|
||||
const resolvedQuestion = resolveConsultationQuestion({
|
||||
visibleQuestion: parsed.data.question,
|
||||
@@ -230,11 +300,60 @@ export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const { history, name } = parsed.data;
|
||||
const consultationMode: ConsultationBirthTimeMode = parsed.data.consultationMode;
|
||||
if (!shouldRunBirthChartWorkflow(consultationMode)) {
|
||||
const result = await getGeneralJyotishAgent(selectedModel).stream([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
currentTimeContext(requestTime),
|
||||
name ? `用户称呼:${name}` : "",
|
||||
"当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
|
||||
resolvedQuestion.modelQuestion,
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const completeAndRecordUsage = async () => {
|
||||
await complete();
|
||||
void recordModelUsage(
|
||||
accounting,
|
||||
userId,
|
||||
requestId,
|
||||
modelSelection.usageModelId,
|
||||
result.totalUsage,
|
||||
);
|
||||
};
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
headers: {
|
||||
"x-jyotish-workflow-route": "general-no-birth-time",
|
||||
"x-jyotish-workflow-status": "ready",
|
||||
"x-jyotish-technique-truth": "not-applicable",
|
||||
"x-jyotish-precise-timing": "blocked",
|
||||
"x-jyotish-missing-layers": "birth-minute",
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
onCancel: settleInterrupted,
|
||||
});
|
||||
}
|
||||
|
||||
const toolInput = consultationInputSchema.parse({
|
||||
...parsed.data,
|
||||
// Unverified use is still a normal chart calculation with a hard answer
|
||||
// boundary. It must never reactivate the retired rectification questionnaire.
|
||||
entryMode: "direct_chart",
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
});
|
||||
const workflowContext = await runConsultationWorkflow(toolInput);
|
||||
const workflowContext = applyBirthTimeModeToWorkflowContext(
|
||||
await runConsultationWorkflow(toolInput),
|
||||
consultationMode,
|
||||
);
|
||||
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
|
||||
|
||||
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
|
||||
@@ -265,10 +384,10 @@ export async function POST(request: Request) {
|
||||
const settleInterrupted = (emitted: boolean) =>
|
||||
settle(emitted ? completeAndRecordUsage : cancel);
|
||||
return streamTextResponse(result.textStream, {
|
||||
transformText:
|
||||
workflowReceipt.preciseTiming === "blocked"
|
||||
? guardPreciseTimingOutput
|
||||
: undefined,
|
||||
transformText: createBirthTimeModeOutputGuard(
|
||||
consultationMode,
|
||||
workflowReceipt.preciseTiming !== "blocked",
|
||||
),
|
||||
mode: "mastra",
|
||||
requestId,
|
||||
headers: {
|
||||
@@ -277,6 +396,7 @@ export async function POST(request: Request) {
|
||||
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
|
||||
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
|
||||
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
|
||||
"x-jyotish-birth-time-mode": consultationMode,
|
||||
},
|
||||
onComplete: () => settle(completeAndRecordUsage),
|
||||
onError: (_error, emitted) => settleInterrupted(emitted),
|
||||
|
||||
Reference in New Issue
Block a user