fix: verify onboarding cache ownership end to end
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "@/lib/onboarding-cache-policy";
|
||||
createOnboardingPost,
|
||||
type OnboardingProfileRepository,
|
||||
} from "@/lib/onboarding-post";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getOnboardingAgent } from "@/mastra";
|
||||
@@ -13,189 +10,74 @@ import { defaultLanguageModel } from "@/mastra/model";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
|
||||
]),
|
||||
function createProfileRepository(
|
||||
admin: ReturnType<typeof createAdminSupabaseClient>,
|
||||
): OnboardingProfileRepository {
|
||||
return {
|
||||
async loadProfile(userId) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.select("id,name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
},
|
||||
async claimProfile(command) {
|
||||
let claim = admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_version: command.pendingVersion,
|
||||
onboarding_generated_at: command.claimedAt,
|
||||
})
|
||||
.eq("id", command.userId);
|
||||
claim = command.expectedVersion === null
|
||||
? claim.is("onboarding_version", null)
|
||||
: claim.eq("onboarding_version", command.expectedVersion);
|
||||
claim = command.expectedGeneratedAt === null
|
||||
? claim.is("onboarding_generated_at", null)
|
||||
: claim.eq("onboarding_generated_at", command.expectedGeneratedAt);
|
||||
return claim.select("id").maybeSingle();
|
||||
},
|
||||
async completeProfile(command) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_payload: command.payload,
|
||||
onboarding_version: command.readyVersion,
|
||||
onboarding_generated_at: command.generatedAt,
|
||||
})
|
||||
.eq("id", command.userId)
|
||||
.eq("onboarding_version", command.expectedPendingVersion)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = createOnboardingPost({
|
||||
openSession: async () => {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return {
|
||||
userId: user?.id ?? null,
|
||||
authError: Boolean(error),
|
||||
repository: createProfileRepository(admin),
|
||||
};
|
||||
},
|
||||
generateText: async (name) => {
|
||||
const model = defaultLanguageModel();
|
||||
if (!model) return null;
|
||||
const result = await getOnboardingAgent(model).generate([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。",
|
||||
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
return result.text;
|
||||
},
|
||||
now: () => new Date(),
|
||||
warn: (message, detail) => console.warn(message, detail),
|
||||
});
|
||||
|
||||
type OnboardingPayload = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const fallbackPayload: OnboardingPayload = {
|
||||
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
|
||||
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
|
||||
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
|
||||
],
|
||||
};
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new Error("onboarding_json_missing");
|
||||
return JSON.parse(normalized.slice(start, end + 1));
|
||||
}
|
||||
|
||||
function hasCompleteBirthProfile(profile: Record<string, unknown>) {
|
||||
return Boolean(
|
||||
profile.name
|
||||
&& profile.birth_date
|
||||
&& (profile.active_birth_time || profile.birth_time)
|
||||
&& (profile.birth_time_status === "confirmed"
|
||||
|| profile.birth_time_status === "candidate"
|
||||
|| (!profile.birth_time_status && profile.birth_time))
|
||||
&& profile.country_code
|
||||
&& profile.province_code
|
||||
&& profile.city_code,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
||||
let admin: ReturnType<typeof createAdminSupabaseClient>;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
admin = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: "请先登录", message: "登录后才能准备初始问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from("profiles")
|
||||
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法读取用户档案", message: profileError?.message || "请重新登录后再试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasCompleteBirthProfile(profile)) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const identity = createOnboardingCacheIdentity({
|
||||
name: profile.name,
|
||||
birthDate: profile.birth_date,
|
||||
birthTime: profile.birth_time,
|
||||
activeBirthTime: profile.active_birth_time,
|
||||
birthTimeStatus: profile.birth_time_status,
|
||||
countryCode: profile.country_code,
|
||||
provinceCode: profile.province_code,
|
||||
cityCode: profile.city_code,
|
||||
});
|
||||
const cached = onboardingSchema.safeParse(profile.onboarding_payload);
|
||||
const generatedAtMs = typeof profile.onboarding_generated_at === "string"
|
||||
? Date.parse(profile.onboarding_generated_at)
|
||||
: 0;
|
||||
const cacheDecision = decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: profile.onboarding_version,
|
||||
generatedAtMs,
|
||||
nowMs: Date.now(),
|
||||
cachedPayload: cached.success ? cached.data : null,
|
||||
});
|
||||
|
||||
switch (cacheDecision.kind) {
|
||||
case "ready":
|
||||
return NextResponse.json({ ...cacheDecision.payload, source: "cache" });
|
||||
case "pending":
|
||||
return NextResponse.json({ ...fallbackPayload, source: "pending" });
|
||||
case "claim":
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDecision: never = cacheDecision;
|
||||
throw exhaustiveDecision;
|
||||
}
|
||||
}
|
||||
|
||||
const claimTime = new Date().toISOString();
|
||||
let claim = admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_version: cacheDecision.pendingVersion,
|
||||
onboarding_generated_at: claimTime,
|
||||
})
|
||||
.eq("id", user.id);
|
||||
claim = cacheDecision.expectedVersion === null
|
||||
? claim.is("onboarding_version", null)
|
||||
: claim.eq("onboarding_version", cacheDecision.expectedVersion);
|
||||
claim = profile.onboarding_generated_at === null
|
||||
? claim.is("onboarding_generated_at", null)
|
||||
: claim.eq("onboarding_generated_at", profile.onboarding_generated_at);
|
||||
const { data: claimedProfile, error: claimError } = await claim.select("id").maybeSingle();
|
||||
if (claimError) {
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法准备初始问题", message: claimError.message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!claimedProfile) {
|
||||
return NextResponse.json({ ...fallbackPayload, source: "pending" });
|
||||
}
|
||||
|
||||
let payload = fallbackPayload;
|
||||
let source: "agent" | "fallback" = "fallback";
|
||||
|
||||
const onboardingModel = defaultLanguageModel();
|
||||
if (onboardingModel) {
|
||||
try {
|
||||
const result = await getOnboardingAgent(onboardingModel).generate([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
profile.name ? `用户称呼:${String(profile.name).slice(0, 80)}` : "用户未填写称呼。",
|
||||
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
const parsed = onboardingSchema.safeParse(parseJsonObject(result.text));
|
||||
if (parsed.success) {
|
||||
payload = parsed.data;
|
||||
source = "agent";
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
console.warn("[onboarding] agent generation failed; using safe fallback", message);
|
||||
}
|
||||
}
|
||||
|
||||
const completionTransition = createOnboardingCompletionTransition(identity);
|
||||
const { error: cacheError } = await admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_payload: payload,
|
||||
onboarding_version: completionTransition.readyVersion,
|
||||
onboarding_generated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.eq("onboarding_version", completionTransition.expectedVersion);
|
||||
|
||||
if (cacheError) {
|
||||
console.warn("[onboarding] unable to cache generated content", cacheError.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...payload, source });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
|
||||
|
||||
export const fallbackOnboardingPayload: OnboardingPayload = {
|
||||
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
|
||||
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
|
||||
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
|
||||
],
|
||||
};
|
||||
|
||||
class OnboardingJsonError extends Error {
|
||||
readonly name = "OnboardingJsonError";
|
||||
}
|
||||
|
||||
export function parseOnboardingPayload(value: unknown): OnboardingPayload | null {
|
||||
const parsed = onboardingSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function parseOnboardingText(text: string): OnboardingPayload | null {
|
||||
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new OnboardingJsonError("onboarding_json_missing");
|
||||
const parsed: unknown = JSON.parse(normalized.slice(start, end + 1));
|
||||
return parseOnboardingPayload(parsed);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "./onboarding-cache-policy.ts";
|
||||
import {
|
||||
fallbackOnboardingPayload,
|
||||
type OnboardingPayload,
|
||||
parseOnboardingPayload,
|
||||
parseOnboardingText,
|
||||
} from "./onboarding-payload.ts";
|
||||
|
||||
export type OnboardingProfileRow = {
|
||||
readonly id: string;
|
||||
readonly name: string | null;
|
||||
readonly birth_date: string | null;
|
||||
readonly birth_time: string | null;
|
||||
readonly active_birth_time: string | null;
|
||||
readonly birth_time_status: string | null;
|
||||
readonly country_code: string | null;
|
||||
readonly province_code: string | null;
|
||||
readonly city_code: string | null;
|
||||
readonly onboarding_payload: unknown;
|
||||
readonly onboarding_version: string | null;
|
||||
readonly onboarding_generated_at: string | null;
|
||||
};
|
||||
|
||||
type RepositoryError = { readonly message: string };
|
||||
type RepositoryResult<Value> = {
|
||||
readonly data: Value | null;
|
||||
readonly error: RepositoryError | null;
|
||||
};
|
||||
|
||||
export type OnboardingClaimCommand = {
|
||||
readonly userId: string;
|
||||
readonly expectedVersion: string | null;
|
||||
readonly expectedGeneratedAt: string | null;
|
||||
readonly pendingVersion: string;
|
||||
readonly claimedAt: string;
|
||||
};
|
||||
|
||||
export type OnboardingCompletionCommand = {
|
||||
readonly userId: string;
|
||||
readonly expectedPendingVersion: string;
|
||||
readonly readyVersion: string;
|
||||
readonly payload: OnboardingPayload;
|
||||
readonly generatedAt: string;
|
||||
};
|
||||
|
||||
export interface OnboardingProfileRepository {
|
||||
loadProfile(userId: string): Promise<RepositoryResult<OnboardingProfileRow>>;
|
||||
claimProfile(command: OnboardingClaimCommand): Promise<RepositoryResult<{ readonly id: string }>>;
|
||||
completeProfile(command: OnboardingCompletionCommand): Promise<RepositoryResult<{ readonly id: string }>>;
|
||||
}
|
||||
|
||||
type OnboardingSession = {
|
||||
readonly userId: string | null;
|
||||
readonly authError: boolean;
|
||||
readonly repository: OnboardingProfileRepository;
|
||||
};
|
||||
|
||||
type OnboardingPostDependencies = {
|
||||
readonly openSession: () => Promise<OnboardingSession>;
|
||||
readonly generateText: (name: string) => Promise<string | null>;
|
||||
readonly now: () => Date;
|
||||
readonly warn: (message: string, detail: string) => void;
|
||||
};
|
||||
|
||||
function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
|
||||
return Boolean(
|
||||
profile.name
|
||||
&& profile.birth_date
|
||||
&& (profile.active_birth_time || profile.birth_time)
|
||||
&& (profile.birth_time_status === "confirmed"
|
||||
|| profile.birth_time_status === "candidate"
|
||||
|| (!profile.birth_time_status && profile.birth_time))
|
||||
&& profile.country_code
|
||||
&& profile.province_code
|
||||
&& profile.city_code,
|
||||
);
|
||||
}
|
||||
|
||||
export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise<Response> {
|
||||
return async function onboardingPost(): Promise<Response> {
|
||||
let session: OnboardingSession;
|
||||
try {
|
||||
session = await dependencies.openSession();
|
||||
} catch { // no-excuse-ok: catch -- route boundary converts missing configuration.
|
||||
return Response.json(
|
||||
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (session.authError || !session.userId) {
|
||||
return Response.json(
|
||||
{ error: "请先登录", message: "登录后才能准备初始问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const loaded = await session.repository.loadProfile(session.userId);
|
||||
if (loaded.error || !loaded.data) {
|
||||
return Response.json(
|
||||
{ error: "无法读取用户档案", message: loaded.error?.message || "请重新登录后再试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const profile = loaded.data;
|
||||
if (!hasCompleteBirthProfile(profile)) {
|
||||
return Response.json(
|
||||
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const identity = createOnboardingCacheIdentity({
|
||||
name: profile.name,
|
||||
birthDate: profile.birth_date,
|
||||
birthTime: profile.birth_time,
|
||||
activeBirthTime: profile.active_birth_time,
|
||||
birthTimeStatus: profile.birth_time_status,
|
||||
countryCode: profile.country_code,
|
||||
provinceCode: profile.province_code,
|
||||
cityCode: profile.city_code,
|
||||
});
|
||||
const cachedPayload = parseOnboardingPayload(profile.onboarding_payload);
|
||||
const generatedAtMs = profile.onboarding_generated_at === null
|
||||
? Number.NaN
|
||||
: Date.parse(profile.onboarding_generated_at);
|
||||
const now = dependencies.now();
|
||||
const decision = decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: profile.onboarding_version,
|
||||
generatedAtMs,
|
||||
nowMs: now.getTime(),
|
||||
cachedPayload,
|
||||
});
|
||||
|
||||
switch (decision.kind) {
|
||||
case "ready":
|
||||
return Response.json({ ...decision.payload, source: "cache" });
|
||||
case "pending":
|
||||
return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
case "claim":
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDecision: never = decision;
|
||||
throw exhaustiveDecision;
|
||||
}
|
||||
}
|
||||
|
||||
const claim = await session.repository.claimProfile({
|
||||
userId: session.userId,
|
||||
expectedVersion: decision.expectedVersion,
|
||||
expectedGeneratedAt: profile.onboarding_generated_at,
|
||||
pendingVersion: decision.pendingVersion,
|
||||
claimedAt: now.toISOString(),
|
||||
});
|
||||
if (claim.error) {
|
||||
return Response.json(
|
||||
{ error: "暂时无法准备初始问题", message: claim.error.message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!claim.data) return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
|
||||
let payload = fallbackOnboardingPayload;
|
||||
let source: "agent" | "fallback" = "fallback";
|
||||
try {
|
||||
const text = await dependencies.generateText(profile.name ?? "");
|
||||
const parsed = text === null ? null : parseOnboardingText(text);
|
||||
if (parsed) {
|
||||
payload = parsed;
|
||||
source = "agent";
|
||||
}
|
||||
} catch (error) { // no-excuse-ok: catch -- generation failure intentionally uses safe fallback.
|
||||
dependencies.warn(
|
||||
"[onboarding] agent generation failed; using safe fallback",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
}
|
||||
|
||||
const completion = createOnboardingCompletionTransition(identity);
|
||||
const completed = await session.repository.completeProfile({
|
||||
userId: session.userId,
|
||||
expectedPendingVersion: completion.expectedVersion,
|
||||
readyVersion: completion.readyVersion,
|
||||
payload,
|
||||
generatedAt: dependencies.now().toISOString(),
|
||||
});
|
||||
if (completed.error) {
|
||||
dependencies.warn("[onboarding] unable to cache generated content", completed.error.message);
|
||||
return Response.json({ ...payload, source });
|
||||
}
|
||||
return completed.data
|
||||
? Response.json({ ...payload, source })
|
||||
: Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user