chore: docs 结构整改 + compose 双目录对齐(合并重建提交)

本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
This commit is contained in:
Simon
2026-08-07 22:31:32 +08:00
parent 5a77a89ab1
commit facc04aa65
573 changed files with 129347 additions and 909 deletions
@@ -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/auth/otp-status` 返回 `bound: true, enabled: true`
- [ ] `GET /api/auth/otp-admin-users` (admin) 看到该坐席 bound=true
---
## 3. MFA 验证(高危操作守卫)
### 3.1 30 分钟有效期
- [ ] 坐席 admin 角色登录 → 绑 MFA → 调 `/api/admin/high-risk/demo/role_change`
- [ ] **未先调 /api/auth/otp-verify** → 返回 `2001 需要 OTP`
- [ ]`POST /api/auth/otp-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/4AI 回复中...(逐字打字)';
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/4AI 长回复中...(前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 驱动 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`(即时生效、重启保留),主干合并保证代码源一致