# 对话 Session 创建拆分设计

> 状态：设计收敛，待实现
> 日期：2026-04-24
> 相关系统：
> - PPT (`/Users/buoy/Development/gitrepo/PPT`) — 前端,对话页面 `DialogueChatView.vue`
> - cococlass-english-speaking-api (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`) — 后端,`/api/speaking/dialogue/...`

---

## 1. 背景与问题

当前 `POST /api/speaking/dialogue/session` 同时做两件事(`app/service/speaking/dialogue_service.py:44-97`):

1. 建 `DialogueSession` DB 记录
2. 调 LLM 生成开场白并落 `DialogueMessage`

前端 `DialogueChatView.vue:950-956` 在 `onMounted` 里调 `engine.initSession(...)`,失败只 `console.error`(`useDialogueEngine.ts:39-41`),产生三个用户体验问题:

1. **无 loading 反馈** —— 聊天区空白,用户不知道 AI 正在准备开场白
2. **失败不可感知、不可重试** —— 报错被吞到控制台,用户只看到一个永远不会说话的 AI
3. **重试边界错位** —— 即使暴露错误,bundled 接口的重试意味着"重建一整个 session",语义上"重新生成开场白"和"重开一场"被混为一谈

---

## 2. 决策

### 2.1 拆后端,不硬塞 `/speak`

`POST /speak` 的语义是"学生音频 → ASR → LLM 回复",必带 `audio` 字段,开场白没有学生音频可喂。硬塞会污染 `/speak` 的单一职责。**新加 `POST /session/{id}/greeting` 独立端点**。

### 2.2 拆的收益确认(相比 bundled)

| 维度 | bundled (现状) | split |
|---|---|---|
| LLM 失败时 DB 状态 | rollback,无孤儿 | 无孤儿(session 早已 commit) |
| LLM 成功但响应丢包 | **真孤儿**(session + msg 已 commit,前端无 sessionId) | 同样孤儿 **但** 有机会触发幂等 retry 自愈 |
| 重试一次开场白的成本 | 1 DB INSERT + 1 LLM 全量调用 | 1 LLM 全量调用 |
| sessionId 生命周期 | 每次重试都变 | 稳定,整个练习期一个 |
| 语义 | "重开一场" | "重新生成开场白" |
| 可靠性耦合 | session 建立被 LLM 失败率拖累 (~1% vs ~0.01%) | 解耦 |

### 2.3 幂等性是关键约束

`POST /session/{id}/greeting` 必须幂等:若该 session `round=1` 已存在 `role='ai'` 消息,直接返回已有内容,不重复落库、不重复调 LLM。这让前端重试 100% 安全。

### 2.4 重试原则:全部 user-triggered,失败即回到可点状态

设计里**不引入任何自动重试机制**(无 setTimeout、无指数退避、无 onError 自动重连)。所有失败的处理一律遵循:

- **不自动重发请求**
- 失败后 UI 回到"失败前状态 + 一个明确可点击的重试入口",用户再次点击才触发请求
- 对 session 创建:失败入口 = Ready 页"开始对话"按钮本身
- 对 greeting / 对话中 AI / 学生消息:失败入口 = 消息上的 error-card"重新生成"/"重试"按钮

之所以 greeting 失败是"把 loading 消息直接改成 error-card"而不是"完全移除 loading,什么都不显示",是为了让重试入口有明确的点击目标 —— 完全清空会让用户无法感知失败位置。从交互语义上,error-card 可视为 typing-bubble 的等价替代态,**仍符合"回到失败前状态"** 的原则。

---

## 3. API 契约变化

### 3.1 `POST /api/speaking/dialogue/session` (改)

**Before:** 创建 session + LLM 开场白 + 落 AI 消息 + commit

**After:** 只建 session,立即 commit 返回

```
Request:  { topic, totalRounds, durationSeconds?, roleId?, userId? }   // 不变
Response: {
  sessionId: string,
  totalRounds: number,
  currentRound: 1,
  expiresAt: string | null
  // 删除 aiMessage 字段
}
```

失败场景:DB 错误(极低概率) → 500。

### 3.2 `POST /api/speaking/dialogue/session/{session_id}/greeting` (新)

```
Request:  (no body)
Response: { aiMessage: string }

200  正常生成 或 已存在(幂等命中)
404  session 不存在
409  session.status != 'active'
500  LLM 失败
```

**行为:**
1. 查 session,校验存在且 active
2. 查该 session `round=1 && role='ai'` 消息,若存在 → 直接返回其 content
3. 否则按原 `create_session` 步骤 2-3 调 LLM + 落 `DialogueMessage` + commit

**幂等性实现重点(MySQL 环境):**
- 后端在同一事务内对 `dialogue_session` 行加行锁:`SELECT * FROM dialogue_session WHERE uuid = :uuid FOR UPDATE`(SQLAlchemy `.with_for_update()`)。并发请求 serialize 到同一 session,后到者会阻塞等待,拿到锁后查 round=1 AI 消息已存在 → 直接返回。
- 不新增 schema / 索引;MySQL 不支持 PostgreSQL 的条件唯一索引,而全局 `UNIQUE(session_id, round, role)` 对历史数据风险太大,放弃 DB 约束路线。
- 前端配合做按钮 disabled 锁(§4.6),防止用户双击派生两个并发请求。前端锁 + 后端行锁双层保险。

### 3.3 其它接口

`POST /speak`、`WS /speak-stream`、`GET /report` **不变**。

---

## 4. 前端改造

### 4.1 `useDialogueEngine.ts`

**拆分 `initSession` 为两个方法:**

```typescript
// 仅设置 session refs + 启动倒计时。不产生网络请求。
function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
  sessionId.value = info.sessionId
  expiresAt.value = info.expiresAt ?? null
  if (info.expiresAt) startCountdown(info.expiresAt)
}

// push loading AI 占位消息 → 请求 /greeting → 填内容或打 error
// greetingInflight ref 锁:期间再次调用直接返回,防止双击派生并发请求
const greetingInflight = ref(false)
let greetingAbortController: AbortController | null = null

async function generateGreeting() {
  if (!sessionId.value || greetingInflight.value) return
  greetingInflight.value = true
  greetingAbortController = new AbortController()
  const aiMsg = reactive<PreviewChatMessage>({
    id: crypto.randomUUID(),
    role: 'ai',
    content: '',
    timestamp: new Date(),
    status: 'loading',
  })
  messages.value.push(aiMsg)
  try {
    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' || 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, signal?: AbortSignal): Promise<{ aiMessage: string }>
  // speak / getReport 不变
}
```

`SessionInfo` 类型相应精简(去掉 `aiMessage`)。

**MockDialogueAPI** 同步拆:`createSession` 只返 mock sessionId;`generateGreeting` 返 mock 开场白。

### 4.2 `TopicDiscussionPreview.vue`

"开始对话"按钮从"纯本地状态切换"改为"先调 `createSession`":

```typescript
const sessionCreating = ref(false)
const sessionError = ref<string | null>(null)
const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)

async function startDialogue() {
  if (sessionCreating.value) return
  sessionCreating.value = true
  sessionError.value = null
  try {
    const api = createDialogueApi(props.mode)  // §4.4 工厂
    const info = await api.createSession({
      topic: speakingStore.config.topic || topic,
      totalRounds: speakingStore.config.practice.rounds || totalRounds,
      roleId: 'tom',
      vocabulary: speakingStore.config.learningGoals.vocabulary,
    })
    preparedSession.value = { sessionId: info.sessionId, expiresAt: info.expiresAt }
    dialogueState.value = 'chatting'
  } catch (err: any) {
    sessionError.value = friendlyErrorMessage(err?.message) || '创建会话失败,请重试'
  } finally {
    sessionCreating.value = false
  }
}
```

**模板改动:**
- 按钮在 `sessionCreating` 时变 disabled + 内嵌 spinner,文案可保持"开始对话"(或加省略号)
- 按钮下方/旁边出 `sessionError` 文案(非阻塞,按钮保持可点击,用户再次点击即重试)

**重启流程:** `resetPreview()` 额外清 `preparedSession.value = null`。

### 4.3 `DialogueChatView.vue`

**Props 新增 `sessionInfo`:**

```typescript
interface Props {
  // ... existing
  sessionInfo?: { sessionId: string; expiresAt: string | null } | null
}
```

**`onMounted` 改动:**
- 不再调 `engine.initSession(...)`
- 若 `props.sessionInfo` 存在 → `engine.attachSession(props.sessionInfo)` + `engine.generateGreeting()`
- 否则保持现状兜底(preview mode 或未来场景)

**error-card 重试按钮需要三路分支(可恢复 / 不可恢复 / 学生消息或非首条 AI):**

当前模板 `DialogueChatView.vue:85-88`:

```vue
<button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
```

改为(在 `msg-ai` 分支内):

```vue
<button
  class="retry-btn"
  @click="retryAiMessage(message)"
>{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
```

```typescript
function retryAiMessage(message: PreviewChatMessage) {
  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()
    return
  }

  // 非首条:沿用原 regenerate
  engine.regenerateAiMessage(message.id)
}
```

**`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 DialogueApiError(`createSession failed: ${res.status}`, res.status)
  return res.json()  // 无 aiMessage 字段
}

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 DialogueApiError(
      `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
      res.status,
    )
  }
  return res.json()
}
```

### 4.5 错误文案与生命周期

`friendlyErrorMessage` 映射表(`useDialogueEngine.ts:429-442`)文案微调:

```typescript
'Session not found': '会话已失效,请重新开始',     // 已有,文案统一
'Session is not active': '会话已结束,请重新开始', // 已有,文案统一
// LLM 失败等 500 场景回落到通用文案"请求失败,请重试",不新加 key
```

**组件卸载时 abort 在飞的 greeting 请求:** `useDialogueEngine` 的 `onUnmounted` 新增 `greetingAbortController?.abort()`(原有 `abort()` 只 cover speak 路径):

```typescript
onUnmounted(() => {
  abort()
  greetingAbortController?.abort()
  cancelTTS()
  stopCountdown()
})
```

### 4.6 并发防护总结

| 层 | 措施 |
|---|---|
| UI | "开始对话"按钮 `sessionCreating` 期间 disabled;error-card "重新生成"按钮在 `greetingInflight` 期间 disabled |
| 前端逻辑 | `generateGreeting` / `retryGreeting` 入口处检 `greetingInflight` ref,in-flight 直接 return |
| 后端 | 在 `dialogue_session` 行上加行锁 `SELECT ... FOR UPDATE`(SQLAlchemy `.with_for_update()`),同一 session 的并发 greeting 请求 serialize;后到者拿锁后查已有 round=1 AI 消息 → 直接返回。MySQL 原生支持,无需 schema 变更 |

---

## 5. 实施顺序

1. **后端** (MySQL,表名 `dialogue_session` / `dialogue_message`)
   1. `dialogue_service.py`:`create_session` 拆成 `create_session_only`(仅建 session,立即 commit) + `generate_greeting`(`with_for_update()` 行锁 → 查 round=1 AI → 有则返回 / 无则调 LLM 落消息 commit)
   2. `api/dialogue.py`:`/session` 响应去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由
   3. 单测:session 只建不生成、greeting 幂等(重复调返回同一内容且 DB 只一条)、greeting 对非 active session 返 409、greeting 对不存在 session 返 404
2. **前端**
   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 创建失败:按钮恢复可点 + 错误文案 + 再次点击成功
   - greeting 500 失败:聊天页 error-card(按钮文案"重新生成")+ 点击重试 → 同一 sessionId 再次请求成功
   - greeting 404/409 失败:error-card 按钮文案变"返回重开",点击后回到 ready 页
   - greeting 幂等:人工并发两次请求,验证 DB 只一条 round=1 AI 消息
   - 对话中重开:退出弹窗点"重新开始" → 回 ready 页 → 再点"开始对话"走新 session 完整流程

---

## 6. 验收标准

- [ ] `POST /session` 响应体不含 `aiMessage`
- [ ] `POST /session/{id}/greeting` 对同一 session 重复调用返回完全相同的 `aiMessage` 且 DB 中仅一条 round=1 AI 消息
- [ ] `POST /session/{id}/greeting` 对非 active session 返 409,对不存在 session 返 404
- [ ] 并发两次 greeting 请求(模拟双击)DB 仅产生一条 round=1 AI 消息;两个请求返回相同 `aiMessage`,第二个通过行锁等待后走"已存在直接返回"路径
- [ ] 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` / 自动重试代码引入;所有重试均由用户点击触发

---

## 7. 非目标

- 不改 `/speak`、`/speak-stream`、`/report`
- 不引入开场白 streaming(1-2 句话,非 streaming 体感差异可忽略)
- 不做"换一种开场白风格"等产品功能(未来若需要,`/greeting` 可扩展 query 参数)
- 不做孤儿 session 清理(依赖 `expires_at` 自然过期)
