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 &&