diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cc8562ca..bbde5ab2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -457,3 +457,19 @@ - 相关记录:BUG-022、BUG-024、BUG-025 - 复发自:无 - 修复版本:待提交(production gate) + +## BUG-027 | 已完成的生时校正会话打开后误建新案例 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正历史会话恢复、范围终态展示、首页继续入口与计费 +- 用户现象:范围校正完成后刷新,点击侧边栏原会话会卡在“正在建立校正记录”,或跳到另一个会话并提示加载最新进度;首页同时重新显示开始入口。 +- 触发条件:已绑定 case 的生时校正会话对应 `completed`/`abandoned` 终态,而账户接口只投影当前未完成 case;或者账户另有一个更新的未完成 case。 +- 根因:会话打开逻辑只按账户当前 case 决定 resume/start,没有优先恢复所点击会话自己的 `rectificationCaseId`;历史终态加载后又会覆盖账户当前可恢复 case,异步账户刷新还拒绝接受 `null` 或另一个最新 case。 +- 修复:已绑定的生时校正会话始终以自身 case id 执行只读 resume;只有首页或未绑定会话才按账户当前 case 复用或创建;历史终态不再覆盖另一个未完成 case,终态后账户刷新接受服务端最新投影。 +- 验证:入口契约测试锁定历史会话按自身 case 恢复、首页重新校正仍创建独立会话,以及历史终态不覆盖当前未完成 case;发布后用 production 合成账号复查范围终态、刷新恢复、首页入口与余额不变量。 +- 防复发:聊天会话的 `rectificationCaseId` 是历史查看的第一绑定键;账户 `rectificationCase` 只代表当前未完成案例,二者不得相互替代。 +- 相关记录:BUG-017、BUG-019、BUG-020 +- 复发自:无 +- 修复版本:待提交(production gate) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3eb02dc7..afd4782c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1913,18 +1913,27 @@ export default function Home() { rectificationCase: account.rectificationCase, hasConfirmedBirthTime: account.hasConfirmedBirthTime, }); - const resumableSession = action === "resume" && account.rectificationCase + const sourceBoundCaseId = sourceSession.sessionType === "birth_time_rectification" + ? sourceSession.rectificationCaseId + : null; + const accountResumeCase = action === "resume" ? account.rectificationCase : null; + const resumeTarget = sourceBoundCaseId + ? { + caseId: sourceBoundCaseId, + turnVersion: accountResumeCase?.caseId === sourceBoundCaseId + ? accountResumeCase.turnVersion + : 0, + } + : accountResumeCase; + const resumableSession = !sourceBoundCaseId && accountResumeCase ? sessions.find((session) => session.sessionType === "birth_time_rectification" - && session.rectificationCaseId === account.rectificationCase?.caseId) + && session.rectificationCaseId === accountResumeCase.caseId) ?? sessions.find((session) => session.sessionType === "birth_time_rectification" && session.rectificationCaseId === null) ?? null : null; - const canReuseSourceRectificationSession = action === "resume" - && sourceSession.sessionType === "birth_time_rectification" - && account.rectificationCase !== null - && (sourceSession.rectificationCaseId === account.rectificationCase.caseId - || sourceSession.rectificationCaseId === null); + const canReuseSourceRectificationSession = sourceSession.sessionType === "birth_time_rectification" + && (sourceBoundCaseId !== null || accountResumeCase !== null); const rectificationSession = canReuseSourceRectificationSession ? sourceSession : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); @@ -1957,7 +1966,7 @@ export default function Home() { setActiveSessionId(rectificationSession.id); try { let turn: ConversationalRectificationTurn; - if (action !== "resume" || !account.rectificationCase) { + if (!resumeTarget) { const durable = await durableRectificationQuestionHandoff.current.load(); turn = durable && durable.status !== "consumed" ? durable.turn @@ -1967,8 +1976,10 @@ export default function Home() { pendingConsultationQuestion: requestedQuestion, }); } else { - let current = account.rectificationCase; - if (pendingConsultationQuestion) { + let current = resumeTarget; + const canAttachQuestion = pendingConsultationQuestion + && accountResumeCase?.caseId === current.caseId; + if (canAttachQuestion) { try { turn = await durableRectificationQuestionHandoff.current.attach({ caseId: current.caseId, @@ -2063,16 +2074,22 @@ export default function Home() { ...current, hasConfirmedBirthTime: current.hasConfirmedBirthTime || (turn.status === "completed" && turn.candidate.status === "confirmed"), - rectificationCase: { - caseId: turn.caseId, - journeyProtocol: "conversational-evidence-v3", - status: turn.status, - turnVersion: turn.turnVersion, - isRevision: current.rectificationCase?.isRevision - ?? current.hasConfirmedBirthTime, - preservesActiveTime: current.rectificationCase?.preservesActiveTime - ?? current.hasConfirmedBirthTime, - }, + rectificationCase: ["active", "paused", "confirming"].includes(turn.status) + ? { + caseId: turn.caseId, + journeyProtocol: "conversational-evidence-v3", + status: turn.status, + turnVersion: turn.turnVersion, + isRevision: current.rectificationCase?.caseId === turn.caseId + ? current.rectificationCase.isRevision + : current.hasConfirmedBirthTime, + preservesActiveTime: current.rectificationCase?.caseId === turn.caseId + ? current.rectificationCase.preservesActiveTime + : current.hasConfirmedBirthTime, + } + : current.rectificationCase?.caseId === turn.caseId + ? null + : current.rectificationCase, } : current); if (turn.status === "completed" && turn.candidate.status === "confirmed" @@ -2094,6 +2111,7 @@ export default function Home() { .then((latest) => { if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; setAccount((current) => { + if (turn.status === "completed" || turn.status === "abandoned") return latest; if (latest.rectificationCase?.caseId !== turn.caseId || latest.rectificationCase.turnVersion < turn.turnVersion) return current; return latest; diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 04c775f1..8722653c 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -149,21 +149,33 @@ test("homepage reuses the session bound to an unfinished rectification case", () const end = source.indexOf("function handleConversationalRectificationTurn", start); const handler = source.slice(start, end); - assert.match(handler, /action === "resume" && account\.rectificationCase/); - assert.match(handler, /session\.rectificationCaseId === account\.rectificationCase\?\.caseId/); + assert.match(handler, /const accountResumeCase = action === "resume" \? account\.rectificationCase : null/); + assert.match(handler, /session\.rectificationCaseId === accountResumeCase\.caseId/); assert.match(handler, /resumableSession \?\? createSession/); }); -test("starting again after a completed rectification creates a new dedicated session", () => { +test("a bound rectification session resumes its own case while a homepage restart stays dedicated", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const start = source.indexOf("async function openBirthTimeRectification"); const end = source.indexOf("function handleConversationalRectificationTurn", start); const handler = source.slice(start, end); - assert.match(handler, /const canReuseSourceRectificationSession = action === "resume"/); - assert.match(handler, /sourceSession\.rectificationCaseId === account\.rectificationCase\.caseId/); + assert.match(handler, /const sourceBoundCaseId = sourceSession\.sessionType === "birth_time_rectification"/); + assert.match(handler, /const resumeTarget = sourceBoundCaseId/); + assert.match(handler, /caseId: sourceBoundCaseId/); + assert.match(handler, /if \(!resumeTarget\) \{[\s\S]*?type: "start"/); assert.match(handler, /const rectificationSession = canReuseSourceRectificationSession[\s\S]*?: resumableSession \?\? createSession/); - assert.doesNotMatch(handler, /const rectificationSession = sourceSession\.sessionType === "birth_time_rectification"/); + assert.match(handler, /type: "resume",[\s\S]*?caseId: current\.caseId/); +}); + +test("historical completed rectification does not replace the account's unfinished case", () => { + const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const start = source.indexOf("function handleConversationalRectificationTurn"); + const end = source.indexOf("async function draftSynastryQuestionFromChart", start); + const handler = source.slice(start, end); + + assert.match(handler, /current\.rectificationCase\?\.caseId === turn\.caseId[\s\S]*?\? null[\s\S]*?: current\.rectificationCase/); + assert.match(handler, /turn\.status === "completed" \|\| turn\.status === "abandoned"\) return latest/); }); test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {