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