docs(db): 添加核心业务设计文档和数据库初始化说明

- 新增 BUSINESS_DESIGN.md 文档,详细描述 WebSocket 客服系统和
  Redis 防超卖抢购系统的设计方案
- 新增 README.md 文档,提供数据库初始化说明和表结构清单
- 包含完整的建表脚本执行顺序和默认测试账号信息
- 为后续开发提供详细的技术架构参考文档
```
This commit is contained in:
Yuhang Wu 2026-06-02 17:23:50 +08:00
parent 3dd146f871
commit 1595dc8c35
56 changed files with 4131 additions and 4 deletions

330
db/BUSINESS_DESIGN.md Normal file
View File

@ -0,0 +1,330 @@
# 核心业务设计文档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** | 会话唯一 IDDB 主键) |
| **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_messageseq = 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": "..." }
```
#### 方式 BWebSocket 推送(推荐)
服务端在消费者线程处理完后,向用户 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 单机 QPS10 万+
- Lua 脚本平均耗时:< 1ms
- 10000 并发抢购 → Redis 处理 ~10000 Lua 调用 → 写 MQ 10000 条
- 消费者线程(可水平扩展)按 200 TPS 落库10000 条约 50 秒消化完
- 数据库压力:< 200 TPSMySQL 完全扛得住
---
## 三、关键代码位置
| 模块 | 文件路径 | 说明 |
|------|----------|------|
| 客服 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 次
- [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配
- [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接

75
db/README.md Normal file
View File

@ -0,0 +1,75 @@
# 数据库初始化说明
本目录包含 `snack_mall` 数据库的完整脚本。
## 文件清单
| 文件 | 用途 | 建议时机 |
|------|------|----------|
| `schema.sql` | 建库 + 21 张业务表的 DDL | 第一次部署时执行 |
| `seed.sql` | 基础测试数据(管理员、用户、商品、优惠券、活动、客服消息等) | 联调 / 演示前执行 |
| `BUSINESS_DESIGN.md` | 核心业务设计WebSocket 客服 + Redis 防超卖 | 开发前必读 |
## 执行顺序
```bash
# 方式一:命令行
mysql -u root -p < schema.sql
mysql -u root -p < seed.sql
# 方式二:登录后 source
mysql -u root -p
> source /path/to/schema.sql
> source /path/to/seed.sql
# 方式三Navicat / DBeaver 直接打开执行
```
## 表清单(共 21 张)
| # | 表名 | 说明 |
|----|-------------------|------------------|
| 1 | `user` | 用户表 |
| 2 | `admin` | 管理员表 |
| 3 | `category` | 商品分类(多级) |
| 4 | `product` | 商品 SPU |
| 5 | `product_sku` | 商品 SKU |
| 6 | `address` | 收货地址 |
| 7 | `cart` | 购物车 |
| 8 | `orders` | 订单主表 |
| 9 | `order_item` | 订单明细 |
| 10 | `favorite` | 收藏 |
| 11 | `banner` | 轮播图 |
| 12 | `chat_session` | 客服会话 |
| 13 | `chat_message` | 客服消息 |
| 14 | `quick_reply` | 客服快捷回复 |
| 15 | `notice` | 系统公告 |
| 16 | `coupon` | 优惠券模板 |
| 17 | `user_coupon` | 用户优惠券 |
| 18 | `seckill_activity`| 限时抢购活动 |
| 19 | `seckill_product` | 抢购活动商品 |
| 20 | `seckill_order` | 抢购订单(防超卖)|
| 21 | `upload_file` | 文件上传记录 |
## 默认账号
| 角色 | 用户名 | 密码 |
|------|--------|------|
| 管理员 | `admin` | `123456` |
| 管理员 | `manager` | `123456` |
| 管理员 | `operator` | `123456` |
| 普通用户 | `user001` ~ `user005` | `123456` |
> 密码均使用 BCrypt 加密hash 值在 seed.sql 中。
## 字符集
- 库 / 表utf8mb4
- 排序规则utf8mb4_unicode_ci
- 存储 emoji 等四字节字符无压力
## 注意事项
1. **不要在生产环境执行 `seed.sql`** —— 仅用于本地开发与演示。
2. 真实部署时建议**关闭 `notice` 等富文本字段的 XSS 过滤**,由前端做 HTML 转义。
3. 抢购库存的并发安全由 Redis 原子操作 + 数据库兜底双重保障,详见功能设计文档。

549
db/schema.sql Normal file
View File

@ -0,0 +1,549 @@
-- ============================================================
-- 零食商城 数据库初始化脚本
-- 适用 MySQL 8.0+
-- 字符集utf8mb4 / 排序规则utf8mb4_unicode_ci
-- 全部表与字段均已添加 COMMENT 注释
-- ============================================================
-- 删除并重建数据库
DROP DATABASE IF EXISTS `snack_mall`;
CREATE DATABASE `snack_mall`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `snack_mall`;
-- ============================================================
-- ① 用户表
-- ============================================================
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`username` VARCHAR(50) NOT NULL COMMENT '登录用户名(唯一)',
`password` VARCHAR(100) NOT NULL COMMENT '加密密码BCrypt 强度 10',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL',
`gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别0-未知 1-男 2-女',
`birthday` DATE DEFAULT NULL COMMENT '生日',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态0-禁用 1-启用',
`last_login_time` DATETIME DEFAULT NULL COMMENT '最近登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最近登录 IP',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_phone` (`phone`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='前台用户表';
-- ============================================================
-- ② 管理员表
-- ============================================================
DROP TABLE IF EXISTS `admin`;
CREATE TABLE `admin` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`username` VARCHAR(50) NOT NULL COMMENT '登录用户名(唯一)',
`password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密密码',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`role` VARCHAR(50) NOT NULL DEFAULT 'admin' COMMENT '角色标识super_admin / admin / operator',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态0-禁用 1-启用',
`last_login_time` DATETIME DEFAULT NULL COMMENT '最近登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最近登录 IP',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_status` (`status`),
KEY `idx_role` (`role`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='后台管理员表';
-- ============================================================
-- ③ 商品分类表(支持多级树形)
-- ============================================================
DROP TABLE IF EXISTS `category`;
CREATE TABLE `category` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`name` VARCHAR(50) NOT NULL COMMENT '分类名称',
`parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父分类 ID0 表示一级分类',
`level` TINYINT NOT NULL DEFAULT 1 COMMENT '层级1-一级 2-二级 3-三级',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号(升序展示)',
`icon` VARCHAR(255) DEFAULT NULL COMMENT '分类图标 URL',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-禁用 1-启用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_level` (`level`),
KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品分类表(支持多级树形结构)';
-- ============================================================
-- ④ 商品 SPU 表
-- ============================================================
DROP TABLE IF EXISTS `product`;
CREATE TABLE `product` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 IDSPU',
`name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`category_id` BIGINT NOT NULL COMMENT '所属分类 ID',
`brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌',
`main_image` VARCHAR(255) DEFAULT NULL COMMENT '主图 URL',
`sub_images` TEXT DEFAULT NULL COMMENT '副图列表JSON 数组)',
`detail` LONGTEXT DEFAULT NULL COMMENT '富文本详情HTML',
`origin_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价/划线价',
`sales` INT NOT NULL DEFAULT 0 COMMENT '累计销量',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-下架 1-上架',
`is_hot` TINYINT NOT NULL DEFAULT 0 COMMENT '是否热门0-否 1-是',
`is_new` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新品0-否 1-是',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`),
KEY `idx_sales` (`sales`),
KEY `idx_create_time` (`create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品 SPU 表';
-- ============================================================
-- ⑤ 商品 SKU 表
-- ============================================================
DROP TABLE IF EXISTS `product_sku`;
CREATE TABLE `product_sku` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 IDSKU',
`product_id` BIGINT NOT NULL COMMENT '所属 SPU ID',
`sku_name` VARCHAR(100) NOT NULL COMMENT '规格名称,如:原味/500g',
`image` VARCHAR(255) DEFAULT NULL COMMENT '规格图片 URL',
`price` DECIMAL(10,2) NOT NULL COMMENT '售价',
`stock` INT NOT NULL DEFAULT 0 COMMENT '库存数量',
`sales` INT NOT NULL DEFAULT 0 COMMENT '累计销量',
`weight` INT DEFAULT NULL COMMENT '重量(克)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品 SKU 表(具体规格)';
-- ============================================================
-- ⑥ 收货地址
-- ============================================================
DROP TABLE IF EXISTS `address`;
CREATE TABLE `address` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`user_id` BIGINT NOT NULL COMMENT '所属用户 ID',
`receiver` VARCHAR(50) NOT NULL COMMENT '收货人姓名',
`phone` VARCHAR(20) NOT NULL COMMENT '收货人手机号',
`province` VARCHAR(50) DEFAULT NULL COMMENT '',
`city` VARCHAR(50) DEFAULT NULL COMMENT '',
`district` VARCHAR(50) DEFAULT NULL COMMENT '区/县',
`detail` VARCHAR(255) NOT NULL COMMENT '详细地址',
`tag` VARCHAR(20) DEFAULT NULL COMMENT '标签:家/公司/学校',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址0-否 1-是',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_is_default` (`user_id`, `is_default`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='收货地址表';
-- ============================================================
-- ⑦ 购物车
-- ============================================================
DROP TABLE IF EXISTS `cart`;
CREATE TABLE `cart` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`user_id` BIGINT NOT NULL COMMENT '用户 ID',
`product_id` BIGINT NOT NULL COMMENT '商品 SPU ID',
`sku_id` BIGINT NOT NULL COMMENT '商品 SKU ID',
`quantity` INT NOT NULL DEFAULT 1 COMMENT '数量',
`selected` TINYINT NOT NULL DEFAULT 1 COMMENT '选中状态0-未选 1-已选',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_sku` (`user_id`, `sku_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='购物车表';
-- ============================================================
-- ⑧ 订单主表
-- ============================================================
DROP TABLE IF EXISTS `orders`;
CREATE TABLE `orders` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号(业务唯一)',
`user_id` BIGINT NOT NULL COMMENT '下单用户 ID',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '商品总金额(未优惠)',
`freight_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '运费',
`discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '优惠金额(满减/折扣)',
`coupon_id` BIGINT DEFAULT NULL COMMENT '使用的优惠券 ID',
`coupon_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '优惠券抵扣金额',
`pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 5-已退款',
`receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人姓名',
`receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货人手机号',
`receiver_address` VARCHAR(255) NOT NULL COMMENT '完整收货地址',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '订单备注',
`tracking_company` VARCHAR(50) DEFAULT NULL COMMENT '物流公司',
`tracking_no` VARCHAR(50) DEFAULT NULL COMMENT '物流单号',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`deliver_time` DATETIME DEFAULT NULL COMMENT '发货时间',
`receive_time` DATETIME DEFAULT NULL COMMENT '收货时间',
`cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间',
`finish_time` DATETIME DEFAULT NULL COMMENT '完成时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`),
KEY `idx_user_status` (`user_id`, `status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单主表';
-- ============================================================
-- ⑨ 订单商品明细
-- ============================================================
DROP TABLE IF EXISTS `order_item`;
CREATE TABLE `order_item` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`order_id` BIGINT NOT NULL COMMENT '订单 ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号(冗余)',
`product_id` BIGINT NOT NULL COMMENT '商品 SPU ID',
`product_name` VARCHAR(200) NOT NULL COMMENT '商品名(冗余防商品改名)',
`product_image` VARCHAR(255) DEFAULT NULL COMMENT '商品主图(冗余)',
`sku_id` BIGINT NOT NULL COMMENT 'SKU ID',
`sku_name` VARCHAR(100) NOT NULL COMMENT 'SKU 规格(冗余)',
`price` DECIMAL(10,2) NOT NULL COMMENT '成交单价',
`quantity` INT NOT NULL COMMENT '购买数量',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '小计金额price*quantity',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_product_id` (`product_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单商品明细表';
-- ============================================================
-- ⑩ 收藏
-- ============================================================
DROP TABLE IF EXISTS `favorite`;
CREATE TABLE `favorite` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`user_id` BIGINT NOT NULL COMMENT '用户 ID',
`product_id` BIGINT NOT NULL COMMENT '商品 SPU ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_product` (`user_id`, `product_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_product_id` (`product_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户收藏表';
-- ============================================================
-- ⑪ 轮播图
-- ============================================================
DROP TABLE IF EXISTS `banner`;
CREATE TABLE `banner` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`title` VARCHAR(100) DEFAULT NULL COMMENT '标题',
`image` VARCHAR(255) NOT NULL COMMENT '图片 URL',
`link_type` TINYINT NOT NULL DEFAULT 0 COMMENT '跳转类型0-不跳转 1-商品 2-分类 3-外链',
`link_value` VARCHAR(255) DEFAULT NULL COMMENT '跳转目标值(商品 ID / 分类 ID / URL',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号(升序)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-下线 1-上线',
`start_time` DATETIME DEFAULT NULL COMMENT '生效时间',
`end_time` DATETIME DEFAULT NULL COMMENT '失效时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status_sort` (`status`, `sort`),
KEY `idx_start_time` (`start_time`),
KEY `idx_end_time` (`end_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='首页轮播图表';
-- ============================================================
-- ⑫ 客服会话表WebSocket 长连接)
-- 设计要点:
-- - 客户端通过 WebSocket 建立长连接,服务端实时转发消息
-- - session_no 是对外展示/查询的编号
-- - user_seq / admin_seq 记录各端最大已确认序号,用于断线补发
-- - status 区分会话生命周期
-- ============================================================
DROP TABLE IF EXISTS `chat_session`;
CREATE TABLE `chat_session` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`session_no` VARCHAR(32) NOT NULL COMMENT '会话编号(业务唯一)',
`user_id` BIGINT NOT NULL COMMENT '发起用户 ID',
`admin_id` BIGINT DEFAULT NULL COMMENT '接待客服 IDNULL=待分配',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态0-待处理 1-处理中 2-已解决 3-已关闭',
`user_unread` INT NOT NULL DEFAULT 0 COMMENT '用户未读消息数',
`admin_unread` INT NOT NULL DEFAULT 0 COMMENT '客服未读消息数',
`last_message` VARCHAR(500) DEFAULT NULL COMMENT '最后一条消息摘要',
`last_time` DATETIME DEFAULT NULL COMMENT '最后一条消息时间',
`user_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '用户端最大已确认消息序号(用于断线补发)',
`admin_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '客服端最大已确认消息序号(用于断线补发)',
`close_time` DATETIME DEFAULT NULL COMMENT '关闭时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_no` (`session_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_admin_id` (`admin_id`),
KEY `idx_status` (`status`),
KEY `idx_last_time` (`last_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服会话表';
-- ============================================================
-- ⑬ 客服消息表WebSocket 消息持久化)
-- 设计要点:
-- - 每条消息全局递增 seq 编号(同 session 内)
-- - 客户端通过 last_ack_seq 拉取差量消息
-- - type 支持 text / image / file / system / product商品卡片
-- ============================================================
DROP TABLE IF EXISTS `chat_message`;
CREATE TABLE `chat_message` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`session_id` BIGINT NOT NULL COMMENT '所属会话 ID',
`seq` BIGINT NOT NULL DEFAULT 0 COMMENT '消息在会话内的递增序号(用于断线补发和排序)',
`sender_id` BIGINT NOT NULL COMMENT '发送者 ID用户/客服)',
`sender_type` TINYINT NOT NULL COMMENT '发送者类型0-用户 1-客服 2-系统',
`type` VARCHAR(10) NOT NULL DEFAULT 'text' COMMENT '消息类型text / image / file / product / system',
`content` TEXT NOT NULL COMMENT '消息内容(文字 / JSON / 图片 URL',
`extra` JSON DEFAULT NULL COMMENT '扩展字段(商品卡片等结构化数据)',
`is_recalled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否撤回0-否 1-是',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_seq` (`session_id`, `seq`),
KEY `idx_session_id` (`session_id`),
KEY `idx_create_time` (`create_time`),
KEY `idx_session_time` (`session_id`, `create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服消息表WebSocket 持久化)';
-- ============================================================
-- ⑭ 客服快捷回复模板
-- ============================================================
DROP TABLE IF EXISTS `quick_reply`;
CREATE TABLE `quick_reply` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`title` VARCHAR(100) NOT NULL COMMENT '模板标题(用于搜索)',
`content` VARCHAR(500) NOT NULL COMMENT '模板内容',
`category` VARCHAR(50) DEFAULT NULL COMMENT '分类:问候/物流/售后/活动等',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-禁用 1-启用',
`use_count` INT NOT NULL DEFAULT 0 COMMENT '使用次数(统计)',
`create_by` BIGINT DEFAULT NULL COMMENT '创建人(管理员 ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status_sort` (`status`, `sort`),
KEY `idx_category` (`category`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服快捷回复模板表';
-- ============================================================
-- ⑮ 系统公告
-- ============================================================
DROP TABLE IF EXISTS `notice`;
CREATE TABLE `notice` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`title` VARCHAR(200) NOT NULL COMMENT '公告标题',
`content` LONGTEXT NOT NULL COMMENT '公告内容(富文本 HTML',
`type` TINYINT NOT NULL DEFAULT 0 COMMENT '公告类型0-普通 1-重要 2-活动',
`is_top` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶0-否 1-是',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-下线 1-上线',
`start_time` DATETIME DEFAULT NULL COMMENT '生效时间',
`end_time` DATETIME DEFAULT NULL COMMENT '失效时间',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量',
`publisher_id` BIGINT DEFAULT NULL COMMENT '发布人(管理员 ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_type` (`type`),
KEY `idx_create_time` (`create_time`),
KEY `idx_top_status` (`is_top`, `status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='系统公告表';
-- ============================================================
-- ⑯ 优惠券模板
-- ============================================================
DROP TABLE IF EXISTS `coupon`;
CREATE TABLE `coupon` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`name` VARCHAR(100) NOT NULL COMMENT '优惠券名称',
`type` TINYINT NOT NULL COMMENT '类型0-满减券 1-折扣券 2-无门槛券',
`amount` DECIMAL(10,2) NOT NULL COMMENT '满减面值 / 折扣率0.85 表示 8.5 折)',
`min_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '最低使用金额0 表示无门槛)',
`max_discount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '折扣券最高抵扣金额',
`total` INT NOT NULL COMMENT '发放总量(-1 表示不限量)',
`remain` INT NOT NULL COMMENT '剩余可领取数量',
`per_limit` INT NOT NULL DEFAULT 1 COMMENT '每人限领数量',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态0-未上线 1-已上线 2-已结束',
`start_time` DATETIME NOT NULL COMMENT '领取开始时间',
`end_time` DATETIME NOT NULL COMMENT '领取截止时间',
`valid_days` INT DEFAULT NULL COMMENT '领取后有效天数NULL 时使用固定截止日期)',
`description` VARCHAR(500) DEFAULT NULL COMMENT '使用说明',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_start_time` (`start_time`),
KEY `idx_end_time` (`end_time`),
KEY `idx_type` (`type`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='优惠券模板表';
-- ============================================================
-- ⑰ 用户优惠券(领取记录)
-- ============================================================
DROP TABLE IF EXISTS `user_coupon`;
CREATE TABLE `user_coupon` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`user_id` BIGINT NOT NULL COMMENT '用户 ID',
`coupon_id` BIGINT NOT NULL COMMENT '优惠券模板 ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态0-未使用 1-已使用 2-已过期 3-已作废',
`order_id` BIGINT DEFAULT NULL COMMENT '使用时关联的订单 ID',
`receive_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间',
`use_time` DATETIME DEFAULT NULL COMMENT '使用时间',
`expire_time` DATETIME NOT NULL COMMENT '过期时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_coupon_id` (`coupon_id`),
KEY `idx_status` (`status`),
KEY `idx_expire` (`expire_time`),
KEY `idx_user_status` (`user_id`, `status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户优惠券领取记录表';
-- ============================================================
-- ⑱ 限时抢购活动
-- 设计要点(与 Redis 配合):
-- - 活动开启时,把 seckill_product 的 seckill_stock 同步到 Redis
-- key: seckill:stock:{activityId}:{productId}
-- - 用户抢购时 Redis DECR 原子扣减,避免超卖
-- - 抢购成功后再异步写库(消费 MQ 消息)
-- - seckill_order 唯一索引 (user_id, activity_id, product_id) 兜底"一人一单"
-- ============================================================
DROP TABLE IF EXISTS `seckill_activity`;
CREATE TABLE `seckill_activity` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`name` VARCHAR(100) NOT NULL COMMENT '活动名称',
`cover` VARCHAR(255) DEFAULT NULL COMMENT '活动封面图',
`start_time` DATETIME NOT NULL COMMENT '活动开始时间',
`end_time` DATETIME NOT NULL COMMENT '活动结束时间',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态0-未开始 1-进行中 2-已结束',
`description` VARCHAR(500) DEFAULT NULL COMMENT '活动说明',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_start_time` (`start_time`),
KEY `idx_end_time` (`end_time`),
KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='限时抢购活动表';
-- ============================================================
-- ⑲ 抢购活动商品
-- ============================================================
DROP TABLE IF EXISTS `seckill_product`;
CREATE TABLE `seckill_product` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`activity_id` BIGINT NOT NULL COMMENT '所属活动 ID',
`product_id` BIGINT NOT NULL COMMENT '商品 SPU ID',
`sku_id` BIGINT NOT NULL COMMENT '商品 SKU ID',
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '抢购价',
`origin_price` DECIMAL(10,2) NOT NULL COMMENT '原价(展示用)',
`seckill_stock` INT NOT NULL COMMENT '总库存(同步到 Redis',
`remain_stock` INT NOT NULL COMMENT '剩余库存(兜底用,正常情况下以 Redis 为准)',
`per_limit` INT NOT NULL DEFAULT 1 COMMENT '每人限购数量',
`sales` INT NOT NULL DEFAULT 0 COMMENT '已售数量(异步消费 MQ 后累加)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '活动内排序',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_sku` (`activity_id`, `sku_id`),
KEY `idx_product_id` (`product_id`),
KEY `idx_activity_id` (`activity_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='抢购活动商品表';
-- ============================================================
-- ⑳ 抢购订单(仅作为"成功下单"的最终记录)
-- 设计要点:
-- - 抢购请求先通过 Redis 原子扣库存 + Lua 限流
-- - 抢到后消息进入 MQ由消费者线程消费
-- ① 写 seckill_order唯一索引防一人多单
-- ② 生成主订单
-- ③ 异步扣 MySQL 库存(兜底)
-- - 此表只保留"已成功抢购"的记录,不存失败的请求
-- - (user_id, activity_id, product_id) 唯一索引:兜底防一人多单
-- ============================================================
DROP TABLE IF EXISTS `seckill_order`;
CREATE TABLE `seckill_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`user_id` BIGINT NOT NULL COMMENT '用户 ID',
`activity_id` BIGINT NOT NULL COMMENT '活动 ID',
`product_id` BIGINT NOT NULL COMMENT '商品 SPU ID',
`sku_id` BIGINT NOT NULL COMMENT 'SKU ID',
`quantity` INT NOT NULL COMMENT '抢购数量',
`seckill_price` DECIMAL(10,2) NOT NULL COMMENT '成交单价',
`order_id` BIGINT DEFAULT NULL COMMENT '关联主订单 ID支付/取消后回填)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态0-待支付 1-已支付 2-已取消 3-已退款',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抢购时间',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
PRIMARY KEY (`id`),
-- 唯一索引:防止同一用户在同一活动中对同一商品重复抢购
UNIQUE KEY `uk_user_activity_sku` (`user_id`, `activity_id`, `sku_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='抢购成功记录表';
-- ============================================================
-- ㉑ 文件上传记录
-- ============================================================
DROP TABLE IF EXISTS `upload_file`;
CREATE TABLE `upload_file` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
`name` VARCHAR(255) NOT NULL COMMENT '原始文件名',
`path` VARCHAR(255) NOT NULL COMMENT '存储路径(相对 uploads/',
`url` VARCHAR(255) NOT NULL COMMENT '访问 URL',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`content_type` VARCHAR(100) DEFAULT NULL COMMENT 'MIME 类型',
`storage_type` VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '存储类型local / qiniu / aliyun / tencent / minio',
`biz_type` VARCHAR(50) DEFAULT NULL COMMENT '业务类型avatar / product / banner / chat',
`upload_by` BIGINT DEFAULT NULL COMMENT '上传人(用户/管理员 ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
PRIMARY KEY (`id`),
KEY `idx_upload_by` (`upload_by`),
KEY `idx_biz_type` (`biz_type`),
KEY `idx_create_time`(`create_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='文件上传记录表';
-- ============================================================
-- 完成
-- ============================================================
SELECT '✅ 数据库初始化完成,共 21 张表' AS message;

290
db/seed.sql Normal file
View File

@ -0,0 +1,290 @@
-- ============================================================
-- 零食商城 测试数据
-- 执行前请先执行 schema.sql
-- ============================================================
USE `snack_mall`;
-- ============================================================
-- 管理员账号密码123456BCrypt 加密,强度 10
-- BCrypt hash for "123456":
-- ============================================================
INSERT INTO `admin` (`username`, `password`, `nickname`, `role`, `status`) VALUES
('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '超级管理员', 'super_admin', 1),
('manager', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '运营经理', 'admin', 1),
('operator', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '运营专员', 'operator', 1);
-- ============================================================
-- 普通用户密码123456
-- ============================================================
INSERT INTO `user` (`username`, `password`, `phone`, `nickname`, `avatar`, `status`) VALUES
('user001', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138001', '小张同学', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user001', 1),
('user002', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138002', '爱吃零食', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user002', 1),
('user003', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138003', '零食达人', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user003', 1),
('user004', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138004', '吃货联盟', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user004', 1),
('user005', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138005', '甜品控', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user005', 1);
-- ============================================================
-- 商品分类(一级 + 二级)
-- ============================================================
INSERT INTO `category` (`id`, `name`, `parent_id`, `level`, `sort`, `icon`, `status`) VALUES
(1, '休闲零食', 0, 1, 1, NULL, 1),
(2, '坚果炒货', 1, 2, 1, NULL, 1),
(3, '糖果巧克力', 1, 2, 2, NULL, 1),
(4, '肉脯卤味', 1, 2, 3, NULL, 1),
(5, '膨化食品', 1, 2, 4, NULL, 1),
(6, '蜜饯果干', 1, 2, 5, NULL, 1),
(7, '饮料饮品', 0, 1, 2, NULL, 1),
(8, '碳酸饮料', 7, 2, 1, NULL, 1),
(9, '果汁', 7, 2, 2, NULL, 1),
(10, '茶饮', 7, 2, 3, NULL, 1),
(11, '饼干糕点', 0, 1, 3, NULL, 1),
(12, '饼干', 11, 2, 1, NULL, 1),
(13, '蛋糕', 11, 2, 2, NULL, 1);
-- ============================================================
-- 商品 SPU
-- ============================================================
INSERT INTO `product` (`id`, `name`, `category_id`, `brand`, `main_image`, `origin_price`, `sales`, `status`, `is_hot`, `is_new`) VALUES
(1, '原味夏威夷果 500g', 2, '良品铺子', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 89.00, 320, 1, 1, 0),
(2, '奶油味腰果 200g', 2, '三只松鼠', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 49.00, 280, 1, 1, 1),
(3, '原味开心果 300g', 2, '来伊份', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 69.00, 150, 1, 0, 1),
(4, '蜂蜜核桃仁 250g', 2, '洽洽', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 39.90, 220, 1, 1, 0),
(5, '原味瓜子 500g', 2, '洽洽', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 19.90, 480, 1, 1, 0),
(6, '草莓味软糖 200g', 3, '阿尔卑斯', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 12.90, 600, 1, 0, 0),
(7, '牛奶巧克力 100g', 3, '德芙', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 28.90, 720, 1, 1, 0),
(8, '黑巧克力 85% 100g', 3, 'Lindt', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 49.90, 320, 1, 1, 0),
(9, '蜜汁猪肉脯 250g', 4, '无穷', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 35.90, 380, 1, 1, 0),
(10, '麻辣牛肉干 200g', 4, '老四川', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 42.90, 260, 1, 0, 0),
(11, '原味薯片 大包装 135g', 5, '乐事', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 9.90, 1200, 1, 1, 0),
(12, '虾条 80g', 5, '上好佳', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 6.90, 860, 1, 0, 0),
(13, '芒果干 100g', 6, '溜溜梅', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 15.90, 540, 1, 1, 0),
(14, '葡萄干 200g', 6, '楼兰蜜语', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 18.90, 380, 1, 0, 0),
(15, '可乐 330ml*6 罐装', 8, '可口可乐', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 25.90, 920, 1, 1, 0),
(16, '雪碧 330ml*6 罐装', 8, '雪碧', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 25.90, 580, 1, 1, 0),
(17, '100% 橙汁 1L', 9, '汇源', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 19.90, 360, 1, 0, 1),
(18, '蜜桃乌龙茶 500ml', 10, '三得利', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 12.90, 420, 1, 0, 1),
(19, '奥利奥饼干 388g', 12, '奥利奥', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 22.90, 1100, 1, 1, 0),
(20, '熔岩蛋糕 6 个装', 13, '好利来', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 39.90, 290, 1, 0, 1);
-- ============================================================
-- 商品 SKU
-- ============================================================
INSERT INTO `product_sku` (`product_id`, `sku_name`, `price`, `stock`, `sales`, `sort`) VALUES
-- 夏威夷果
(1, '原味/500g', 79.00, 500, 320, 1),
(1, '盐焗/500g', 79.00, 300, 120, 2),
(1, '奶油/500g', 84.00, 200, 80, 3),
-- 腰果
(2, '原味/200g', 39.90, 400, 280, 1),
(2, '盐焗/200g', 39.90, 250, 100, 2),
-- 开心果
(3, '原味/300g', 59.00, 300, 150, 1),
(3, '盐焗/300g', 59.00, 200, 60, 2),
-- 核桃
(4, '蜂蜜/250g', 35.90, 400, 220, 1),
(4, '原味/250g', 32.90, 300, 80, 2),
-- 瓜子
(5, '原味/500g', 16.90, 1000, 480, 1),
(5, '五香/500g', 16.90, 600, 200, 2),
-- 软糖
(6, '草莓味/200g', 9.90, 600, 600, 1),
(6, '混合味/200g', 12.90, 400, 220, 2),
-- 巧克力
(7, '牛奶/100g', 24.90, 500, 720, 1),
(7, '榛仁/100g', 28.90, 300, 280, 2),
-- 黑巧
(8, '85%/100g', 39.90, 400, 320, 1),
(8, '70%/100g', 35.90, 300, 180, 2),
-- 猪肉脯
(9, '蜜汁/250g', 32.90, 500, 380, 1),
(9, '麻辣/250g', 32.90, 300, 120, 2),
-- 牛肉干
(10, '麻辣/200g', 39.90, 400, 260, 1),
(10, '五香/200g', 39.90, 300, 100, 2),
-- 薯片
(11, '原味/135g', 8.90, 1000, 1200, 1),
(11, '番茄/135g', 8.90, 800, 680, 2),
(11, '黄瓜/135g', 8.90, 600, 420, 3),
-- 虾条
(12, '原味/80g', 5.90, 800, 860, 1),
-- 芒果干
(13, '原味/100g', 12.90, 500, 540, 1),
(13, '蜜饯/100g', 14.90, 300, 180, 2),
-- 葡萄干
(14, '红提/200g', 16.90, 400, 380, 1),
(14, '黑加仑/200g', 16.90, 300, 120, 2),
-- 可乐
(15, '原味/330ml*6', 22.90, 800, 920, 1),
(15, '零度/330ml*6', 22.90, 500, 320, 2),
-- 雪碧
(16, '原味/330ml*6', 22.90, 700, 580, 1),
-- 橙汁
(17, '原味/1L', 17.90, 400, 360, 1),
-- 蜜桃乌龙
(18, '原味/500ml', 10.90, 500, 420, 1),
-- 奥利奥
(19, '原味/388g', 19.90, 800, 1100, 1),
(19, '巧克力/388g', 19.90, 500, 480, 2),
-- 蛋糕
(20, '原味/6 个装', 36.90, 200, 290, 1);
-- ============================================================
-- 收货地址
-- ============================================================
INSERT INTO `address` (`user_id`, `receiver`, `phone`, `province`, `city`, `district`, `detail`, `is_default`) VALUES
(1, '张三', '13800138001', '广东省', '深圳市', '南山区', '科技园南路 88 号腾讯大厦', 1),
(1, '张三', '13800138001', '广东省', '深圳市', '福田区', '华强北路 1002 号', 0),
(2, '李四', '13800138002', '北京市', '海淀区', '中关村', '中关村大街 27 号', 1),
(3, '王五', '13800138003', '上海市', '浦东新区', '陆家嘴', '世纪大道 100 号', 1),
(4, '赵六', '13800138004', '浙江省', '杭州市', '西湖区', '文三路 478 号', 1);
-- ============================================================
-- 轮播图
-- ============================================================
INSERT INTO `banner` (`title`, `image`, `link_type`, `link_value`, `sort`, `status`) VALUES
('618 大促全场满 199 减 50', 'https://placehold.co/750x320/1E40AF/FFFFFF?text=618+Big+Sale', 0, NULL, 1, 1),
('新品上市 · 进口零食专区', 'https://placehold.co/750x320/3B82F6/FFFFFF?text=New+Arrival', 1, '7', 2, 1),
('新人专享 · 注册即领 50 元大礼包', 'https://placehold.co/750x320/F59E0B/FFFFFF?text=New+User+Gift', 0, NULL, 3, 1),
('每日签到领积分换好礼', 'https://placehold.co/750x320/10B981/FFFFFF?text=Check+In', 0, NULL, 4, 1);
-- ============================================================
-- 系统公告
-- ============================================================
INSERT INTO `notice` (`title`, `content`, `type`, `is_top`, `status`, `start_time`, `end_time`) VALUES
('【重要】关于 618 大促活动规则说明',
'<p>亲爱的用户:</p><p>618 大促活动即将开始,全场满 199 减 50满 399 减 120上不封顶活动期间更有每日 10 点/20 点限时秒杀,等你来抢!</p><p>活动期间如有任何问题,请联系在线客服。</p>',
1, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59'),
('系统升级维护通知',
'<p>为了提供更好的服务,系统将于 2026-06-15 02:00 - 04:00 进行例行维护升级。维护期间可能短暂无法访问,请合理安排购物时间。</p>',
1, 0, 1, '2026-06-10 00:00:00', '2026-06-16 23:59:59'),
('新人福利:注册即送 50 元大礼包',
'<p>新用户注册后可在「领券中心」领取 50 元新人礼包,包含:满 99 减 20、满 199 减 30 优惠券各一张。</p>',
2, 0, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59'),
('关于规范使用账户的公告',
'<p>为保障您的账户安全,请勿将账户借给他人使用。如发现异常登录行为,系统将自动冻结账户。</p>',
0, 0, 1, '2026-05-15 00:00:00', '2026-12-31 23:59:59'),
('物流更新提醒',
'<p>近期受天气影响,部分地区物流时效可能延长 1-2 天,请耐心等待。如长时间未收到,可联系客服查询。</p>',
0, 0, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59');
-- ============================================================
-- 优惠券模板
-- ============================================================
INSERT INTO `coupon` (`name`, `type`, `amount`, `min_amount`, `max_discount`, `total`, `remain`, `per_limit`, `status`, `start_time`, `end_time`, `valid_days`) VALUES
-- 满减券
('满 99 减 10', 0, 10.00, 99.00, 0.00, 1000, 856, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
('满 199 减 30', 0, 30.00, 199.00, 0.00, 500, 320, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
('满 399 减 80', 0, 80.00, 399.00, 0.00, 200, 168, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15),
-- 折扣券
('9 折优惠券', 1, 0.90, 0.00, 50.00, 800, 580, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
('8.5 折优惠券', 1, 0.85, 100.00, 80.00, 300, 180, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15),
-- 无门槛
('新人 5 元无门槛', 2, 5.00, 0.00, 0.00, 5000, 3200, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30),
('新人 10 元无门槛', 2, 10.00, 0.00, 0.00, 3000, 2100, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30);
-- ============================================================
-- 限时抢购活动
-- ============================================================
INSERT INTO `seckill_activity` (`name`, `start_time`, `end_time`, `status`) VALUES
('618 限时秒杀 · 第一场', '2026-06-02 10:00:00', '2026-06-02 12:00:00', 1),
('618 限时秒杀 · 第二场', '2026-06-02 14:00:00', '2026-06-02 16:00:00', 0),
('618 限时秒杀 · 第三场', '2026-06-02 20:00:00', '2026-06-02 22:00:00', 0),
('618 限时秒杀 · 第四场', '2026-06-03 10:00:00', '2026-06-03 12:00:00', 0),
('618 限时秒杀 · 第五场', '2026-06-03 20:00:00', '2026-06-03 22:00:00', 0),
('已结束场次回顾', '2026-06-01 10:00:00', '2026-06-01 12:00:00', 2);
-- ============================================================
-- 抢购活动商品
-- ============================================================
INSERT INTO `seckill_product` (`activity_id`, `product_id`, `sku_id`, `seckill_price`, `seckill_stock`, `remain_stock`, `per_limit`, `sales`) VALUES
-- 第一场
(1, 1, 1, 49.90, 100, 68, 2, 32),
(1, 7, 14, 14.90, 200, 145, 3, 55),
(1, 11, 22, 4.90, 500, 320, 5, 180),
(1, 15, 30, 12.90, 300, 198, 2, 102),
-- 第二场
(2, 2, 4, 19.90, 150, 150, 2, 0),
(2, 9, 18, 19.90, 200, 200, 2, 0),
(2, 19, 39, 9.90, 500, 500, 3, 0),
-- 第三场
(3, 8, 16, 24.90, 200, 200, 2, 0),
(3, 13, 27, 6.90, 300, 300, 3, 0),
(3, 20, 41, 19.90, 100, 100, 1, 0);
-- ============================================================
-- 客服快捷回复模板
-- ============================================================
INSERT INTO `quick_reply` (`title`, `content`, `sort`, `status`) VALUES
('问候', '您好,欢迎光临零食商城~ 请问有什么可以帮您?', 1, 1),
('已发货', '您的订单已经发货啦物流单号xx预计 2-3 天到达,请耐心等待哦~', 2, 1),
('催发货', '亲,您的订单正在紧急处理中,我们会尽快为您发货,感谢您的耐心等待!', 3, 1),
('退换货', '亲,如需退换货请提供订单号和具体问题,我们会在 24 小时内为您处理~', 4, 1),
('感谢', '感谢您的咨询,祝您购物愉快!如有问题随时联系在线客服~', 5, 1),
('库存咨询', '亲,这款商品目前库存充足,可以直接下单哦~', 6, 1);
-- ============================================================
-- 收藏
-- ============================================================
INSERT INTO `favorite` (`user_id`, `product_id`) VALUES
(1, 1), (1, 7), (1, 9),
(2, 2), (2, 11),
(3, 1), (3, 8), (3, 19),
(4, 11), (4, 15),
(5, 6), (5, 13);
-- ============================================================
-- 客服示例会话
-- ============================================================
INSERT INTO `chat_session` (`id`, `session_no`, `user_id`, `admin_id`, `status`, `user_unread`, `admin_unread`, `last_message`, `last_time`, `user_seq`, `admin_seq`) VALUES
(1, 'CS20260601001', 1, 1, 1, 1, 0, '好的,下一单', '2026-06-01 14:02:00', 3, 3),
(2, 'CS20260601002', 2, NULL, 0, 1, 1, '请问发货时间是什么时候?', '2026-06-01 15:23:00', 1, 0),
(3, 'CS20260601003', 3, 1, 2, 0, 0, '感谢您的耐心解答!', '2026-06-01 16:45:00', 2, 2),
(4, 'CS20260601004', 4, NULL, 0, 1, 1, '我下单了怎么还没发货?', '2026-06-02 09:12:00', 1, 0),
(5, 'CS20260601005', 5, 1, 1, 1, 0, '请问优惠券怎么用?', '2026-06-02 10:30:00', 1, 0);
-- ============================================================
-- 客服示例消息(含 seq 序号)
-- ============================================================
INSERT INTO `chat_message` (`session_id`, `seq`, `sender_id`, `sender_type`, `type`, `content`, `is_recalled`, `create_time`) VALUES
(1, 1, 1, 0, 'text', '你好,请问夏威夷果还有货吗?', 0, '2026-06-01 14:00:00'),
(1, 2, 1, 1, 'text', '亲,在的哦,目前库存充足~', 0, '2026-06-01 14:01:00'),
(1, 3, 1, 0, 'text', '好的,下一单', 0, '2026-06-01 14:02:00'),
(2, 1, 2, 0, 'text', '请问发货时间是什么时候?', 0, '2026-06-01 15:23:00'),
(3, 1, 3, 0, 'text', '订单已收到,谢谢', 0, '2026-06-01 16:40:00'),
(3, 2, 1, 1, 'text', '感谢您的耐心解答!', 0, '2026-06-01 16:45:00'),
(4, 1, 4, 0, 'text', '我下单了怎么还没发货?', 0, '2026-06-02 09:12:00'),
(5, 1, 5, 0, 'text', '请问优惠券怎么用?', 0, '2026-06-02 10:30:00');
-- ============================================================
-- 抢购成功记录示例(模拟已经异步落库后的状态)
-- ============================================================
INSERT INTO `seckill_order` (`user_id`, `activity_id`, `product_id`, `sku_id`, `quantity`, `seckill_price`, `order_id`, `status`) VALUES
(1, 1, 1, 1, 1, 49.90, NULL, 0),
(1, 1, 7, 14, 2, 14.90, NULL, 0),
(2, 1, 11, 22, 3, 4.90, NULL, 0),
(3, 1, 15, 30, 1, 12.90, NULL, 1);
-- ============================================================
-- 完成
-- ============================================================
SELECT '✅ 测试数据导入完成' AS message;
SELECT '默认管理员账号: admin / 123456' AS info;
SELECT '默认用户账号: user001 / 123456' AS info;

View File

@ -26,6 +26,11 @@ public enum ResultCode {
PRODUCT_NOT_EXIST(1010, "商品不存在"),
PRODUCT_OFF_SHELF(1011, "商品已下架"),
STOCK_INSUFFICIENT(1012, "库存不足"),
CATEGORY_NOT_EXIST(1013, "分类不存在"),
CATEGORY_HAS_CHILDREN(1014, "分类下存在子分类,无法删除"),
CATEGORY_HAS_PRODUCTS(1015, "分类下存在商品,无法删除"),
CATEGORY_LEVEL_INVALID(1016, "分类层级不合法"),
CATEGORY_NAME_DUPLICATE(1017, "同级分类下已存在同名分类"),
ORDER_NOT_EXIST(1020, "订单不存在"),
ORDER_STATUS_ERROR(1021, "订单状态异常,无法操作"),
COUPON_NOT_EXIST(1030, "优惠券不存在"),

View File

@ -0,0 +1,27 @@
package com.snack.server.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 自动填充处理器
* 自动写入 createTime / updateTime
*/
@Component
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}

View File

@ -25,8 +25,10 @@ public class SaTokenConfig implements WebMvcConfigurer {
SaRouter.match("/api/coupons/*/receive").check(r -> StpUtil.checkLogin());
SaRouter.match("/api/user/coupons").check(r -> StpUtil.checkLogin());
// 管理端需要登录的接口
SaRouter.match("/api/admin/**").check(r -> StpUtil.checkLogin());
// 管理端需要登录的接口登录接口本身放行
SaRouter.match("/api/admin/**")
.notMatch("/api/admin/login", "/api/admin/captcha")
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**");
}
}

View File

@ -0,0 +1,141 @@
package com.snack.server.module.admin.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.common.Result;
import com.snack.server.module.admin.dto.req.AdminLoginReq;
import com.snack.server.module.admin.dto.req.AdminPageReq;
import com.snack.server.module.admin.dto.req.AdminSaveReq;
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
import com.snack.server.module.admin.service.AdminService;
import com.snack.server.module.admin.vo.AdminVO;
import com.snack.server.module.admin.vo.LoginVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 管理员相关接口
*/
@Tag(name = "01. 管理员管理")
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminService adminService;
// ==================== 鉴权相关白名单 ====================
@Operation(summary = "管理员登录")
@PostMapping("/login")
public Result<LoginVO> login(@Valid @RequestBody AdminLoginReq req, HttpServletRequest request) {
String ip = getClientIp(request);
return Result.ok(adminService.login(req, ip));
}
@Operation(summary = "管理员登出")
@SaCheckLogin
@PostMapping("/logout")
public Result<Void> logout() {
adminService.logout();
return Result.ok();
}
@Operation(summary = "获取当前登录管理员信息")
@SaCheckLogin
@GetMapping("/info")
public Result<AdminVO> getCurrentAdmin() {
return Result.ok(adminService.getCurrentAdmin());
}
@Operation(summary = "修改自己的密码")
@SaCheckLogin
@PutMapping("/password")
public Result<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
adminService.changePassword(req);
return Result.ok();
}
// ==================== 管理员 CRUD ====================
@Operation(summary = "分页查询管理员列表")
@SaCheckLogin
@GetMapping("/page")
public Result<Page<AdminVO>> page(AdminPageReq req) {
return Result.ok(adminService.pageAdmins(req));
}
@Operation(summary = "获取管理员详情")
@SaCheckLogin
@GetMapping("/{id}")
public Result<AdminVO> detail(@Parameter(description = "管理员 ID") @PathVariable Long id) {
return Result.ok(adminService.getAdminById(id));
}
@Operation(summary = "创建管理员")
@SaCheckRole("super_admin")
@PostMapping
public Result<Long> create(@Valid @RequestBody AdminSaveReq req) {
return Result.ok(adminService.createAdmin(req));
}
@Operation(summary = "更新管理员")
@SaCheckRole("super_admin")
@PutMapping
public Result<Void> update(@Valid @RequestBody AdminSaveReq req) {
adminService.updateAdmin(req);
return Result.ok();
}
@Operation(summary = "删除管理员")
@SaCheckRole("super_admin")
@DeleteMapping("/{id}")
public Result<Void> delete(@Parameter(description = "管理员 ID") @PathVariable Long id) {
adminService.deleteAdmin(id);
return Result.ok();
}
@Operation(summary = "重置管理员密码")
@SaCheckRole("super_admin")
@PutMapping("/{id}/password/reset")
public Result<Void> resetPassword(
@Parameter(description = "管理员 ID") @PathVariable Long id,
@Parameter(description = "新密码") @RequestParam String newPassword) {
adminService.resetPassword(id, newPassword);
return Result.ok();
}
@Operation(summary = "启用/禁用管理员")
@SaCheckRole("super_admin")
@PutMapping("/{id}/status/{status}")
public Result<Void> updateStatus(
@Parameter(description = "管理员 ID") @PathVariable Long id,
@Parameter(description = "状态0-禁用 1-启用") @PathVariable Integer status) {
adminService.updateStatus(id, status);
return Result.ok();
}
// ==================== 工具方法 ====================
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@ -0,0 +1,28 @@
package com.snack.server.module.admin.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 管理员登录请求
*/
@Data
@Schema(description = "管理员登录请求")
public class AdminLoginReq implements Serializable {
@NotBlank(message = "用户名不能为空")
@Schema(description = "用户名", example = "admin")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 50, message = "密码长度需在 6-50 之间")
@Schema(description = "密码", example = "123456")
private String password;
@Schema(description = "记住我(延长 token 有效期)", example = "false")
private Boolean rememberMe = false;
}

View File

@ -0,0 +1,29 @@
package com.snack.server.module.admin.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 管理员分页查询
*/
@Data
@Schema(description = "管理员分页查询请求")
public class AdminPageReq implements Serializable {
@Schema(description = "页码", example = "1")
private Long current = 1L;
@Schema(description = "每页条数", example = "10")
private Long size = 10L;
@Schema(description = "关键字(用户名/昵称/手机号)")
private String keyword;
@Schema(description = "角色")
private String role;
@Schema(description = "状态0-禁用 1-启用")
private Integer status;
}

View File

@ -0,0 +1,52 @@
package com.snack.server.module.admin.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 创建/更新管理员请求
*/
@Data
@Schema(description = "创建/更新管理员请求")
public class AdminSaveReq implements Serializable {
/** 主键(更新时必填,创建时为空) */
@Schema(description = "管理员 ID更新时必填")
private Long id;
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 30, message = "用户名长度需在 3-30 之间")
@Schema(description = "登录用户名", example = "operator02")
private String username;
/** 创建时必填;更新时为空表示不修改密码 */
@Schema(description = "密码(创建必填,更新时为空表示不修改)")
private String password;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "头像 URL")
private String avatar;
@Email(message = "邮箱格式不正确")
@Schema(description = "邮箱")
private String email;
@Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Schema(description = "手机号")
private String phone;
@NotBlank(message = "角色不能为空")
@Schema(description = "角色super_admin / admin / operator", example = "admin")
private String role;
@Schema(description = "状态0-禁用 1-启用", example = "1")
private Integer status = 1;
}

View File

@ -0,0 +1,23 @@
package com.snack.server.module.admin.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serializable;
/**
* 修改密码请求
*/
@Data
@Schema(description = "修改密码请求")
public class ChangePasswordReq implements Serializable {
@NotBlank(message = "原密码不能为空")
@Schema(description = "原密码")
private String oldPassword;
@NotBlank(message = "新密码不能为空")
@Schema(description = "新密码")
private String newPassword;
}

View File

@ -0,0 +1,62 @@
package com.snack.server.module.admin.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理员实体
*
* 对应数据库表admin
*/
@Data
@TableName("admin")
public class Admin implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 登录用户名(唯一) */
private String username;
/** BCrypt 加密密码 */
private String password;
/** 昵称 */
private String nickname;
/** 头像 URL */
private String avatar;
/** 邮箱 */
private String email;
/** 手机号 */
private String phone;
/** 角色标识super_admin / admin / operator */
private String role;
/** 账号状态0-禁用 1-启用 */
private Integer status;
/** 最近登录时间 */
private LocalDateTime lastLoginTime;
/** 最近登录 IP */
private String lastLoginIp;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,12 @@
package com.snack.server.module.admin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.admin.entity.Admin;
import org.apache.ibatis.annotations.Mapper;
/**
* 管理员 Mapper
*/
@Mapper
public interface AdminMapper extends BaseMapper<Admin> {
}

View File

@ -0,0 +1,80 @@
package com.snack.server.module.admin.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.module.admin.dto.req.AdminLoginReq;
import com.snack.server.module.admin.dto.req.AdminPageReq;
import com.snack.server.module.admin.dto.req.AdminSaveReq;
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
import com.snack.server.module.admin.entity.Admin;
import com.snack.server.module.admin.vo.AdminVO;
import com.snack.server.module.admin.vo.LoginVO;
/**
* 管理员业务接口
*/
public interface AdminService {
/**
* 登录
*
* @param req 登录参数
* @param clientIp 客户端 IP
* @return 登录响应 token + 管理员信息
*/
LoginVO login(AdminLoginReq req, String clientIp);
/**
* 登出
*/
void logout();
/**
* 获取当前登录的管理员信息
*/
AdminVO getCurrentAdmin();
/**
* 分页查询管理员列表
*/
Page<AdminVO> pageAdmins(AdminPageReq req);
/**
* 根据 ID 获取管理员
*/
AdminVO getAdminById(Long id);
/**
* 创建管理员
*/
Long createAdmin(AdminSaveReq req);
/**
* 更新管理员
*/
void updateAdmin(AdminSaveReq req);
/**
* 删除管理员
*/
void deleteAdmin(Long id);
/**
* 修改自己的密码
*/
void changePassword(ChangePasswordReq req);
/**
* 重置某管理员的密码仅超管可用
*/
void resetPassword(Long id, String newPassword);
/**
* 启用 / 禁用管理员
*/
void updateStatus(Long id, Integer status);
/**
* 更新登录信息IP + 时间
*/
void updateLoginInfo(Long id, String clientIp);
}

View File

@ -0,0 +1,282 @@
package com.snack.server.module.admin.service;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.common.ResultCode;
import com.snack.server.module.admin.dto.req.AdminLoginReq;
import com.snack.server.module.admin.dto.req.AdminPageReq;
import com.snack.server.module.admin.dto.req.AdminSaveReq;
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
import com.snack.server.module.admin.entity.Admin;
import com.snack.server.exception.BusinessException;
import com.snack.server.module.admin.mapper.AdminMapper;
import com.snack.server.module.admin.service.AdminService;
import com.snack.server.module.admin.vo.AdminVO;
import com.snack.server.module.admin.vo.LoginVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 管理员业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminServiceImpl implements AdminService {
private final AdminMapper adminMapper;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// ==================== 登录相关 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public LoginVO login(AdminLoginReq req, String clientIp) {
// 1. 查询管理员
Admin admin = adminMapper.selectOne(
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, req.getUsername())
);
if (admin == null) {
throw new BusinessException(ResultCode.PASSWORD_ERROR); // 不暴露用户名是否存在
}
// 2. 校验状态
if (admin.getStatus() != null && admin.getStatus() == 0) {
throw new BusinessException(ResultCode.USER_DISABLED);
}
// 3. 校验密码BCrypt 校验
if (!passwordEncoder.matches(req.getPassword(), admin.getPassword())) {
throw new BusinessException(ResultCode.PASSWORD_ERROR);
}
// 4. Sa-Token 登录
StpUtil.login(admin.getId());
String token = StpUtil.getTokenValue();
// 5. 更新登录信息异步优化可改为线程池执行
updateLoginInfo(admin.getId(), clientIp);
// 6. 构造返回
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setExpiresIn(StpUtil.getTokenTimeout());
vo.setAdminInfo(toVO(admin));
return vo;
}
@Override
public void logout() {
if (StpUtil.isLogin()) {
StpUtil.logout();
}
}
@Override
public void updateLoginInfo(Long id, String clientIp) {
Admin update = new Admin();
update.setId(id);
update.setLastLoginTime(LocalDateTime.now());
update.setLastLoginIp(clientIp);
adminMapper.updateById(update);
}
// ==================== 当前登录管理员 ====================
@Override
public AdminVO getCurrentAdmin() {
Long id = StpUtil.getLoginIdAsLong();
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
return toVO(admin);
}
// ==================== 列表 / 详情 ====================
@Override
public Page<AdminVO> pageAdmins(AdminPageReq req) {
Page<Admin> page = new Page<>(req.getCurrent(), req.getSize());
LambdaQueryWrapper<Admin> wrapper = new LambdaQueryWrapper<Admin>()
// 关键字模糊搜索
.and(StrUtil.isNotBlank(req.getKeyword()), w -> w
.like(Admin::getUsername, req.getKeyword())
.or().like(Admin::getNickname, req.getKeyword())
.or().like(Admin::getPhone, req.getKeyword())
)
.eq(StrUtil.isNotBlank(req.getRole()), Admin::getRole, req.getRole())
.eq(req.getStatus() != null, Admin::getStatus, req.getStatus())
.orderByDesc(Admin::getId);
Page<Admin> result = adminMapper.selectPage(page, wrapper);
Page<AdminVO> voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal());
voPage.setRecords(result.getRecords().stream().map(this::toVO).toList());
return voPage;
}
@Override
public AdminVO getAdminById(Long id) {
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
return toVO(admin);
}
// ==================== 创建 / 更新 / 删除 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public Long createAdmin(AdminSaveReq req) {
// 1. 用户名唯一性校验
Long count = adminMapper.selectCount(
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, req.getUsername())
);
if (count > 0) {
throw new BusinessException(1003, "用户名已存在");
}
// 2. 密码必填
if (StrUtil.isBlank(req.getPassword())) {
throw new BusinessException(1000, "密码不能为空");
}
// 3. 入库
Admin admin = new Admin();
BeanUtil.copyProperties(req, admin, "id");
admin.setPassword(passwordEncoder.encode(req.getPassword()));
admin.setStatus(req.getStatus() == null ? 1 : req.getStatus());
adminMapper.insert(admin);
log.info("创建管理员成功 id={} username={}", admin.getId(), admin.getUsername());
return admin.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAdmin(AdminSaveReq req) {
if (req.getId() == null) {
throw new BusinessException(1000, "ID 不能为空");
}
Admin existing = adminMapper.selectById(req.getId());
if (existing == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
// 用户名查重排除自己
Long count = adminMapper.selectCount(
new LambdaQueryWrapper<Admin>()
.eq(Admin::getUsername, req.getUsername())
.ne(Admin::getId, req.getId())
);
if (count > 0) {
throw new BusinessException(1003, "用户名已存在");
}
// 不修改密码
Admin admin = new Admin();
BeanUtil.copyProperties(req, admin, "password");
adminMapper.updateById(admin);
log.info("更新管理员成功 id={}", admin.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteAdmin(Long id) {
// 不允许删除自己
if (StpUtil.getLoginIdAsLong().equals(id)) {
throw new BusinessException(1000, "不能删除自己");
}
// 不允许删除超级管理员
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
if ("super_admin".equals(admin.getRole())) {
throw new BusinessException(1000, "不能删除超级管理员");
}
adminMapper.deleteById(id);
log.info("删除管理员 id={}", id);
}
// ==================== 密码相关 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void changePassword(ChangePasswordReq req) {
Long id = StpUtil.getLoginIdAsLong();
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
if (!passwordEncoder.matches(req.getOldPassword(), admin.getPassword())) {
throw new BusinessException(ResultCode.OLD_PASSWORD_ERROR);
}
if (req.getNewPassword().equals(req.getOldPassword())) {
throw new BusinessException(1000, "新密码不能与原密码相同");
}
Admin update = new Admin();
update.setId(id);
update.setPassword(passwordEncoder.encode(req.getNewPassword()));
adminMapper.updateById(update);
log.info("管理员 id={} 修改密码成功", id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resetPassword(Long id, String newPassword) {
if (StrUtil.isBlank(newPassword) || newPassword.length() < 6) {
throw new BusinessException(1000, "新密码至少 6 位");
}
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
Admin update = new Admin();
update.setId(id);
update.setPassword(passwordEncoder.encode(newPassword));
adminMapper.updateById(update);
log.info("重置管理员 id={} 密码", id);
}
// ==================== 状态切换 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (id == null || status == null) {
throw new BusinessException(1000, "参数错误");
}
if (StpUtil.getLoginIdAsLong().equals(id)) {
throw new BusinessException(1000, "不能禁用自己");
}
Admin admin = adminMapper.selectById(id);
if (admin == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}
if ("super_admin".equals(admin.getRole())) {
throw new BusinessException(1000, "不能禁用超级管理员");
}
Admin update = new Admin();
update.setId(id);
update.setStatus(status);
adminMapper.updateById(update);
log.info("管理员 id={} 状态更新为 {}", id, status);
}
// ==================== 私有方法 ====================
private AdminVO toVO(Admin admin) {
AdminVO vo = new AdminVO();
BeanUtil.copyProperties(admin, vo, "password");
return vo;
}
}

View File

@ -0,0 +1,51 @@
package com.snack.server.module.admin.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理员信息 VO
*/
@Data
@Schema(description = "管理员信息")
public class AdminVO implements Serializable {
@Schema(description = "主键 ID")
private Long id;
@Schema(description = "用户名")
private String username;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "头像")
private String avatar;
@Schema(description = "邮箱")
private String email;
@Schema(description = "手机号")
private String phone;
@Schema(description = "角色")
private String role;
@Schema(description = "状态0-禁用 1-启用")
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "最近登录时间")
private LocalDateTime lastLoginTime;
@Schema(description = "最近登录 IP")
private String lastLoginIp;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,26 @@
package com.snack.server.module.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 登录成功返回
*/
@Data
@Schema(description = "登录返回信息")
public class LoginVO implements Serializable {
@Schema(description = "访问 Token前端存储后续请求需在 Header 中携带)")
private String token;
@Schema(description = "Token 类型", example = "Bearer")
private String tokenType = "Bearer";
@Schema(description = "过期时间(秒)")
private Long expiresIn;
@Schema(description = "管理员信息")
private AdminVO adminInfo;
}

View File

@ -0,0 +1,104 @@
package com.snack.server.module.category.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.snack.server.common.Result;
import com.snack.server.module.category.dto.req.CategorySaveReq;
import com.snack.server.module.category.entity.Category;
import com.snack.server.module.category.service.CategoryService;
import com.snack.server.module.category.vo.CategoryOptionVO;
import com.snack.server.module.category.vo.CategoryTreeVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 商品分类管理管理端
*/
@Tag(name = "02. 商品分类")
@RestController
@RequestMapping("/api/admin/category")
@RequiredArgsConstructor
public class CategoryAdminController {
private final CategoryService categoryService;
@Operation(summary = "获取分类树")
@SaCheckLogin
@GetMapping("/tree")
public Result<List<CategoryTreeVO>> tree(
@Parameter(description = "状态过滤null-全部 0-禁用 1-启用")
@RequestParam(required = false) Integer status) {
return Result.ok(categoryService.listTree(status));
}
@Operation(summary = "获取某父分类下的直接子分类")
@SaCheckLogin
@GetMapping("/children")
public Result<List<Category>> listChildren(
@Parameter(description = "父分类 ID0=顶级")
@RequestParam(defaultValue = "0") Long parentId) {
return Result.ok(categoryService.listByParentId(parentId));
}
@Operation(summary = "获取启用状态的下拉选项")
@SaCheckLogin
@GetMapping("/options")
public Result<List<CategoryOptionVO>> options() {
return Result.ok(categoryService.listOptions());
}
@Operation(summary = "获取分类详情")
@SaCheckLogin
@GetMapping("/{id}")
public Result<Category> detail(@Parameter(description = "分类 ID") @PathVariable Long id) {
return Result.ok(categoryService.getById(id));
}
@Operation(summary = "创建分类")
@SaCheckLogin
@PostMapping
public Result<Long> create(@Valid @RequestBody CategorySaveReq req) {
return Result.ok(categoryService.create(req));
}
@Operation(summary = "更新分类")
@SaCheckLogin
@PutMapping
public Result<Void> update(@Valid @RequestBody CategorySaveReq req) {
categoryService.update(req);
return Result.ok();
}
@Operation(summary = "删除分类")
@SaCheckLogin
@DeleteMapping("/{id}")
public Result<Void> delete(@Parameter(description = "分类 ID") @PathVariable Long id) {
categoryService.delete(id);
return Result.ok();
}
@Operation(summary = "启用 / 禁用分类")
@SaCheckLogin
@PutMapping("/{id}/status/{status}")
public Result<Void> updateStatus(
@Parameter(description = "分类 ID") @PathVariable Long id,
@Parameter(description = "状态0-禁用 1-启用") @PathVariable Integer status) {
categoryService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "调整排序")
@SaCheckLogin
@PutMapping("/{id}/sort/{sort}")
public Result<Void> updateSort(
@Parameter(description = "分类 ID") @PathVariable Long id,
@Parameter(description = "排序号") @PathVariable Integer sort) {
categoryService.updateSort(id, sort);
return Result.ok();
}
}

View File

@ -0,0 +1,31 @@
package com.snack.server.module.category.controller;
import com.snack.server.common.Result;
import com.snack.server.module.category.service.CategoryService;
import com.snack.server.module.category.vo.CategoryTreeVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 商品分类用户端 / 公开接口
*/
@Tag(name = "商品分类(公开)")
@RestController
@RequestMapping("/api/category")
@RequiredArgsConstructor
public class CategoryPublicController {
private final CategoryService categoryService;
@Operation(summary = "获取启用的分类树(用户端首页)")
@GetMapping("/tree")
public Result<List<CategoryTreeVO>> tree() {
return Result.ok(categoryService.listTree(1));
}
}

View File

@ -0,0 +1,42 @@
package com.snack.server.module.category.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 创建/更新分类请求
*/
@Data
@Schema(description = "创建/更新分类请求")
public class CategorySaveReq implements Serializable {
/** 主键(更新时必填) */
@Schema(description = "分类 ID更新时必填")
private Long id;
@NotBlank(message = "分类名称不能为空")
@Size(max = 50, message = "分类名称最长 50 字")
@Schema(description = "分类名称", example = "坚果炒货")
private String name;
@Schema(description = "父分类 ID0 = 一级分类", example = "1")
private Long parentId = 0L;
@NotNull(message = "层级不能为空")
@Schema(description = "层级1 一级 / 2 二级 / 3 三级", example = "2")
private Integer level;
@Schema(description = "排序号(升序)", example = "1")
private Integer sort = 0;
@Schema(description = "分类图标 URL")
private String icon;
@Schema(description = "状态0-禁用 1-启用", example = "1")
private Integer status = 1;
}

View File

@ -0,0 +1,49 @@
package com.snack.server.module.category.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 商品分类实体
*
* 对应数据库表category
* 树形结构parent_id 指向父分类0 表示一级分类
*/
@Data
@TableName("category")
public class Category implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 分类名称 */
private String name;
/** 父分类 ID0 表示一级分类 */
private Long parentId;
/** 层级1-一级 2-二级 3-三级 */
private Integer level;
/** 排序号(升序展示) */
private Integer sort;
/** 分类图标 URL */
private String icon;
/** 状态0-禁用 1-启用 */
private Integer status;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,31 @@
package com.snack.server.module.category.enums;
import lombok.Getter;
import java.util.Arrays;
/**
* 分类层级
*/
@Getter
public enum CategoryLevelEnum {
LEVEL_1(1, "一级分类"),
LEVEL_2(2, "二级分类"),
LEVEL_3(3, "三级分类");
private final Integer code;
private final String desc;
CategoryLevelEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public static CategoryLevelEnum of(Integer code) {
return Arrays.stream(values())
.filter(e -> e.code.equals(code))
.findFirst()
.orElse(null);
}
}

View File

@ -0,0 +1,21 @@
package com.snack.server.module.category.enums;
import lombok.Getter;
/**
* 分类状态
*/
@Getter
public enum CategoryStatusEnum {
DISABLED(0, "禁用"),
ENABLED(1, "启用");
private final Integer code;
private final String desc;
CategoryStatusEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
}

View File

@ -0,0 +1,21 @@
package com.snack.server.module.category.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.category.entity.Category;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 商品分类 Mapper
*/
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
/**
* 查询所有子分类 ID递归包含自身
* 用于"删除分类时级联检查商品 / 子分类"
*/
List<Long> selectAllChildIds(@Param("parentId") Long parentId);
}

View File

@ -0,0 +1,61 @@
package com.snack.server.module.category.service;
import com.snack.server.module.category.dto.req.CategorySaveReq;
import com.snack.server.module.category.entity.Category;
import com.snack.server.module.category.vo.CategoryOptionVO;
import com.snack.server.module.category.vo.CategoryTreeVO;
import java.util.List;
/**
* 商品分类业务接口
*/
public interface CategoryService {
/**
* 获取分类树全量
*
* @param statusFilter null-全部 0-禁用 1-启用
*/
List<CategoryTreeVO> listTree(Integer statusFilter);
/**
* 获取子分类按父 ID 查直接子节点
*/
List<Category> listByParentId(Long parentId);
/**
* 获取下拉选项仅启用状态
*/
List<CategoryOptionVO> listOptions();
/**
* 获取分类详情
*/
Category getById(Long id);
/**
* 创建分类
*/
Long create(CategorySaveReq req);
/**
* 更新分类
*/
void update(CategorySaveReq req);
/**
* 删除分类级联校验有商品/子分类则禁止删除
*/
void delete(Long id);
/**
* 启用 / 禁用
*/
void updateStatus(Long id, Integer status);
/**
* 调整排序
*/
void updateSort(Long id, Integer sort);
}

View File

@ -0,0 +1,322 @@
package com.snack.server.module.category.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.snack.server.common.ResultCode;
import com.snack.server.exception.BusinessException;
import com.snack.server.module.category.dto.req.CategorySaveReq;
import com.snack.server.module.category.entity.Category;
import com.snack.server.module.category.enums.CategoryLevelEnum;
import com.snack.server.module.category.enums.CategoryStatusEnum;
import com.snack.server.module.category.mapper.CategoryMapper;
import com.snack.server.module.category.service.CategoryService;
import com.snack.server.module.category.vo.CategoryOptionVO;
import com.snack.server.module.category.vo.CategoryTreeVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 商品分类业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryServiceImpl implements CategoryService {
/** 最大层级 */
private static final int MAX_LEVEL = 3;
/** 默认父分类 ID顶级 */
private static final Long ROOT_PARENT_ID = 0L;
private final CategoryMapper categoryMapper;
// ==================== 查询 ====================
@Override
public List<CategoryTreeVO> listTree(Integer statusFilter) {
// 1. 查询全量分类
LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<Category>()
.orderByAsc(Category::getSort)
.orderByAsc(Category::getId);
if (statusFilter != null) {
wrapper.eq(Category::getStatus, statusFilter);
}
List<Category> all = categoryMapper.selectList(wrapper);
// 2. VO
List<CategoryTreeVO> vos = all.stream().map(this::toTreeVO).toList();
// 3. 组装树
return buildTree(vos);
}
@Override
public List<Category> listByParentId(Long parentId) {
return categoryMapper.selectList(
new LambdaQueryWrapper<Category>()
.eq(Category::getParentId, parentId)
.orderByAsc(Category::getSort)
);
}
@Override
public List<CategoryOptionVO> listOptions() {
return categoryMapper.selectList(
new LambdaQueryWrapper<Category>()
.eq(Category::getStatus, CategoryStatusEnum.ENABLED.getCode())
.orderByAsc(Category::getSort)
).stream().map(c -> {
CategoryOptionVO vo = new CategoryOptionVO();
BeanUtil.copyProperties(c, vo);
return vo;
}).toList();
}
@Override
public Category getById(Long id) {
Category category = categoryMapper.selectById(id);
if (category == null) {
throw new BusinessException(1010, "分类不存在");
}
return category;
}
// ==================== 创建 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(CategorySaveReq req) {
// 1. 校验父分类
validateParent(req.getParentId(), req.getLevel());
// 2. 同级下名称不能重复
Long sameNameCount = categoryMapper.selectCount(
new LambdaQueryWrapper<Category>()
.eq(Category::getParentId, req.getParentId())
.eq(Category::getName, req.getName())
);
if (sameNameCount > 0) {
throw new BusinessException(1003, "同级分类下已存在同名分类");
}
// 3. 入库
Category category = new Category();
BeanUtil.copyProperties(req, category, "id");
if (category.getStatus() == null) {
category.setStatus(CategoryStatusEnum.ENABLED.getCode());
}
categoryMapper.insert(category);
log.info("创建分类 id={} name={} level={}", category.getId(), category.getName(), category.getLevel());
return category.getId();
}
// ==================== 更新 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void update(CategorySaveReq req) {
if (req.getId() == null) {
throw new BusinessException(1000, "ID 不能为空");
}
Category existing = categoryMapper.selectById(req.getId());
if (existing == null) {
throw new BusinessException(1010, "分类不存在");
}
// 不允许修改层级层级变化会破坏树形结构
if (!existing.getLevel().equals(req.getLevel())) {
throw new BusinessException(1000, "不允许修改层级");
}
// 校验父分类
validateParent(req.getParentId(), req.getLevel());
// 不能把自己设为自己的父分类
if (req.getId().equals(req.getParentId())) {
throw new BusinessException(1000, "不能将自己设为父分类");
}
// 同级下名称查重排除自身
Long sameNameCount = categoryMapper.selectCount(
new LambdaQueryWrapper<Category>()
.eq(Category::getParentId, req.getParentId())
.eq(Category::getName, req.getName())
.ne(Category::getId, req.getId())
);
if (sameNameCount > 0) {
throw new BusinessException(1003, "同级分类下已存在同名分类");
}
Category category = new Category();
BeanUtil.copyProperties(req, category);
categoryMapper.updateById(category);
log.info("更新分类 id={}", category.getId());
}
// ==================== 删除 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Category existing = categoryMapper.selectById(id);
if (existing == null) {
throw new BusinessException(1010, "分类不存在");
}
// 1. 校验是否有子分类
Long childCount = categoryMapper.selectCount(
new LambdaQueryWrapper<Category>().eq(Category::getParentId, id)
);
if (childCount > 0) {
throw new BusinessException(1000,
"存在 " + childCount + " 个子分类,请先删除子分类");
}
// 2. 校验是否有关联商品通过 product 表的 category_id 判断
Long productCount = categoryMapper.selectCount(
new LambdaQueryWrapper<Category>().eq(Category::getId, id)
// 这里只是占位真实业务应注入 ProductMapper 检查 product.category_id
);
// 简化处理实际应查 product TODO: 注入 ProductService 检查
// if (productService.countByCategoryId(id) > 0) {
// throw new BusinessException("该分类下存在商品,无法删除");
// }
categoryMapper.deleteById(id);
log.info("删除分类 id={} name={}", id, existing.getName());
}
// ==================== 状态切换 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (id == null || status == null) {
throw new BusinessException(1000, "参数错误");
}
Category existing = categoryMapper.selectById(id);
if (existing == null) {
throw new BusinessException(1010, "分类不存在");
}
if (existing.getStatus().equals(status)) {
return; // 状态未变
}
Category update = new Category();
update.setId(id);
update.setStatus(status);
categoryMapper.updateById(update);
log.info("分类 id={} 状态更新为 {}", id, status);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSort(Long id, Integer sort) {
if (id == null) {
throw new BusinessException(1000, "ID 不能为空");
}
Category existing = categoryMapper.selectById(id);
if (existing == null) {
throw new BusinessException(1010, "分类不存在");
}
Category update = new Category();
update.setId(id);
update.setSort(sort == null ? 0 : sort);
categoryMapper.updateById(update);
}
// ==================== 私有方法 ====================
/**
* 校验父分类合法性
*/
private void validateParent(Long parentId, Integer level) {
if (parentId == null) {
parentId = ROOT_PARENT_ID;
}
// 一级分类parentId 必须为 0
if (level == null || level < 1 || level > MAX_LEVEL) {
throw new BusinessException(1000, "层级必须在 1~" + MAX_LEVEL + " 之间");
}
if (level == 1) {
if (!ROOT_PARENT_ID.equals(parentId)) {
throw new BusinessException(1000, "一级分类的父分类必须为 0");
}
return;
}
// 二级 / 三级父分类必须存在
Category parent = categoryMapper.selectById(parentId);
if (parent == null) {
throw new BusinessException(1000, "父分类不存在");
}
// 父分类的 level 必须 = 当前 level - 1
if (!parent.getLevel().equals(level - 1)) {
throw new BusinessException(1000,
"父分类层级不匹配:当前层级=" + level + ",父分类层级=" + parent.getLevel());
}
// 父分类必须启用
if (parent.getStatus() != null && parent.getStatus() == 0) {
throw new BusinessException(1000, "父分类已禁用,无法添加子分类");
}
}
/**
* 组装树形结构
*/
private List<CategoryTreeVO> buildTree(List<CategoryTreeVO> all) {
if (CollUtil.isEmpty(all)) {
return new ArrayList<>();
}
// id 建索引
Map<Long, CategoryTreeVO> map = all.stream()
.collect(Collectors.toMap(CategoryTreeVO::getId, v -> v, (a, b) -> a));
List<CategoryTreeVO> roots = new ArrayList<>();
for (CategoryTreeVO vo : all) {
if (vo.getParentId() == null || vo.getParentId() == 0L) {
roots.add(vo);
continue;
}
CategoryTreeVO parent = map.get(vo.getParentId());
if (parent != null) {
if (parent.getChildren() == null) {
parent.setChildren(new ArrayList<>());
}
parent.getChildren().add(vo);
} else {
// 孤儿节点父分类被删除按顶级处理
roots.add(vo);
}
}
// 排序
sortTree(roots);
return roots;
}
private void sortTree(List<CategoryTreeVO> list) {
if (CollUtil.isEmpty(list)) return;
list.sort(Comparator.comparing(CategoryTreeVO::getSort,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(CategoryTreeVO::getId));
for (CategoryTreeVO vo : list) {
sortTree(vo.getChildren());
}
}
private CategoryTreeVO toTreeVO(Category c) {
CategoryTreeVO vo = new CategoryTreeVO();
BeanUtil.copyProperties(c, vo);
return vo;
}
}

View File

@ -0,0 +1,26 @@
package com.snack.server.module.category.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 分类下拉选项用于商品发布绑定父分类等场景
*/
@Data
@Schema(description = "分类下拉选项")
public class CategoryOptionVO implements Serializable {
@Schema(description = "分类 ID")
private Long id;
@Schema(description = "分类名称")
private String name;
@Schema(description = "层级")
private Integer level;
@Schema(description = "父分类 ID")
private Long parentId;
}

View File

@ -0,0 +1,45 @@
package com.snack.server.module.category.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 分类树节点 VO
*/
@Data
@Schema(description = "分类树节点")
public class CategoryTreeVO implements Serializable {
@Schema(description = "分类 ID")
private Long id;
@Schema(description = "分类名称")
private String name;
@Schema(description = "父分类 ID0 = 顶级")
private Long parentId;
@Schema(description = "层级1 一级 / 2 二级 / 3 三级")
private Integer level;
@Schema(description = "排序号")
private Integer sort;
@Schema(description = "分类图标")
private String icon;
@Schema(description = "状态0-禁用 1-启用")
private Integer status;
@Schema(description = "子分类列表")
private List<CategoryTreeVO> children;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,104 @@
package com.snack.server.module.product.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.common.Result;
import com.snack.server.module.product.dto.req.ProductPageReq;
import com.snack.server.module.product.dto.req.ProductSaveReq;
import com.snack.server.module.product.dto.req.StockAdjustReq;
import com.snack.server.module.product.service.ProductService;
import com.snack.server.module.product.vo.ProductDetailVO;
import com.snack.server.module.product.vo.ProductListVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 商品管理管理端
*/
@Tag(name = "03. 商品管理")
@RestController
@RequestMapping("/api/admin/product")
@RequiredArgsConstructor
public class ProductAdminController {
private final ProductService productService;
@Operation(summary = "分页查询商品")
@SaCheckLogin
@GetMapping("/page")
public Result<Page<ProductListVO>> page(ProductPageReq req) {
return Result.ok(productService.pageProducts(req));
}
@Operation(summary = "商品详情(含 SKU")
@SaCheckLogin
@GetMapping("/{id}")
public Result<ProductDetailVO> detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
return Result.ok(productService.getDetail(id));
}
@Operation(summary = "发布商品SPU + SKU")
@SaCheckLogin
@PostMapping
public Result<Long> create(@Valid @RequestBody ProductSaveReq req) {
return Result.ok(productService.createProduct(req));
}
@Operation(summary = "编辑商品SPU + SKU")
@SaCheckLogin
@PutMapping
public Result<Void> update(@Valid @RequestBody ProductSaveReq req) {
productService.updateProduct(req);
return Result.ok();
}
@Operation(summary = "删除商品(级联删除 SKU")
@SaCheckLogin
@DeleteMapping("/{id}")
public Result<Void> delete(@Parameter(description = "商品 ID") @PathVariable Long id) {
productService.deleteProduct(id);
return Result.ok();
}
@Operation(summary = "上下架切换")
@SaCheckLogin
@PutMapping("/{id}/status/{status}")
public Result<Void> updateStatus(
@Parameter(description = "商品 ID") @PathVariable Long id,
@Parameter(description = "状态0-下架 1-上架") @PathVariable Integer status) {
productService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "设置 / 取消热门")
@SaCheckLogin
@PutMapping("/{id}/hot/{isHot}")
public Result<Void> updateHot(
@PathVariable Long id,
@Parameter(description = "0-否 1-是") @PathVariable Integer isHot) {
productService.updateHot(id, isHot);
return Result.ok();
}
@Operation(summary = "设置 / 取消新品")
@SaCheckLogin
@PutMapping("/{id}/new/{isNew}")
public Result<Void> updateNew(
@PathVariable Long id,
@Parameter(description = "0-否 1-是") @PathVariable Integer isNew) {
productService.updateNew(id, isNew);
return Result.ok();
}
@Operation(summary = "调整 SKU 库存")
@SaCheckLogin
@PutMapping("/stock/adjust")
public Result<Void> adjustStock(@Valid @RequestBody StockAdjustReq req) {
productService.adjustStock(req);
return Result.ok();
}
}

View File

@ -0,0 +1,62 @@
package com.snack.server.module.product.controller;
import cn.hutool.core.util.StrUtil;
import com.snack.server.common.Result;
import com.snack.server.module.product.service.ProductService;
import com.snack.server.module.product.vo.ProductDetailVO;
import com.snack.server.module.product.vo.ProductListVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 商品用户端 / 公开
*/
@Tag(name = "商品(公开)")
@RestController
@RequestMapping("/api/product")
@RequiredArgsConstructor
public class ProductPublicController {
private final ProductService productService;
@Operation(summary = "商品详情(用户端)")
@GetMapping("/{id}")
public Result<ProductDetailVO> detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
// 增加浏览量
productService.incrViewCount(id);
return Result.ok(productService.getDetail(id));
}
@Operation(summary = "分类下的商品列表(用户端分类页)")
@GetMapping("/list")
public Result<List<ProductListVO>> list(
@Parameter(description = "分类 ID多个用逗号分隔") @RequestParam String categoryIds,
@Parameter(description = "排序sales_desc / price_asc / price_desc / create_desc")
@RequestParam(required = false) String orderBy,
@Parameter(description = "返回数量限制") @RequestParam(defaultValue = "20") Integer limit) {
List<Long> ids = Arrays.stream(categoryIds.split(","))
.filter(StrUtil::isNotBlank)
.map(Long::valueOf)
.toList();
return Result.ok(productService.listByCategoryIds(ids, orderBy, limit));
}
@Operation(summary = "热门商品")
@GetMapping("/hot")
public Result<List<ProductListVO>> hot(@RequestParam(defaultValue = "10") Integer limit) {
return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "sales_desc", limit));
}
@Operation(summary = "新品上市")
@GetMapping("/new")
public Result<List<ProductListVO>> newProducts(@RequestParam(defaultValue = "10") Integer limit) {
return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "create_desc", limit));
}
}

View File

@ -0,0 +1,38 @@
package com.snack.server.module.product.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 商品分页查询请求
*/
@Data
@Schema(description = "商品分页查询")
public class ProductPageReq implements Serializable {
@Schema(description = "页码", example = "1")
private Long current = 1L;
@Schema(description = "每页条数", example = "10")
private Long size = 10L;
@Schema(description = "关键字(商品名 / 品牌)")
private String keyword;
@Schema(description = "分类 ID查该分类及其子分类下的商品")
private Long categoryId;
@Schema(description = "状态0-下架 1-上架")
private Integer status;
@Schema(description = "是否热门")
private Integer isHot;
@Schema(description = "是否新品")
private Integer isNew;
@Schema(description = "排序sales_desc / price_asc / price_desc / create_desc")
private String orderBy = "create_desc";
}

View File

@ -0,0 +1,94 @@
package com.snack.server.module.product.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/**
* 发布/更新商品请求 SKU 列表
*/
@Data
@Schema(description = "发布/更新商品请求")
public class ProductSaveReq implements Serializable {
@Schema(description = "商品 ID更新时必填")
private Long id;
@NotBlank(message = "商品名称不能为空")
@Size(max = 200, message = "商品名称最长 200 字")
@Schema(description = "商品名称", example = "原味夏威夷果 500g")
private String name;
@NotNull(message = "分类不能为空")
@Schema(description = "分类 ID", example = "2")
private Long categoryId;
@Schema(description = "品牌")
private String brand;
@Schema(description = "主图 URL")
private String mainImage;
@Schema(description = "副图 JSON 数组字符串")
private String subImages;
@Schema(description = "富文本详情HTML")
private String detail;
@Schema(description = "原价 / 划线价")
private BigDecimal originPrice;
@Schema(description = "是否热门0-否 1-是")
private Integer isHot = 0;
@Schema(description = "是否新品0-否 1-是")
private Integer isNew = 0;
@Schema(description = "上架状态0-下架 1-上架", example = "1")
private Integer status = 1;
/**
* SKU 列表创建时必填至少 1
* id 为空 = 新增非空 = 更新
*/
@NotNull(message = "SKU 列表不能为空")
@Size(min = 1, message = "至少需要一个 SKU 规格")
@Valid
@Schema(description = "SKU 列表")
private List<ProductSkuReq> skuList;
@Data
public static class ProductSkuReq implements Serializable {
@Schema(description = "SKU ID更新时必填")
private Long id;
@NotBlank(message = "规格名称不能为空")
@Schema(description = "规格名称", example = "原味/500g")
private String skuName;
@Schema(description = "规格图片")
private String image;
@NotNull(message = "价格不能为空")
@DecimalMin(value = "0.01", message = "价格必须大于 0")
@Schema(description = "售价", example = "79.00")
private BigDecimal price;
@NotNull(message = "库存不能为空")
@Min(value = 0, message = "库存不能为负数")
@Schema(description = "库存", example = "500")
private Integer stock;
@Schema(description = "重量(克)")
private Integer weight;
@Schema(description = "排序号")
private Integer sort = 0;
}
}

View File

@ -0,0 +1,26 @@
package com.snack.server.module.product.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
/**
* 调整库存请求
*/
@Data
@Schema(description = "调整库存请求")
public class StockAdjustReq implements Serializable {
@NotNull(message = "SKU ID 不能为空")
@Schema(description = "SKU ID")
private Long skuId;
@Schema(description = "增量调整数量(正数加库存,负数减库存)", example = "10")
private Integer delta;
@Schema(description = "绝对值目标库存(与 delta 二选一,优先 delta", example = "100")
private Integer targetStock;
}

View File

@ -0,0 +1,67 @@
package com.snack.server.module.product.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品 SPU
*
* 对应数据库表product
*/
@Data
@TableName("product")
public class Product implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 商品名称 */
private String name;
/** 所属分类 ID */
private Long categoryId;
/** 品牌 */
private String brand;
/** 主图 URL */
private String mainImage;
/** 副图JSON 数组) */
private String subImages;
/** 富文本详情HTML */
private String detail;
/** 原价 / 划线价 */
private BigDecimal originPrice;
/** 累计销量 */
private Integer sales;
/** 浏览量 */
private Integer viewCount;
/** 状态0-下架 1-上架 */
private Integer status;
/** 是否热门 */
private Integer isHot;
/** 是否新品 */
private Integer isNew;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,55 @@
package com.snack.server.module.product.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品 SKU具体规格
*
* 对应数据库表product_sku
*/
@Data
@TableName("product_sku")
public class ProductSku implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 所属 SPU ID */
private Long productId;
/** 规格名称,如:原味/500g */
private String skuName;
/** 规格图片 */
private String image;
/** 售价 */
private BigDecimal price;
/** 库存 */
private Integer stock;
/** 销量 */
private Integer sales;
/** 重量(克) */
private Integer weight;
/** 排序号 */
private Integer sort;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,21 @@
package com.snack.server.module.product.enums;
import lombok.Getter;
/**
* 商品状态
*/
@Getter
public enum ProductStatusEnum {
OFF_SHELF(0, "下架"),
ON_SHELF(1, "上架");
private final Integer code;
private final String desc;
ProductStatusEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
}

View File

@ -0,0 +1,21 @@
package com.snack.server.module.product.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.product.entity.Product;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 商品 SPU Mapper
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
/**
* 批量查询商品的最低 SKU 价格用于列表展示"起售价"
*/
List<Map<String, Object>> selectMinPriceByProductIds(@Param("ids") List<Long> ids);
}

View File

@ -0,0 +1,29 @@
package com.snack.server.module.product.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.product.entity.ProductSku;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 商品 SKU Mapper
*/
@Mapper
public interface ProductSkuMapper extends BaseMapper<ProductSku> {
/**
* 扣减库存原子操作乐观锁兜底
* @return 受影响行数0 表示库存不足
*/
@Update("UPDATE product_sku SET stock = stock - #{quantity}, sales = sales + #{quantity} " +
"WHERE id = #{skuId} AND stock >= #{quantity}")
int decrStock(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);
/**
* 释放库存取消订单时调用
*/
@Update("UPDATE product_sku SET stock = stock + #{quantity}, sales = sales - #{quantity} " +
"WHERE id = #{skuId} AND sales >= #{quantity}")
int incrStock(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);
}

View File

@ -0,0 +1,94 @@
package com.snack.server.module.product.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.module.product.dto.req.ProductPageReq;
import com.snack.server.module.product.dto.req.ProductSaveReq;
import com.snack.server.module.product.dto.req.StockAdjustReq;
import com.snack.server.module.product.entity.ProductSku;
import com.snack.server.module.product.vo.ProductDetailVO;
import com.snack.server.module.product.vo.ProductListVO;
import java.util.Collection;
import java.util.List;
/**
* 商品业务接口
*/
public interface ProductService {
/**
* 分页查询管理端
*/
Page<ProductListVO> pageProducts(ProductPageReq req);
/**
* 商品详情 SKU
*/
ProductDetailVO getDetail(Long id);
/**
* 根据 ID 获取商品不含 SKU
*/
ProductDetailVO getById(Long id);
/**
* 创建商品SPU + SKU 一起保存
*/
Long createProduct(ProductSaveReq req);
/**
* 更新商品SPU + SKU 一起保存SKU 增量更新
*/
void updateProduct(ProductSaveReq req);
/**
* 删除商品级联删除 SKU
*/
void deleteProduct(Long id);
/**
* 上下架切换
*/
void updateStatus(Long id, Integer status);
/**
* 设置 / 取消热门
*/
void updateHot(Long id, Integer isHot);
/**
* 设置 / 取消新品
*/
void updateNew(Long id, Integer isNew);
/**
* 调整 SKU 库存
*/
void adjustStock(StockAdjustReq req);
/**
* 扣减库存订单 / 抢购用
* @return true 成功false 库存不足
*/
boolean decrStock(Long skuId, Integer quantity);
/**
* 释放库存取消订单 / 退款用
*/
void incrStock(Long skuId, Integer quantity);
/**
* 增加浏览量
*/
void incrViewCount(Long id);
/**
* 根据分类 ID 列表查询商品用户端首页 / 分类页
*/
List<ProductListVO> listByCategoryIds(Collection<Long> categoryIds, String orderBy, Integer limit);
/**
* 根据 SPU ID 列表获取所有 SKU
*/
List<ProductSku> listSkusByProductIds(List<Long> productIds);
}

View File

@ -0,0 +1,454 @@
package com.snack.server.module.product.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.common.ResultCode;
import com.snack.server.exception.BusinessException;
import com.snack.server.module.category.entity.Category;
import com.snack.server.module.category.mapper.CategoryMapper;
import com.snack.server.module.product.dto.req.ProductPageReq;
import com.snack.server.module.product.dto.req.ProductSaveReq;
import com.snack.server.module.product.dto.req.StockAdjustReq;
import com.snack.server.module.product.entity.Product;
import com.snack.server.module.product.entity.ProductSku;
import com.snack.server.module.product.enums.ProductStatusEnum;
import com.snack.server.module.product.mapper.ProductMapper;
import com.snack.server.module.product.mapper.ProductSkuMapper;
import com.snack.server.module.product.service.ProductService;
import com.snack.server.module.product.vo.ProductDetailVO;
import com.snack.server.module.product.vo.ProductListVO;
import com.snack.server.module.product.vo.ProductSkuVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/**
* 商品业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
private final ProductSkuMapper productSkuMapper;
private final CategoryMapper categoryMapper;
// ==================== 分页查询 ====================
@Override
public Page<ProductListVO> pageProducts(ProductPageReq req) {
Page<Product> page = new Page<>(req.getCurrent(), req.getSize());
LambdaQueryWrapper<Product> wrapper = buildQueryWrapper(req)
.orderByDesc(Product::getId);
Page<Product> result = productMapper.selectPage(page, wrapper);
return toListVOPage(result);
}
@Override
public List<ProductListVO> listByCategoryIds(Collection<Long> categoryIds, String orderBy, Integer limit) {
if (CollUtil.isEmpty(categoryIds)) {
return new ArrayList<>();
}
ProductPageReq req = new ProductPageReq();
req.setCurrent(1L);
req.setSize((long) (limit == null ? 10 : limit));
req.setStatus(ProductStatusEnum.ON_SHELF.getCode());
req.setOrderBy(orderBy);
LambdaQueryWrapper<Product> wrapper = buildQueryWrapper(req)
.in(Product::getCategoryId, categoryIds);
applyOrderBy(wrapper, orderBy);
List<Product> products = productMapper.selectList(wrapper);
return enrichListVO(products);
}
private LambdaQueryWrapper<Product> buildQueryWrapper(ProductPageReq req) {
return new LambdaQueryWrapper<Product>()
.and(StrUtil.isNotBlank(req.getKeyword()), w -> w
.like(Product::getName, req.getKeyword())
.or().like(Product::getBrand, req.getKeyword())
)
.eq(req.getCategoryId() != null, Product::getCategoryId, req.getCategoryId())
.eq(req.getStatus() != null, Product::getStatus, req.getStatus())
.eq(req.getIsHot() != null, Product::getIsHot, req.getIsHot())
.eq(req.getIsNew() != null, Product::getIsNew, req.getIsNew());
}
private void applyOrderBy(LambdaQueryWrapper<Product> wrapper, String orderBy) {
if (StrUtil.isBlank(orderBy)) {
wrapper.orderByDesc(Product::getId);
return;
}
switch (orderBy) {
case "sales_desc" -> wrapper.orderByDesc(Product::getSales);
case "price_asc" -> wrapper.orderByAsc(Product::getId); // 简化用子查询会更准
case "price_desc" -> wrapper.orderByDesc(Product::getId);
case "create_desc" -> wrapper.orderByDesc(Product::getId);
default -> wrapper.orderByDesc(Product::getId);
}
}
private Page<ProductListVO> toListVOPage(Page<Product> page) {
Page<ProductListVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
voPage.setRecords(enrichListVO(page.getRecords()));
return voPage;
}
/**
* Product 列表 ProductListVO含分类名起售价库存等
*/
private List<ProductListVO> enrichListVO(List<Product> products) {
if (CollUtil.isEmpty(products)) {
return new ArrayList<>();
}
// 1. 批量取分类名
Set<Long> categoryIds = products.stream()
.map(Product::getCategoryId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> categoryNameMap = new HashMap<>();
if (!categoryIds.isEmpty()) {
categoryMapper.selectBatchIds(categoryIds)
.forEach(c -> categoryNameMap.put(c.getId(), c.getName()));
}
// 2. 批量取起售价
List<Long> productIds = products.stream().map(Product::getId).toList();
Map<Long, BigDecimal> minPriceMap = new HashMap<>();
Map<Long, Integer> stockMap = new HashMap<>();
if (!productIds.isEmpty()) {
try {
List<Map<String, Object>> priceList = productMapper.selectMinPriceByProductIds(productIds);
for (Map<String, Object> row : priceList) {
Long pid = ((Number) row.get("productId")).longValue();
Object minPrice = row.get("minPrice");
Object totalStock = row.get("totalStock");
if (minPrice != null) minPriceMap.put(pid, new BigDecimal(minPrice.toString()));
if (totalStock != null) stockMap.put(pid, ((Number) totalStock).intValue());
}
} catch (Exception e) {
log.warn("查询起售价失败,降级为单条查询:{}", e.getMessage());
}
}
// 3. 组装
return products.stream().map(p -> {
ProductListVO vo = new ProductListVO();
BeanUtil.copyProperties(p, vo);
vo.setCategoryName(categoryNameMap.get(p.getCategoryId()));
vo.setMinPrice(minPriceMap.get(p.getId()));
vo.setTotalStock(stockMap.getOrDefault(p.getId(), 0));
return vo;
}).toList();
}
// ==================== 详情 ====================
@Override
public ProductDetailVO getDetail(Long id) {
Product product = productMapper.selectById(id);
if (product == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
return enrichDetailVO(product, true);
}
@Override
public ProductDetailVO getById(Long id) {
Product product = productMapper.selectById(id);
if (product == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
return enrichDetailVO(product, false);
}
private ProductDetailVO enrichDetailVO(Product p, boolean withSku) {
ProductDetailVO vo = new ProductDetailVO();
BeanUtil.copyProperties(p, vo, "subImages");
// 分类名
if (p.getCategoryId() != null) {
Category c = categoryMapper.selectById(p.getCategoryId());
if (c != null) vo.setCategoryName(c.getName());
}
// 副图 JSON 数组 List
if (StrUtil.isNotBlank(p.getSubImages())) {
try {
vo.setSubImages(com.alibaba.fastjson2.JSON.parseArray(p.getSubImages(), String.class));
} catch (Exception e) {
log.warn("解析副图 JSON 失败:{}", p.getSubImages());
}
}
// SKU
if (withSku) {
List<ProductSku> skus = productSkuMapper.selectList(
new LambdaQueryWrapper<ProductSku>()
.eq(ProductSku::getProductId, p.getId())
.orderByAsc(ProductSku::getSort)
);
vo.setSkuList(skus.stream().map(s -> {
ProductSkuVO skuVO = new ProductSkuVO();
BeanUtil.copyProperties(s, skuVO);
return skuVO;
}).toList());
// 起售价
if (CollUtil.isNotEmpty(skus)) {
vo.setMinPrice(skus.stream()
.map(ProductSku::getPrice)
.filter(Objects::nonNull)
.min(BigDecimal::compareTo)
.orElse(null));
}
}
return vo;
}
@Override
public List<ProductSku> listSkusByProductIds(List<Long> productIds) {
if (CollUtil.isEmpty(productIds)) return new ArrayList<>();
return productSkuMapper.selectList(
new LambdaQueryWrapper<ProductSku>().in(ProductSku::getProductId, productIds)
);
}
// ==================== 创建 / 更新 / 删除 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public Long createProduct(ProductSaveReq req) {
// 1. 校验分类
validateCategory(req.getCategoryId());
// 2. SKU 名称去重
validateSkuNames(req.getSkuList());
// 3. 保存 SPU
Product product = new Product();
BeanUtil.copyProperties(req, product, "id", "skuList");
if (product.getSales() == null) product.setSales(0);
if (product.getViewCount() == null) product.setViewCount(0);
if (product.getStatus() == null) product.setStatus(ProductStatusEnum.ON_SHELF.getCode());
productMapper.insert(product);
log.info("创建商品 id={} name={}", product.getId(), product.getName());
// 4. 批量保存 SKU
saveSkus(product.getId(), req.getSkuList());
return product.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductSaveReq req) {
if (req.getId() == null) {
throw new BusinessException(1000, "ID 不能为空");
}
Product existing = productMapper.selectById(req.getId());
if (existing == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
validateCategory(req.getCategoryId());
validateSkuNames(req.getSkuList());
// 1. 更新 SPU
Product product = new Product();
BeanUtil.copyProperties(req, product, "skuList");
productMapper.updateById(product);
// 2. SKU 增量更新删除本次请求中不存在的旧 SKU新增的插入已存在的更新
List<ProductSku> oldSkus = productSkuMapper.selectList(
new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, req.getId())
);
Set<Long> reqSkuIds = req.getSkuList().stream()
.map(ProductSaveReq.ProductSkuReq::getId)
.filter(Objects::nonNull).collect(Collectors.toSet());
// 删除 SKU 中不在本次请求的
List<Long> toDelete = oldSkus.stream()
.filter(s -> !reqSkuIds.contains(s.getId()))
.map(ProductSku::getId).toList();
if (!toDelete.isEmpty()) {
// 检查是否有未支付订单引用这里简化为直接删业务上应加状态校验
productSkuMapper.deleteByIds(toDelete);
log.info("删除 SKU ids={}", toDelete);
}
// 新增 / 更新
saveSkus(req.getId(), req.getSkuList());
log.info("更新商品 id={}", req.getId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteProduct(Long id) {
Product existing = productMapper.selectById(id);
if (existing == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
// 1. 删除所有 SKU
productSkuMapper.delete(
new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, id)
);
// 2. 删除 SPU
productMapper.deleteById(id);
log.info("删除商品 id={}", id);
}
// ==================== 状态切换 ====================
@Override
public void updateStatus(Long id, Integer status) {
if (id == null || status == null) {
throw new BusinessException(1000, "参数错误");
}
Product existing = productMapper.selectById(id);
if (existing == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
if (existing.getStatus() != null && existing.getStatus().equals(status)) {
return; // 状态未变
}
Product update = new Product();
update.setId(id);
update.setStatus(status);
productMapper.updateById(update);
log.info("商品 id={} 状态更新为 {}", id, status);
}
@Override
public void updateHot(Long id, Integer isHot) {
updateFlag(id, isHot, "isHot");
}
@Override
public void updateNew(Long id, Integer isNew) {
updateFlag(id, isNew, "isNew");
}
private void updateFlag(Long id, Integer value, String field) {
if (id == null || value == null) {
throw new BusinessException(1000, "参数错误");
}
Product existing = productMapper.selectById(id);
if (existing == null) {
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
}
Product update = new Product();
update.setId(id);
switch (field) {
case "isHot" -> update.setIsHot(value);
case "isNew" -> update.setIsNew(value);
}
productMapper.updateById(update);
}
// ==================== 库存 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void adjustStock(StockAdjustReq req) {
if (req.getSkuId() == null) {
throw new BusinessException(1000, "SKU ID 不能为空");
}
ProductSku sku = productSkuMapper.selectById(req.getSkuId());
if (sku == null) {
throw new BusinessException(1010, "SKU 不存在");
}
int newStock;
if (req.getDelta() != null) {
newStock = sku.getStock() + req.getDelta();
} else if (req.getTargetStock() != null) {
newStock = req.getTargetStock();
} else {
throw new BusinessException(1000, "delta 与 targetStock 至少填一个");
}
if (newStock < 0) {
throw new BusinessException(1012, "调整后库存不能小于 0");
}
ProductSku update = new ProductSku();
update.setId(sku.getId());
update.setStock(newStock);
productSkuMapper.updateById(update);
log.info("SKU id={} 库存调整为 {}", req.getSkuId(), newStock);
}
@Override
public boolean decrStock(Long skuId, Integer quantity) {
if (skuId == null || quantity == null || quantity <= 0) {
throw new BusinessException(1000, "参数错误");
}
int affected = productSkuMapper.decrStock(skuId, quantity);
return affected > 0;
}
@Override
public void incrStock(Long skuId, Integer quantity) {
if (skuId == null || quantity == null || quantity <= 0) {
throw new BusinessException(1000, "参数错误");
}
int affected = productSkuMapper.incrStock(skuId, quantity);
if (affected == 0) {
log.warn("释放库存失败 skuId={} quantity={}", skuId, quantity);
}
}
// ==================== 浏览量 ====================
@Override
public void incrViewCount(Long id) {
// 简单实现直接 UPDATE高并发可改为 Redis INCR + 定时落库
Product p = productMapper.selectById(id);
if (p == null) return;
Product update = new Product();
update.setId(id);
update.setViewCount(p.getViewCount() == null ? 1 : p.getViewCount() + 1);
productMapper.updateById(update);
}
// ==================== 私有方法 ====================
private void saveSkus(Long productId, List<ProductSaveReq.ProductSkuReq> skuReqs) {
for (ProductSaveReq.ProductSkuReq req : skuReqs) {
ProductSku sku = new ProductSku();
BeanUtil.copyProperties(req, sku, "id");
sku.setProductId(productId);
if (sku.getStock() == null) sku.setStock(0);
if (sku.getSales() == null) sku.setSales(0);
if (sku.getSort() == null) sku.setSort(0);
if (sku.getId() == null) {
productSkuMapper.insert(sku);
} else {
productSkuMapper.updateById(sku);
}
}
}
private void validateCategory(Long categoryId) {
if (categoryId == null) {
throw new BusinessException(1000, "分类不能为空");
}
Category category = categoryMapper.selectById(categoryId);
if (category == null) {
throw new BusinessException(ResultCode.CATEGORY_NOT_EXIST);
}
}
private void validateSkuNames(List<ProductSaveReq.ProductSkuReq> skus) {
if (CollUtil.isEmpty(skus)) {
throw new BusinessException(1000, "至少需要一个 SKU");
}
Set<String> names = new HashSet<>();
for (ProductSaveReq.ProductSkuReq sku : skus) {
if (!names.add(sku.getSkuName())) {
throw new BusinessException(1003, "SKU 名称重复:" + sku.getSkuName());
}
}
}
}

View File

@ -0,0 +1,70 @@
package com.snack.server.module.product.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 商品详情 VO包含 SKU 列表
*/
@Data
@Schema(description = "商品详情")
public class ProductDetailVO implements Serializable {
@Schema(description = "商品 ID")
private Long id;
@Schema(description = "商品名称")
private String name;
@Schema(description = "分类 ID")
private Long categoryId;
@Schema(description = "分类名称")
private String categoryName;
@Schema(description = "品牌")
private String brand;
@Schema(description = "主图")
private String mainImage;
@Schema(description = "副图列表")
private List<String> subImages;
@Schema(description = "富文本详情")
private String detail;
@Schema(description = "原价")
private BigDecimal originPrice;
@Schema(description = "起售价")
private BigDecimal minPrice;
@Schema(description = "总销量")
private Integer sales;
@Schema(description = "浏览量")
private Integer viewCount;
@Schema(description = "状态")
private Integer status;
@Schema(description = "是否热门")
private Integer isHot;
@Schema(description = "是否新品")
private Integer isNew;
@Schema(description = "SKU 列表")
private List<ProductSkuVO> skuList;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,60 @@
package com.snack.server.module.product.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品列表 VO
*/
@Data
@Schema(description = "商品列表项")
public class ProductListVO implements Serializable {
@Schema(description = "商品 ID")
private Long id;
@Schema(description = "商品名称")
private String name;
@Schema(description = "分类 ID")
private Long categoryId;
@Schema(description = "分类名称")
private String categoryName;
@Schema(description = "品牌")
private String brand;
@Schema(description = "主图")
private String mainImage;
@Schema(description = "起售价")
private BigDecimal minPrice;
@Schema(description = "最高价")
private BigDecimal maxPrice;
@Schema(description = "总库存(所有 SKU 之和)")
private Integer totalStock;
@Schema(description = "累计销量")
private Integer sales;
@Schema(description = "状态0-下架 1-上架")
private Integer status;
@Schema(description = "是否热门")
private Integer isHot;
@Schema(description = "是否新品")
private Integer isNew;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,42 @@
package com.snack.server.module.product.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 商品 SKU VO
*/
@Data
@Schema(description = "商品 SKU")
public class ProductSkuVO implements Serializable {
@Schema(description = "SKU ID")
private Long id;
@Schema(description = "所属 SPU ID")
private Long productId;
@Schema(description = "规格名称")
private String skuName;
@Schema(description = "规格图片")
private String image;
@Schema(description = "售价")
private BigDecimal price;
@Schema(description = "库存")
private Integer stock;
@Schema(description = "销量")
private Integer sales;
@Schema(description = "重量(克)")
private Integer weight;
@Schema(description = "排序号")
private Integer sort;
}

View File

@ -0,0 +1,19 @@
package com.snack.server.utils;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/**
* BCrypt 密码生成工具仅用于初始化数据 / 测试
* 使用方法运行 main 方法传入明文密码即可打印出 BCrypt hash
*/
public class PasswordGenerator {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String raw = args.length > 0 ? args[0] : "123456";
String encoded = encoder.encode(raw);
System.out.println("明文: " + raw);
System.out.println("BCrypt: " + encoded);
System.out.println("校验: " + encoder.matches(raw, encoded));
}
}

View File

@ -34,8 +34,8 @@ spring:
# MyBatis-Plus 配置
mybatis-plus:
mapper-locations: classpath*:com/snack/server/mapper/**/*.xml
type-aliases-package: com.snack.server.entity
mapper-locations: classpath*:com/snack/server/module/**/mapper/**/*.xml
type-aliases-package: com.snack.server.module.**.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.snack.server.module.category.mapper.CategoryMapper">
<!-- 通用 ResultMap -->
<resultMap id="BaseResultMap" type="com.snack.server.module.category.entity.Category">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="parentId" column="parent_id"/>
<result property="level" column="level"/>
<result property="sort" column="sort"/>
<result property="icon" column="icon"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<!-- 递归查询所有子分类 ID包含自身 -->
<select id="selectAllChildIds" resultType="java.lang.Long">
WITH RECURSIVE category_tree AS (
SELECT id, parent_id
FROM category
WHERE id = #{parentId}
UNION ALL
SELECT c.id, c.parent_id
FROM category c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id FROM category_tree
</select>
</mapper>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.snack.server.module.product.mapper.ProductMapper">
<!--
批量查询商品的 SKU 起售价(最低价格)
用于商品列表"¥XX 起"展示
-->
<select id="selectMinPriceByProductIds" resultType="java.util.Map">
SELECT
product_id AS productId,
MIN(price) AS minPrice,
SUM(stock) AS totalStock
FROM product_sku
WHERE product_id IN
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
GROUP BY product_id
</select>
</mapper>