249 lines
8.2 KiB
Markdown
249 lines
8.2 KiB
Markdown
# 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*
|