Files
wecom_it_smart_desk/docs/02-技术文档/技术方案-REQ-用户-004-坐席在线状态查询.md
T
Simon 44e77dcb0e 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 行
2026-08-03 18:46:55 +08:00

379 lines
9.9 KiB
Markdown
Raw 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: "暂无在线坐席..." }` |