# 核心业务设计文档：WebSocket 客服 + Redis 防超卖

本文件补充功能设计文档中两个关键业务模块的**详细设计**：
1. 客服聊天（WebSocket 长连接 + 消息补发）
2. 限时抢购（Redis 原子操作 + 异步削峰 + 一人一单）

---

## 一、客服聊天（WebSocket 实时通信）

### 1.1 整体架构

```
┌──────────┐   WebSocket         ┌──────────────┐
│ 用户端   │ ◄────────────────► │  Spring      │
│  H5/小程序│  /ws/chat?token=  │  WebSocket   │
└──────────┘                      │  Handler     │
                                  └──────┬───────┘
                                         │
                                  ┌──────┴───────────────┐
                                  │   Message Broker     │
                                  │   (In-Memory / Redis │
                                  │    Pub/Sub 集群扩展)  │
                                  └──────┬───────────────┘
                                         │
                                  ┌──────┴───────┐
                                  │  MySQL 持久化 │
                                  │  chat_session│
                                  │  chat_message│
                                  └──────────────┘
                                         ▲
┌──────────┐   WebSocket                 │
│ 管理端   │ ◄───────────────────────────┘
│  Web    │  /ws/chat?token=admin
└──────────┘
```

### 1.2 关键概念

| 概念 | 说明 |
|------|------|
| **session_id** | 会话唯一 ID（DB 主键） |
| **session_no** | 业务编号（如 CS20260601001） |
| **seq** | 消息在会话内的**单调递增序号**（1, 2, 3...），用于排序和断线补发 |
| **user_seq** | 用户端最大已确认序号（断线时只推 `seq > user_seq` 的消息） |
| **admin_seq** | 客服端最大已确认序号（断线时只推 `seq > admin_seq` 的消息） |
| **user_unread / admin_unread** | 双方各自未读消息数（在线时实时 +1，已读后清零） |

### 1.3 WebSocket 握手

```
客户端 → 服务端：
  GET ws://host/ws/chat?token={JWT_TOKEN}
  
服务端处理：
  1. 拦截器中校验 JWT Token
  2. 根据 Token 解析出 userId / adminId
  3. 建立 STOMP 连接，绑定到会话频道
  4. 推送离线消息（seq > last_ack_seq）
```

### 1.4 消息发送流程（用户发消息为例）

```
用户客户端发送 WebSocket 消息：
  { sessionId, type: "text", content: "你好" }
        │
        ▼
服务端 ChatWebSocketHandler.receive()
        │
        ├──► 1. 校验登录态 & 会话归属
        ├──► 2. 查询 chat_session 中当前最大 seq
        │       new_seq = max_seq + 1
        ├──► 3. 写入 chat_message（seq = new_seq）
        ├──► 4. 更新 chat_session：
        │       - last_message / last_time
        │       - admin_unread = admin_unread + 1
        ├──► 5. 推送给会话中在线的客服（如果有）
        │       /topic/chat/{sessionId} → { id, seq, content, ... }
        └──► 6. ACK 给用户端
```

### 1.5 消息接收 ACK（已读回执）

```
客户端收到消息后发送 ACK：
  { type: "ack", sessionId, lastSeq: 42 }
        │
        ▼
服务端：
  - 更新 chat_session.user_seq = 42（如果用户端）
  - 或 chat_session.admin_seq = 42（如果客服端）
  - 同时如果对端离线，user_unread / admin_unread 清零
```

### 1.6 断线重连 / 离线消息补发

```
客户端断线后重新连接：
  WebSocket /ws/chat?token=xxx&last_ack_seq=42
        │
        ▼
服务端：
  1. 校验 Token
  2. SELECT * FROM chat_message
     WHERE session_id = ? AND seq > 42
     ORDER BY seq ASC
  3. 推送给客户端
  4. 客户端渲染后回传 ACK 更新 user_seq / admin_seq
```

### 1.7 关键 Redis 键（可选，用于集群扩展）

| Key | 类型 | 用途 |
|-----|------|------|
| `chat:online:{userId}` | string (TTL 30s 心跳) | 用户在线状态 |
| `chat:admin:online:{adminId}` | string | 客服在线状态 |
| `chat:unread:{sessionId}:{type}` | int | 未读消息计数（与 MySQL 同步） |
| `ws:user:{userId}` | WebSocket Session | 集群路由用 |

### 1.8 与"轮询"的区别

| 轮询（旧设计） | WebSocket（新设计） |
|----------------|---------------------|
| 客户端每秒请求一次 `/api/chat/messages?lastId=` | 一次 WebSocket 长连接，服务端主动推送 |
| 服务器压力随用户数线性增长 | 服务器仅在有消息时推送，连接空闲几乎零开销 |
| 消息延迟 1~N 秒 | 实时（毫秒级） |
| 移动端耗电 | 移动端省电（长连接心跳即可） |
| 无重连机制 | 断线自动重连 + 序号补发 |

---

## 二、限时抢购（Redis 防超卖）

### 2.1 设计目标

- **不能超卖**：100 件商品最多卖 100 单
- **一人一单**：同一用户在同一活动同一商品只能抢 1 单
- **高性能**：10000+ 用户同时抢能扛住
- **不超时不泄洪**：流量高峰削峰后端

### 2.2 整体流程

```
                  客户端                         服务端
                    │                              │
                    │  POST /api/seckill/buy       │
                    │  { activityId, skuId, qty }  │
                    │ ──────────────────────────► │
                    │                              │
                    │                       ┌──────┴──────┐
                    │                       │ ① 登录校验  │
                    │                       │ ② 活动状态  │
                    │                       │ ③ Lua 脚本  │ ◄── Redis 原子操作
                    │                       │   一次性完成│     (防超卖 + 限购)
                    │                       └──────┬──────┘
                    │                              │
                    │                       ┌──────┴──────┐
                    │                       │ ④ 写 MQ     │ ◄── 削峰
                    │                       └──────┬──────┘
                    │                              │
                    │  ◄─── "排队中" 202 ─────────│
                    │                              │
                    │  ┌──── 轮询或推送 ────┐     │
                    │  │  /api/seckill/     │     │
                    │  │   result/{orderNo} │     │
                    │  └────────────────────┘     │
                    │                              │
                    │                       ┌──────┴──────┐
                    │                       │ ⑤ 消费者线程│
                    │                       │   写 MySQL  │ ◄── 异步落库
                    │                       │   - seckill_order
                    │                       │   - orders / order_item
                    │                       │   - 扣 SKU 库存
                    │                       └──────┬──────┘
                    │                              │
                    │  ◄─── 推送抢购结果 ─────────│
                    │       "success: orderNo"    │
                    │       "fail: 已抢完"         │
```

### 2.3 Redis 数据结构

| Key | 类型 | 初始值 | 用途 |
|-----|------|--------|------|
| `seckill:stock:{activityId}:{skuId}` | int | seckill_stock | **总库存**（DECR 原子扣减） |
| `seckill:bought:{activityId}:{skuId}:{userId}` | int 0/1 | 0 | **一人一单标记**（SETNX） |
| `seckill:user_count:{activityId}:{skuId}:{userId}` | int | 0 | 累计购买数量（INBY 用于限购 N 件） |
| `seckill:result:{orderNo}` | hash | — | 抢购结果（pending / success / fail） |
| `seckill:queue:{activityId}` | list | — | 异步下单消息队列 |

### 2.4 核心 Lua 脚本（原子完成"校验 + 扣库存 + 限购"）

```lua
-- KEYS[1] = stock key
-- KEYS[2] = bought flag key
-- KEYS[3] = user count key
-- ARGV[1] = 限购数量

-- 1. 限购校验
local bought = redis.call('GET', KEYS[2])
if bought == '1' then
    return {-1, 'ALREADY_BOUGHT'}
end

local userCount = tonumber(redis.call('GET', KEYS[3]) or '0')
if userCount + 1 > tonumber(ARGV[1]) then
    return {-2, 'LIMIT_EXCEEDED'}
end

-- 2. 库存校验
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
    return {-3, 'STOCK_EMPTY'}
end

-- 3. 原子扣减
redis.call('DECR', KEYS[1])
redis.call('SET', KEYS[2], '1', 'EX', 86400)  -- 24h 过期
redis.call('INCR', KEYS[3])
redis.call('EXPIRE', KEYS[3], 86400)

return {0, 'OK'}
```

**为什么用 Lua？** 多个 Redis 命令包成一个原子操作，避免"已读库存但 SETNX 失败"的并发问题。

### 2.5 异步落库（消费者线程）

```java
@RabbitListener(queues = "seckill.order.queue")
public void handleSeckillOrder(SeckillMessage msg) {
    // 1. 写 seckill_order（唯一索引兜底一人一单）
    try {
        seckillOrderMapper.insert(msg);
    } catch (DuplicateKeyException e) {
        // 数据库兜底：说明 Redis 已扣但库写失败，需要回补 Redis
        redisService.incrStock(msg.getActivityId(), msg.getSkuId());
        return;
    }

    // 2. 生成主订单
    Long orderId = orderService.createSeckillOrder(msg);

    // 3. 异步扣 MySQL SKU 库存（最终一致）
    productSkuMapper.decrStock(msg.getSkuId(), msg.getQuantity());

    // 4. 写抢购结果到 Redis
    redisService.setSeckillResult(msg.getOrderNo(), "success");
}
```

### 2.6 客户端查询结果（轮询或推送）

#### 方式 A：轮询（最简单）

```http
GET /api/seckill/result/{orderNo}?token=xxx

Response:
  { "status": "pending" | "success" | "fail", "orderNo": "...", "message": "..." }
```

#### 方式 B：WebSocket 推送（推荐）

服务端在消费者线程处理完后，向用户 WebSocket 频道推送：
```json
{ "type": "seckill_result", "orderNo": "SK20260602...", "status": "success" }
```

### 2.7 关键时间点

| 时间点 | 操作 |
|--------|------|
| 活动开始前 5 分钟 | 预热：把 `seckill_product.seckill_stock` 同步到 Redis（`SETNX`） |
| 活动开始 | 客户端开启抢购入口 |
| 活动进行中 | Lua 原子扣库存 + 异步下单 |
| 活动结束 | 关闭入口；剩余库存回写 MySQL；活动状态置为已结束 |
| 订单超时未支付（30 分钟） | 定时任务扫描 seckill_order，**回补 Redis 库存 + 释放"已购"标记** |

### 2.8 表结构对应关系

| 表 | 作用 | 写入时机 |
|----|------|----------|
| `seckill_activity` | 活动元数据 | 管理员创建活动 |
| `seckill_product` | 活动商品配置 + 兜底库存 | 管理员添加商品；活动开始时预热 Redis |
| `seckill_order` | 抢购成功记录（**唯一索引防一人多单**） | 消费者线程异步写入 |
| `orders` / `order_item` | 主订单 | 消费者线程生成 |
| `product_sku.stock` | SKU 真实库存 | 消费者线程扣减（最终一致） |

### 2.9 为什么需要 MySQL 唯一索引

- Redis 是单点权威库存，但**不能保证事务一致性**
- 极端情况下（Redis 主从切换、网络分区），可能出现"Redis 扣减成功但消费者没写入"
- `seckill_order` 的 `UNIQUE KEY (user_id, activity_id, sku_id)` 是**最后一道防线**：
  - 重复插入时抛 `DuplicateKeyException`
  - 捕获后回补 Redis 库存
  - 保证"一人一单"不会因 Redis 故障被绕过

### 2.10 性能估算

- Redis 单机 QPS：10 万+
- Lua 脚本平均耗时：< 1ms
- 10000 并发抢购 → Redis 处理 ~10000 Lua 调用 → 写 MQ 10000 条
- 消费者线程（可水平扩展）按 200 TPS 落库，10000 条约 50 秒消化完
- 数据库压力：< 200 TPS，MySQL 完全扛得住

---

## 三、关键代码位置

| 模块 | 文件路径 | 说明 |
|------|----------|------|
| 客服 WebSocket 配置 | `server-snack/src/main/java/com/snack/server/websocket/WebSocketConfig.java` | 注册 STOMP 端点 |
| 客服消息处理 | `server-snack/src/main/java/com/snack/server/websocket/ChatWebSocketHandler.java` | 消息接收、ACK、推送 |
| 抢购 Lua 脚本 | `server-snack/src/main/resources/lua/seckill.lua` | 原子校验 + 扣库存 |
| 抢购 Controller | `server-snack/src/main/java/com/snack/server/controller/user/SeckillController.java` | `/api/seckill/buy` |
| 抢购消费者 | `server-snack/src/main/java/com/snack/server/mq/SeckillOrderConsumer.java` | 异步落库 |
| 抢购服务 | `server-snack/src/main/java/com/snack/server/service/SeckillService.java` | 业务编排 |
| 抢购结果推送 | `server-snack/src/main/java/com/snack/server/websocket/SeckillResultPushService.java` | 通过 WebSocket 通知结果 |

---

## 四、待办（后续开发）

- [ ] WebSocket 集群方案：使用 Redis Pub/Sub 广播跨节点消息
- [ ] 抢购接口限流：Sentinel / Resilience4j 按用户 ID 限流
- [ ] 抢购风控：同一 IP / 设备 5 秒内最多抢 1 次
- [ ] 客服智能分配：会话进入时按"客服当前未读最少"分配
- [ ] 客服坐席状态：客服可设置"离开 / 忙碌"，自动转接
