From d82218e70a7f93cc0037d3ff98a8181031e0bddb Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Tue, 21 Jul 2026 15:36:56 +0800 Subject: [PATCH] fix: block unconfirmed birth-time candidate adoption --- .../birth-time-candidate-completion/route.ts | 63 +----- .../birth-time-candidate-result.tsx | 14 +- .../lib/birth-time-candidate-completion.ts | 35 +-- .../src/lib/birth-time-guided-terminal.ts | 30 +-- .../birth-time-candidate-completion.test.ts | 205 +----------------- .../birth-time-guided-review-fixes.test.ts | 21 +- .../birth-time-rectification-contract.test.ts | 5 +- 7 files changed, 36 insertions(+), 337 deletions(-) diff --git a/frontend/src/app/api/birth-time-candidate-completion/route.ts b/frontend/src/app/api/birth-time-candidate-completion/route.ts index d86196bb..b8b0d8cd 100644 --- a/frontend/src/app/api/birth-time-candidate-completion/route.ts +++ b/frontend/src/app/api/birth-time-candidate-completion/route.ts @@ -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 }, + ); } diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index ed1358f7..50708638 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -121,20 +121,10 @@ function TerminalAction({ controller, error, path }: { readonly error: string; readonly path: NonNullable>; }) { - if (path.kind === "complete_with_candidate") { - return ( -
- 评估已完成,下一步 -

点击后将使用 {path.time} 作为当前排盘时间并进入对话;原始填报和本次候选结果仍会保留

- - {error ?

{error}

: null} -
- ); - } return (
+ 尚未达到采用条件 +

候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。

{error ?

{error}

: null} 会建立新的记录,当前结果仍会保留。 diff --git a/frontend/src/lib/birth-time-candidate-completion.ts b/frontend/src/lib/birth-time-candidate-completion.ts index a018bf2d..50af9a20 100644 --- a/frontend/src/lib/birth-time-candidate-completion.ts +++ b/frontend/src/lib/birth-time-candidate-completion.ts @@ -5,37 +5,10 @@ type CandidateCompletionRequest = { readonly time: string; }; -function record(value: unknown): Record | null { - return value !== null && typeof value === "object" - ? value as Record - : 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; } diff --git a/frontend/src/lib/birth-time-guided-terminal.ts b/frontend/src/lib/birth-time-guided-terminal.ts index bcd2de3f..5107cf3c 100644 --- a/frontend/src/lib/birth-time-guided-terminal.ts +++ b/frontend/src/lib/birth-time-guided-terminal.ts @@ -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; } diff --git a/frontend/tests/birth-time-candidate-completion.test.ts b/frontend/tests/birth-time-candidate-completion.test.ts index 0e69ecf5..a5674e72 100644 --- a/frontend/tests/birth-time-candidate-completion.test.ts +++ b/frontend/tests/birth-time-candidate-completion.test.ts @@ -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); - }); -} diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 3c950bc6..61c62405 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -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>并进入对话;原始填报<\/span>和本次候选结果<\/span>仍会保留<\/span>。/); + assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/); assert.match(pageSource, /当前使用候选时间排盘;原始填报范围<\/span>仍保留。/); }); diff --git a/frontend/tests/birth-time-rectification-contract.test.ts b/frontend/tests/birth-time-rectification-contract.test.ts index decb96d3..eaf2a67c 100644 --- a/frontend/tests/birth-time-rectification-contract.test.ts +++ b/frontend/tests/birth-time-rectification-contract.test.ts @@ -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, }); });