Przeglądaj źródła

docs: 对话 session 创建拆分 - 实现计划

7 个 task 的分步 TDD 计划:后端 2 个 (service 层幂等 + API 路由),
前端 4 个 (types/llmService/useDialogueEngine/两个 Vue 组件),最后
联调 + 验收。每个后端 task 都配套单测 (pytest-asyncio + httpx
AsyncClient),前端靠 type-check + 手动验证 (PPT 项目未配 vitest)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 2 miesięcy temu
rodzic
commit
5ad95b6758
1 zmienionych plików z 1424 dodań i 0 usunięć
  1. 1424 0
      doc/DialogueSessionSplitPlan.md

+ 1424 - 0
doc/DialogueSessionSplitPlan.md

@@ -0,0 +1,1424 @@
+# 对话 Session 创建拆分 - 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把 `POST /session` 里的"建会话 + 生成开场白"拆成两个独立请求,前端每步独立 loading / 重试;全部重试 user-triggered,无自动重试。
+
+**Architecture:** 后端新增 `POST /session/{id}/greeting`,幂等实现 + `SELECT ... FOR UPDATE` 行锁并发保护。前端 `useDialogueEngine` 拆 `initSession` → `attachSession` + `generateGreeting` + `retryGreeting`;`TopicDiscussionPreview` 的"开始对话"按钮 async 化,进入聊天页后自动触发 greeting;error-card 按 HTTP status 区分可恢复 / 不可恢复(404/409 换"返回重开")。
+
+**Tech Stack:**
+- 后端:FastAPI + SQLAlchemy async + MySQL (asyncmy) + pytest/pytest-asyncio
+- 前端:Vue 3 + TypeScript + composables (无测试框架,验证靠 `pnpm type-check` + 手动联调)
+
+**Spec:** `/Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md`
+
+---
+
+## File Structure
+
+### 后端 (`/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`)
+
+| 路径 | 改动 |
+|---|---|
+| `app/service/speaking/dialogue_service.py` | `create_session()` 拆为 `create_session_only()` + `generate_greeting()` |
+| `app/api/dialogue.py` | `/session` 响应去掉 `aiMessage`;新增 `POST /session/{session_id}/greeting` 路由 |
+| `tests/service/speaking/test_dialogue_service_greeting.py` | 新建:service 层幂等、行锁、lookup 逻辑单测 |
+| `tests/api/__init__.py` | 新建(空文件,包标记) |
+| `tests/api/test_dialogue_greeting.py` | 新建:端点层 200/404/409 集成测试 |
+
+### 前端 (`/Users/buoy/Development/gitrepo/PPT`)
+
+| 路径 | 改动 |
+|---|---|
+| `src/types/englishSpeaking.ts` | `SessionInfo` 去掉 `aiMessage`;`PreviewChatMessage` 加 `unrecoverable?: boolean` |
+| `src/views/Editor/EnglishSpeaking/services/llmService.ts` | 新增 `DialogueApiError` + `createDialogueApi()` 工厂;`DialogueAPI` 接口扩 `generateGreeting`;Real/Mock 都实现 |
+| `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` | 拆 `initSession` → `attachSession` + `generateGreeting`(含 inflight 锁 + AbortController) + `retryGreeting`;`onUnmounted` 追加 abort |
+| `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` | 新 `sessionInfo` prop + `restart` emit;`onMounted` 改为 `attachSession + generateGreeting`;error-card 按 `unrecoverable` 三路分支;`handleRestart` 改为 emit |
+| `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` | `startDialogue` async 化,持有 `sessionCreating`/`sessionError`/`preparedSession`;按钮 loading/error UI;监听 `@restart` |
+
+---
+
+## Task 1: 后端 - DialogueService 拆分与幂等实现
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` (拆 `create_session` 方法)
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py` (新建)
+
+- [ ] **Step 1.1: 写 service 层测试**
+
+新建 `tests/service/speaking/test_dialogue_service_greeting.py`:
+
+```python
+"""Tests for split session creation + greeting generation."""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.pool import StaticPool
+
+from app.models.dialogue import Base, DialogueMessage, DialogueSession
+from app.service.speaking.dialogue_service import DialogueService
+
+
+@pytest_asyncio.fixture
+async def db_session():
+    """In-memory SQLite for fast tests. Uses SQLAlchemy core types that work on both MySQL and SQLite."""
+    engine = create_async_engine(
+        "sqlite+aiosqlite:///:memory:",
+        poolclass=StaticPool,
+        connect_args={"check_same_thread": False},
+    )
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+    async with SessionLocal() as session:
+        yield session
+    await engine.dispose()
+
+
+def _build_service(llm_response: str = "Hi! What's your favorite animal?") -> DialogueService:
+    llm = MagicMock()
+    llm.chat = AsyncMock(return_value=llm_response)
+    return DialogueService(
+        asr=MagicMock(),
+        llm=llm,
+        assessor=MagicMock(),
+        storage=MagicMock(),
+    )
+
+
+@pytest.mark.asyncio
+async def test_create_session_only_returns_session_without_llm_call(db_session: AsyncSession):
+    """create_session_only 只建会话记录,不调 LLM,响应不含 aiMessage。"""
+    service = _build_service()
+    result = await service.create_session_only(
+        db=db_session,
+        topic="My favorite animal",
+        total_rounds=3,
+    )
+
+    assert "sessionId" in result
+    assert "aiMessage" not in result
+    assert result["totalRounds"] == 3
+    assert result["currentRound"] == 1
+
+    # DB 只有 session 无 message
+    sessions = (await db_session.execute(select(DialogueSession))).scalars().all()
+    assert len(sessions) == 1
+    messages = (await db_session.execute(select(DialogueMessage))).scalars().all()
+    assert len(messages) == 0
+
+    # LLM 未被调用
+    service.llm.chat.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_generate_greeting_happy_path(db_session: AsyncSession):
+    """对新建 session 首次生成开场白:调 LLM,落 round=1 AI 消息,返回内容。"""
+    service = _build_service(llm_response="Hello! Ready to chat?")
+    created = await service.create_session_only(
+        db=db_session, topic="Hobbies", total_rounds=3,
+    )
+    session_uuid = created["sessionId"]
+
+    result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
+
+    assert result["aiMessage"] == "Hello! Ready to chat?"
+    msgs = (await db_session.execute(
+        select(DialogueMessage).where(DialogueMessage.role == "ai")
+    )).scalars().all()
+    assert len(msgs) == 1
+    assert msgs[0].round == 1
+    assert msgs[0].content == "Hello! Ready to chat?"
+    service.llm.chat.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_generate_greeting_idempotent_returns_existing(db_session: AsyncSession):
+    """重复调 generate_greeting 不重复调 LLM,不重复落消息,返回相同内容。"""
+    service = _build_service(llm_response="First greeting")
+    created = await service.create_session_only(
+        db=db_session, topic="Sports", total_rounds=3,
+    )
+    session_uuid = created["sessionId"]
+
+    r1 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
+    # 把 mock 改成会抛异常的,证明二次调用不会走到 LLM
+    service.llm.chat = AsyncMock(side_effect=AssertionError("LLM should NOT be called on idempotent hit"))
+    r2 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
+
+    assert r1["aiMessage"] == "First greeting"
+    assert r2["aiMessage"] == "First greeting"
+    msgs = (await db_session.execute(
+        select(DialogueMessage).where(DialogueMessage.role == "ai")
+    )).scalars().all()
+    assert len(msgs) == 1
+
+
+@pytest.mark.asyncio
+async def test_generate_greeting_session_not_found(db_session: AsyncSession):
+    """不存在的 session → raise LookupError。"""
+    service = _build_service()
+    with pytest.raises(LookupError):
+        await service.generate_greeting(
+            db=db_session, session_uuid="00000000-0000-0000-0000-000000000000",
+        )
+
+
+@pytest.mark.asyncio
+async def test_generate_greeting_session_not_active(db_session: AsyncSession):
+    """completed session → raise ValueError('not active')。"""
+    service = _build_service()
+    created = await service.create_session_only(
+        db=db_session, topic="X", total_rounds=3,
+    )
+    session_uuid = created["sessionId"]
+
+    # 手动把 session 置为 completed
+    session = (await db_session.execute(
+        select(DialogueSession).where(DialogueSession.uuid == session_uuid)
+    )).scalar_one()
+    session.status = "completed"
+    await db_session.commit()
+
+    with pytest.raises(ValueError, match="not active"):
+        await service.generate_greeting(db=db_session, session_uuid=session_uuid)
+```
+
+- [ ] **Step 1.2: 运行测试确认失败**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v
+```
+
+Expected: 5 个 test 全部 ERROR 或 FAIL(`AttributeError: 'DialogueService' object has no attribute 'create_session_only'` 等)。
+
+- [ ] **Step 1.3: 实现 `create_session_only` 和 `generate_greeting`**
+
+修改 `app/service/speaking/dialogue_service.py`。先看现有 `create_session`(约 44-97 行),**保留**原方法不要删(后面任务 2 才改 API 层);新增两个方法并让 `create_session` 委派给它们以便零破坏。
+
+在 `class DialogueService` 里插入两个新方法(比如放在 `create_session` 之前,44 行上方):
+
+```python
+    async def create_session_only(
+        self,
+        db: AsyncSession,
+        topic: str,
+        total_rounds: int = 3,
+        duration_seconds: int | None = None,
+        role_config: dict | None = None,
+        user_id: str | None = None,
+    ) -> dict:
+        """只建会话记录,不调 LLM。"""
+        system_prompt = SYSTEM_PROMPT_TEMPLATE.format(topic=topic)
+        expires_at = None
+        if duration_seconds:
+            expires_at = datetime.now() + timedelta(seconds=duration_seconds)
+
+        session = DialogueSession(
+            user_id=user_id,
+            topic=topic,
+            role_config=role_config,
+            total_rounds=total_rounds,
+            current_round=1,
+            status="active",
+            system_prompt=system_prompt,
+            expires_at=expires_at,
+        )
+        db.add(session)
+        await db.commit()
+        await db.refresh(session)
+
+        return {
+            "sessionId": session.uuid,
+            "totalRounds": total_rounds,
+            "currentRound": 1,
+            "expiresAt": expires_at.isoformat() if expires_at else None,
+        }
+
+    async def generate_greeting(
+        self,
+        db: AsyncSession,
+        session_uuid: str,
+    ) -> dict:
+        """为已存在的 session 生成开场白。幂等:已有 round=1 AI 消息直接返回。
+
+        Raises:
+            LookupError: session 不存在
+            ValueError: session 非 active
+        """
+        # 行锁 serialize 同一 session 的并发请求
+        result = await db.execute(
+            select(DialogueSession)
+            .where(DialogueSession.uuid == session_uuid)
+            .with_for_update()
+        )
+        session = result.scalar_one_or_none()
+        if session is None:
+            raise LookupError(f"Session not found: {session_uuid}")
+        if session.status != "active":
+            raise ValueError(f"Session is not active: status={session.status}")
+
+        # 幂等:查 round=1 的 AI 消息
+        existing = await db.execute(
+            select(DialogueMessage)
+            .where(DialogueMessage.session_id == session.id)
+            .where(DialogueMessage.round == 1)
+            .where(DialogueMessage.role == "ai")
+        )
+        existing_msg = existing.scalar_one_or_none()
+        if existing_msg is not None:
+            await db.commit()  # 释放行锁
+            return {"aiMessage": existing_msg.content}
+
+        # 生成开场白
+        messages = [
+            {"role": "system", "content": session.system_prompt},
+            {"role": "user", "content": f"Start a conversation about: {session.topic}"},
+        ]
+        ai_greeting = await self.llm.chat(messages, model="")
+
+        ai_msg = DialogueMessage(
+            session_id=session.id,
+            round=1,
+            role="ai",
+            content=ai_greeting,
+        )
+        db.add(ai_msg)
+        await db.commit()
+
+        return {"aiMessage": ai_greeting}
+```
+
+- [ ] **Step 1.4: 运行测试确认通过**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v
+```
+
+Expected: 全部 5 个 test PASS。
+
+- [ ] **Step 1.5: 确认现有 dialogue_service 测试仍通过**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/service/speaking/ -v
+```
+
+Expected: 所有既有测试 + 新测试都 PASS。
+
+- [ ] **Step 1.6: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_greeting.py
+git commit -m "feat(speaking): split create_session into create_session_only + generate_greeting
+
+New methods on DialogueService:
+- create_session_only: only inserts session row, returns sessionId (no LLM)
+- generate_greeting: idempotent greeting generation with SELECT ... FOR UPDATE
+  row lock; returns existing round=1 AI message if already present
+
+Old create_session() left untouched — API layer still uses it. Will be
+removed in the next task when /session endpoint switches over."
+```
+
+---
+
+## Task 2: 后端 - API 路由拆分
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py` (`/session` 改响应,新增 `/session/{id}/greeting` 路由)
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py` (新建)
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/__init__.py` (空文件)
+
+- [ ] **Step 2.1: 创建 api 测试包目录**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+mkdir -p tests/api
+touch tests/api/__init__.py
+```
+
+- [ ] **Step 2.2: 写 API 层集成测试(用 httpx.AsyncClient + ASGITransport)**
+
+新建 `tests/api/test_dialogue_greeting.py`:
+
+```python
+"""End-to-end HTTP tests for POST /session and POST /session/{id}/greeting.
+
+Uses httpx.AsyncClient + ASGITransport so everything runs on a single asyncio
+event loop — no threading / TestClient contortions when we want to mutate DB
+state between requests.
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy import update
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlalchemy.pool import StaticPool
+
+from app.api.dialogue import get_dialogue_service
+from app.main import app
+from app.models.database import get_db
+from app.models.dialogue import Base, DialogueSession
+from app.service.speaking.dialogue_service import DialogueService
+
+
+@pytest_asyncio.fixture
+async def test_env():
+    """Bring up in-memory SQLite + dependency overrides + async HTTP client."""
+    engine = create_async_engine(
+        "sqlite+aiosqlite:///:memory:",
+        poolclass=StaticPool,
+        connect_args={"check_same_thread": False},
+    )
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+
+    async def _override_db():
+        async with SessionLocal() as s:
+            yield s
+
+    def _override_service():
+        llm = MagicMock()
+        llm.chat = AsyncMock(return_value="Hi! Ready to talk?")
+        return DialogueService(
+            asr=MagicMock(), llm=llm, assessor=MagicMock(), storage=MagicMock(),
+        )
+
+    app.dependency_overrides[get_db] = _override_db
+    app.dependency_overrides[get_dialogue_service] = _override_service
+
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        yield client, SessionLocal
+
+    app.dependency_overrides.clear()
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_post_session_does_not_include_ai_message(test_env):
+    client, _ = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "My favorite animal", "totalRounds": 3,
+    })
+    assert r.status_code == 200
+    body = r.json()
+    assert "sessionId" in body
+    assert "aiMessage" not in body
+
+
+@pytest.mark.asyncio
+async def test_post_greeting_happy_path(test_env):
+    client, _ = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Sports", "totalRounds": 3,
+    })
+    session_id = r.json()["sessionId"]
+
+    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
+    assert g.status_code == 200
+    assert g.json() == {"aiMessage": "Hi! Ready to talk?"}
+
+
+@pytest.mark.asyncio
+async def test_post_greeting_idempotent(test_env):
+    client, _ = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "X", "totalRounds": 3,
+    })
+    session_id = r.json()["sessionId"]
+
+    g1 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
+    g2 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
+    assert g1.status_code == 200
+    assert g2.status_code == 200
+    assert g1.json() == g2.json()
+
+
+@pytest.mark.asyncio
+async def test_post_greeting_session_not_found_returns_404(test_env):
+    client, _ = test_env
+    g = await client.post(
+        "/api/speaking/dialogue/session/00000000-0000-0000-0000-000000000000/greeting"
+    )
+    assert g.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_post_greeting_non_active_session_returns_409(test_env):
+    client, SessionLocal = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "X", "totalRounds": 3,
+    })
+    session_id = r.json()["sessionId"]
+
+    # 直接在共享 engine 上更新 session 状态为 completed
+    async with SessionLocal() as s:
+        await s.execute(
+            update(DialogueSession)
+            .where(DialogueSession.uuid == session_id)
+            .values(status="completed")
+        )
+        await s.commit()
+
+    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
+    assert g.status_code == 409
+```
+
+- [ ] **Step 2.3: 运行测试确认失败**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/api/test_dialogue_greeting.py -v
+```
+
+Expected: 5 个 test 全部 FAIL(路由不存在 / 响应含 aiMessage 等)。
+
+- [ ] **Step 2.4: 修改 `/session` 端点使用 `create_session_only`**
+
+编辑 `app/api/dialogue.py`,找到 `@router.post("/session")`(约 51-67 行),替换 `service.create_session(...)` 为 `service.create_session_only(...)`(签名更干净,去掉了内部不用的 role_config):
+
+```python
+@router.post("/session")
+async def create_session(
+    req: CreateSessionRequest,
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    """创建对话 session(不生成开场白)。开场白由 POST /session/{id}/greeting 触发。"""
+    logger.info(f"Creating session: topic={req.topic}, rounds={req.totalRounds}")
+    result = await service.create_session_only(
+        db=db,
+        topic=req.topic,
+        total_rounds=req.totalRounds,
+        duration_seconds=req.durationSeconds,
+        user_id=req.userId,
+    )
+    logger.info(f"Session created: {result['sessionId']}")
+    return result
+```
+
+- [ ] **Step 2.5: 新增 `/session/{session_id}/greeting` 路由**
+
+在 `app/api/dialogue.py` 中,`/speak` 路由上方(约 70 行之前)插入:
+
+```python
+@router.post("/session/{session_id}/greeting")
+async def generate_greeting(
+    session_id: str,
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    """为已存在的 session 生成开场白(幂等)。"""
+    logger.info(f"Generating greeting: session={session_id}")
+    try:
+        result = await service.generate_greeting(db=db, session_uuid=session_id)
+    except LookupError:
+        raise HTTPException(status_code=404, detail="Session not found")
+    except ValueError:
+        raise HTTPException(status_code=409, detail="Session is not active")
+    logger.info(f"Greeting ready: session={session_id}")
+    return result
+```
+
+- [ ] **Step 2.6: 运行 API 测试确认通过**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest tests/api/test_dialogue_greeting.py -v
+```
+
+Expected: 5 个 test 全部 PASS。
+
+- [ ] **Step 2.7: 删除(或废弃)旧的 `create_session` 方法**
+
+回到 `app/service/speaking/dialogue_service.py`,删除原 `create_session` 方法(约 44-97 行)—— 现在没有调用方了。
+
+- [ ] **Step 2.8: 全量回归测试**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest -v
+```
+
+Expected: 所有测试 PASS;没有 `create_session` 相关测试残留报错。
+
+- [ ] **Step 2.9: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/
+git commit -m "feat(speaking): split session endpoint - /session creates only, /session/{id}/greeting generates opening
+
+Breaking change for frontend:
+- POST /session response no longer contains aiMessage field
+- New POST /session/{id}/greeting endpoint for the AI opening message
+  - 200 + {aiMessage}: success or idempotent hit
+  - 404: session not found
+  - 409: session not active
+
+Old DialogueService.create_session removed (replaced by create_session_only +
+generate_greeting, see previous commit)."
+```
+
+---
+
+## Task 3: 前端 - 类型与 API 层
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts` (`SessionInfo` 去 `aiMessage`;`PreviewChatMessage` 加 `unrecoverable`;`DialogueAPI` 扩 `generateGreeting`)
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts` (`DialogueApiError`, `createDialogueApi` 工厂,Real/Mock 都加 `generateGreeting`)
+
+- [ ] **Step 3.1: 更新类型定义**
+
+编辑 `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`:
+
+1. 找到 `PreviewChatMessage` 接口(约 201-227 行),在 `error?: string` 下方加:
+
+```typescript
+  /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */
+  unrecoverable?: boolean
+```
+
+2. 找到 `SessionInfo` 接口(约 247-251 行),改为:
+
+```typescript
+// 对话会话信息 (createSession 返回)
+export interface SessionInfo {
+  sessionId: string
+  totalRounds: number
+  currentRound: number
+  expiresAt: string | null
+}
+
+// 开场白生成结果 (generateGreeting 返回)
+export interface GreetingInfo {
+  aiMessage: string
+}
+```
+
+3. 找到 `DialogueAPI` 接口(约 259-263 行),改为:
+
+```typescript
+// 对话 API 接口
+export interface DialogueAPI {
+  createSession(config: SessionConfig): Promise<SessionInfo>
+  generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo>
+  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
+  getReport(sessionId: string): Promise<DialogueReport>
+}
+```
+
+- [ ] **Step 3.2: 扩展 `llmService.ts`**
+
+编辑 `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`。
+
+1. 在文件顶部 import 行下方加 `DialogueApiError` 和 `GreetingInfo` 引用(GreetingInfo 已在 types 里,引进来):
+
+```typescript
+import type {
+  DialogueAPI,
+  SSEEvent,
+  SessionConfig,
+  SessionInfo,
+  GreetingInfo,
+  DialogueReport,
+  SentenceEvaluation,
+} from '@/types/englishSpeaking'
+```
+
+2. 在 `const API_BASE = ...` 下方定义 error 类与工厂(约第 4 行后):
+
+```typescript
+export class DialogueApiError extends Error {
+  status: number
+  constructor(message: string, status: number) {
+    super(message)
+    this.status = status
+    this.name = 'DialogueApiError'
+  }
+}
+
+export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
+  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
+}
+```
+
+3. 替换 `RealDialogueAPI.createSession`(约 137-151 行):
+
+```typescript
+  async createSession(config: SessionConfig): Promise<SessionInfo> {
+    const res = await fetch(`${API_BASE}/session`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',
+      body: JSON.stringify({
+        topic: config.topic,
+        totalRounds: config.totalRounds,
+        roleId: config.roleId,
+      }),
+    })
+    if (!res.ok) {
+      throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
+    }
+    const body = await res.json()
+    return {
+      sessionId: body.sessionId,
+      totalRounds: body.totalRounds,
+      currentRound: body.currentRound,
+      expiresAt: body.expiresAt ?? null,
+    }
+  }
+```
+
+4. 在 `RealDialogueAPI` 里,`createSession` 方法之后新加:
+
+```typescript
+  async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
+    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()
+  }
+```
+
+5. 替换 `MockDialogueAPI.createSession`(约 191-197 行):
+
+```typescript
+  async createSession(_config: SessionConfig): Promise<SessionInfo> {
+    this.roundIndex = 0
+    return {
+      sessionId: 'mock-session-' + Date.now(),
+      totalRounds: _config.totalRounds,
+      currentRound: 1,
+      expiresAt: null,
+    }
+  }
+
+  async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise<GreetingInfo> {
+    // 模拟 300ms 延迟
+    await new Promise(r => setTimeout(r, 300))
+    return { aiMessage: "Hi! What's your favorite animal?" }
+  }
+```
+
+- [ ] **Step 3.3: 类型检查**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+pnpm type-check 2>&1 | tail -30
+```
+
+Expected: 没有 error;如果报 `useDialogueEngine.ts:39` 附近 `aiMessage` 访问错,留到 Task 4 里一起修(预期行为)。
+
+如有其它地方引用了 `SessionInfo.aiMessage`,搜一遍:
+
+```bash
+grep -rn "\.aiMessage\b" /Users/buoy/Development/gitrepo/PPT/src
+```
+
+Expected:只剩 `useDialogueEngine.ts` 一处(本次任务不改它,Task 4 处理)。
+
+- [ ] **Step 3.4: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts
+git commit -m "feat(speaking): add generateGreeting to DialogueAPI + DialogueApiError type
+
+- SessionInfo no longer carries aiMessage (moved to separate GreetingInfo)
+- PreviewChatMessage has new optional unrecoverable flag
+- DialogueApiError carries HTTP status so callers can distinguish 404/409
+- createDialogueApi factory for shared API instantiation
+
+useDialogueEngine still references old SessionInfo shape — fixed in next task."
+```
+
+---
+
+## Task 4: 前端 - useDialogueEngine 拆分
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` (拆 `initSession`,新增 `attachSession` / `generateGreeting` / `retryGreeting`,含 inflight 锁 + AbortController)
+
+- [ ] **Step 4.1: 更新 imports**
+
+编辑 `useDialogueEngine.ts`,把第 2 行 import 扩展(加入工厂和 error 类):
+
+```typescript
+import { MockDialogueAPI, RealDialogueAPI, createDialogueApi, DialogueApiError } from '../services/llmService'
+```
+
+保留现有 MockDialogueAPI / RealDialogueAPI 的 import 不强求,但用工厂就不需要它们了 —— 建议清掉改为:
+
+```typescript
+import { createDialogueApi, DialogueApiError } from '../services/llmService'
+```
+
+第 13 行把 `let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()` 改为:
+
+```typescript
+  const api: DialogueAPI = createDialogueApi(mode)
+```
+
+- [ ] **Step 4.2: 新增 greeting 相关 state + 方法,并删除老 `initSession`**
+
+删除原 `initSession` 函数(约 23-42 行),在同一位置插入:
+
+```typescript
+  // ==================== Session Attach ====================
+
+  const greetingInflight = ref(false)
+  let greetingAbortController: AbortController | null = null
+
+  /** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
+  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。 */
+  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  // 组件卸载:不改 UI
+      aiMsg.status = 'error'
+      aiMsg.error = friendlyErrorMessage(err?.message)
+      const status = err instanceof DialogueApiError ? err.status : undefined
+      aiMsg.unrecoverable = status === 404 || status === 409
+    } finally {
+      greetingInflight.value = false
+      greetingAbortController = null
+    }
+  }
+
+  /** 重试失败的开场白:移除 error 消息,再走一遍 generateGreeting(同 sessionId)。 */
+  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()
+  }
+```
+
+- [ ] **Step 4.3: `onUnmounted` 追加 greeting abort**
+
+找到 `onUnmounted`(约 391 行),把原本的:
+
+```typescript
+  onUnmounted(() => {
+    abort()
+    cancelTTS()
+    stopCountdown()
+  })
+```
+
+改为:
+
+```typescript
+  onUnmounted(() => {
+    abort()
+    greetingAbortController?.abort()
+    cancelTTS()
+    stopCountdown()
+  })
+```
+
+- [ ] **Step 4.4: 更新 return 出去的接口**
+
+找到返回对象(约 397 行),把 `initSession` 行替换为三个新方法:
+
+```typescript
+  return {
+    messages,
+    sessionId,
+    currentRound,
+    isComplete,
+    isProcessing,
+    canRecord,
+    countdownSeconds,
+    greetingInflight,   // 新增:UI 可用来 disable 重试按钮
+
+    attachSession,      // 替代 initSession
+    generateGreeting,
+    retryGreeting,
+    sendStudentMessage,
+    beginStudentStream,
+    streamFallback,
+    retryMessage,
+    regenerateAiMessage,
+    getReport,
+    abort,
+    cancelTTS,
+  }
+```
+
+- [ ] **Step 4.5: 类型检查**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+pnpm type-check 2>&1 | tail -40
+```
+
+Expected:在 `DialogueChatView.vue` 里会报 `engine.initSession is not a function` 之类的错(下一个任务修);`useDialogueEngine.ts` 本身应干净。
+
+- [ ] **Step 4.6: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
+git commit -m "refactor(speaking): split initSession into attachSession + generateGreeting + retryGreeting
+
+- attachSession: sync method, only wires sessionId/expiresAt into engine refs
+- generateGreeting: posts to /greeting, pushes loading AI placeholder, handles
+  DialogueApiError status to flag 404/409 as unrecoverable
+- retryGreeting: removes the failed AI message and re-runs generateGreeting
+- greetingInflight ref guards concurrent requests
+- onUnmounted aborts in-flight greeting fetch
+
+Next task: wire DialogueChatView/TopicDiscussionPreview to the new API."
+```
+
+---
+
+## Task 5: 前端 - DialogueChatView 接入新 API
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` (新 `sessionInfo` prop + `restart` emit;`onMounted` 改写;error-card 按钮分支;`handleRestart` 改 emit)
+
+- [ ] **Step 5.1: 新增 prop 和 emit**
+
+编辑 `DialogueChatView.vue`。找到 `interface Props`(约 491 行),在最后加一行:
+
+```typescript
+interface Props {
+  topic?: string
+  keywords?: string[]
+  aiName?: string
+  aiAvatar?: string
+  totalRounds?: number
+  mode?: 'preview' | 'real'
+  showEnglishText?: boolean
+  showChineseText?: boolean
+  sessionInfo?: { sessionId: string; expiresAt: string | null } | null
+}
+```
+
+`withDefaults` 调用(约 502 行)对应加 `sessionInfo: null`:
+
+```typescript
+const props = withDefaults(defineProps<Props>(), {
+  topic: '我最喜欢的动物',
+  keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
+  aiName: 'Tom',
+  aiAvatar: '😊',
+  totalRounds: 3,
+  mode: 'preview',
+  showEnglishText: true,
+  showChineseText: false,
+  sessionInfo: null,
+})
+```
+
+找到 `const emit = defineEmits<...>`(约 513 行),扩展为:
+
+```typescript
+const emit = defineEmits<{
+  complete: [report: DialogueReport | null]
+  restart: []
+}>()
+```
+
+- [ ] **Step 5.2: 替换 `onMounted` 逻辑**
+
+找到 `onMounted`(约 950 行)。把内部 `engine.initSession(...)` 调用整体替换:
+
+```typescript
+onMounted(() => {
+  if (props.sessionInfo) {
+    engine.attachSession(props.sessionInfo)
+    engine.generateGreeting()
+  }
+  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
+
+  totalTimer = setInterval(() => {
+    if (engine.countdownSeconds.value == null) totalSeconds.value += 1
+  }, 1000)
+})
+```
+
+- [ ] **Step 5.3: 添加 `retryAiMessage` 帮助函数**
+
+在 `script setup` 里 `handleRetry` 附近(约 721 行之后)插入:
+
+```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) {
+    if (message.unrecoverable) {
+      emit('restart')
+      return
+    }
+    engine.retryGreeting()
+    return
+  }
+
+  // 非首条:沿用原 regenerate
+  engine.regenerateAiMessage(message.id)
+}
+```
+
+- [ ] **Step 5.4: 替换模板里 AI error-card 的重试按钮**
+
+找到模板里 AI 消息的 error-card 块(约 85-88 行):
+
+```vue
+<!-- AI 错误 -->
+<div v-if="message.status === 'error'" class="error-card">
+  <span class="error-text">{{ message.error || '生成失败' }}</span>
+  <button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
+</div>
+```
+
+替换为:
+
+```vue
+<!-- AI 错误 -->
+<div v-if="message.status === 'error'" class="error-card">
+  <span class="error-text">{{ message.error || '生成失败' }}</span>
+  <button
+    class="retry-btn"
+    :disabled="engine.greetingInflight.value"
+    @click="retryAiMessage(message)"
+  >{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
+</div>
+```
+
+- [ ] **Step 5.5: 改写 `handleRestart`**
+
+找到 `handleRestart`(约 817 行),替换为:
+
+```typescript
+function handleRestart() {
+  showExitConfirm.value = false
+  engine.abort()
+  engine.cancelTTS()
+  emit('restart')
+}
+```
+
+(父组件 `TopicDiscussionPreview` 会在收到 emit 后清状态并回 ready 页,session 重建由用户再点"开始对话"触发。)
+
+- [ ] **Step 5.6: 类型检查**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+pnpm type-check 2>&1 | tail -30
+```
+
+Expected:DialogueChatView 内部应干净;在 `TopicDiscussionPreview.vue` 处可能还报 prop/emit 未使用的警告(下一个任务修)。
+
+- [ ] **Step 5.7: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat(speaking): wire DialogueChatView to new sessionInfo prop + greeting flow
+
+- New sessionInfo prop (from parent) replaces engine.initSession auto-trigger
+- On mount: attachSession + generateGreeting → user sees typing bubble immediately
+- Error retry button: three branches (retryGreeting / restart / regenerateAi)
+  driven by message.unrecoverable flag and presence of prior student message
+- handleRestart emits 'restart' instead of recreating session internally;
+  parent returns to ready stage"
+```
+
+---
+
+## Task 6: 前端 - TopicDiscussionPreview 异步启动
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` (async `startDialogue`,loading/error UI,监听 `@restart`)
+
+- [ ] **Step 6.1: 更新 imports**
+
+编辑 `TopicDiscussionPreview.vue`,在 `import` 区域(约 63-69 行)加:
+
+```typescript
+import { createDialogueApi, DialogueApiError } from '../services/llmService'
+```
+
+- [ ] **Step 6.2: 新增 session 创建状态 ref**
+
+在 `dialogueState` ref 定义(约 89 行)之后加:
+
+```typescript
+const sessionCreating = ref(false)
+const sessionError = ref<string | null>(null)
+const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
+```
+
+- [ ] **Step 6.3: 改写 `startDialogue`**
+
+找到 `startDialogue` 函数(约 198-200 行):
+
+```typescript
+function startDialogue() {
+  dialogueState.value = 'chatting'
+}
+```
+
+替换为:
+
+```typescript
+async function startDialogue() {
+  if (sessionCreating.value) return
+  sessionCreating.value = true
+  sessionError.value = null
+  try {
+    const api = createDialogueApi(props.mode)
+    const info = await api.createSession({
+      topic: speakingStore.config.topic || props.topic,
+      totalRounds: speakingStore.config.practice.rounds || props.totalRounds,
+      roleId: 'tom',
+      vocabulary: speakingStore.config.learningGoals.vocabulary,
+    })
+    preparedSession.value = {
+      sessionId: info.sessionId,
+      expiresAt: info.expiresAt,
+    }
+    dialogueState.value = 'chatting'
+  } catch (err: unknown) {
+    if (err instanceof DialogueApiError) {
+      sessionError.value = `创建会话失败(${err.status}),请重试`
+    } else {
+      sessionError.value = '创建会话失败,请重试'
+    }
+  } finally {
+    sessionCreating.value = false
+  }
+}
+```
+
+- [ ] **Step 6.4: 扩展 `resetPreview` 清 `preparedSession`**
+
+找到 `resetPreview`(约 207 行):
+
+```typescript
+function resetPreview() {
+  dialogueState.value = 'ready'
+  realEvaluation.value = null
+}
+```
+
+替换为:
+
+```typescript
+function resetPreview() {
+  dialogueState.value = 'ready'
+  realEvaluation.value = null
+  preparedSession.value = null
+  sessionError.value = null
+  sessionCreating.value = false
+}
+```
+
+- [ ] **Step 6.5: 模板里传 sessionInfo + 监听 restart**
+
+找到模板中 `<DialogueChatView>` 标签(约 33-42 行):
+
+```vue
+<DialogueChatView
+  v-else-if="dialogueState === 'chatting'"
+  :topic="speakingStore.config.topic || topic"
+  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
+  :ai-name="mockRole.name"
+  :ai-avatar="mockRole.avatar"
+  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
+  :mode="mode"
+  @complete="handleDialogueComplete"
+/>
+```
+
+扩展为:
+
+```vue
+<DialogueChatView
+  v-else-if="dialogueState === 'chatting'"
+  :topic="speakingStore.config.topic || topic"
+  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
+  :ai-name="mockRole.name"
+  :ai-avatar="mockRole.avatar"
+  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
+  :mode="mode"
+  :session-info="preparedSession"
+  @complete="handleDialogueComplete"
+  @restart="resetPreview"
+/>
+```
+
+- [ ] **Step 6.6: 改按钮 UI + 加错误提示**
+
+找到 ready 阶段的 `start-btn`(约 18-29 行):
+
+```vue
+<div class="ready-footer">
+  <button class="start-btn" @click="startDialogue">
+    <svg ...><!-- 麦克风图标 --></svg>
+    开始对话
+  </button>
+</div>
+```
+
+替换为:
+
+```vue
+<div class="ready-footer">
+  <button class="start-btn" :disabled="sessionCreating" @click="startDialogue">
+    <svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+      stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+      <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
+      <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
+      <line x1="12" y1="19" x2="12" y2="23" />
+      <line x1="8" y1="23" x2="16" y2="23" />
+    </svg>
+    <span v-else class="start-btn-spinner" />
+    {{ sessionCreating ? '创建中…' : '开始对话' }}
+  </button>
+  <p v-if="sessionError" class="session-error-text">{{ sessionError }}</p>
+</div>
+```
+
+- [ ] **Step 6.7: 样式补丁**
+
+在 `<style lang="scss" scoped>` 区(找 `.start-btn` 样式附近,约第 312 行)下方追加:
+
+```scss
+.start-btn:disabled {
+  opacity: 0.6;
+  cursor: wait;
+  background: #fb923c;
+}
+.start-btn-spinner {
+  width: 14px;
+  height: 14px;
+  border: 2px solid rgba(255, 255, 255, 0.4);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: start-btn-spin 0.8s linear infinite;
+  display: inline-block;
+}
+@keyframes start-btn-spin {
+  to { transform: rotate(360deg); }
+}
+.session-error-text {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: #dc2626;
+  text-align: center;
+}
+```
+
+- [ ] **Step 6.8: 类型检查**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+pnpm type-check 2>&1 | tail -30
+```
+
+Expected: 无 error,无 warning。
+
+- [ ] **Step 6.9: 提交**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git commit -m "feat(speaking): TopicDiscussionPreview creates session on start button click
+
+- 'Start dialogue' button now triggers POST /session before navigating
+- Button shows loading spinner while creating; disabled during in-flight
+- On failure: error text below button, button re-enabled for retry
+- On success: passes sessionInfo prop to DialogueChatView so it can attach
+  and trigger greeting immediately
+- Listens to child 'restart' emit to return to ready stage"
+```
+
+---
+
+## Task 7: 联调与验收
+
+- [ ] **Step 7.1: 启动后端**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run uvicorn app.main:app --reload --port 8000
+```
+
+Expected: uvicorn started,无异常日志。
+
+- [ ] **Step 7.2: 启动前端**
+
+```bash
+cd /Users/buoy/Development/gitrepo/PPT
+pnpm dev
+```
+
+- [ ] **Step 7.3: 手动测 - 正常流**
+
+1. 打开浏览器到 PPT,编辑模式进入包含英语口语元素的 PPT
+2. 切到预览 → ready 页 → 点击"开始对话"
+3. 按钮短暂变"创建中…" + spinner
+4. 进入聊天页 → 立即看到 typing-bubble
+5. ~2 秒后 greeting 文字出现 + TTS 朗读
+
+打开浏览器 DevTools Network 面板验证请求顺序:
+- `POST /api/speaking/dialogue/session` → 200,响应体 **不含** `aiMessage`
+- `POST /api/speaking/dialogue/session/{id}/greeting` → 200,响应体 `{"aiMessage":"..."}`
+
+- [ ] **Step 7.4: 手动测 - session 创建失败重试**
+
+1. 临时停掉后端(Ctrl+C)
+2. 回到 ready 页(可刷新)→ 点"开始对话"
+3. 验证:按钮短暂 loading,错误文案显示在按钮下方,按钮恢复可点
+4. 启动后端,再次点击"开始对话"
+5. 验证:成功进入聊天页
+
+- [ ] **Step 7.5: 手动测 - greeting 500 失败重试**
+
+在 `OneHubLLM.chat` 里临时 raise Exception(如 `raise RuntimeError("forced fail")`),重启后端。
+
+1. ready 页点"开始对话"→ 进入聊天页 → 看到 typing-bubble
+2. 几秒后 typing-bubble 变成 error-card,按钮文案"重新生成"
+3. 恢复 `OneHubLLM.chat`,重启后端
+4. 点击"重新生成"→ 再次 loading → greeting 成功
+
+验证 Network 面板:第二次 `/greeting` 请求命中**同一个** sessionId。
+
+- [ ] **Step 7.6: 手动测 - greeting 404 不可恢复**
+
+1. 进入聊天页拿到 sessionId(Network 里看)
+2. 后端通过 SQL 删除该 session `DELETE FROM dialogue_session WHERE uuid = '...'`
+3. 模拟失败场景:打开新标签,手动 POST 一次 `/session/{删除的id}/greeting`(curl 即可),看是否返 404
+4. 回到聊天页 → 刷新 → 再次进入对话页 → 此时 attachSession 会拿新 sessionId,所以没法直接复现 404
+5. 更好的重现:在 `RealDialogueAPI.generateGreeting` 临时返固定的 404 mock 错误,点击"重新生成"按钮,观察文案变成"返回重开",点击后应回到 ready 页
+
+或更简单:在 DevTools Console 手动操控消息状态:
+
+```js
+// 找到第一条 AI 消息,把 unrecoverable 改 true,status 改 error
+// (仅用于 smoke 检查 UI 分支)
+```
+
+- [ ] **Step 7.7: 手动测 - 幂等(并发模拟)**
+
+在 Network 面板打开 Throttling → Slow 3G。
+
+1. 走正常流,聊天页 typing-bubble 期间迅速(2 秒内)点击退出弹窗→"重新开始"
+2. resetPreview 回 ready → 再立刻点"开始对话"进新 session
+3. 观察老 session 的 greeting 请求被 abort(Network 面板 cancelled)
+
+验证 DB:新老两个 session 各自 round=1 AI 只有一条。
+
+或用 curl 并发验证:
+
+```bash
+# 建个 session
+SID=$(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
+  -H 'Content-Type: application/json' \
+  -d '{"topic":"test","totalRounds":3}' | jq -r .sessionId)
+echo "session=$SID"
+
+# 并发两次 greeting
+(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
+ curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
+ wait) | tee /tmp/greetings.log
+
+# 应当两个响应 aiMessage 完全相同
+cat /tmp/greetings.log
+
+# DB 只有一条 round=1 AI 消息
+mysql -u root speaking -e \
+  "SELECT COUNT(*) FROM dialogue_message m JOIN dialogue_session s ON m.session_id=s.id \
+   WHERE s.uuid='$SID' AND m.role='ai' AND m.round=1"
+# → 1
+```
+
+- [ ] **Step 7.8: 手动测 - unmount 时 abort**
+
+1. ready 页点"开始对话"→ 进入聊天页
+2. greeting 请求 in-flight 时(Slow 3G 下有时间),点退出弹窗→"重新开始"
+3. Network 面板:原 greeting 请求 status 变 `(canceled)`
+
+- [ ] **Step 7.9: 回归测 - 原有对话流程**
+
+1. 完整走完一次 3 轮对话
+2. 验证:录音 → STT → AI 回复 → 评分的流程全部正常
+3. 中间某一轮 AI 回复若失败,error-card 上按钮仍是"重新生成"(非"返回重开"),点击后 `regenerateAiMessage` 照旧工作
+4. 报告页按既有行为出现
+
+- [ ] **Step 7.10: 全量回归后端单测**
+
+```bash
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
+uv run pytest -v
+```
+
+Expected: 所有 test PASS。
+
+- [ ] **Step 7.11: 验收清单核对**
+
+对着 `/Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md` §6 的 11 条验收标准逐条打勾。
+
+---
+
+## 附录:回滚策略
+
+如果上线后 LLM 失败率暴增导致用户大量走"重新生成"路径挤压 LLM 配额,可以通过以下方式紧急回滚:
+
+1. 后端回滚到 Task 2 之前的提交(保留 Task 1 的 service 层 `create_session_only` / `generate_greeting`,但恢复 `/session` 的旧 bundled 行为)—— 因为类型和前端已经不兼容,实际回滚路径是**回滚前端+后端两边**。
+2. 所有修改都在 feature branch(`feat/english-speaking`)上,反转 commits 用 `git revert <hash>` 即可。
+
+没有 DB schema 变更,回滚不涉及数据清理。