# 方案A 消息发送延时改造 — E2E 浏览器验证报告 > 验证日期:2026-07-08 > 验证方式:**真实浏览器端到端实测**(系统 Chrome 驱动 H5,Playwright 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 150(headless, 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 在真实浏览器中验证通过** — 发送不阻塞 UI,AI 经 WebSocket 流式打字机渲染。 --- ## 5. 验证过程中发现并修复的 4 个阻断问题 > 这些不是方案A 本身的缺陷,而是「dev 容器化栈跑真实浏览器」暴露的环境/代码阻断。其中第 4 条是**真实后端 bug**,会影响生产 WS。 ### ① H5 应用无法挂载(Vite 资源解析失败) - **现象**:打开 `/itdesk/` 只剩骨架屏,`` 报错 `Failed to resolve import "/duckula.webp"` - **根因**:`MessageBubble.vue` / `MessageItem.vue` 引用 ``,该资源在**容器镜像烘焙时尚未加入 `public/`**,而 dev compose 只挂载了 `src` 没挂载 `public/` - **修复**:`docker-compose.dev.yml` frontend-h5 增加 `- ./frontend-h5/public:/app/public`(与 `src` 一致的热更新挂载) ### ② Mock 登录返回 500(Vite 代理目标错误) - **现象**:浏览器点登录 → 后端 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/`(`jms_ops pack-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`(即时生效、重启保留),主干合并保证代码源一致