From d5453681ad87b6f782950e76e9920631fb22fff8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 22 Jul 2026 02:35:31 +0800 Subject: [PATCH] fix(frontend): streamline birth time onboarding --- docs/BUG_HISTORY.md | 48 ++++++++++ frontend/src/app/api/sessions/[id]/route.ts | 27 ++++++ frontend/src/app/api/sessions/route.ts | 27 ++++++ frontend/src/app/page.tsx | 56 ++--------- frontend/src/components/birth-time-intake.tsx | 93 ++++++++++++------- frontend/src/components/chat-message-row.tsx | 4 +- .../lib/birth-time-consultation-consent.ts | 21 ++--- frontend/src/lib/birth-time-intake-model.ts | 11 +-- .../src/lib/chat-session-write-contract.ts | 87 +++++++++++++++++ .../src/lib/consultation-birth-time-mode.ts | 12 +-- .../birth-time-consultation-consent.test.ts | 57 +++++++----- frontend/tests/chat-session-write.test.ts | 65 +++++++++++++ frontend/tests/claim-boundary-badge.test.ts | 6 +- .../consultation-birth-time-mode.test.ts | 4 +- .../tests/consultation-entrypoint.test.ts | 11 +-- .../conversational-rectification-e2e.test.ts | 6 +- frontend/tests/evidence-audit-panel.test.ts | 8 +- tests/test_supabase_user_data_contract.py | 19 +++- 18 files changed, 408 insertions(+), 154 deletions(-) create mode 100644 frontend/src/app/api/sessions/route.ts create mode 100644 frontend/src/lib/chat-session-write-contract.ts create mode 100644 frontend/tests/chat-session-write.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a3725507..5fa59632 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -169,3 +169,51 @@ - 相关记录:ERR-091 - 复发自:无 - 修复版本:`b981c4e` + +## BUG-009 | 未校正的已填报时间被降级为零星盘咨询 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生日初始化、普通咨询路由、生时校正提示 +- 用户现象:已填写具体出生时间但没有完成生时校正时,普通问题仍提示只能回答一般知识或必须先完成校正。 +- 触发条件:出生时间来源包含有效具体分钟,但状态不是 `confirmed`,且当前聊天没有旧的临时授权状态。 +- 根因:前端把“使用未校正填报时间”设计成逐会话授权;没有授权时默认退回 `general_no_birth_time`,因此完全绕过现有的未校正星盘安全模式。 +- 修复:具体填报时间现在自动进入 `unverified_birth_time`,保留禁止精确应期的安全边界但不禁用个人分析;无具体分钟时直接进入无分钟模式;移除校正 toast、弹窗和每条回答前重复的校正警告,并把生日入口收敛为“知道准确时间 / 不确定准确时间”两个选择。 +- 验证:`frontend/tests/birth-time-consultation-consent.test.ts`、`frontend/tests/consultation-entrypoint.test.ts`、`frontend/tests/consultation-birth-time-mode.test.ts`、`frontend/tests/birth-time-intake.test.ts`。 +- 防复发:咨询路由测试锁定“有效填报分钟无需授权即可使用”;页面契约禁止重新引入生时校正 toast 或阻断式选择。 +- 相关记录:BUG-003、BUG-004 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-010 | 浏览器直连 Supabase 写会话泄露 `TypeError: Load failed` + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:回答完成后的聊天记录持久化、移动 Safari 错误提示 +- 用户现象:回答已经生成,但页面反复显示“云端同步失败:TypeError: Load failed”,并要求复制保存后重试。 +- 触发条件:浏览器直接向 Supabase `chat_sessions` 发起跨域写入时发生传输失败。 +- 根因:会话读取和多数业务写入已经使用同源 Next.js API,但会话创建与更新仍由浏览器客户端直写 Supabase;异常原文又被拼进回答错误区域。 +- 修复:新增同源 `POST /api/sessions` 与 `PATCH /api/sessions/[id]`,服务端校验登录、所有权和写入负载;客户端对可重试失败短重试一次,并把最终失败降级为输入区状态提示,不再把浏览器异常原文渲染成回答错误。 +- 验证:`frontend/tests/chat-session-write.test.ts` 覆盖同源路由、所有者约束、短重试和 `Load failed` 脱敏;相关咨询与资料回归测试通过。 +- 防复发:会话写入契约禁止页面直接调用 `supabase.from("chat_sessions")`;网络异常必须映射为稳定用户文案。 +- 相关记录:BUG-001、BUG-003 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-011 | 对话消息暴露内部证据审计状态 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:普通问答消息、Agent 回答顶部区域 +- 用户现象:回答正文上方显示“证据状态:not-applicable”和“证据链摘要 · not-applicable”等工程审计信息。 +- 触发条件:任意已完成的 Agent 回答,尤其是后端返回 `not-applicable` 时。 +- 根因:消息行对每条非思考态 Agent 消息无条件渲染 claim boundary 与 Technique Audit Table;未识别状态又直接回退显示原始状态值。 +- 修复:从普通聊天消息行移除内部证据徽章与审计面板;证据状态和 workflow receipt 仍随消息保存并供内部约束及报告生成使用。 +- 验证:`frontend/tests/claim-boundary-badge.test.ts`、`frontend/tests/evidence-audit-panel.test.ts` 锁定聊天消息不再挂载两个内部组件。 +- 防复发:聊天消息渲染契约禁止直接展示 `techniqueTruth` 和 `workflowReceipt`;需要运营或调试时使用独立的受控界面。 +- 相关记录:BUG-009 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 5c0919f7..a71ded96 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -1,9 +1,36 @@ import { NextResponse } from "next/server"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract"; type RouteContext = { params: Promise<{ id: string }> }; +export async function PATCH(request: Request, context: RouteContext) { + try { + const { id } = await context.params; + const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { data, error } = await supabase + .from("chat_sessions") + .update(parsed.data) + .eq("id", id) + .eq("user_id", user.id) + .select("id") + .maybeSingle(); + if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); + if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); + } +} + export async function DELETE(_request: Request, context: RouteContext) { try { const { id } = await context.params; diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts new file mode 100644 index 00000000..833b268a --- /dev/null +++ b/frontend/src/app/api/sessions/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +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 = chatSessionCreateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); + const { id, ...values } = parsed.data; + const { error } = await supabase.from("chat_sessions").insert({ + id, + user_id: user.id, + ...values, + }); + if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); + return NextResponse.json({ ok: true }, { status: 201 }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c75243e6..8ddbd0ce 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AppSidebar } from "@/components/app-sidebar"; import { @@ -11,7 +11,6 @@ import { type BirthTimeAssessmentPhase, } from "@/components/birth-time-assessment-overlay"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; -import { BirthTimeSoftNotice } from "@/components/birth-time-soft-notice"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; @@ -68,7 +67,7 @@ import { import { defaultGuidedJyotishTopics } from "@/lib/guided-jyotish-topics"; import { keepFocusWithin } from "@/lib/focus-trap"; import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; -import { persistExistingChatSession } from "@/lib/chat-session-persistence"; +import { writeChatSession } from "@/lib/chat-session-write-contract"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { OnboardingAuthenticationError, @@ -780,7 +779,6 @@ export default function Home() { const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( createBirthTimeConsultationConsentState, ); - const [birthTimeSoftNotice, setBirthTimeSoftNotice] = useState(""); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); @@ -873,7 +871,6 @@ export default function Home() { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); - const dismissBirthTimeSoftNotice = useCallback(() => setBirthTimeSoftNotice(""), []); useEffect(() => { if (!hydrated @@ -1347,7 +1344,6 @@ export default function Home() { async function persistSession(session: ChatSession, mode: "create" | "update" = "update") { if (!account) throw new Error("账户尚未加载完成"); if (process.env.NODE_ENV === "development" && uiPreview.current) return; - const supabase = createBrowserSupabaseClient(); const values = { title: session.title, theme: session.theme, @@ -1357,26 +1353,7 @@ export default function Home() { rectification_case_id: session.rectificationCaseId, updated_at: new Date(session.updatedAt).toISOString(), }; - if (mode === "create") { - const { error } = await supabase.from("chat_sessions").insert({ - id: session.id, - user_id: account.user.id, - ...values, - }); - if (error) throw new Error(`云端同步失败:${error.message}`); - return; - } - - await persistExistingChatSession(async () => { - const { data, error } = await supabase - .from("chat_sessions") - .update(values) - .eq("id", session.id) - .eq("user_id", account.user.id) - .select("id") - .maybeSingle(); - return { found: Boolean(data), error: error?.message ?? null }; - }); + await writeChatSession(session.id, values, mode); } async function renameSession(session: ChatSession) { @@ -2351,15 +2328,6 @@ export default function Home() { consentForDecision, sessionId, ); - if (initialConsultationRoute.kind === "choice") { - const generalConsent = grantBirthTimeConsultationConsent( - consentForDecision, - sessionId, - "general_no_birth_time", - ); - setBirthTimeConsultationConsent(generalConsent); - setBirthTimeSoftNotice("出生时间尚未校正,本次将按不依赖具体分钟的一般咨询回答。"); - } const consultationRoute = initialConsultationRoute.kind === "choice" ? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null } : initialConsultationRoute; @@ -2551,10 +2519,8 @@ export default function Home() { try { await persistSession(completedSession); } catch (caught) { - setRequestError({ - sessionId, - message: `${caught instanceof Error ? caught.message : "云端同步失败"} 回答仍保留在当前页面,请复制保存后重试。`, - }); + void caught; + setComposerNotice("回答已保留;网络恢复后,下一次对话会继续同步完整记录。"); } void refreshAccount(); return true; @@ -2603,10 +2569,8 @@ export default function Home() { message: "回答中途断开,已保留生成内容;请复制现有内容或继续追问,系统正在以账户记录为准同步点数。", }); } catch (persistError) { - setRequestError({ - sessionId, - message: `${persistError instanceof Error ? persistError.message : "云端同步失败"} 部分回答仍保留在当前页面,请复制保存后继续追问。`, - }); + void persistError; + setComposerNotice("已保留当前回答;网络恢复后,下一次对话会继续同步完整记录。"); } if (activeSessionIdRef.current === sessionId) { setComposerNotice("回答中途断开,已保留现有内容;请继续追问或复制保存。"); @@ -2798,10 +2762,6 @@ export default function Home() { return (
- {composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete - ? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "完成上方生时校正后可提问" : "请先完成上方资料" + ? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料" : "Enter 发送 · Shift + Enter 换行")}

} diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 78bd910e..52da3b2b 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -55,6 +55,9 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) const source = value.birthTimeSource; const isConfirmed = value.birthTimeStatus === "confirmed"; const displayState = birthTimeDisplayState(value); + const knowledgeMode = source === "period_only" || source === "unknown" + ? "uncertain" + : source ? "exact" : ""; const usesClockTime = source === "hospital_record" || source === "family_exact" || source === "approximate" @@ -96,15 +99,19 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) )} {!isConfirmed &&
- 你知道的出生时间属于哪一种? + 你是否知道准确出生时间?
{birthTimeSourceOptions.map((option) => (