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