fix: block unconfirmed birth-time candidate adoption

This commit is contained in:
732642856
2026-07-21 15:36:56 +08:00
parent fcf5794513
commit d82218e70a
7 changed files with 36 additions and 337 deletions
@@ -1,9 +1,5 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { candidateWorkingTime } from "@/lib/birth-time-candidate-completion";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
@@ -13,57 +9,12 @@ const requestSchema = z.object({
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
}).strict();
/** Compatibility endpoint for stale clients; unconfirmed candidates never write profiles. */
export async function POST(request: Request) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
}
const admin = createAdminSupabaseClient();
const { data: stored, error: caseError } = await admin
.from("birth_time_rectification_cases")
.select("id,user_id,status,candidate_result_id,candidate_result,turn_state")
.eq("id", parsed.data.caseId)
.eq("user_id", user.id)
.maybeSingle();
const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id });
if (caseError || !time) {
return NextResponse.json(
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
{ status: 409 },
);
}
const { data: profile, error: profileError } = await admin
.from("profiles")
.update({
active_birth_time: time,
birth_time_status: "candidate",
updated_at: new Date().toISOString(),
})
.eq("id", user.id)
.eq("rectification_case_id", parsed.data.caseId)
.select("id")
.maybeSingle();
if (profileError || !profile) {
return NextResponse.json(
{ error: "候选时间暂时无法保存", message: "当前评估结果仍已保留,请稍后重试。" },
{ status: 503 },
);
}
return NextResponse.json({ ok: true, activeTime: time, birthTimeStatus: "candidate" });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置" }, { status: 503 });
}
return NextResponse.json({ error: "候选时间暂时无法保存" }, { status: 500 });
}
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
return NextResponse.json(
{ error: "候选时间不能直接采用", message: "候选范围已保留;请补充资料,或在高置信结果出现后通过正式确认继续。" },
{ status: 409 },
);
}
@@ -121,20 +121,10 @@ function TerminalAction({ controller, error, path }: {
readonly error: string;
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
}) {
if (path.kind === "complete_with_candidate") {
return (
<div className="birth-time-next-step">
<b></b>
<p>使 {path.time} <span className="phrase-nowrap"></span><span className="phrase-nowrap"></span><span className="phrase-nowrap"></span><span className="phrase-nowrap"></span></p>
<button className="button-primary birth-time-guided-action" disabled={controller.pending} onClick={() => controller.completeCandidate(path.time)} type="button">
{controller.pending ? `正在采用 ${path.time}` : `采用 ${path.time} 并进入对话`}
</button>
{error ? <p className="form-error" role="alert">{error}</p> : null}
</div>
);
}
return (
<div className="birth-time-new-assessment">
<b></b>
<p></p>
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button"></button>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<small></small>
@@ -5,37 +5,10 @@ type CandidateCompletionRequest = {
readonly time: string;
};
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object"
? value as Record<string, unknown>
: null;
}
/** Direct adoption was superseded by versioned high-confidence confirmation. */
export function candidateWorkingTime(
stored: unknown,
request: CandidateCompletionRequest,
_stored: unknown,
_request: CandidateCompletionRequest,
): string | null {
const assessment = record(stored);
const candidate = record(assessment?.candidate_result);
const winner = record(candidate?.winningSegment);
const turn = record(assessment?.turn_state);
const action = record(turn?.nextAction);
const actionKind = action?.kind;
const terminal = actionKind === "present_low_result"
|| actionKind === "present_medium_result"
|| actionKind === "candidate_saved";
const terminalStatusMatches = actionKind === "present_low_result"
? assessment?.status === "rectifying"
: (actionKind === "present_medium_result" || actionKind === "candidate_saved")
&& assessment?.status === "candidate";
return assessment?.id === request.caseId
&& assessment?.user_id === request.userId
&& terminalStatusMatches
&& assessment.candidate_result_id === request.resultId
&& action?.resultId === request.resultId
&& terminal
&& winner?.representativeTime === request.time
? request.time
: null;
return null;
}
+6 -24
View File
@@ -1,32 +1,14 @@
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
export type GuidedTerminalPath =
| {
readonly kind: "edit_birth_time_details";
readonly preservesCase: true;
readonly appliesCandidateTime: false;
}
| {
readonly kind: "complete_with_candidate";
readonly time: string;
readonly preservesCase: true;
readonly appliesCandidateTime: true;
};
export type GuidedTerminalPath = {
readonly kind: "edit_birth_time_details";
readonly preservesCase: true;
readonly appliesCandidateTime: false;
};
export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null {
const kind = journey.nextAction.kind;
const winner = journey.candidateResult?.winningSegment;
if (journey.journeyProtocol === "dynamic-choice-v2"
&& winner
&& (kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved")) {
return {
kind: "complete_with_candidate",
time: winner.representativeTime,
preservesCase: true,
appliesCandidateTime: true,
};
}
return kind === "present_low_result" || kind === "candidate_saved"
return kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved"
? { kind: "edit_birth_time_details", preservesCase: true, appliesCandidateTime: false }
: null;
}
@@ -2,204 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion.ts";
const terminalCase = {
id: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
user_id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
status: "candidate",
candidate_result_id: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
candidate_result: {
confidence: "medium",
winningSegment: { representativeTime: "04:53" },
},
turn_state: {
nextAction: {
kind: "present_medium_result",
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
},
},
};
const lowTerminalCase = {
...terminalCase,
status: "rectifying",
candidate_result: {
...terminalCase.candidate_result,
confidence: "low",
},
turn_state: {
...terminalCase.turn_state,
nextAction: {
kind: "present_low_result",
resultId: terminalCase.candidate_result_id,
},
},
};
const completionRequest = {
userId: terminalCase.user_id,
caseId: terminalCase.id,
resultId: terminalCase.candidate_result_id,
time: "04:53",
} as const;
test("candidate completion only accepts the persisted terminal representative time", () => {
assert.equal(candidateWorkingTime(terminalCase, {
...completionRequest,
}), "04:53");
assert.equal(candidateWorkingTime(terminalCase, {
...completionRequest,
time: "04:54",
test("unconfirmed candidate results cannot directly become the active consultation time", () => {
assert.equal(candidateWorkingTime({}, {
userId: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
caseId: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
time: "04:53",
}), null);
});
test("accepts a matching low-confidence result from the rectifying state", () => {
assert.equal(candidateWorkingTime(lowTerminalCase, {
...completionRequest,
}), "04:53");
});
test("accepts a persisted candidate-saved compatibility action", () => {
assert.equal(candidateWorkingTime({
...terminalCase,
turn_state: {
nextAction: {
kind: "candidate_saved",
resultId: terminalCase.candidate_result_id,
},
},
}, completionRequest), "04:53");
});
test("does not accept a medium terminal action from the rectifying state", () => {
assert.equal(candidateWorkingTime({
...lowTerminalCase,
turn_state: {
...lowTerminalCase.turn_state,
nextAction: {
kind: "present_medium_result",
resultId: terminalCase.candidate_result_id,
},
},
}, {
...completionRequest,
}), null);
});
test("non-terminal cases cannot be adopted for consultation", () => {
assert.equal(candidateWorkingTime({
...terminalCase,
turn_state: { nextAction: { kind: "ask_dynamic_choice" } },
}, {
...completionRequest,
}), null);
});
const rejectedCompletions = [
{
name: "case owned by another user",
stored: { ...terminalCase, user_id: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
request: completionRequest,
},
{
name: "request from another user",
stored: terminalCase,
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
},
{
name: "missing case owner",
stored: { ...terminalCase, user_id: null },
request: completionRequest,
},
{
name: "empty case owner",
stored: { ...terminalCase, user_id: "" },
request: completionRequest,
},
{
name: "wrong case ID",
stored: terminalCase,
request: { ...completionRequest, caseId: "c84052ca-bcea-40a8-a32a-56980bbf7b22" },
},
{
name: "wrong persisted result ID",
stored: { ...terminalCase, candidate_result_id: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
request: completionRequest,
},
{
name: "wrong action result ID",
stored: {
...terminalCase,
turn_state: {
nextAction: {
...terminalCase.turn_state.nextAction,
resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0",
},
},
},
request: completionRequest,
},
{
name: "wrong requested result ID",
stored: terminalCase,
request: { ...completionRequest, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
},
{
name: "missing winning segment",
stored: { ...terminalCase, candidate_result: { confidence: "medium" } },
request: completionRequest,
},
{
name: "missing representative time",
stored: {
...terminalCase,
candidate_result: { confidence: "medium", winningSegment: {} },
},
request: completionRequest,
},
{
name: "low-result action paired with candidate status",
stored: {
...terminalCase,
turn_state: {
nextAction: {
kind: "present_low_result",
resultId: terminalCase.candidate_result_id,
},
},
},
request: completionRequest,
},
{
name: "medium-result action paired with rectifying status",
stored: {
...lowTerminalCase,
turn_state: {
nextAction: {
kind: "present_medium_result",
resultId: terminalCase.candidate_result_id,
},
},
},
request: completionRequest,
},
{
name: "candidate-saved action paired with rectifying status",
stored: {
...lowTerminalCase,
turn_state: {
nextAction: {
kind: "candidate_saved",
resultId: terminalCase.candidate_result_id,
},
},
},
request: completionRequest,
},
] as const;
for (const scenario of rejectedCompletions) {
test(`rejects candidate completion with ${scenario.name}`, () => {
assert.equal(candidateWorkingTime(scenario.stored, scenario.request), null);
});
}
@@ -56,14 +56,13 @@ test("low without a result and saved medium both return to declared-time editing
});
});
test("dynamic medium terminal completes with its candidate working time", () => {
test("dynamic medium terminal preserves its candidate range without direct adoption", () => {
const medium = dynamicBirthTimePreview("medium");
assert.deepEqual(guidedTerminalPath(medium), {
kind: "complete_with_candidate",
time: "05:43",
kind: "edit_birth_time_details",
preservesCase: true,
appliesCandidateTime: true,
appliesCandidateTime: false,
});
});
@@ -121,31 +120,29 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
assert.doesNotMatch(candidateSource, /controller\.finish/);
});
test("terminal candidate owns one explicit next step and its completion error", () => {
test("unconfirmed terminal candidates preserve the range without offering direct adoption", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
const globalCssSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(candidateResultSource, /评估已完成,下一步/);
assert.match(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
assert.match(candidateResultSource, /正在采用 \$\{path\.time\}/);
assert.match(candidateResultSource, /birth-time-next-step/);
assert.match(candidateResultSource, /尚未达到采用条件/);
assert.match(candidateResultSource, /补充资料并重新评估/);
assert.doesNotMatch(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
assert.doesNotMatch(candidateResultSource, /birth-time-next-step/);
assert.match(rectificationSource, /error=\{error\}/);
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length, 1);
assert.match(legacyRectificationSource, /error=\{error\}/);
assert.match(legacyRectificationSource, /error && !showsCandidate/);
assert.match(globalCssSource, /\.birth-time-next-step/);
});
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
});
@@ -75,9 +75,8 @@ test("low-confidence preview mirrors the persisted dynamic terminal state", () =
assert.equal(low.candidateResult.winningSegment?.representativeTime, "05:21");
assert.equal(low.nextAction.resultId, low.candidateResult.resultId);
assert.deepEqual(guidedTerminalPath(low), {
kind: "complete_with_candidate",
time: low.candidateResult.winningSegment?.representativeTime,
kind: "edit_birth_time_details",
preservesCase: true,
appliesCandidateTime: true,
appliesCandidateTime: false,
});
});