diff --git a/db/BUSINESS_DESIGN.md b/db/BUSINESS_DESIGN.md
new file mode 100644
index 0000000..2d55568
--- /dev/null
+++ b/db/BUSINESS_DESIGN.md
@@ -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** | 会话唯一 ID(DB 主键) |
+| **session_no** | 业务编号(如 CS20260601001) |
+| **seq** | 消息在会话内的**单调递增序号**(1, 2, 3...),用于排序和断线补发 |
+| **user_seq** | 用户端最大已确认序号(断线时只推 `seq > user_seq` 的消息) |
+| **admin_seq** | 客服端最大已确认序号(断线时只推 `seq > admin_seq` 的消息) |
+| **user_unread / admin_unread** | 双方各自未读消息数(在线时实时 +1,已读后清零) |
+
+### 1.3 WebSocket 握手
+
+```
+客户端 → 服务端:
+ GET ws://host/ws/chat?token={JWT_TOKEN}
+
+服务端处理:
+ 1. 拦截器中校验 JWT Token
+ 2. 根据 Token 解析出 userId / adminId
+ 3. 建立 STOMP 连接,绑定到会话频道
+ 4. 推送离线消息(seq > last_ack_seq)
+```
+
+### 1.4 消息发送流程(用户发消息为例)
+
+```
+用户客户端发送 WebSocket 消息:
+ { sessionId, type: "text", content: "你好" }
+ │
+ ▼
+服务端 ChatWebSocketHandler.receive()
+ │
+ ├──► 1. 校验登录态 & 会话归属
+ ├──► 2. 查询 chat_session 中当前最大 seq
+ │ new_seq = max_seq + 1
+ ├──► 3. 写入 chat_message(seq = new_seq)
+ ├──► 4. 更新 chat_session:
+ │ - last_message / last_time
+ │ - admin_unread = admin_unread + 1
+ ├──► 5. 推送给会话中在线的客服(如果有)
+ │ /topic/chat/{sessionId} → { id, seq, content, ... }
+ └──► 6. ACK 给用户端
+```
+
+### 1.5 消息接收 ACK(已读回执)
+
+```
+客户端收到消息后发送 ACK:
+ { type: "ack", sessionId, lastSeq: 42 }
+ │
+ ▼
+服务端:
+ - 更新 chat_session.user_seq = 42(如果用户端)
+ - 或 chat_session.admin_seq = 42(如果客服端)
+ - 同时如果对端离线,user_unread / admin_unread 清零
+```
+
+### 1.6 断线重连 / 离线消息补发
+
+```
+客户端断线后重新连接:
+ WebSocket /ws/chat?token=xxx&last_ack_seq=42
+ │
+ ▼
+服务端:
+ 1. 校验 Token
+ 2. SELECT * FROM chat_message
+ WHERE session_id = ? AND seq > 42
+ ORDER BY seq ASC
+ 3. 推送给客户端
+ 4. 客户端渲染后回传 ACK 更新 user_seq / admin_seq
+```
+
+### 1.7 关键 Redis 键(可选,用于集群扩展)
+
+| Key | 类型 | 用途 |
+|-----|------|------|
+| `chat:online:{userId}` | string (TTL 30s 心跳) | 用户在线状态 |
+| `chat:admin:online:{adminId}` | string | 客服在线状态 |
+| `chat:unread:{sessionId}:{type}` | int | 未读消息计数(与 MySQL 同步) |
+| `ws:user:{userId}` | WebSocket Session | 集群路由用 |
+
+### 1.8 与"轮询"的区别
+
+| 轮询(旧设计) | WebSocket(新设计) |
+|----------------|---------------------|
+| 客户端每秒请求一次 `/api/chat/messages?lastId=` | 一次 WebSocket 长连接,服务端主动推送 |
+| 服务器压力随用户数线性增长 | 服务器仅在有消息时推送,连接空闲几乎零开销 |
+| 消息延迟 1~N 秒 | 实时(毫秒级) |
+| 移动端耗电 | 移动端省电(长连接心跳即可) |
+| 无重连机制 | 断线自动重连 + 序号补发 |
+
+---
+
+## 二、限时抢购(Redis 防超卖)
+
+### 2.1 设计目标
+
+- **不能超卖**:100 件商品最多卖 100 单
+- **一人一单**:同一用户在同一活动同一商品只能抢 1 单
+- **高性能**:10000+ 用户同时抢能扛住
+- **不超时不泄洪**:流量高峰削峰后端
+
+### 2.2 整体流程
+
+```
+ 客户端 服务端
+ │ │
+ │ POST /api/seckill/buy │
+ │ { activityId, skuId, qty } │
+ │ ──────────────────────────► │
+ │ │
+ │ ┌──────┴──────┐
+ │ │ ① 登录校验 │
+ │ │ ② 活动状态 │
+ │ │ ③ Lua 脚本 │ ◄── Redis 原子操作
+ │ │ 一次性完成│ (防超卖 + 限购)
+ │ └──────┬──────┘
+ │ │
+ │ ┌──────┴──────┐
+ │ │ ④ 写 MQ │ ◄── 削峰
+ │ └──────┬──────┘
+ │ │
+ │ ◄─── "排队中" 202 ─────────│
+ │ │
+ │ ┌──── 轮询或推送 ────┐ │
+ │ │ /api/seckill/ │ │
+ │ │ result/{orderNo} │ │
+ │ └────────────────────┘ │
+ │ │
+ │ ┌──────┴──────┐
+ │ │ ⑤ 消费者线程│
+ │ │ 写 MySQL │ ◄── 异步落库
+ │ │ - seckill_order
+ │ │ - orders / order_item
+ │ │ - 扣 SKU 库存
+ │ └──────┬──────┘
+ │ │
+ │ ◄─── 推送抢购结果 ─────────│
+ │ "success: orderNo" │
+ │ "fail: 已抢完" │
+```
+
+### 2.3 Redis 数据结构
+
+| Key | 类型 | 初始值 | 用途 |
+|-----|------|--------|------|
+| `seckill:stock:{activityId}:{skuId}` | int | seckill_stock | **总库存**(DECR 原子扣减) |
+| `seckill:bought:{activityId}:{skuId}:{userId}` | int 0/1 | 0 | **一人一单标记**(SETNX) |
+| `seckill:user_count:{activityId}:{skuId}:{userId}` | int | 0 | 累计购买数量(INBY 用于限购 N 件) |
+| `seckill:result:{orderNo}` | hash | — | 抢购结果(pending / success / fail) |
+| `seckill:queue:{activityId}` | list | — | 异步下单消息队列 |
+
+### 2.4 核心 Lua 脚本(原子完成"校验 + 扣库存 + 限购")
+
+```lua
+-- KEYS[1] = stock key
+-- KEYS[2] = bought flag key
+-- KEYS[3] = user count key
+-- ARGV[1] = 限购数量
+
+-- 1. 限购校验
+local bought = redis.call('GET', KEYS[2])
+if bought == '1' then
+ return {-1, 'ALREADY_BOUGHT'}
+end
+
+local userCount = tonumber(redis.call('GET', KEYS[3]) or '0')
+if userCount + 1 > tonumber(ARGV[1]) then
+ return {-2, 'LIMIT_EXCEEDED'}
+end
+
+-- 2. 库存校验
+local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
+if stock <= 0 then
+ return {-3, 'STOCK_EMPTY'}
+end
+
+-- 3. 原子扣减
+redis.call('DECR', KEYS[1])
+redis.call('SET', KEYS[2], '1', 'EX', 86400) -- 24h 过期
+redis.call('INCR', KEYS[3])
+redis.call('EXPIRE', KEYS[3], 86400)
+
+return {0, 'OK'}
+```
+
+**为什么用 Lua?** 多个 Redis 命令包成一个原子操作,避免"已读库存但 SETNX 失败"的并发问题。
+
+### 2.5 异步落库(消费者线程)
+
+```java
+@RabbitListener(queues = "seckill.order.queue")
+public void handleSeckillOrder(SeckillMessage msg) {
+ // 1. 写 seckill_order(唯一索引兜底一人一单)
+ try {
+ seckillOrderMapper.insert(msg);
+ } catch (DuplicateKeyException e) {
+ // 数据库兜底:说明 Redis 已扣但库写失败,需要回补 Redis
+ redisService.incrStock(msg.getActivityId(), msg.getSkuId());
+ return;
+ }
+
+ // 2. 生成主订单
+ Long orderId = orderService.createSeckillOrder(msg);
+
+ // 3. 异步扣 MySQL SKU 库存(最终一致)
+ productSkuMapper.decrStock(msg.getSkuId(), msg.getQuantity());
+
+ // 4. 写抢购结果到 Redis
+ redisService.setSeckillResult(msg.getOrderNo(), "success");
+}
+```
+
+### 2.6 客户端查询结果(轮询或推送)
+
+#### 方式 A:轮询(最简单)
+
+```http
+GET /api/seckill/result/{orderNo}?token=xxx
+
+Response:
+ { "status": "pending" | "success" | "fail", "orderNo": "...", "message": "..." }
+```
+
+#### 方式 B:WebSocket 推送(推荐)
+
+服务端在消费者线程处理完后,向用户 WebSocket 频道推送:
+```json
+{ "type": "seckill_result", "orderNo": "SK20260602...", "status": "success" }
+```
+
+### 2.7 关键时间点
+
+| 时间点 | 操作 |
+|--------|------|
+| 活动开始前 5 分钟 | 预热:把 `seckill_product.seckill_stock` 同步到 Redis(`SETNX`) |
+| 活动开始 | 客户端开启抢购入口 |
+| 活动进行中 | Lua 原子扣库存 + 异步下单 |
+| 活动结束 | 关闭入口;剩余库存回写 MySQL;活动状态置为已结束 |
+| 订单超时未支付(30 分钟) | 定时任务扫描 seckill_order,**回补 Redis 库存 + 释放"已购"标记** |
+
+### 2.8 表结构对应关系
+
+| 表 | 作用 | 写入时机 |
+|----|------|----------|
+| `seckill_activity` | 活动元数据 | 管理员创建活动 |
+| `seckill_product` | 活动商品配置 + 兜底库存 | 管理员添加商品;活动开始时预热 Redis |
+| `seckill_order` | 抢购成功记录(**唯一索引防一人多单**) | 消费者线程异步写入 |
+| `orders` / `order_item` | 主订单 | 消费者线程生成 |
+| `product_sku.stock` | SKU 真实库存 | 消费者线程扣减(最终一致) |
+
+### 2.9 为什么需要 MySQL 唯一索引
+
+- Redis 是单点权威库存,但**不能保证事务一致性**
+- 极端情况下(Redis 主从切换、网络分区),可能出现"Redis 扣减成功但消费者没写入"
+- `seckill_order` 的 `UNIQUE KEY (user_id, activity_id, sku_id)` 是**最后一道防线**:
+ - 重复插入时抛 `DuplicateKeyException`
+ - 捕获后回补 Redis 库存
+ - 保证"一人一单"不会因 Redis 故障被绕过
+
+### 2.10 性能估算
+
+- Redis 单机 QPS:10 万+
+- Lua 脚本平均耗时:< 1ms
+- 10000 并发抢购 → Redis 处理 ~10000 Lua 调用 → 写 MQ 10000 条
+- 消费者线程(可水平扩展)按 200 TPS 落库,10000 条约 50 秒消化完
+- 数据库压力:< 200 TPS,MySQL 完全扛得住
+
+---
+
+## 三、关键代码位置
+
+| 模块 | 文件路径 | 说明 |
+|------|----------|------|
+| 客服 WebSocket 配置 | `server-snack/src/main/java/com/snack/server/websocket/WebSocketConfig.java` | 注册 STOMP 端点 |
+| 客服消息处理 | `server-snack/src/main/java/com/snack/server/websocket/ChatWebSocketHandler.java` | 消息接收、ACK、推送 |
+| 抢购 Lua 脚本 | `server-snack/src/main/resources/lua/seckill.lua` | 原子校验 + 扣库存 |
+| 抢购 Controller | `server-snack/src/main/java/com/snack/server/controller/user/SeckillController.java` | `/api/seckill/buy` |
+| 抢购消费者 | `server-snack/src/main/java/com/snack/server/mq/SeckillOrderConsumer.java` | 异步落库 |
+| 抢购服务 | `server-snack/src/main/java/com/snack/server/service/SeckillService.java` | 业务编排 |
+| 抢购结果推送 | `server-snack/src/main/java/com/snack/server/websocket/SeckillResultPushService.java` | 通过 WebSocket 通知结果 |
+
+---
+
+## 四、待办(后续开发)
+
+- [ ] WebSocket 集群方案:使用 Redis Pub/Sub 广播跨节点消息
+- [ ] 抢购接口限流:Sentinel / Resilience4j 按用户 ID 限流
+- [ ] 抢购风控:同一 IP / 设备 5 秒内最多抢 1 次
+- [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配
+- [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接
diff --git a/db/README.md b/db/README.md
new file mode 100644
index 0000000..3dbd65e
--- /dev/null
+++ b/db/README.md
@@ -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 原子操作 + 数据库兜底双重保障,详见功能设计文档。
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..d8fed90
--- /dev/null
+++ b/db/schema.sql
@@ -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 '父分类 ID,0 表示一级分类',
+ `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 '主键 ID(SPU)',
+ `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 '主键 ID(SKU)',
+ `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 '接待客服 ID,NULL=待分配',
+ `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;
diff --git a/db/seed.sql b/db/seed.sql
new file mode 100644
index 0000000..133abe1
--- /dev/null
+++ b/db/seed.sql
@@ -0,0 +1,290 @@
+-- ============================================================
+-- 零食商城 测试数据
+-- 执行前请先执行 schema.sql
+-- ============================================================
+
+USE `snack_mall`;
+
+
+-- ============================================================
+-- 管理员账号(密码:123456,BCrypt 加密,强度 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 大促活动规则说明',
+ '
亲爱的用户:
618 大促活动即将开始,全场满 199 减 50,满 399 减 120,上不封顶!活动期间更有每日 10 点/20 点限时秒杀,等你来抢!
活动期间如有任何问题,请联系在线客服。
',
+ 1, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59'),
+('系统升级维护通知',
+ '为了提供更好的服务,系统将于 2026-06-15 02:00 - 04:00 进行例行维护升级。维护期间可能短暂无法访问,请合理安排购物时间。
',
+ 1, 0, 1, '2026-06-10 00:00:00', '2026-06-16 23:59:59'),
+('新人福利:注册即送 50 元大礼包',
+ '新用户注册后可在「领券中心」领取 50 元新人礼包,包含:满 99 减 20、满 199 减 30 优惠券各一张。
',
+ 2, 0, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59'),
+('关于规范使用账户的公告',
+ '为保障您的账户安全,请勿将账户借给他人使用。如发现异常登录行为,系统将自动冻结账户。
',
+ 0, 0, 1, '2026-05-15 00:00:00', '2026-12-31 23:59:59'),
+('物流更新提醒',
+ '近期受天气影响,部分地区物流时效可能延长 1-2 天,请耐心等待。如长时间未收到,可联系客服查询。
',
+ 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;
diff --git a/server-snack/src/main/java/com/snack/server/common/ResultCode.java b/server-snack/src/main/java/com/snack/server/common/ResultCode.java
index 3af7e6d..23536d7 100644
--- a/server-snack/src/main/java/com/snack/server/common/ResultCode.java
+++ b/server-snack/src/main/java/com/snack/server/common/ResultCode.java
@@ -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, "优惠券不存在"),
diff --git a/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java b/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java
new file mode 100644
index 0000000..691963a
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java
@@ -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());
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java b/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java
index f4ed0ec..b60bbb4 100644
--- a/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java
+++ b/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java
@@ -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("/**");
}
}
diff --git a/server-snack/src/main/java/com/snack/server/controller/admin/.gitkeep b/server-snack/src/main/java/com/snack/server/controller/admin/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/controller/user/.gitkeep b/server-snack/src/main/java/com/snack/server/controller/user/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/dto/req/.gitkeep b/server-snack/src/main/java/com/snack/server/dto/req/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/dto/resp/.gitkeep b/server-snack/src/main/java/com/snack/server/dto/resp/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/entity/.gitkeep b/server-snack/src/main/java/com/snack/server/entity/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/mapper/.gitkeep b/server-snack/src/main/java/com/snack/server/mapper/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java b/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java
new file mode 100644
index 0000000..48e1f3a
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java
@@ -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 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 logout() {
+ adminService.logout();
+ return Result.ok();
+ }
+
+ @Operation(summary = "获取当前登录管理员信息")
+ @SaCheckLogin
+ @GetMapping("/info")
+ public Result getCurrentAdmin() {
+ return Result.ok(adminService.getCurrentAdmin());
+ }
+
+ @Operation(summary = "修改自己的密码")
+ @SaCheckLogin
+ @PutMapping("/password")
+ public Result changePassword(@Valid @RequestBody ChangePasswordReq req) {
+ adminService.changePassword(req);
+ return Result.ok();
+ }
+
+ // ==================== 管理员 CRUD ====================
+
+ @Operation(summary = "分页查询管理员列表")
+ @SaCheckLogin
+ @GetMapping("/page")
+ public Result> page(AdminPageReq req) {
+ return Result.ok(adminService.pageAdmins(req));
+ }
+
+ @Operation(summary = "获取管理员详情")
+ @SaCheckLogin
+ @GetMapping("/{id}")
+ public Result detail(@Parameter(description = "管理员 ID") @PathVariable Long id) {
+ return Result.ok(adminService.getAdminById(id));
+ }
+
+ @Operation(summary = "创建管理员")
+ @SaCheckRole("super_admin")
+ @PostMapping
+ public Result create(@Valid @RequestBody AdminSaveReq req) {
+ return Result.ok(adminService.createAdmin(req));
+ }
+
+ @Operation(summary = "更新管理员")
+ @SaCheckRole("super_admin")
+ @PutMapping
+ public Result update(@Valid @RequestBody AdminSaveReq req) {
+ adminService.updateAdmin(req);
+ return Result.ok();
+ }
+
+ @Operation(summary = "删除管理员")
+ @SaCheckRole("super_admin")
+ @DeleteMapping("/{id}")
+ public Result delete(@Parameter(description = "管理员 ID") @PathVariable Long id) {
+ adminService.deleteAdmin(id);
+ return Result.ok();
+ }
+
+ @Operation(summary = "重置管理员密码")
+ @SaCheckRole("super_admin")
+ @PutMapping("/{id}/password/reset")
+ public Result 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 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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java
new file mode 100644
index 0000000..68cea46
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java
new file mode 100644
index 0000000..e8a030c
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java
new file mode 100644
index 0000000..5430b89
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java
new file mode 100644
index 0000000..79d832f
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java b/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java
new file mode 100644
index 0000000..da32c7d
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java b/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java
new file mode 100644
index 0000000..969c476
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java
@@ -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 {
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java
new file mode 100644
index 0000000..5fde016
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java
@@ -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 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);
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java
new file mode 100644
index 0000000..dd0c314
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java
@@ -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().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 pageAdmins(AdminPageReq req) {
+ Page page = new Page<>(req.getCurrent(), req.getSize());
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ // 关键字模糊搜索
+ .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 result = adminMapper.selectPage(page, wrapper);
+ Page 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().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()
+ .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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java b/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java
new file mode 100644
index 0000000..facbd2d
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java b/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java
new file mode 100644
index 0000000..0090b23
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java
new file mode 100644
index 0000000..711ee40
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java
@@ -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> tree(
+ @Parameter(description = "状态过滤:null-全部 0-禁用 1-启用")
+ @RequestParam(required = false) Integer status) {
+ return Result.ok(categoryService.listTree(status));
+ }
+
+ @Operation(summary = "获取某父分类下的直接子分类")
+ @SaCheckLogin
+ @GetMapping("/children")
+ public Result> listChildren(
+ @Parameter(description = "父分类 ID,0=顶级")
+ @RequestParam(defaultValue = "0") Long parentId) {
+ return Result.ok(categoryService.listByParentId(parentId));
+ }
+
+ @Operation(summary = "获取启用状态的下拉选项")
+ @SaCheckLogin
+ @GetMapping("/options")
+ public Result> options() {
+ return Result.ok(categoryService.listOptions());
+ }
+
+ @Operation(summary = "获取分类详情")
+ @SaCheckLogin
+ @GetMapping("/{id}")
+ public Result detail(@Parameter(description = "分类 ID") @PathVariable Long id) {
+ return Result.ok(categoryService.getById(id));
+ }
+
+ @Operation(summary = "创建分类")
+ @SaCheckLogin
+ @PostMapping
+ public Result create(@Valid @RequestBody CategorySaveReq req) {
+ return Result.ok(categoryService.create(req));
+ }
+
+ @Operation(summary = "更新分类")
+ @SaCheckLogin
+ @PutMapping
+ public Result update(@Valid @RequestBody CategorySaveReq req) {
+ categoryService.update(req);
+ return Result.ok();
+ }
+
+ @Operation(summary = "删除分类")
+ @SaCheckLogin
+ @DeleteMapping("/{id}")
+ public Result delete(@Parameter(description = "分类 ID") @PathVariable Long id) {
+ categoryService.delete(id);
+ return Result.ok();
+ }
+
+ @Operation(summary = "启用 / 禁用分类")
+ @SaCheckLogin
+ @PutMapping("/{id}/status/{status}")
+ public Result 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 updateSort(
+ @Parameter(description = "分类 ID") @PathVariable Long id,
+ @Parameter(description = "排序号") @PathVariable Integer sort) {
+ categoryService.updateSort(id, sort);
+ return Result.ok();
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java
new file mode 100644
index 0000000..9e6e150
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java
@@ -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> tree() {
+ return Result.ok(categoryService.listTree(1));
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java b/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java
new file mode 100644
index 0000000..0055a44
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java
@@ -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 = "父分类 ID,0 = 一级分类", 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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java b/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java
new file mode 100644
index 0000000..1e9a6c4
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java
@@ -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;
+
+ /** 父分类 ID,0 表示一级分类 */
+ 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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java
new file mode 100644
index 0000000..c984175
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java
@@ -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);
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java
new file mode 100644
index 0000000..3e59fd8
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java
@@ -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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java b/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java
new file mode 100644
index 0000000..1eea41a
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java
@@ -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 {
+
+ /**
+ * 查询所有子分类 ID(递归,包含自身)
+ * 用于"删除分类时级联检查商品 / 子分类"
+ */
+ List selectAllChildIds(@Param("parentId") Long parentId);
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java b/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java
new file mode 100644
index 0000000..06d58a6
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java
@@ -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 listTree(Integer statusFilter);
+
+ /**
+ * 获取子分类(按父 ID 查直接子节点)
+ */
+ List listByParentId(Long parentId);
+
+ /**
+ * 获取下拉选项(仅启用状态)
+ */
+ List 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);
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java
new file mode 100644
index 0000000..5d1945f
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java
@@ -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 listTree(Integer statusFilter) {
+ // 1. 查询全量分类
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .orderByAsc(Category::getSort)
+ .orderByAsc(Category::getId);
+ if (statusFilter != null) {
+ wrapper.eq(Category::getStatus, statusFilter);
+ }
+ List all = categoryMapper.selectList(wrapper);
+
+ // 2. 转 VO
+ List vos = all.stream().map(this::toTreeVO).toList();
+
+ // 3. 组装树
+ return buildTree(vos);
+ }
+
+ @Override
+ public List listByParentId(Long parentId) {
+ return categoryMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(Category::getParentId, parentId)
+ .orderByAsc(Category::getSort)
+ );
+ }
+
+ @Override
+ public List listOptions() {
+ return categoryMapper.selectList(
+ new LambdaQueryWrapper()
+ .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()
+ .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()
+ .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().eq(Category::getParentId, id)
+ );
+ if (childCount > 0) {
+ throw new BusinessException(1000,
+ "存在 " + childCount + " 个子分类,请先删除子分类");
+ }
+
+ // 2. 校验是否有关联商品(通过 product 表的 category_id 判断)
+ Long productCount = categoryMapper.selectCount(
+ new LambdaQueryWrapper().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 buildTree(List all) {
+ if (CollUtil.isEmpty(all)) {
+ return new ArrayList<>();
+ }
+ // 按 id 建索引
+ Map map = all.stream()
+ .collect(Collectors.toMap(CategoryTreeVO::getId, v -> v, (a, b) -> a));
+
+ List 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 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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java
new file mode 100644
index 0000000..2e558ad
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java
new file mode 100644
index 0000000..20ad44f
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java
@@ -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 = "父分类 ID,0 = 顶级")
+ 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 children;
+
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java
new file mode 100644
index 0000000..1ae40b0
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java
@@ -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(ProductPageReq req) {
+ return Result.ok(productService.pageProducts(req));
+ }
+
+ @Operation(summary = "商品详情(含 SKU)")
+ @SaCheckLogin
+ @GetMapping("/{id}")
+ public Result detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
+ return Result.ok(productService.getDetail(id));
+ }
+
+ @Operation(summary = "发布商品(SPU + SKU)")
+ @SaCheckLogin
+ @PostMapping
+ public Result create(@Valid @RequestBody ProductSaveReq req) {
+ return Result.ok(productService.createProduct(req));
+ }
+
+ @Operation(summary = "编辑商品(SPU + SKU)")
+ @SaCheckLogin
+ @PutMapping
+ public Result update(@Valid @RequestBody ProductSaveReq req) {
+ productService.updateProduct(req);
+ return Result.ok();
+ }
+
+ @Operation(summary = "删除商品(级联删除 SKU)")
+ @SaCheckLogin
+ @DeleteMapping("/{id}")
+ public Result delete(@Parameter(description = "商品 ID") @PathVariable Long id) {
+ productService.deleteProduct(id);
+ return Result.ok();
+ }
+
+ @Operation(summary = "上下架切换")
+ @SaCheckLogin
+ @PutMapping("/{id}/status/{status}")
+ public Result 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 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 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 adjustStock(@Valid @RequestBody StockAdjustReq req) {
+ productService.adjustStock(req);
+ return Result.ok();
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java
new file mode 100644
index 0000000..fc8848f
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java
@@ -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 detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
+ // 增加浏览量
+ productService.incrViewCount(id);
+ return Result.ok(productService.getDetail(id));
+ }
+
+ @Operation(summary = "分类下的商品列表(用户端分类页)")
+ @GetMapping("/list")
+ public Result> 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 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> hot(@RequestParam(defaultValue = "10") Integer limit) {
+ return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "sales_desc", limit));
+ }
+
+ @Operation(summary = "新品上市")
+ @GetMapping("/new")
+ public Result> newProducts(@RequestParam(defaultValue = "10") Integer limit) {
+ return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "create_desc", limit));
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java
new file mode 100644
index 0000000..74f0f15
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java
@@ -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";
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java
new file mode 100644
index 0000000..fc831a2
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java
@@ -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 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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java
new file mode 100644
index 0000000..64edebc
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java b/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java
new file mode 100644
index 0000000..a2d7b86
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java b/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java
new file mode 100644
index 0000000..e862a32
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java
@@ -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;
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java b/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java
new file mode 100644
index 0000000..6c2ed34
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java
@@ -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;
+ }
+}
diff --git a/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java
new file mode 100644
index 0000000..29dc662
--- /dev/null
+++ b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java
@@ -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 {
+
+ /**
+ * 批量查询商品的最低 SKU 价格(用于列表展示"起售价")
+ */
+ List