chore(docs): docs/ 目录全面重新编号 + 重组
**重构前**(旧编号 02-11): - docs/02-产品需求/ → 00 产品规划/PRD - docs/03-技术架构/ → 01-05 子目录散落 - docs/04-原型设计/ → 01-02 产品设计(HTML 原型) - docs/05-原型设计/ → screens/ - docs/06-测试素材/ → 02-E2E / 03-功能 / 04-版本测试 - docs/07-项目管理/ → 任务说明书/日报/计划 - docs/08-安全审计/ → 审计报告 - docs/09-堡垒运维/ → toolbox / deploy - docs/10-项目管理/ → 任务说明书(重复) - docs/11-历史归档/ → deploy-nas-archived **重构后**(新编号 00-07,语义化): - docs/00-产品开发流程与文档管理规范.md - docs/00-版本迭代总览.md - docs/01-产品文档/ (PRD/原型/认证/会话/AI 服务/坐席/集成) - docs/02-技术文档/ (技术方案/架构图/重构记录/前端改造/实现配置) - docs/03-测试文档/ (E2E/功能用例/版本报告/缺陷单) - docs/04-运维文档/ (部署运维/运维指南) - docs/05-运营文档/ (品牌推广/用户手册) - docs/06-安全审计/ (审计报告) - docs/07-项目管理/ (任务说明书/日报/计划/看板) **净收益**: - 目录编号与产品文档管理规范对齐(按文档阶段 01-07 编号) - 消除 02-产品需求 与 10-项目管理 的编号重叠 - 子目录按文档类型分组(如 01-产品文档/00-产品规划、01-产品文档/01-认证与登录) - 把运维/安全/项目管理从 0X 散落改为 04/06/07 合计 494 文件 + 78495 行 / - 14076 行
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# E2E 验收清单 v0.7.0(扫码登录 + MFA)
|
||||
|
||||
> 部署完 v0.7.0 后,**逐项打勾**。任何一项 ❌ 立即回滚。
|
||||
> 每项给出预期结果 + 验证方法 + 失败处理。
|
||||
|
||||
---
|
||||
|
||||
## 0. 部署完成(用户跑过 DEPLOY-LOGIN-MIGRATION-v0.7.0.md 全部步骤)
|
||||
|
||||
- [ ] 后端 `alembic upgrade head` 跑通(head = `025_messages_id_uuid`)
|
||||
- [ ] 4 端 dist 已上传到宿主机 `/opt/wecom-it-desk/frontend-*/dist/`
|
||||
- [ ] nginx `nginx -t` 通过 + `nginx -s reload` 完成
|
||||
- [ ] `docker restart wecom_it_backend` 成功
|
||||
- [ ] 容器状态 `docker ps` 显示 backend/redis/postgres 全部 Up
|
||||
|
||||
---
|
||||
|
||||
## 1. 扫码登录(Phase 1.1 / 1.2 / 1.3)
|
||||
|
||||
### 1.1 门户页面加载
|
||||
- [ ] 浏览器打开 `https://<生产域名>/itportal/`
|
||||
- [ ] 看到 QrcodeLogin 页面(二维码 + 倒计时)
|
||||
- [ ] 不再显示旧的"账号密码"登录
|
||||
|
||||
### 1.2 二维码生成
|
||||
- [ ] 倒计时从 120 秒开始
|
||||
- [ ] 刷新按钮可用
|
||||
- [ ] DevTools Network: `POST /api/auth_qrcode/create` 返回 200 + ticket
|
||||
|
||||
### 1.3 扫码
|
||||
- [ ] 用企微扫 → 企微 OAuth2 跳回 callback
|
||||
- [ ] 门户页面状态从 `waiting` → `scanned`(显示"已扫码,等待确认")
|
||||
- [ ] DevTools Network: `POST /api/auth_qrcode/scan` 成功
|
||||
|
||||
### 1.4 坐席确认
|
||||
- [ ] 已登录坐席在 `/itagent/` 收到确认弹窗
|
||||
- [ ] 点"确认"→ 门户 `waiting` → `confirmed` → 跳转 `/itagent/`
|
||||
- [ ] localStorage 有 `agent_token` / `portal_token`
|
||||
|
||||
### 1.5 角色分发
|
||||
- [ ] 双角色坐席(admin+agent)→ 跳 `/itportal/select`
|
||||
- [ ] 仅 admin → 跳 `/itadmin/`
|
||||
- [ ] 仅 agent → 跳 `/itagent/`
|
||||
- [ ] 仅 user → 跳 `/itdesk/`
|
||||
|
||||
### 1.6 过期处理
|
||||
- [ ] 120 秒不扫 → 状态变 `expired` + 提示"二维码已过期,请刷新"
|
||||
|
||||
---
|
||||
|
||||
## 2. MFA 绑定(Phase 2.4)
|
||||
|
||||
### 2.1 绑定入口
|
||||
- [ ] 坐席登录后 → 顶栏头像 → "绑定 MFA"
|
||||
- [ ] 进 `/itagent/mfa-bind` 页面
|
||||
|
||||
### 2.2 扫码绑定
|
||||
- [ ] 看到 TOTP 二维码(otpauth://totp/...)
|
||||
- [ ] 用 Google Authenticator / 微软 Authenticator 扫
|
||||
- [ ] 输入 6 位 OTP → 点"验证" → 成功
|
||||
- [ ] 页面显示"已绑定" + 备份信息
|
||||
|
||||
### 2.3 API 验证
|
||||
- [ ] `GET /api/mfa/status` 返回 `bound: true, enabled: true`
|
||||
- [ ] `GET /api/mfa/users` (admin) 看到该坐席 bound=true
|
||||
|
||||
---
|
||||
|
||||
## 3. MFA 验证(高危操作守卫)
|
||||
|
||||
### 3.1 30 分钟有效期
|
||||
- [ ] 坐席 admin 角色登录 → 绑 MFA → 调 `/api/admin/high-risk/demo/role_change`
|
||||
- [ ] **未先调 /api/mfa/verify** → 返回 `2001 需要 OTP`
|
||||
- [ ] 调 `POST /api/mfa/verify {otp_code: "123456"}` → 成功
|
||||
- [ ] **再调** 高危端点 → 200 通过
|
||||
- [ ] 等 31 分钟 → 再次调 → 又返回 2001(TTL 失效)
|
||||
|
||||
### 3.2 5 类高危操作
|
||||
- [ ] `POST /api/admin/high-risk/demo/role_change` → 200
|
||||
- [ ] `POST /api/admin/high-risk/demo/config_change` → 200
|
||||
- [ ] `POST /api/admin/high-risk/demo/data_export` → 200
|
||||
- [ ] `POST /api/admin/high-risk/demo/account_disable` → 200
|
||||
- [ ] `POST /api/admin/high-risk/demo/account_create_reset` → 200
|
||||
|
||||
### 3.3 角色拒绝
|
||||
- [ ] 非 admin 角色调高危端点 → 4003 仅管理员
|
||||
|
||||
### 3.4 白名单查询
|
||||
- [ ] `GET /api/admin/high-risk/whitelist` 返回 5 类元数据
|
||||
|
||||
---
|
||||
|
||||
## 4. P0/P1 合规验证
|
||||
|
||||
### 4.1 WebSocket 连接
|
||||
- [ ] H5 员工端开 DevTools → Network → WS
|
||||
- [ ] WS 连接建立,**没有 1006 / missing argument 错误**
|
||||
- [ ] 坐席发消息 → H5 端 100ms 内收到(无轮询 3-5s 延迟)
|
||||
|
||||
### 4.2 消息 ID 类型
|
||||
- [ ] `psql -d wecom_it -c 'SELECT id FROM messages LIMIT 1;'` 返回 UUID 格式
|
||||
- [ ] 前端消息轮询不再偶发 500
|
||||
- [ ] 跨会话消息不再串号
|
||||
|
||||
### 4.3 nginx access_log
|
||||
- [ ] `docker exec wecom_it_nginx tail /var/log/nginx/access.log | head -3`
|
||||
- [ ] 不包含 `Authorization:` / `Cookie:` 字样
|
||||
- [ ] 只剩 IP / method / path / status
|
||||
|
||||
### 4.4 Gitea token
|
||||
- [ ] `cat .git/config | grep 5ad83d` 返回空(token 已撤销)
|
||||
- [ ] `git push` 试一下:**应该失败**(无 push 权限,符合预期)
|
||||
|
||||
---
|
||||
|
||||
## 5. 端到端业务流(回归)
|
||||
|
||||
### 5.1 H5 → 坐席 完整流程
|
||||
- [ ] H5 员工发起会话 → 排队
|
||||
- [ ] 坐席收到分配 → WS 推送
|
||||
- [ ] 坐席发消息 → 员工 < 100ms 收到
|
||||
- [ ] 转人工、邀请、满意度流程无 regression
|
||||
|
||||
### 5.2 管理员后台
|
||||
- [ ] 仪表盘加载正常
|
||||
- [ ] 坐席管理 CRUD 正常
|
||||
- [ ] 功能开关可切换
|
||||
- [ ] 集成配置 6 个系统显示完整
|
||||
- [ ] MFA 管理页 `/mfa-manage` 表格可搜索/过滤/分页
|
||||
- [ ] 重置 MFA 按钮可弹 ElMessageBox 二次确认
|
||||
|
||||
### 5.3 端点路径(临时 4 xfail)
|
||||
- [ ] `POST /api/conversations/{id}/messages` **仍 404** — pre-existing,不影响生产
|
||||
- [ ] 实际走 H5 的 `/api/h5/conversations/current/messages` 路径
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能与稳定性
|
||||
|
||||
- [ ] 长时间压测(可选): `wrk -t4 -c100 -d60s https://<域>/api/auth_qrcode/create`
|
||||
- [ ] 无 5xx 错误
|
||||
- [ ] Redis 连接稳定(无 timeout)
|
||||
- [ ] PG CPU < 50%
|
||||
|
||||
---
|
||||
|
||||
## 7. 回滚预案
|
||||
|
||||
如果任意 ❌ 项:
|
||||
|
||||
```bash
|
||||
# 1. 停止后端
|
||||
sudo docker stop wecom_it_backend
|
||||
|
||||
# 2. 恢复 4 端 dist
|
||||
sudo cp -r /opt/wecom-it-desk/dist-backup-*/* /opt/wecom-it-desk/frontend-*/dist/
|
||||
|
||||
# 3. 回滚 alembic(只回 025,022/023 保留)
|
||||
sudo docker start wecom_it_backend
|
||||
sudo docker exec wecom_it_backend alembic downgrade 024
|
||||
|
||||
# 4. nginx 回滚
|
||||
sudo docker exec wecom_it_nginx nginx -s reload
|
||||
```
|
||||
|
||||
详见 `DEPLOY-LOGIN-MIGRATION-v0.7.0.md` 末尾"回滚预案"。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验收人签字
|
||||
|
||||
| 角色 | 姓名 | 日期 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 部署 | | | |
|
||||
| 验收 | | | |
|
||||
| 复核 | | | |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,174 @@
|
||||
|
||||
===== DIAG login-form-missing @ 00:48:26 =====
|
||||
URL: http://localhost:5174/itdesk/
|
||||
HTML_LEN: 2595
|
||||
<!DOCTYPE html><html lang="zh-CN"><head>
|
||||
<script type="module" src="/itdesk/@vite/client"></script>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台</title>
|
||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
|
||||
#app-skeleton {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
color: #969799;
|
||||
z-index: 9999;
|
||||
}
|
||||
#app-skeleton .logo {
|
||||
width: 64px; height: 64px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
|
||||
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
|
||||
#app-skeleton .spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid #ebedf0;
|
||||
border-top-color: #1989fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
|
||||
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
|
||||
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
|
||||
body.app-loaded #app-skeleton { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
<!-- 首屏骨架屏(JS 加载期间显示,挂载后自动隐藏) v0.5.2 -->
|
||||
<div id="app-skeleton">
|
||||
<div class="logo"></div>
|
||||
<div class="title">智能IT支持服务台</div>
|
||||
<div class="subtitle">正在加载...</div>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<!-- 入口脚本 -->
|
||||
<script type="module" src="/itdesk/src/main.ts"></script>
|
||||
|
||||
|
||||
<vite-error-overlay></vite-error-overlay></body></html>
|
||||
|
||||
===== DIAG fatal @ 00:48:26 =====
|
||||
URL: http://localhost:5174/itdesk/
|
||||
HTML_LEN: 2595
|
||||
<!DOCTYPE html><html lang="zh-CN"><head>
|
||||
<script type="module" src="/itdesk/@vite/client"></script>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台</title>
|
||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
|
||||
#app-skeleton {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
color: #969799;
|
||||
z-index: 9999;
|
||||
}
|
||||
#app-skeleton .logo {
|
||||
width: 64px; height: 64px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
|
||||
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
|
||||
#app-skeleton .spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid #ebedf0;
|
||||
border-top-color: #1989fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
|
||||
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
|
||||
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
|
||||
body.app-loaded #app-skeleton { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
<!-- 首屏骨架屏(JS 加载期间显示,挂载后自动隐藏) v0.5.2 -->
|
||||
<div id="app-skeleton">
|
||||
<div class="logo"></div>
|
||||
<div class="title">智能IT支持服务台</div>
|
||||
<div class="subtitle">正在加载...</div>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<!-- 入口脚本 -->
|
||||
<script type="module" src="/itdesk/src/main.ts"></script>
|
||||
|
||||
|
||||
<vite-error-overlay></vite-error-overlay></body></html>
|
||||
|
||||
===== DIAG fatal @ 00:51:07 =====
|
||||
URL: http://localhost:5174/itdesk/login
|
||||
HTML_LEN: 158173
|
||||
<!DOCTYPE html><html lang="zh-CN" class="van-theme-light" data-theme="light"><head>
|
||||
<script type="module" src="/itdesk/@vite/client"></script>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;">
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台</title>
|
||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
|
||||
#app-skeleton {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
color: #969799;
|
||||
z-index: 9999;
|
||||
}
|
||||
#app-skeleton .logo {
|
||||
width: 64px; height: 64px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
|
||||
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
|
||||
#app-skeleton .spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid #ebedf0;
|
||||
border-top-color: #1989fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
|
||||
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
|
||||
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
|
||||
body.app-loaded #app-skeleton { display: none !important; }
|
||||
</style>
|
||||
<style type="text/css" data-vite-dev-id="/app/node_modules/.pnpm/vant@4.10.0_vue@3.5.39_typescript@5.9.3_/node_modules/vant/es/style/base.css">:root,:host{--van-black: #000;--van-white: #fff;--van-gray-1: #f7f8fa;--van-gray-2: #f2f3f5;--van-gray-3: #ebedf0;--van-gray-4: #dcdee0;--van-gray-5: #c8c9cc;--van-gray-6: #969799;--van-gray-7: #646566;--van-gray-8: #323233;--van-red: #ee0a24;--van-blue: #1989fa;--van-orange: #ff976a;--van-orange-dark: #ed6a0c;--van-orange-light: #fffbe8;--van-green: #07c160;--van-gradient-red: linear-gradient(to right, #ff6034, #ee0a24);--van-gradient-orange: linear-gradient(to right, #ffd01e, #ff8917);--van-primary-color: var(--van-blue);--van-success-color: var(--van-green);--van-danger-color: var(--van-red);--van-warning-color: var(--van-orange);--van-text-col
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"employee_id": "E2E_BROWSER",
|
||||
"ws_open_after_login": 2,
|
||||
"send_click_s": 1.257,
|
||||
"send_ui_instant": true,
|
||||
"ai_first_render_s": 2.27,
|
||||
"final_text_len": 1469,
|
||||
"final_has_duckula": true,
|
||||
"final_has_real_content": true,
|
||||
"len_samples": [
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
880,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469,
|
||||
1469
|
||||
],
|
||||
"len_grew": true,
|
||||
"ws_open_total": 2,
|
||||
"ws_ai_chunk_frames": 343,
|
||||
"typewriter_proven": true,
|
||||
"console_errors": [
|
||||
"Loading the font 'data:font/woff2;charset=utf-8;base64,d09GMgABAAAAAGNAAA0AAAAA6ngAAGLlAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cGh4GYACCWhEICoOqHILKFAuEDgABNgIkA4QUBCAFhQ4HllAbe7dFB2rYOIAxOG/nKOrEpKWbGbVlVHRZ9v816Tis0RbhPC4JZQk1ws72WlBGJJIsL3bc5Y/x5HdtBrzwoZQX/Ls/uAhsXMZIVk73Ds/ntvd3cezvhO1/2HExro3B2ID/4d7GxjXObZwqxy0gG8pQPDBFMAW980hTNIuhpqZleVwJlHSKR6WkDM3KECuz083Qu+8BCnabd+4tsemRGtBnHBAHxuTmuLWNUbd7fuSZA88fOBlzekCqfDPnV1BArpTKjp/r0AfE0+Lc97SXNa3ugaSqW2AfIo5Ghr2YAos8H+krfQ3L8DwA+F1V4Mecr9JV2ljSM/wUzQWAITlxoJSfEmfsi321rwHH2T...LQReO8YZ6mjq2e6QIdzdGLzR8ziEWSLVQd2NoiYGkRGaMXLCt0oq7fOWoCTc11KgkMttjj0jFJ6zIc7BafifCk0pZOhSbraAopcEpG4/kOwyNmF39Mt5ppTW7jRoB8WgPkOc51rC8oKZl62iKAamlu4DmuBkvCcCtjwcMLAlVzR32t10CwTyM6AbagGllb48gU5ACDzUmBCyJqxlxs8U4XAyTcTtjWMNGdH9GuwTBmjyI3YJFEgrovcQDR5kFZ0NMkmgTDZclc183uTcoBDNAaimIE2Cwu9OXklBGRkNvItMU7zXTlX5Zz/V8YIO2iK//xMnpu22WBNXRyG9vGQuJ0mGsc26/Aag5YQelZZs5Bi2ubsxHV/DzGXRt3P3KTOvQ4HG8aKpm9OyoHnIgzG/GkIEerHqEBaYCQ8UJPWbg8/vNFDyFvqkhIe6O6l0WordG9pHAAyyTWbhCGHCtBD7vDjzljqRT4+8E2POgLpACHDw+E5jqrtyanHw0AAA==' violates the following Content Security Policy directive: \"default-src 'self'\". Note that 'font-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.",
|
||||
"Loading the font 'http://at.alicdn.com/t/c/font_2553510_ciljc7axaw7.woff?t=1705587463221' violates the following Content Security Policy directive: \"default-src 'self'\". Note that 'font-src' was not explicitly set, so 'default-src' is used as a fallback. The action has been blocked.",
|
||||
"Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>打字机效果演示 — IT智能服务台</title>
|
||||
<style>
|
||||
/* ================================================================
|
||||
整体布局 — 模拟手机端 H5 聊天界面
|
||||
================================================================ */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f0f2f5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 手机框 */
|
||||
.phone-frame {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
height: 700px;
|
||||
background: #ededed;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶栏 */
|
||||
.chat-header {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.chat-header .avatar {
|
||||
width: 36px; height: 36px;
|
||||
background: #07C160;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: bold; font-size: 14px;
|
||||
}
|
||||
.chat-header .title { font-size: 15px; font-weight: 600; }
|
||||
.chat-header .subtitle { font-size: 12px; color: #999; }
|
||||
|
||||
/* 消息区 */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
/* 滚动条美化 */
|
||||
.chat-messages::-webkit-scrollbar { width: 4px; }
|
||||
.chat-messages::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }
|
||||
|
||||
/* 消息行 */
|
||||
.msg-row { display: flex; flex-direction: column; }
|
||||
.msg-row.employee { align-items: flex-end; }
|
||||
.msg-row.ai { align-items: flex-start; }
|
||||
.msg-row.system { align-items: center; }
|
||||
|
||||
/* 发送者名称 */
|
||||
.msg-sender {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.msg-sender .ai-avatar {
|
||||
width: 20px; height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 消息气泡 */
|
||||
.msg-bubble {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工气泡 — 蓝底白字靠右 */
|
||||
.msg-row.employee .msg-bubble {
|
||||
background: #1989fa;
|
||||
color: #fff;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* AI 气泡 — 绿底白字靠左 */
|
||||
.msg-row.ai .msg-bubble {
|
||||
background: #07C160;
|
||||
color: #fff;
|
||||
border-top-left-radius: 2px;
|
||||
}
|
||||
|
||||
/* 系统消息 */
|
||||
.msg-row.system .msg-bubble {
|
||||
background: transparent;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
打字机光标 — 闪烁动画
|
||||
================================================================ */
|
||||
.typewriter-cursor {
|
||||
display: inline;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-weight: 400;
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 控制面板 */
|
||||
.control-panel {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-primary { background: #1989fa; color: #fff; }
|
||||
.btn-primary:hover { background: #1676d9; }
|
||||
.btn-success { background: #07C160; color: #fff; }
|
||||
.btn-success:hover { background: #06ad56; }
|
||||
.btn-outline { background: #fff; color: #666; border: 1px solid #ddd; }
|
||||
.btn-outline:hover { background: #f5f5f5; }
|
||||
.btn-warning { background: #ff976a; color: #fff; }
|
||||
.btn-warning:hover { background: #f08050; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.control-info {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 高亮标记 */
|
||||
.highlight-box {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #8c6d00;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="phone-frame">
|
||||
<!-- 顶栏 -->
|
||||
<div class="chat-header">
|
||||
<div class="avatar">IT</div>
|
||||
<div>
|
||||
<div class="title">IT智能服务台</div>
|
||||
<div class="subtitle">Duckula(达寇拉)在线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<!-- 消息由 JS 动态插入 -->
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="control-panel">
|
||||
<div class="highlight-box" id="statusBox">
|
||||
👆 上方聊天框展示了打字机效果在实际界面中的表现<br>
|
||||
点击下方按钮查看不同场景:
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<button class="btn btn-success" id="btnDemo" onclick="runDemo()">
|
||||
▶ 演示完整对话
|
||||
</button>
|
||||
<button class="btn btn-outline" id="btnSkip" onclick="skipAll()" disabled>
|
||||
⏩ 跳过动画
|
||||
</button>
|
||||
<button class="btn btn-outline" id="btnReset" onclick="resetChat()">
|
||||
🔄 重置
|
||||
</button>
|
||||
<button class="btn btn-warning" id="btnNoEffect" onclick="showNoEffect()">
|
||||
❌ 无打字机(对比)
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-info" id="controlInfo">
|
||||
当前:等待开始...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ================================================================
|
||||
// 打字机核心逻辑(与 useTypewriter.ts 完全一致的 JS 实现)
|
||||
// ================================================================
|
||||
class Typewriter {
|
||||
constructor(speed = 25, speedUpThreshold = 300, speedUpBatch = 3) {
|
||||
this.speed = speed;
|
||||
this.speedUpThreshold = speedUpThreshold;
|
||||
this.speedUpBatch = speedUpBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对目标元素执行打字机动画
|
||||
* @param {HTMLElement} el 显示文本的元素
|
||||
* @param {HTMLElement} cursorEl 光标元素
|
||||
* @param {string} fullText 完整文本
|
||||
* @returns {Promise} 动画完成时 resolve
|
||||
*/
|
||||
typeTo(el, cursorEl, fullText) {
|
||||
return new Promise((resolve) => {
|
||||
let index = 0;
|
||||
el.textContent = '';
|
||||
|
||||
const typeNext = () => {
|
||||
if (index >= fullText.length) {
|
||||
// 完成:隐藏光标
|
||||
if (cursorEl) cursorEl.style.display = 'none';
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 自适应速度:前 300 字逐字,超出后批量
|
||||
const batchSize = index < this.speedUpThreshold ? 1 : this.speedUpBatch;
|
||||
const end = Math.min(index + batchSize, fullText.length);
|
||||
el.textContent += fullText.slice(index, end);
|
||||
index = end;
|
||||
|
||||
this._timer = setTimeout(typeNext, this.speed);
|
||||
};
|
||||
|
||||
// 初始微延迟让气泡先渲染
|
||||
this._timer = setTimeout(typeNext, 80);
|
||||
});
|
||||
}
|
||||
|
||||
/** 立即完成 */
|
||||
finish(el, cursorEl, fullText) {
|
||||
clearTimeout(this._timer);
|
||||
el.textContent = fullText;
|
||||
if (cursorEl) cursorEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 全局状态
|
||||
// ================================================================
|
||||
const chatEl = document.getElementById('chatMessages');
|
||||
const statusBox = document.getElementById('statusBox');
|
||||
const controlInfo = document.getElementById('controlInfo');
|
||||
const btnDemo = document.getElementById('btnDemo');
|
||||
const btnSkip = document.getElementById('btnSkip');
|
||||
const btnReset = document.getElementById('btnReset');
|
||||
const btnNoEffect = document.getElementById('btnNoEffect');
|
||||
const tw = new Typewriter();
|
||||
let activeTypewriters = []; // 追踪所有活跃的打字机,用于 skipAll
|
||||
|
||||
// ================================================================
|
||||
// 消息创建
|
||||
// ================================================================
|
||||
function createMessage(type, senderName, text, withTypewriter = true) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `msg-row ${type}`;
|
||||
|
||||
// 发送者名称
|
||||
if (type === 'ai' || type === 'employee') {
|
||||
const sender = document.createElement('div');
|
||||
sender.className = 'msg-sender';
|
||||
if (type === 'ai') {
|
||||
sender.innerHTML = `🦆 <span>${senderName}</span>`;
|
||||
} else {
|
||||
sender.textContent = senderName;
|
||||
}
|
||||
row.appendChild(sender);
|
||||
}
|
||||
|
||||
// 消息气泡
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'msg-bubble';
|
||||
|
||||
if (type === 'system') {
|
||||
bubble.textContent = text;
|
||||
} else {
|
||||
// 文本内容 + 光标 span
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'typewriter-text';
|
||||
bubble.appendChild(textSpan);
|
||||
|
||||
const cursorSpan = document.createElement('span');
|
||||
cursorSpan.className = 'typewriter-cursor';
|
||||
cursorSpan.textContent = '|';
|
||||
cursorSpan.style.display = 'none';
|
||||
bubble.appendChild(cursorSpan);
|
||||
|
||||
// 打字机动画
|
||||
if (withTypewriter && type === 'ai') {
|
||||
cursorSpan.style.display = 'inline';
|
||||
const promise = tw.typeTo(textSpan, cursorSpan, text);
|
||||
activeTypewriters.push({ bubble, textSpan, cursorSpan, text, promise });
|
||||
promise.then(() => {
|
||||
activeTypewriters = activeTypewriters.filter(t => t.textSpan !== textSpan);
|
||||
updateSkipButton();
|
||||
});
|
||||
} else {
|
||||
// 无打字机:直接显示全部
|
||||
textSpan.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
row.appendChild(bubble);
|
||||
chatEl.appendChild(row);
|
||||
|
||||
// 滚动到底部
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
|
||||
updateSkipButton();
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateSkipButton() {
|
||||
btnSkip.disabled = activeTypewriters.length === 0;
|
||||
}
|
||||
|
||||
function skipAll() {
|
||||
activeTypewriters.forEach(t => {
|
||||
tw.finish(t.textSpan, t.cursorSpan, t.text);
|
||||
});
|
||||
activeTypewriters = [];
|
||||
updateSkipButton();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 演示场景
|
||||
// ================================================================
|
||||
async function runDemo() {
|
||||
btnDemo.disabled = true;
|
||||
btnNoEffect.disabled = true;
|
||||
resetChat();
|
||||
|
||||
// 场景 1:员工发消息(无打字机效果 — 直接显示)
|
||||
statusBox.innerHTML = '📌 <b>场景 1</b>:员工消息直接显示(无打字机效果)<br>员工消息靠右蓝底白字,一次性完整渲染';
|
||||
controlInfo.textContent = '场景 1/4:员工发消息 → 直接显示';
|
||||
createMessage('employee', '我(宋献)', '打印机连不上了,帮忙看看');
|
||||
|
||||
await sleep(800);
|
||||
|
||||
// 场景 2:AI 短回复(打字机效果明显)
|
||||
statusBox.innerHTML = '📌 <b>场景 2</b>:AI 短回复 — 打字机逐字显示<br>注意看 👈 绿色气泡中的文字一个一个字地"打"出来,<br>末尾有闪烁的 <span style="color:#07C160">|</span> 光标';
|
||||
controlInfo.textContent = '场景 2/4:AI 回复中...(逐字打字)';
|
||||
createMessage('ai', 'Duckula(达寇拉)', '您好!打印机连接问题可能有几种原因:\n\n1. 检查打印机电源是否开启\n2. 检查 USB 或网络连接\n3. 尝试重启打印服务\n\n请先确认打印机电源灯是否亮起?');
|
||||
|
||||
await sleep(5000);
|
||||
|
||||
// 场景 3:员工回复
|
||||
statusBox.innerHTML = '📌 <b>场景 3</b>:员工继续对话(正常显示)';
|
||||
controlInfo.textContent = '场景 3/4:员工回复 → 直接显示';
|
||||
createMessage('employee', '我(宋献)', '电源灯是亮的,但电脑显示离线');
|
||||
|
||||
await sleep(800);
|
||||
|
||||
// 场景 4:AI 长回复(超过 300 字会加速)
|
||||
statusBox.innerHTML = '📌 <b>场景 4</b>:AI 长回复 — 前 300 字逐字,之后加速<br>长消息会自动提速,避免等太久<br>按 <b>"⏩ 跳过动画"</b> 可以立即显示全部内容';
|
||||
controlInfo.textContent = '场景 4/4:AI 长回复中...(前300字逐字,之后批量加速)';
|
||||
createMessage('ai', 'Duckula(达寇拉)',
|
||||
'好的,电源灯亮说明打印机本身通电正常。电脑显示"离线"通常是因为以下原因:\n\n' +
|
||||
'🔌 连接问题:如果是有线打印机,检查 USB 线是否插紧,尝试换个 USB 口。无线打印机需要确认电脑和打印机连接的是同一个 WiFi。\n\n' +
|
||||
'🖨️ 驱动问题:打印机驱动程序可能损坏或过时。建议访问打印机品牌官网下载最新驱动重新安装。\n\n' +
|
||||
'⚙️ 打印服务:按 Win+R 输入 services.msc,找到 Print Spooler 服务,右键"重新启动"。\n\n' +
|
||||
'📋 打印队列:打开"设置 → 蓝牙和其他设备 → 打印机和扫描仪",选中你的打印机,点"打开打印队列",取消所有挂起的文档。\n\n' +
|
||||
'按以上顺序排查,80% 的情况都能解决。需要我进一步指导哪一步吗?'
|
||||
);
|
||||
|
||||
btnDemo.disabled = false;
|
||||
btnNoEffect.disabled = false;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 对比模式:无打字机效果
|
||||
// ================================================================
|
||||
function showNoEffect() {
|
||||
btnDemo.disabled = true;
|
||||
btnNoEffect.disabled = true;
|
||||
resetChat();
|
||||
|
||||
statusBox.innerHTML = '📌 <b>对比模式</b>:AI 消息一次性完整渲染(<b>没有</b>打字机效果)<br>文字瞬间全部出现 — 这就是现在的行为';
|
||||
controlInfo.textContent = '对比:无打字机 → 文字瞬间全部出现';
|
||||
|
||||
createMessage('employee', '我(宋献)', '打印机连不上了,帮忙看看');
|
||||
// withTypewriter = false → 直接显示全部
|
||||
createMessage('ai', 'Duckula(达寇拉)',
|
||||
'您好!打印机连接问题可能有几种原因:\n\n1. 检查打印机电源是否开启\n2. 检查 USB 或网络连接\n3. 尝试重启打印服务\n\n请先确认打印机电源灯是否亮起?',
|
||||
false // ← 关键参数:不启用打字机
|
||||
);
|
||||
|
||||
btnDemo.disabled = false;
|
||||
btnNoEffect.disabled = false;
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
// 清理活跃打字机
|
||||
activeTypewriters.forEach(t => tw.finish(t.textSpan, t.cursorSpan, t.text));
|
||||
activeTypewriters = [];
|
||||
chatEl.innerHTML = '';
|
||||
statusBox.innerHTML = '👆 上方聊天框展示了打字机效果在实际界面中的表现<br>点击下方按钮查看不同场景:';
|
||||
controlInfo.textContent = '当前:等待开始...';
|
||||
btnDemo.disabled = false;
|
||||
btnNoEffect.disabled = false;
|
||||
updateSkipButton();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 初始化
|
||||
updateSkipButton();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
# 方案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/` 只剩骨架屏,`<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 登录返回 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/`(`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`(即时生效、重启保留),主干合并保证代码源一致
|
||||
Reference in New Issue
Block a user