Просмотр исходного кода

docs: 补充 404/409 处理、并发防护、API 工厂

- §3.2 + §4.6:后端条件唯一索引 + 前端 inflight 锁,双层并发保护
- §4.1 + §4.3:greeting 失败按 status 区分可恢复/不可恢复,404/409 走"返回重开"回 ready 页
- §4.4:抽 createDialogueApi 工厂 + DialogueApiError 类型(携带 HTTP status)
- §4.5:onUnmounted abort 在飞 greeting 请求
- §5 + §6:实施顺序和验收标准同步更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 2 месяцев назад
Родитель
Сommit
2ba673a20f
1 измененных файлов с 127 добавлено и 36 удалено
  1. 127 36
      doc/DialogueSessionSplitDesign.md

+ 127 - 36
doc/DialogueSessionSplitDesign.md

@@ -95,7 +95,9 @@ Response: { aiMessage: string }
 2. 查该 session `round=1 && role='ai'` 消息,若存在 → 直接返回其 content
 3. 否则按原 `create_session` 步骤 2-3 调 LLM + 落 `DialogueMessage` + commit
 
-**幂等性实现重点:** 查询必须在同一事务内先查后写,避免并发双开场白(虽然单个用户并发调此接口的概率极低,仍加一层兜底)。
+**幂等性实现重点:**
+- 后端加 DB 层兜底:在 `dialogue_messages` 表上建条件唯一索引 `UNIQUE(session_id, round, role) WHERE role='ai' AND round=1`(PostgreSQL 条件索引语法 `CREATE UNIQUE INDEX ... WHERE ...`)。只约束 round=1 的 AI 消息以限定作用域,避免干扰后续轮次的常规 INSERT。并发 INSERT 时第二个会被 DB 拒掉,捕获 `IntegrityError` 后重查已有消息返回即可。
+- 前端配合做按钮 disabled 锁(§4.6),防止用户双击派生两个并发请求。前端锁 + 后端约束双层保险。
 
 ### 3.3 其它接口
 
@@ -118,8 +120,14 @@ function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
 }
 
 // push loading AI 占位消息 → 请求 /greeting → 填内容或打 error
+// greetingInflight ref 锁:期间再次调用直接返回,防止双击派生并发请求
+const greetingInflight = ref(false)
+let greetingAbortController: AbortController | null = null
+
 async function generateGreeting() {
-  if (!sessionId.value) return
+  if (!sessionId.value || greetingInflight.value) return
+  greetingInflight.value = true
+  greetingAbortController = new AbortController()
   const aiMsg = reactive<PreviewChatMessage>({
     id: crypto.randomUUID(),
     role: 'ai',
@@ -129,31 +137,43 @@ async function generateGreeting() {
   })
   messages.value.push(aiMsg)
   try {
-    const { aiMessage } = await api.generateGreeting(sessionId.value)
+    const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
     aiMsg.content = aiMessage
     aiMsg.status = 'done'
     speakTTS(aiMessage)
   } catch (err: any) {
+    if (err?.name === 'AbortError') return  // 组件卸载,不改状态
     aiMsg.status = 'error'
+    // 分辨可恢复 vs 不可恢复:404/409 打上 unrecoverable 标记,UI 层换"返回重开"按钮
+    const code = err?.status as number | undefined
     aiMsg.error = friendlyErrorMessage(err?.message)
+    aiMsg.unrecoverable = code === 404 || code === 409
+  } finally {
+    greetingInflight.value = false
+    greetingAbortController = null
   }
 }
 
 // 重试开场白:找到失败的首条 AI 消息,移除后重跑 generateGreeting
 async function retryGreeting() {
+  if (greetingInflight.value) return
   const firstAi = messages.value.find(m => m.role === 'ai')
-  if (firstAi?.status !== 'error') return
+  if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
   messages.value = messages.value.filter(m => m.id !== firstAi.id)
   await generateGreeting()
 }
 ```
 
+`PreviewChatMessage` 类型扩一个可选 `unrecoverable?: boolean` 字段(只在 greeting 失败场景用,其它路径不设)。
+
+`RealDialogueAPI.generateGreeting` 需要抛 **带 status 的 error**(不能只塞进 message 字符串),见 §4.4。
+
 **`DialogueAPI` 接口新增 `generateGreeting`,`createSession` 返回结构改变:**
 
 ```typescript
 interface DialogueAPI {
   createSession(config: SessionConfig): Promise<{ sessionId: string; totalRounds: number; currentRound: number; expiresAt: string | null }>
-  generateGreeting(sessionId: string): Promise<{ aiMessage: string }>
+  generateGreeting(sessionId: string, signal?: AbortSignal): Promise<{ aiMessage: string }>
   // speak / getReport 不变
 }
 ```
@@ -176,7 +196,7 @@ async function startDialogue() {
   sessionCreating.value = true
   sessionError.value = null
   try {
-    const api = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+    const api = createDialogueApi(props.mode)  // §4.4 工厂
     const info = await api.createSession({
       topic: speakingStore.config.topic || topic,
       totalRounds: speakingStore.config.practice.rounds || totalRounds,
@@ -215,7 +235,7 @@ interface Props {
 - 若 `props.sessionInfo` 存在 → `engine.attachSession(props.sessionInfo)` + `engine.generateGreeting()`
 - 否则保持现状兜底(preview mode 或未来场景)
 
-**error-card 重试按钮需要分支:**
+**error-card 重试按钮需要三路分支(可恢复 / 不可恢复 / 学生消息或非首条 AI):**
 
 当前模板 `DialogueChatView.vue:85-88`:
 
@@ -226,79 +246,146 @@ interface Props {
 改为(在 `msg-ai` 分支内):
 
 ```vue
-<button class="retry-btn" @click="retryAiMessage(message)">重新生成</button>
+<button
+  class="retry-btn"
+  @click="retryAiMessage(message)"
+>{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
 ```
 
 ```typescript
 function retryAiMessage(message: PreviewChatMessage) {
-  // 第一条 AI 消息(前面没有学生消息)→ 走 retryGreeting
   const idx = engine.messages.value.indexOf(message)
   const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
+
+  // 第一条 AI 消息(前面没有学生消息)= greeting
   if (!hasPrevStudent) {
+    // 404/409 等不可恢复:回 ready 页重新创建 session
+    if (message.unrecoverable) {
+      emit('restart')
+      return
+    }
     engine.retryGreeting()
-  } else {
-    engine.regenerateAiMessage(message.id)
+    return
   }
+
+  // 非首条:沿用原 regenerate
+  engine.regenerateAiMessage(message.id)
 }
 ```
 
-**`handleRestart` 改动:** 不再调 `engine.initSession`,改为走父组件 `resetPreview` 流程 —— 当前 `handleRestart` 在子组件内部直接重置状态并调 `initSession`,拆分后应由父组件发起"重新创建 session"。最简单的做法:`handleRestart` 触发一个 `emit('restart')` 事件,父组件接收后清状态回 ready。
+**`emit` 定义新增 `restart` 事件:**
+
+```typescript
+const emit = defineEmits<{
+  complete: [report: DialogueReport | null]
+  restart: []
+}>()
+```
+
+**`handleRestart` 改动(路径 A):** 不再调 `engine.initSession`,直接 `emit('restart')`。父组件 `TopicDiscussionPreview` 接收后清状态回 ready 页,用户再次点击"开始对话"创建新 session。弹窗里"重新开始"按钮的一键体验降级为"回起点再开始",代码最少,语义最直白。
 
 ### 4.4 `llmService.ts`
 
+**抽取 API 工厂**(供父子组件共用,避免各自 `new`):
+
+```typescript
+export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
+  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+}
+```
+
+`useDialogueEngine` 和 `TopicDiscussionPreview.startDialogue` 都通过工厂拿 API 实例。
+
+**自定义 error 类型**(让上层能区分 404/409 vs 其它):
+
+```typescript
+export class DialogueApiError extends Error {
+  status: number
+  constructor(message: string, status: number) {
+    super(message)
+    this.status = status
+    this.name = 'DialogueApiError'
+  }
+}
+```
+
 **`RealDialogueAPI`:**
 
 ```typescript
 async createSession(config: SessionConfig): Promise<{ sessionId: string; ... }> {
   const res = await fetch(`${API_BASE}/session`, { method: 'POST', ... })
-  if (!res.ok) throw new Error(`createSession failed: ${res.status}`)
+  if (!res.ok) throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
   return res.json()  // 无 aiMessage 字段
 }
 
-async generateGreeting(sessionId: string): Promise<{ aiMessage: string }> {
+async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<{ aiMessage: string }> {
   const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
     method: 'POST',
     credentials: 'include',
+    signal,
   })
   if (!res.ok) {
     const text = await res.text().catch(() => '')
-    throw new Error(`greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`)
+    throw new DialogueApiError(
+      `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
+      res.status,
+    )
   }
   return res.json()
 }
 ```
 
-### 4.5 错误文案
+### 4.5 错误文案与生命周期
+
+`friendlyErrorMessage` 映射表(`useDialogueEngine.ts:429-442`)文案微调:
+
+```typescript
+'Session not found': '会话已失效,请重新开始',     // 已有,文案统一
+'Session is not active': '会话已结束,请重新开始', // 已有,文案统一
+// LLM 失败等 500 场景回落到通用文案"请求失败,请重试",不新加 key
+```
 
-`friendlyErrorMessage` 映射表(`useDialogueEngine.ts:429-442`)补充:
+**组件卸载时 abort 在飞的 greeting 请求:** `useDialogueEngine` 的 `onUnmounted` 新增 `greetingAbortController?.abort()`(原有 `abort()` 只 cover speak 路径):
 
 ```typescript
-'Session not found': '会话已失效,请刷新页面重新开始',  // 已有
-'Session is not active': '会话已结束',                  // 已有
-// 新加 LLM 失败时前端显示的中文文案:
-'Greeting generation failed': '开场白生成失败,请重试',  // 对应后端 500
+onUnmounted(() => {
+  abort()
+  greetingAbortController?.abort()
+  cancelTTS()
+  stopCountdown()
+})
 ```
 
-后端 500 时前端拿不到稳定 error code,`friendlyErrorMessage` 对未知 message 回落到"请求失败,请重试"已可用。可不特殊处理,保留通用文案即可。
+### 4.6 并发防护总结
+
+| 层 | 措施 |
+|---|---|
+| UI | "开始对话"按钮 `sessionCreating` 期间 disabled;error-card "重新生成"按钮在 `greetingInflight` 期间 disabled |
+| 前端逻辑 | `generateGreeting` / `retryGreeting` 入口处检 `greetingInflight` ref,in-flight 直接 return |
+| 后端 | `dialogue_messages` 加条件唯一索引 `UNIQUE(session_id, round, role) WHERE role='ai' AND round=1`(PostgreSQL `CREATE UNIQUE INDEX ... WHERE ...`);并发 INSERT 由 DB 保证唯一,第二个捕 `IntegrityError` → 重查已有消息返回 |
 
 ---
 
 ## 5. 实施顺序
 
 1. **后端**
-   1. `dialogue_service.py`:`create_session` 拆成 `create_session_only` + `generate_greeting`(含幂等查现有消息)
-   2. `api/dialogue.py`:`/session` 返回去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由
-   3. 单测:session 只建不生成、greeting 幂等(重复调返回同一内容)、greeting 对非 active session 返 409、greeting 对不存在 session 返 404
+   1. `migrations/`:新增 migration,在 `dialogue_messages` 上建条件唯一索引 `UNIQUE(session_id, round, role) WHERE role='ai' AND round=1`
+   2. `dialogue_service.py`:`create_session` 拆成 `create_session_only` + `generate_greeting`(含幂等查现有消息 + 捕获 `IntegrityError` 重查兜底)
+   3. `api/dialogue.py`:`/session` 返回去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由
+   4. 单测:session 只建不生成、greeting 幂等(重复调返回同一内容)、greeting 对非 active session 返 409、greeting 对不存在 session 返 404、并发 greeting 只产一条 AI 消息
 2. **前端**
-   1. `llmService.ts`:`DialogueAPI` 接口扩展;Real/Mock 都实现 `generateGreeting`;`createSession` 返回类型更新
-   2. `useDialogueEngine.ts`:拆 `initSession` → `attachSession` + `generateGreeting` + `retryGreeting`
-   3. `DialogueChatView.vue`:接受 `sessionInfo` prop;`onMounted` 改为 `attachSession + generateGreeting`;error-card 重试分支;`handleRestart` 改走 emit
-   4. `TopicDiscussionPreview.vue`:`startDialogue` 改为 async,持有 `preparedSession`;按钮 loading/error UI;接收子组件 `restart` 事件
+   1. `llmService.ts`:新增 `DialogueApiError` + `createDialogueApi` 工厂;`DialogueAPI` 接口扩展;Real/Mock 都实现 `generateGreeting(signal)`;`createSession` 返回类型更新
+   2. `useDialogueEngine.ts`:拆 `initSession` → `attachSession` + `generateGreeting`(含 `greetingInflight` 锁 + `greetingAbortController`) + `retryGreeting`;`onUnmounted` 追加 abort
+   3. `types/englishSpeaking.ts`:`PreviewChatMessage` 加 `unrecoverable?: boolean`;`SessionInfo` 去掉 `aiMessage`
+   4. `DialogueChatView.vue`:接受 `sessionInfo` prop + 新 `restart` emit;`onMounted` 改为 `attachSession + generateGreeting`;error-card 按钮文案/行为按 `unrecoverable` 三路分支;`handleRestart` 改为 emit
+   5. `TopicDiscussionPreview.vue`:`startDialogue` 改为 async,持有 `preparedSession`/`sessionCreating`/`sessionError`;按钮 loading/error UI;监听子组件 `@restart` 事件清状态回 ready
 3. **联调**
    - 正常流:点击 → session 创建 → 进入聊天 → typing-bubble → greeting 出现
-   - session 失败:按钮可见 error + 再次点击成功
-   - greeting 失败:聊天页 error-card + 点击重试 → 同一 sessionId 再次请求成功
-   - greeting 幂等:人工强制重复请求,验证不重复落库
+   - session 创建失败:按钮恢复可点 + 错误文案 + 再次点击成功
+   - greeting 500 失败:聊天页 error-card(按钮文案"重新生成")+ 点击重试 → 同一 sessionId 再次请求成功
+   - greeting 404/409 失败:error-card 按钮文案变"返回重开",点击后回到 ready 页
+   - greeting 幂等:人工并发两次请求,验证 DB 只一条 round=1 AI 消息
+   - 对话中重开:退出弹窗点"重新开始" → 回 ready 页 → 再点"开始对话"走新 session 完整流程
 
 ---
 
@@ -307,10 +394,14 @@ async generateGreeting(sessionId: string): Promise<{ aiMessage: string }> {
 - [ ] `POST /session` 响应体不含 `aiMessage`
 - [ ] `POST /session/{id}/greeting` 对同一 session 重复调用返回完全相同的 `aiMessage` 且 DB 中仅一条 round=1 AI 消息
 - [ ] `POST /session/{id}/greeting` 对非 active session 返 409,对不存在 session 返 404
-- [ ] Ready 页点击"开始对话"期间按钮显示 loading,session 创建失败后按钮恢复可点且下方显示错误文案,再点击可成功重试
-- [ ] 进入聊天页后立刻出现 typing-bubble(LLM 期间),greeting 到达后变为正常 AI 消息气泡
-- [ ] greeting 生成失败时,聊天页 error-card 出现"重新生成"按钮,点击后复用同一 sessionId 再请求
-- [ ] 原有对话中间 AI 消息失败重试(`regenerateAiMessage`)行为不变
+- [ ] 并发两次 greeting 请求(模拟双击)DB 仅产生一条 round=1 AI 消息,其中一个请求由 `IntegrityError` 路径重查后返回相同内容
+- [ ] Ready 页点击"开始对话"期间按钮 disabled + loading,session 创建失败后按钮恢复可点且下方显示错误文案,再点击可成功重试
+- [ ] 进入聊天页后立刻出现 typing-bubble(greeting 期间),greeting 到达后变为正常 AI 消息气泡
+- [ ] greeting 500/网络失败时,error-card 按钮文案 "重新生成",点击后复用同一 sessionId 再请求;in-flight 期间按钮 disabled
+- [ ] greeting 404/409 失败时,error-card 按钮文案 "返回重开",点击触发 emit('restart'),父组件清状态回 ready 页
+- [ ] 聊天页 unmount 时在飞的 greeting 请求被 abort(浏览器 Network 面板可见 `cancelled`)
+- [ ] 原有对话中间 AI 消息失败重试(`regenerateAiMessage`)和学生消息重试(`retryMessage`)行为不变
+- [ ] 无任何 `setTimeout` / 自动重试代码引入;所有重试均由用户点击触发
 
 ---