# OPSX Specs: 在线客服模块 ## 变更标识 - **变更ID**: customer-service-module - **版本**: 2.0 - **状态**: SPEC_COMPLETE - **更新日期**: 2026-01-28 --- ## 1. 确认约束 (Confirmed Constraints) ### 1.1 会话分配约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-01 | 分配策略 | least-load | 分配给当前会话数最少的在线客服 | | CC-02 | 分配锁超时 | 3000ms | SET NX PX 3000 防止并发双分配 | | CC-03 | 单客服最大会话数 | 10 | 超过后不再分配新会话 | | CC-04 | 分配锁Key | `cs:lock:assign:{userId}` | Redis分布式锁 | ### 1.2 消息机制约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-05 | 送达确认 | 两段ACK | server-ack(服务端已接收) + peer-ack(对端已送达) | | CC-06 | 重试策略 | 3次指数退避 | 间隔: 1s → 2s → 4s,失败后标记failed | | CC-07 | 消息ID生成 | 雪花算法 | 分布式唯一、有序、高性能 | | CC-08 | 消息长度限制 | 500字符 | 超过截断或拒绝 | | CC-09 | 重连拉取上限 | 50条 | 用户重连后最多拉取50条未读消息 | ### 1.3 连接管理约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-10 | 心跳间隔 | 30秒 | 客户端每30秒发送ping | | CC-11 | 离线判定 | 60秒 | 60秒无心跳判定为离线 | | CC-12 | 事件命名空间 | `chat.*` | 与游戏事件 `game.*` 隔离 | | CC-13 | 在线状态TTL | 60秒 | Redis Key TTL,心跳续期 | ### 1.4 离线处理约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-14 | 离线模式 | 留言模式 | 提示"客服不在线",消息入队待处理 | | CC-15 | 队列优先级 | 余额优先 | 按用户余额降序处理 | | CC-16 | 队列Key | `cs:queue:pending` | Redis ZSET,score=用户余额(负数实现降序) | ### 1.5 图片存储约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-17 | 存储方式 | 本地文件系统 | `public/uploads/chat/YYYYMMDD/` | | CC-18 | 大小限制 | 2MB | 2097152 bytes | | CC-19 | 格式支持 | jpg,png,gif | MIME类型白名单校验 | ### 1.6 会话生命周期约束 | 约束ID | 约束 | 值 | 说明 | |--------|------|-----|------| | CC-20 | 自动结束 | 禁用 | 仅支持手动关闭会话 | | CC-21 | 评价触发 | 手动 | 用户主动点击评价按钮,无弹窗 | --- ## 2. PBT 属性 (Property-Based Testing) ### 2.1 幂等性属性 ``` PROPERTY: MessageIdempotency INVARIANT: ∀ msg1, msg2 ∈ Messages: msg1.msgId = msg2.msgId → DB.count(msgId) = 1 BOUNDARY: 并发发送相同msgId消息 FALSIFICATION: - 生成随机msgId - 并发100个goroutine/协程发送相同msgId - 断言: SELECT COUNT(*) FROM cg_chat_message WHERE msg_id = ? 返回 1 ``` ### 2.2 会话唯一性属性 ``` PROPERTY: SessionUniqueness INVARIANT: ∀ user ∈ Users: COUNT(sessions WHERE user_id = user AND status = 'active') ≤ 1 BOUNDARY: 用户快速多次发起咨询 FALSIFICATION: - 选择随机用户 - 并发10个请求创建会话 - 断言: SELECT COUNT(*) FROM cg_chat_session WHERE user_id = ? AND status IN (0,1) 返回 ≤ 1 ``` ### 2.3 分配原子性属性 ``` PROPERTY: AssignmentAtomicity INVARIANT: 会话分配要么完全成功(session.admin_id != NULL),要么完全失败(session.status = 0) BOUNDARY: 分配过程中客服下线 FALSIFICATION: - 开始分配流程 - 在分配锁获取后、写入admin_id前,模拟客服下线 - 断言: session.admin_id = NULL AND session.status = 0 (待分配) ``` ### 2.4 消息顺序性属性 ``` PROPERTY: MessageOrdering INVARIANT: ∀ session: messages ORDER BY id ASC = messages ORDER BY create_time ASC BOUNDARY: 高并发消息发送 FALSIFICATION: - 发送100条带序号(1-100)的消息 - 查询: SELECT content FROM cg_chat_message WHERE session_id = ? ORDER BY id - 断言: 序号严格递增 1,2,3,...,100 ``` ### 2.5 ACK一致性属性 ``` PROPERTY: AckConsistency INVARIANT: ∀ msg: msg.status = 'delivered' → 不会再次推送给同一接收方 BOUNDARY: 网络抖动导致ACK延迟 FALSIFICATION: - 发送消息并模拟ACK丢失 - 客户端重连 - 断言: 已标记delivered的消息不在补发列表中 ``` ### 2.6 负载均衡属性 ``` PROPERTY: LoadBalancing INVARIANT: ∀ agents a1, a2 ∈ OnlineAgents: |a1.sessionCount - a2.sessionCount| ≤ 1 BOUNDARY: 多用户同时发起咨询 FALSIFICATION: - 10个客服在线,各0个会话 - 100个用户并发发起咨询 - 断言: 每个客服会话数在 [9, 11] 范围内 ``` ### 2.7 离线消息完整性属性 ``` PROPERTY: OfflineMessageIntegrity INVARIANT: ∀ msg sent while all agents offline: msg ∈ PendingQueue BOUNDARY: 客服全部离线时大量消息 FALSIFICATION: - 所有客服下线 - 发送50条消息 - 断言: cg_chat_message.count = 50 AND all status = 'pending' ``` ### 2.8 余额排序正确性属性 ``` PROPERTY: BalancePriorityOrdering INVARIANT: 队列处理顺序按用户余额降序 BOUNDARY: 相同余额用户按时间排序 FALSIFICATION: - 插入用户: balance=[100,500,200,500,300] - 客服上线处理 - 断言: 处理顺序为 balance 500(先到), 500(后到), 300, 200, 100 ``` ### 2.9 心跳续期属性 ``` PROPERTY: HeartbeatRenewal INVARIANT: 心跳后在线状态TTL重置为60秒 BOUNDARY: 心跳边界时间 FALSIFICATION: - 建立连接,记录Redis TTL - 等待29秒,发送心跳 - 断言: TTL重置为60秒 - 等待31秒不发心跳 - 断言: 用户被标记为离线 ``` ### 2.10 图片大小校验属性 ``` PROPERTY: ImageSizeValidation INVARIANT: 图片大小 > 2MB 时上传被拒绝 BOUNDARY: 边界值 2MB ± 1 byte FALSIFICATION: - 上传 2097151 bytes (2MB-1): 断言成功 - 上传 2097152 bytes (2MB): 断言成功 - 上传 2097153 bytes (2MB+1): 断言失败,返回错误码 ``` --- ## 3. Redis Key 设计 | Key Pattern | 类型 | TTL | 说明 | |-------------|------|-----|------| | `cs:online:agent:{adminId}` | STRING | 60s | 客服在线状态,心跳续期 | | `cs:conn:user:{userId}` | STRING | 60s | 用户连接映射 fd | | `cs:conn:agent:{adminId}` | STRING | 60s | 客服连接映射 fd | | `cs:session:owner:{sessionId}` | STRING | - | 会话归属客服ID | | `cs:user:active_session:{userId}` | STRING | - | 用户当前活跃会话ID | | `cs:lock:assign:{userId}` | STRING | 3s | 会话分配锁 | | `cs:queue:pending` | ZSET | - | 待处理队列,score=-balance | | `cs:agent:load:{adminId}` | STRING | - | 客服当前会话数 | --- ## 4. WebSocket 事件协议 ### 4.1 客户端 → 服务端 | 事件 | Payload | 说明 | |------|---------|------| | `chat.connect` | `{token, source}` | 建立聊天连接 | | `chat.message.send` | `{sessionId, msgType, content, clientMsgId}` | 发送消息 | | `chat.message.ack` | `{msgId}` | 消息已读回执 | | `chat.typing` | `{sessionId, isTyping}` | 正在输入状态 | | `chat.session.end` | `{sessionId}` | 用户结束会话 | | `chat.session.rate` | `{sessionId, rating, content}` | 会话评价 | | `chat.ping` | `{}` | 心跳 | ### 4.2 服务端 → 客户端 | 事件 | Payload | 说明 | |------|---------|------| | `chat.connected` | `{sessionId, agentInfo}` | 连接成功,返回会话信息 | | `chat.message.new` | `{msgId, sessionId, senderType, content, time}` | 新消息 | | `chat.message.server_ack` | `{clientMsgId, msgId, status}` | 服务端确认收到 | | `chat.message.peer_ack` | `{msgId, status}` | 对端已送达/已读 | | `chat.typing` | `{sessionId, isTyping}` | 对方正在输入 | | `chat.session.assigned` | `{sessionId, agentInfo}` | 会话已分配客服 | | `chat.session.ended` | `{sessionId}` | 会话已结束 | | `chat.offline_notice` | `{message}` | 客服离线提示 | | `chat.pong` | `{}` | 心跳响应 | ### 4.3 客服端专用事件 | 事件 | 方向 | Payload | 说明 | |------|------|---------|------| | `chat.agent.online` | C→S | `{maxSessions}` | 客服上线 | | `chat.agent.offline` | C→S | `{}` | 客服下线 | | `chat.session.new` | S→C | `{sessionId, userInfo, source}` | 新会话通知 | | `chat.session.transfer` | C→S | `{sessionId, targetAdminId}` | 转接会话 | | `chat.queue.list` | S→C | `{sessions: [...]}` | 待处理队列 | --- *文档版本: 2.0* *最后更新: 2026-01-28*