Files

147 lines
9.2 KiB
Markdown
Raw Permalink Normal View History

# 方案A 消息发送延时改造 — E2E 浏览器验证报告
> 验证日期:2026-07-08
> 验证方式:**真实浏览器端到端实测**(系统 Chrome 驱动 H5Playwright Python
> 验证目标:员工端发送消息**瞬时返回不阻塞 UI**AI 回复经 **WebSocket 打字机流式**推送(方案A 核心机制)
> 结论:✅ **通过** — 发送即时、AI 经 WS 流式渲染、Duckula 头像/名称正常
---
## 1. 验真目标(方案A 是什么)
方案A 的核心改造:
- 员工发送消息 → 后端 `POST /h5/conversations/current/messages` **立即返回**`ai_reply: null`,消息落库 `status: sent`),**不再同步等待 AI**
- AI 回复由 `asyncio.create_task(process_h5_ai_reply)` 异步生成,逐 chunk 通过 WebSocket 广播 `ai_reply_chunk` / `ai_reply` 给 H5
- H5 监听 WS 帧,做**打字机流式渲染**;WS 断连时降级为 3s 轮询
本次 E2E 要证明的是:**UI 不阻塞(发送瞬时)** + **AI 回复走 WS 流式(非轮询兜底)**
---
## 2. 测试环境
| 组件 | 版本/地址 | 说明 |
|------|-----------|------|
| 前端 H5 | `localhost:5174/itdesk/`Vite dev | `docker-compose.dev.yml` frontend-h5 |
| 后端 | `localhost:8000`FastAPI, `--reload` | dev 栈,单 worker |
| 浏览器 | 系统 Chrome 150headless, Playwright 驱动) | 走系统 Chrome,非下载 Chromium |
| AI | Dify `app-UaTWYdBSwN6VktKQlbh5YN5H`(dev 临时切的可流式 app | 用于验证 typewriter 主路径 |
| 登录 | Mock 登录 `POST /h5/mock-login` | dev 模式免企微 OAuth |
---
## 3. 测试步骤
1. 打开 H5 登录页 `http://localhost:5174/itdesk/`
2. Mock 登录(employee_id=`E2E_BROWSER`employee_name=`浏览器实测`
3. 在输入框发送「打印机无法连接网络怎么办」
4. **计时发送点击返回**(验证 UI 不阻塞)
5. 监听页面 WebSocket 接收帧,轮询 `.chat-panel__messages` 文本,捕获:
- Duckula 头像/名称是否渲染
- AI 回复是否经 `ai_reply_chunk` 流式到达(打字机证据)
- 最终 AI 文本长度与真实内容
---
## 4. 验证结果(来自 `e2e-screenshots/e2e_result.json`
| 指标 | 值 | 判定 |
|------|-----|------|
| 发送点击耗时 `send_click_s` | **1.257s** | ✅ 瞬时(< 3s 阈值) |
| 发送 UI 不阻塞 `send_ui_instant` | **true** | ✅ |
| AI 首屏渲染 `ai_first_render_s` | **2.27s** | ✅ 发送后 2.3s 出现 |
| WS 收到 `ai_reply_chunk` 帧数 | **343** | ✅ 流式打字机路径成立 |
| 打字机已证实 `typewriter_proven` | **true** | ✅ 非轮询兜底 |
| 最终消息文本长度 `final_text_len` | **1469** 字符 | ✅ 完整真实 AI 内容 |
| 含 Duckula 名称 `final_has_duckula` | **true** | ✅ 头像/名称渲染 |
| 含真实内容 `final_has_real_content` | **true** | ✅("问题描述/打印机/网络" |
| 文本增长 `len_grew` | **true** | ✅ 流式累积 |
| WS 打开总数 `ws_open_total` | 2(含 1 个 Vite HMR | ✅ 无握手失败 |
**截图证据**(真实浏览器会话):
- `e2e-screenshots/01-login.png` — 登录页
- `e2e-screenshots/02-chat-after-login.png` — 登录后进会话
- `e2e-screenshots/03-after-send-instant.png` — 发送后即时(用户气泡出现,AI 占位)
- `e2e-screenshots/04-typewriter-mid.png` — 打字机进行中
- `e2e-screenshots/05-final.png` — AI 完整回复(Duckula 头像 + 1469 字)
**结论:方案A 在真实浏览器中验证通过** — 发送不阻塞 UIAI 经 WebSocket 流式打字机渲染。
---
## 5. 验证过程中发现并修复的 4 个阻断问题
> 这些不是方案A 本身的缺陷,而是「dev 容器化栈跑真实浏览器」暴露的环境/代码阻断。其中第 4 条是**真实后端 bug**,会影响生产 WS。
### ① H5 应用无法挂载(Vite 资源解析失败)
- **现象**:打开 `/itdesk/` 只剩骨架屏,`<vite-error-overlay>` 报错 `Failed to resolve import "/duckula.webp"`
- **根因**`MessageBubble.vue` / `MessageItem.vue` 引用 `<img src="/duckula.webp">`,该资源在**容器镜像烘焙时尚未加入 `public/`**,而 dev compose 只挂载了 `src` 没挂载 `public/`
- **修复**`docker-compose.dev.yml` frontend-h5 增加 `- ./frontend-h5/public:/app/public`(与 `src` 一致的热更新挂载)
### ② Mock 登录返回 500Vite 代理目标错误)
- **现象**:浏览器点登录 → 后端 500;但纯 Python 直连 `:8000` 却 200
- **根因**`vite.config.ts` 代理 `/api``target: 'http://localhost:8000'`,但**容器内 localhost 不是后端**(是容器自己)→ ECONNREFUSED → Vite 返 500
- **修复**:代理目标改为可配置 `process.env.VITE_PROXY_TARGET || 'http://localhost:8000'`dev compose 注入 `VITE_PROXY_TARGET=http://backend:8000`(compose 服务名)。本地非 Docker 开发仍走默认 `localhost:8000`
### ③ CSP 阻断 dev WebSocket
- **现象**`Connecting to 'ws://localhost:8000/ws/h5/...' violates CSP connect-src`(握手前被拦)
- **根因**`index.html` CSP `connect-src` 仅允许 `ws://localhost`(默认端口 80),但 dev WS 用显式端口 **8000**,CSP 按端口精确匹配 → 视为不同源被拒
- **修复**`index.html` CSP `connect-src` 增加 `ws://localhost:8000 ws://127.0.0.1:8000`
### ④ ⚠️ WebSocket 握手失败(真实后端 bug,生产相关)
- **现象**:CSP 放开后握手仍失败 `Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received`
- **根因**:浏览器用子协议 `Sec-WebSocket-Protocol: bearer.{token}` 传递 token**后端 `ws_manager.connect()` / `connect_employee()` 读取该头做认证,但 `websocket.accept()` 未回显子协议** → 浏览器严格拒绝握手
- **影响**:不仅 dev,生产环境坐席端/员工端 WS 同样会握手失败(之前被 3s 轮询兜底**掩盖**,导致 AI 回复实际走轮询而非流畅打字机)
- **修复**`backend/app/services/ws_manager.py``connect` / `connect_employee` 增加可选 `subprotocol` 参数,`accept(subprotocol=subprotocol if subprotocol else None)``backend/app/api/ws.py` 两处调用传入 `subprotocol`
- **验证**:修复后 WS 握手成功,`ai_reply_chunk` 343 帧正常流式到达
---
## 6. 遗留(非阻断)事项
- **CSP `font-src` 缺口**:控制台仍有 `data:font/woff2``at.alicdn.com` 字体被 CSP 拦截(仅影响字体显示,不影响功能)。如需消除,可在 CSP 增加 `font-src 'self' data: https://at.alicdn.com`
- **404**:一个资源 404(疑似 favicon 或字体文件),无害。
- **dev Dify key 临时切换**`docker-compose.dev.yml` 中 Dify key 为验证 typewriter 主路径临时切到可流式 app,已在注释中标注。**✅ 已还原 (2026-07-09) 回 `app-J3s8sHarZQ2SCaNF3xCppliL`dev 该 app 经 dify2openai 返回空 SSE,本地 typewriter 主路径需改用 backend/.env 工作 app 才能看到流式效果)。**
---
## 7. 结论
**方案A(员工端消息发送即时返回 + AI 经 WS 打字机流式推送)在真实浏览器端到端验证通过**
发送 1.26s 即时返回、AI 回复 2.3s 起经 343 个 WS chunk 流式渲染、Duckula 头像与完整 1469 字回复正常显示。
同时修复了 1 个真实 WebSocket 握手 bug(生产相关,此前被轮询兜底掩盖)及 3 个 dev 容器化环境阻断,使「Docker dev 栈 + 真实浏览器 E2E」链路从此可用。
---
## 8. 生产部署验证 (2026-07-09)
> WS 子协议修复(第④条 bug)按既定建议合入 main 并部署到生产服务器 `itsupport.servyou.com.cn` (10.90.5.110)。
### 8.1 部署方式
生产后端容器 `wecom_it_backend` 代码**烘焙进镜像**(唯一挂载是 `uploads`),无源码卷挂载。因此采用:
1. 将 2 个修复文件打包上传至生产服务器 `/tmp/``v2_ops.py upload`
2. `docker cp` 进运行容器:`/app/app/services/ws_manager.py``/app/app/api/ws.py`
3. 同步更新宿主机源码 `/opt/wecom-it-desk/backend/app/...`(供后续镜像重建)
4. `docker restart wecom_it_backend`
5. 原文件备份于 `/tmp/ws_manager.py.bak``/tmp/ws.py.bak`(回滚点)
### 8.2 部署后验证(真实证据)
| 检查项 | 结果 |
|--------|------|
| 容器状态 | `Up (healthy)` — 重启后健康 |
| 修复代码就位 | `ws_manager.py:76``:185` 均为 `accept(subprotocol=subprotocol if subprotocol else None)` |
| **真实 WS 连接(重启后)** | 日志显示 `WebSocket /ws/h5/tangzhenzhen [accepted]` + `H5员工 WebSocket 连接建立: employee_id=tangzhenzhen`,以及 `WebSocket /ws/sxn [accepted]` + 坐席连接建立 |
| 后端服务 | `GET /conversations ... 200 OK`(正常服务流量) |
**关键证据**:上述 H5 员工(tangzhenzhen)/坐席(sxn) 连接时间戳(01:15:03 / 01:15:06)均在容器重启(09:14:26)**之后**。用旧 bug 代码,浏览器会因「未回显 subprotocol」直接拒绝握手,连接根本到不了 `[accepted]`。现在成功建立 = **修复在生产真实生效**(旧代码下这些连接本应失败)。
### 8.3 Git 合并
- 修复 commit `bacd34c`feature/message-reliability
- cherry-pick → `6db1c0e`main),**已推送 `origin/main`**`6277db3..6db1c0e`
- 生产部署与主干合并相互独立:部署用 `docker cp`(即时生效、重启保留),主干合并保证代码源一致