fix: bind onboarding cache to profile identity

This commit is contained in:
Jesse_Chen
2026-07-19 23:26:58 +08:00
parent 308e5d532a
commit e13d595d6b
7 changed files with 539 additions and 39 deletions
@@ -33,7 +33,7 @@ export async function POST(request: Request) {
.eq("id", parsed.data.caseId)
.eq("user_id", user.id)
.maybeSingle();
const time = candidateWorkingTime(stored, parsed.data);
const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id });
if (caseError || !time) {
return NextResponse.json(
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
+45 -22
View File
@@ -1,5 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
createOnboardingCacheIdentity,
createOnboardingCompletionTransition,
decideOnboardingCache,
} from "@/lib/onboarding-cache-policy";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getOnboardingAgent } from "@/mastra";
@@ -8,10 +13,6 @@ import { defaultLanguageModel } from "@/mastra/model";
export const runtime = "nodejs";
export const maxDuration = 30;
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
const ONBOARDING_PENDING_VERSION = `${ONBOARDING_VERSION}:pending`;
const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
const onboardingSchema = z.object({
greeting: z.string().trim().min(8).max(180),
suggestions: z.tuple([
@@ -95,34 +96,55 @@ export async function POST() {
);
}
if (profile.onboarding_version === ONBOARDING_VERSION) {
const cached = onboardingSchema.safeParse(profile.onboarding_payload);
if (cached.success) {
return NextResponse.json({ ...cached.data, source: "cache" });
}
}
const generatedAt = typeof profile.onboarding_generated_at === "string"
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 activeClaim = profile.onboarding_version === ONBOARDING_PENDING_VERSION
&& Number.isFinite(generatedAt)
&& Date.now() - generatedAt < ONBOARDING_CLAIM_TTL_MS;
if (activeClaim) {
return NextResponse.json({ ...fallbackPayload, source: "pending" });
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: ONBOARDING_PENDING_VERSION,
onboarding_version: cacheDecision.pendingVersion,
onboarding_generated_at: claimTime,
})
.eq("id", user.id);
claim = profile.onboarding_version === null
claim = cacheDecision.expectedVersion === null
? claim.is("onboarding_version", null)
: claim.eq("onboarding_version", profile.onboarding_version);
: 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(
@@ -160,15 +182,16 @@ export async function POST() {
}
}
const completionTransition = createOnboardingCompletionTransition(identity);
const { error: cacheError } = await admin
.from("profiles")
.update({
onboarding_payload: payload,
onboarding_version: ONBOARDING_VERSION,
onboarding_version: completionTransition.readyVersion,
onboarding_generated_at: new Date().toISOString(),
})
.eq("id", user.id)
.eq("onboarding_version", ONBOARDING_PENDING_VERSION);
.eq("onboarding_version", completionTransition.expectedVersion);
if (cacheError) {
console.warn("[onboarding] unable to cache generated content", cacheError.message);
@@ -1,4 +1,5 @@
type CandidateCompletionRequest = {
readonly userId: string;
readonly caseId: string;
readonly resultId: string;
readonly time: string;
@@ -29,8 +30,7 @@ export function candidateWorkingTime(
&& assessment?.status === "candidate";
return assessment?.id === request.caseId
&& typeof assessment?.user_id === "string"
&& assessment.user_id.length > 0
&& assessment?.user_id === request.userId
&& terminalStatusMatches
&& assessment.candidate_result_id === request.resultId
&& action?.resultId === request.resultId
@@ -0,0 +1,94 @@
import { createHash } from "node:crypto";
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
type OnboardingProfileInput = {
readonly name: string | null;
readonly birthDate: string | null;
readonly birthTime: string | null;
readonly activeBirthTime: string | null;
readonly birthTimeStatus: string | null;
readonly countryCode: string | null;
readonly provinceCode: string | null;
readonly cityCode: string | null;
};
export type OnboardingCacheIdentity = {
readonly readyVersion: string;
readonly pendingVersion: string;
};
export type OnboardingCompletionTransition = {
readonly expectedVersion: string;
readonly readyVersion: string;
};
type OnboardingCacheObservation<Payload> = {
readonly identity: OnboardingCacheIdentity;
readonly observedVersion: string | null;
readonly generatedAtMs: number;
readonly nowMs: number;
readonly cachedPayload: Payload | null;
};
type OnboardingCacheDecision<Payload> =
| { readonly kind: "ready"; readonly payload: Payload }
| { readonly kind: "pending" }
| {
readonly kind: "claim";
readonly expectedVersion: string | null;
readonly pendingVersion: string;
};
export function createOnboardingCacheIdentity(
profile: OnboardingProfileInput,
): OnboardingCacheIdentity {
const fingerprint = createHash("sha256")
.update(JSON.stringify([
profile.name,
profile.birthDate,
profile.birthTime,
profile.activeBirthTime,
profile.birthTimeStatus,
profile.countryCode,
profile.provinceCode,
profile.cityCode,
]))
.digest("hex");
return {
readyVersion: `${ONBOARDING_VERSION}:${fingerprint}`,
pendingVersion: `${ONBOARDING_VERSION}:pending:${fingerprint}`,
};
}
export function decideOnboardingCache<Payload>(
observation: OnboardingCacheObservation<Payload>,
): OnboardingCacheDecision<Payload> {
if (observation.observedVersion === observation.identity.readyVersion
&& observation.cachedPayload !== null) {
return { kind: "ready", payload: observation.cachedPayload };
}
if (observation.observedVersion === observation.identity.pendingVersion
&& Number.isFinite(observation.generatedAtMs)
&& observation.nowMs - observation.generatedAtMs < ONBOARDING_CLAIM_TTL_MS) {
return { kind: "pending" };
}
return {
kind: "claim",
expectedVersion: observation.observedVersion,
pendingVersion: observation.identity.pendingVersion,
};
}
export function createOnboardingCompletionTransition(
identity: OnboardingCacheIdentity,
): OnboardingCompletionTransition {
return {
expectedVersion: identity.pendingVersion,
readyVersion: identity.readyVersion,
};
}