Files
wecom_it_smart_desk/docs/02-技术文档/技术方案-REQ-用户-004-坐席在线状态查询.md
Simon facc04aa65 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-*/
2026-08-07 22:31:32 +08:00

379 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术方案-REQ-用户-004-坐席在线状态查询
## 基本信息
| 字段 | 内容 |
|------|------|
| **需求编号** | REQ-用户-004 |
| **版本** | v1.2 |
| **日期** | 2026-07-25 |
| **状态** | 已实现(BUG-用户-001 已修复) |
| **作者** | Simon |
| **关联文档** | `PRD-REQ-用户-004``BUG-用户-001` |
---
## 1. 技术架构设计
### 1.1 接口设计
#### 1.1.1 新增 API
**路径**`GET /h5/agents/online-status`
**所属模块**`backend/app/api/h5.py`Nginx 将 `/h5/agents/*` 代理到 FastAPI `/h5/agents/*`
**认证方式**:公开接口,无需认证
**请求参数**:无
**响应格式**
```json
{
"code": 0,
"message": "success",
"data": {
"online": true
}
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 0=成功,非0=失败 |
| message | string | 响应描述 |
| data.online | boolean | 是否有在线坐席 |
### 1.2 数据流设计
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ H5前端 │ HTTP │ 后端API │ SQL │ PostgreSQL │
│ (轮询30秒) │────────▶│ /h5/agents │───────▶│ agents表 │
│ │ │ /online-status│ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ online: true/false │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 标题栏状态 │ │ status='online'│
│ 坐席在线 │ │ 的坐席记录 │
└─────────────┘ └─────────────┘
```
---
## 2. 后端实现
### 2.1 接口实现
**文件**`backend/app/api/h5.py`
```python
@router.get("/h5/agents/online-status")
async def get_agents_online_status(db: AsyncSession = Depends(get_db)):
"""获取坐席在线状态(H5公开接口)。
查询当前是否有在线坐席,返回 true/false。
Returns:
Dict: 统一响应格式,包含 online 字段
"""
# 查询在线坐席数量
stmt = select(func.count(Agent.id)).where(Agent.status == "online")
result = await db.execute(stmt)
count = result.scalar() or 0
return success_response(data={"online": count > 0})
```
### 2.2 依赖导入
```python
from sqlalchemy import select, func
from app.models.agent import Agent
```
### 2.3 路由注册
`backend/app/api/router.py` 中确认 h5 路由已注册:
```python
# 确认已有
router.include_router(h5.router, prefix="/h5", tags=["H5"])
```
---
## 3. 前端实现
### 3.1 Store 修改
**文件**`frontend-h5/src/stores/conversation.ts`
**修改内容**
```typescript
// 将硬编码改为从 API 获取
const agentOnline = ref<boolean>(false)
// 新增获取坐席在线状态的方法
async function fetchAgentOnlineStatus() {
try {
const res = await fetch('/api/h5/agents/online-status')
const data = await res.json()
if (data.code === 0) {
agentOnline.value = data.data.online
}
} catch (e) {
console.error('获取坐席在线状态失败:', e)
// 失败时默认离线,避免误导
agentOnline.value = false
}
}
// 新增轮询定时器
let pollTimer: number | null = null
function startPolling() {
if (pollTimer) return
// 立即获取一次
fetchAgentOnlineStatus()
// 每30秒轮询
pollTimer = window.setInterval(fetchAgentOnlineStatus, 30000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// 页面可见性处理
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling()
} else {
fetchAgentOnlineStatus()
startPolling()
}
})
}
// 初始化时启动轮询
onMounted(() => {
startPolling()
})
// 组件卸载时停止轮询
onUnmounted(() => {
stopPolling()
})
```
### 3.2 模板修改
**文件**`frontend-h5/src/components/chat/ChatPanel.vue`
现有模板已支持:
- `v-if="store.agentOnline"` 显示"坐席在线"
- `v-else` 显示"坐席离线"
无需修改。
---
## 4. WebSocket 断连自动离线
### 4.1 背景
坐席端关闭浏览器窗口时,需要自动将坐席状态更新为 `offline`,否则 H5 端将始终显示"坐席在线"。
### 4.2 实现
**文件**`backend/app/api/ws.py`
`agent_websocket_endpoint()``finally` 块中添加离线更新逻辑:
```python
finally:
# 更新坐席状态为 offline(finally 块确保断开时一定执行)
try:
session_factory = _get_session_factory()
async with session_factory() as db:
# ⚠️ agent_id 在 URL 中对应 Agent.user_id(不是 Agent.id
stmt = select(Agent).where(Agent.user_id == agent_id)
result = await db.execute(stmt)
agent = result.scalar_one_or_none()
if agent and agent.status != "offline":
agent.status = "offline"
await db.commit()
except Exception as e:
logger.warning(f"更新坐席状态失败: {e}")
```
### 4.3 Bug 根因(重要教训)
WS URL 参数 `{agent_id}` 实际传递的是企微 `user_id`(如 `sxn`),而非 DB 主键 `Agent.id`(如 `agent-sxn-001`)。最初代码错误使用 `Agent.id == agent_id`,导致每次都查不到坐席,离线状态从未生效。
| 字段 | 值 | 说明 |
|------|-----|------|
| `Agent.id` | `agent-sxn-001` | DB 主键(UUID 格式) |
| `Agent.user_id` | `sxn` | 企微 userid |
| URL `{agent_id}` | `sxn` | 实际传递的是 user_id |
**修复**`Agent.id``Agent.user_id`
---
## 5. Nginx 路由配置
### 5.1 问题
Nginx 中 `/h5/` 被配置为静态文件 alias,导致 `/h5/agents/online-status` 返回 H5 首页 HTML 而非 JSON。
### 5.2 解决方案
`nginx.conf``location /h5/` 之前添加代理规则(nginx 最长前缀匹配优先):
```nginx
# H5 坐席状态 API — /h5/agents/
location /h5/agents/ {
proxy_pass http://backend_api/h5/agents/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# H5 坐席队列状态 API — /h5/queue/
location /h5/queue/ {
proxy_pass http://backend_api/h5/queue/;
...
}
# H5 用户端(兜底,静态文件)
location /h5/ {
alias /usr/share/nginx/html/h5/;
index index.html;
try_files $uri /h5/index.html;
}
```
需要同时在 HTTPS (443) 和 HTTP (80) 两个 server block 中添加。
---
## 6. 部署与配置
### 6.1 无需新增配置
- 不需要新的环境变量
- 不需要新的数据库表
- 不需要新的 Redis 缓存
### 6.2 部署顺序
1. 部署后端(重启)
2. 部署 H5 前端
---
## 7. 测试要点
### 5.1 后端测试
```bash
# 测试接口返回
curl http://localhost:8000/api/h5/agents/online-status
# 预期响应
{"code":0,"message":"success","data":{"online":true}}
```
### 5.2 前端测试
| 测试场景 | 预期结果 |
|---------|---------|
| 有在线坐席 | 显示"坐席在线"(绿色) |
| 无在线坐席 | 显示"坐席离线"(灰色) |
| 30秒后 | 状态自动刷新 |
| 切换浏览器标签页 | 暂停轮询 |
| 切回浏览器标签页 | 立即刷新一次 |
---
## 8. 风险与回滚
### 8.1 风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 轮询频率过高 | 增加数据库查询压力 | 限制30秒,且只查 count |
| 接口被恶意调用 | 无(只读数据) | - |
### 8.2 回滚方案
- 后端:回滚 h5.py 中的接口代码
- 前端:恢复 `agentOnline = true` 硬编码
---
## 9. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|------|------|---------|--------|
| 2026-07-25 | v1.0 | 初始版本 | Simon |
| 2026-07-25 | v1.1 | API 路径修正(/api/h5/ → /h5/);新增 §4 WS 断连自动离线(含 `Agent.id→user_id` bug 修复);新增 §5 Nginx 路由配置(/h5/agents/ 代理);状态更新为已实现 | Simon |
| 2026-07-25 | v1.2 | BUG-用户-001 修复:新增 §10 离线限制呼叫人工(后端 shake 加在线检查 + 前端 InputBar 加 agentOnline 判断) | Simon |
---
## 10. BUG-用户-001 补充:离线限制呼叫人工(v1.2)
### 10.1 问题
坐席离线时,用户点击"人工咨询"按钮,后端直接入队 queued,因无在线坐席永久无人接单。
### 10.2 后端修改
**文件**`backend/app/api/h5.py` — shake 接口
`conversation.status = "queued"` 之前增加坐席在线检查:
```python
# 前置校验:必须有在线坐席
from app.models.agent import Agent
from sqlalchemy import select, func
online_count = await db.execute(
select(func.count(Agent.id)).where(Agent.status == "online")
)
count = online_count.scalar() or 0
if count == 0:
raise AppException(
code=1003,
message="暂无在线坐席,请稍后再试。Duckula(达寇拉)仍在努力为您提供帮助~"
)
```
### 10.3 前端修改
**文件**`frontend-h5/src/components/chat/InputBar.vue`
`callAgentState` 计算属性首行增加坐席在线判断:
```typescript
// 坐席离线 → 按钮禁用
if (!store.agentOnline) return 'disabled'
```
按钮提示文案调整:`callAgentBtnTitle` 中离线时显示"坐席离线,暂不可用"。
### 10.4 测试要点
| 场景 | 预期 |
|------|------|
| 坐席离线,AI ≥3次 | 按钮灰色,提示"坐席离线,暂不可用" |
| 坐席上线(30s内),AI ≥3次 | 按钮绿色,可点击 |
| 离线时 curl shake | `{ code: 1003, message: "暂无在线坐席..." }` |