```
docs(db): 添加核心业务设计文档和数据库初始化说明 - 新增 BUSINESS_DESIGN.md 文档,详细描述 WebSocket 客服系统和 Redis 防超卖抢购系统的设计方案 - 新增 README.md 文档,提供数据库初始化说明和表结构清单 - 包含完整的建表脚本执行顺序和默认测试账号信息 - 为后续开发提供详细的技术架构参考文档 ```
This commit is contained in:
parent
3dd146f871
commit
1595dc8c35
|
|
@ -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 次
|
||||
- [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配
|
||||
- [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接
|
||||
|
|
@ -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 原子操作 + 数据库兜底双重保障,详见功能设计文档。
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 大促活动规则说明',
|
||||
'<p>亲爱的用户:</p><p>618 大促活动即将开始,全场满 199 减 50,满 399 减 120,上不封顶!活动期间更有每日 10 点/20 点限时秒杀,等你来抢!</p><p>活动期间如有任何问题,请联系在线客服。</p>',
|
||||
1, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59'),
|
||||
('系统升级维护通知',
|
||||
'<p>为了提供更好的服务,系统将于 2026-06-15 02:00 - 04:00 进行例行维护升级。维护期间可能短暂无法访问,请合理安排购物时间。</p>',
|
||||
1, 0, 1, '2026-06-10 00:00:00', '2026-06-16 23:59:59'),
|
||||
('新人福利:注册即送 50 元大礼包',
|
||||
'<p>新用户注册后可在「领券中心」领取 50 元新人礼包,包含:满 99 减 20、满 199 减 30 优惠券各一张。</p>',
|
||||
2, 0, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59'),
|
||||
('关于规范使用账户的公告',
|
||||
'<p>为保障您的账户安全,请勿将账户借给他人使用。如发现异常登录行为,系统将自动冻结账户。</p>',
|
||||
0, 0, 1, '2026-05-15 00:00:00', '2026-12-31 23:59:59'),
|
||||
('物流更新提醒',
|
||||
'<p>近期受天气影响,部分地区物流时效可能延长 1-2 天,请耐心等待。如长时间未收到,可联系客服查询。</p>',
|
||||
0, 0, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 优惠券模板
|
||||
-- ============================================================
|
||||
INSERT INTO `coupon` (`name`, `type`, `amount`, `min_amount`, `max_discount`, `total`, `remain`, `per_limit`, `status`, `start_time`, `end_time`, `valid_days`) VALUES
|
||||
-- 满减券
|
||||
('满 99 减 10', 0, 10.00, 99.00, 0.00, 1000, 856, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
|
||||
('满 199 减 30', 0, 30.00, 199.00, 0.00, 500, 320, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
|
||||
('满 399 减 80', 0, 80.00, 399.00, 0.00, 200, 168, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15),
|
||||
-- 折扣券
|
||||
('9 折优惠券', 1, 0.90, 0.00, 50.00, 800, 580, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30),
|
||||
('8.5 折优惠券', 1, 0.85, 100.00, 80.00, 300, 180, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15),
|
||||
-- 无门槛
|
||||
('新人 5 元无门槛', 2, 5.00, 0.00, 0.00, 5000, 3200, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30),
|
||||
('新人 10 元无门槛', 2, 10.00, 0.00, 0.00, 3000, 2100, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 限时抢购活动
|
||||
-- ============================================================
|
||||
INSERT INTO `seckill_activity` (`name`, `start_time`, `end_time`, `status`) VALUES
|
||||
('618 限时秒杀 · 第一场', '2026-06-02 10:00:00', '2026-06-02 12:00:00', 1),
|
||||
('618 限时秒杀 · 第二场', '2026-06-02 14:00:00', '2026-06-02 16:00:00', 0),
|
||||
('618 限时秒杀 · 第三场', '2026-06-02 20:00:00', '2026-06-02 22:00:00', 0),
|
||||
('618 限时秒杀 · 第四场', '2026-06-03 10:00:00', '2026-06-03 12:00:00', 0),
|
||||
('618 限时秒杀 · 第五场', '2026-06-03 20:00:00', '2026-06-03 22:00:00', 0),
|
||||
('已结束场次回顾', '2026-06-01 10:00:00', '2026-06-01 12:00:00', 2);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 抢购活动商品
|
||||
-- ============================================================
|
||||
INSERT INTO `seckill_product` (`activity_id`, `product_id`, `sku_id`, `seckill_price`, `seckill_stock`, `remain_stock`, `per_limit`, `sales`) VALUES
|
||||
-- 第一场
|
||||
(1, 1, 1, 49.90, 100, 68, 2, 32),
|
||||
(1, 7, 14, 14.90, 200, 145, 3, 55),
|
||||
(1, 11, 22, 4.90, 500, 320, 5, 180),
|
||||
(1, 15, 30, 12.90, 300, 198, 2, 102),
|
||||
-- 第二场
|
||||
(2, 2, 4, 19.90, 150, 150, 2, 0),
|
||||
(2, 9, 18, 19.90, 200, 200, 2, 0),
|
||||
(2, 19, 39, 9.90, 500, 500, 3, 0),
|
||||
-- 第三场
|
||||
(3, 8, 16, 24.90, 200, 200, 2, 0),
|
||||
(3, 13, 27, 6.90, 300, 300, 3, 0),
|
||||
(3, 20, 41, 19.90, 100, 100, 1, 0);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 客服快捷回复模板
|
||||
-- ============================================================
|
||||
INSERT INTO `quick_reply` (`title`, `content`, `sort`, `status`) VALUES
|
||||
('问候', '您好,欢迎光临零食商城~ 请问有什么可以帮您?', 1, 1),
|
||||
('已发货', '您的订单已经发货啦,物流单号:xx,预计 2-3 天到达,请耐心等待哦~', 2, 1),
|
||||
('催发货', '亲,您的订单正在紧急处理中,我们会尽快为您发货,感谢您的耐心等待!', 3, 1),
|
||||
('退换货', '亲,如需退换货请提供订单号和具体问题,我们会在 24 小时内为您处理~', 4, 1),
|
||||
('感谢', '感谢您的咨询,祝您购物愉快!如有问题随时联系在线客服~', 5, 1),
|
||||
('库存咨询', '亲,这款商品目前库存充足,可以直接下单哦~', 6, 1);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 收藏
|
||||
-- ============================================================
|
||||
INSERT INTO `favorite` (`user_id`, `product_id`) VALUES
|
||||
(1, 1), (1, 7), (1, 9),
|
||||
(2, 2), (2, 11),
|
||||
(3, 1), (3, 8), (3, 19),
|
||||
(4, 11), (4, 15),
|
||||
(5, 6), (5, 13);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 客服示例会话
|
||||
-- ============================================================
|
||||
INSERT INTO `chat_session` (`id`, `session_no`, `user_id`, `admin_id`, `status`, `user_unread`, `admin_unread`, `last_message`, `last_time`, `user_seq`, `admin_seq`) VALUES
|
||||
(1, 'CS20260601001', 1, 1, 1, 1, 0, '好的,下一单', '2026-06-01 14:02:00', 3, 3),
|
||||
(2, 'CS20260601002', 2, NULL, 0, 1, 1, '请问发货时间是什么时候?', '2026-06-01 15:23:00', 1, 0),
|
||||
(3, 'CS20260601003', 3, 1, 2, 0, 0, '感谢您的耐心解答!', '2026-06-01 16:45:00', 2, 2),
|
||||
(4, 'CS20260601004', 4, NULL, 0, 1, 1, '我下单了怎么还没发货?', '2026-06-02 09:12:00', 1, 0),
|
||||
(5, 'CS20260601005', 5, 1, 1, 1, 0, '请问优惠券怎么用?', '2026-06-02 10:30:00', 1, 0);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 客服示例消息(含 seq 序号)
|
||||
-- ============================================================
|
||||
INSERT INTO `chat_message` (`session_id`, `seq`, `sender_id`, `sender_type`, `type`, `content`, `is_recalled`, `create_time`) VALUES
|
||||
(1, 1, 1, 0, 'text', '你好,请问夏威夷果还有货吗?', 0, '2026-06-01 14:00:00'),
|
||||
(1, 2, 1, 1, 'text', '亲,在的哦,目前库存充足~', 0, '2026-06-01 14:01:00'),
|
||||
(1, 3, 1, 0, 'text', '好的,下一单', 0, '2026-06-01 14:02:00'),
|
||||
(2, 1, 2, 0, 'text', '请问发货时间是什么时候?', 0, '2026-06-01 15:23:00'),
|
||||
(3, 1, 3, 0, 'text', '订单已收到,谢谢', 0, '2026-06-01 16:40:00'),
|
||||
(3, 2, 1, 1, 'text', '感谢您的耐心解答!', 0, '2026-06-01 16:45:00'),
|
||||
(4, 1, 4, 0, 'text', '我下单了怎么还没发货?', 0, '2026-06-02 09:12:00'),
|
||||
(5, 1, 5, 0, 'text', '请问优惠券怎么用?', 0, '2026-06-02 10:30:00');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 抢购成功记录示例(模拟已经异步落库后的状态)
|
||||
-- ============================================================
|
||||
INSERT INTO `seckill_order` (`user_id`, `activity_id`, `product_id`, `sku_id`, `quantity`, `seckill_price`, `order_id`, `status`) VALUES
|
||||
(1, 1, 1, 1, 1, 49.90, NULL, 0),
|
||||
(1, 1, 7, 14, 2, 14.90, NULL, 0),
|
||||
(2, 1, 11, 22, 3, 4.90, NULL, 0),
|
||||
(3, 1, 15, 30, 1, 12.90, NULL, 1);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 完成
|
||||
-- ============================================================
|
||||
SELECT '✅ 测试数据导入完成' AS message;
|
||||
SELECT '默认管理员账号: admin / 123456' AS info;
|
||||
SELECT '默认用户账号: user001 / 123456' AS info;
|
||||
|
|
@ -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, "优惠券不存在"),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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("/**");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
package com.snack.server.module.admin.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.admin.dto.req.AdminLoginReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminPageReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminSaveReq;
|
||||
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
|
||||
import com.snack.server.module.admin.service.AdminService;
|
||||
import com.snack.server.module.admin.vo.AdminVO;
|
||||
import com.snack.server.module.admin.vo.LoginVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 管理员相关接口
|
||||
*/
|
||||
@Tag(name = "01. 管理员管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
|
||||
// ==================== 鉴权相关(白名单) ====================
|
||||
|
||||
@Operation(summary = "管理员登录")
|
||||
@PostMapping("/login")
|
||||
public Result<LoginVO> login(@Valid @RequestBody AdminLoginReq req, HttpServletRequest request) {
|
||||
String ip = getClientIp(request);
|
||||
return Result.ok(adminService.login(req, ip));
|
||||
}
|
||||
|
||||
@Operation(summary = "管理员登出")
|
||||
@SaCheckLogin
|
||||
@PostMapping("/logout")
|
||||
public Result<Void> logout() {
|
||||
adminService.logout();
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前登录管理员信息")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/info")
|
||||
public Result<AdminVO> getCurrentAdmin() {
|
||||
return Result.ok(adminService.getCurrentAdmin());
|
||||
}
|
||||
|
||||
@Operation(summary = "修改自己的密码")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/password")
|
||||
public Result<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
|
||||
adminService.changePassword(req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 管理员 CRUD ====================
|
||||
|
||||
@Operation(summary = "分页查询管理员列表")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/page")
|
||||
public Result<Page<AdminVO>> page(AdminPageReq req) {
|
||||
return Result.ok(adminService.pageAdmins(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取管理员详情")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/{id}")
|
||||
public Result<AdminVO> detail(@Parameter(description = "管理员 ID") @PathVariable Long id) {
|
||||
return Result.ok(adminService.getAdminById(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建管理员")
|
||||
@SaCheckRole("super_admin")
|
||||
@PostMapping
|
||||
public Result<Long> create(@Valid @RequestBody AdminSaveReq req) {
|
||||
return Result.ok(adminService.createAdmin(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新管理员")
|
||||
@SaCheckRole("super_admin")
|
||||
@PutMapping
|
||||
public Result<Void> update(@Valid @RequestBody AdminSaveReq req) {
|
||||
adminService.updateAdmin(req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除管理员")
|
||||
@SaCheckRole("super_admin")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@Parameter(description = "管理员 ID") @PathVariable Long id) {
|
||||
adminService.deleteAdmin(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "重置管理员密码")
|
||||
@SaCheckRole("super_admin")
|
||||
@PutMapping("/{id}/password/reset")
|
||||
public Result<Void> resetPassword(
|
||||
@Parameter(description = "管理员 ID") @PathVariable Long id,
|
||||
@Parameter(description = "新密码") @RequestParam String newPassword) {
|
||||
adminService.resetPassword(id, newPassword);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "启用/禁用管理员")
|
||||
@SaCheckRole("super_admin")
|
||||
@PutMapping("/{id}/status/{status}")
|
||||
public Result<Void> updateStatus(
|
||||
@Parameter(description = "管理员 ID") @PathVariable Long id,
|
||||
@Parameter(description = "状态:0-禁用 1-启用") @PathVariable Integer status) {
|
||||
adminService.updateStatus(id, status);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 多级代理取第一个
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.snack.server.module.admin.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.admin.entity.Admin;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 管理员 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface AdminMapper extends BaseMapper<Admin> {
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.snack.server.module.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.module.admin.dto.req.AdminLoginReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminPageReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminSaveReq;
|
||||
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
|
||||
import com.snack.server.module.admin.entity.Admin;
|
||||
import com.snack.server.module.admin.vo.AdminVO;
|
||||
import com.snack.server.module.admin.vo.LoginVO;
|
||||
|
||||
/**
|
||||
* 管理员业务接口
|
||||
*/
|
||||
public interface AdminService {
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*
|
||||
* @param req 登录参数
|
||||
* @param clientIp 客户端 IP
|
||||
* @return 登录响应(含 token + 管理员信息)
|
||||
*/
|
||||
LoginVO login(AdminLoginReq req, String clientIp);
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
void logout();
|
||||
|
||||
/**
|
||||
* 获取当前登录的管理员信息
|
||||
*/
|
||||
AdminVO getCurrentAdmin();
|
||||
|
||||
/**
|
||||
* 分页查询管理员列表
|
||||
*/
|
||||
Page<AdminVO> pageAdmins(AdminPageReq req);
|
||||
|
||||
/**
|
||||
* 根据 ID 获取管理员
|
||||
*/
|
||||
AdminVO getAdminById(Long id);
|
||||
|
||||
/**
|
||||
* 创建管理员
|
||||
*/
|
||||
Long createAdmin(AdminSaveReq req);
|
||||
|
||||
/**
|
||||
* 更新管理员
|
||||
*/
|
||||
void updateAdmin(AdminSaveReq req);
|
||||
|
||||
/**
|
||||
* 删除管理员
|
||||
*/
|
||||
void deleteAdmin(Long id);
|
||||
|
||||
/**
|
||||
* 修改自己的密码
|
||||
*/
|
||||
void changePassword(ChangePasswordReq req);
|
||||
|
||||
/**
|
||||
* 重置某管理员的密码(仅超管可用)
|
||||
*/
|
||||
void resetPassword(Long id, String newPassword);
|
||||
|
||||
/**
|
||||
* 启用 / 禁用管理员
|
||||
*/
|
||||
void updateStatus(Long id, Integer status);
|
||||
|
||||
/**
|
||||
* 更新登录信息(IP + 时间)
|
||||
*/
|
||||
void updateLoginInfo(Long id, String clientIp);
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
package com.snack.server.module.admin.service;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.ResultCode;
|
||||
import com.snack.server.module.admin.dto.req.AdminLoginReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminPageReq;
|
||||
import com.snack.server.module.admin.dto.req.AdminSaveReq;
|
||||
import com.snack.server.module.admin.dto.req.ChangePasswordReq;
|
||||
import com.snack.server.module.admin.entity.Admin;
|
||||
import com.snack.server.exception.BusinessException;
|
||||
import com.snack.server.module.admin.mapper.AdminMapper;
|
||||
import com.snack.server.module.admin.service.AdminService;
|
||||
import com.snack.server.module.admin.vo.AdminVO;
|
||||
import com.snack.server.module.admin.vo.LoginVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 管理员业务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminServiceImpl implements AdminService {
|
||||
|
||||
private final AdminMapper adminMapper;
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
// ==================== 登录相关 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LoginVO login(AdminLoginReq req, String clientIp) {
|
||||
// 1. 查询管理员
|
||||
Admin admin = adminMapper.selectOne(
|
||||
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, req.getUsername())
|
||||
);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.PASSWORD_ERROR); // 不暴露用户名是否存在
|
||||
}
|
||||
|
||||
// 2. 校验状态
|
||||
if (admin.getStatus() != null && admin.getStatus() == 0) {
|
||||
throw new BusinessException(ResultCode.USER_DISABLED);
|
||||
}
|
||||
|
||||
// 3. 校验密码(BCrypt 校验)
|
||||
if (!passwordEncoder.matches(req.getPassword(), admin.getPassword())) {
|
||||
throw new BusinessException(ResultCode.PASSWORD_ERROR);
|
||||
}
|
||||
|
||||
// 4. Sa-Token 登录
|
||||
StpUtil.login(admin.getId());
|
||||
String token = StpUtil.getTokenValue();
|
||||
|
||||
// 5. 更新登录信息(异步优化:可改为线程池执行)
|
||||
updateLoginInfo(admin.getId(), clientIp);
|
||||
|
||||
// 6. 构造返回
|
||||
LoginVO vo = new LoginVO();
|
||||
vo.setToken(token);
|
||||
vo.setExpiresIn(StpUtil.getTokenTimeout());
|
||||
vo.setAdminInfo(toVO(admin));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout() {
|
||||
if (StpUtil.isLogin()) {
|
||||
StpUtil.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateLoginInfo(Long id, String clientIp) {
|
||||
Admin update = new Admin();
|
||||
update.setId(id);
|
||||
update.setLastLoginTime(LocalDateTime.now());
|
||||
update.setLastLoginIp(clientIp);
|
||||
adminMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 当前登录管理员 ====================
|
||||
|
||||
@Override
|
||||
public AdminVO getCurrentAdmin() {
|
||||
Long id = StpUtil.getLoginIdAsLong();
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
return toVO(admin);
|
||||
}
|
||||
|
||||
// ==================== 列表 / 详情 ====================
|
||||
|
||||
@Override
|
||||
public Page<AdminVO> pageAdmins(AdminPageReq req) {
|
||||
Page<Admin> page = new Page<>(req.getCurrent(), req.getSize());
|
||||
LambdaQueryWrapper<Admin> wrapper = new LambdaQueryWrapper<Admin>()
|
||||
// 关键字模糊搜索
|
||||
.and(StrUtil.isNotBlank(req.getKeyword()), w -> w
|
||||
.like(Admin::getUsername, req.getKeyword())
|
||||
.or().like(Admin::getNickname, req.getKeyword())
|
||||
.or().like(Admin::getPhone, req.getKeyword())
|
||||
)
|
||||
.eq(StrUtil.isNotBlank(req.getRole()), Admin::getRole, req.getRole())
|
||||
.eq(req.getStatus() != null, Admin::getStatus, req.getStatus())
|
||||
.orderByDesc(Admin::getId);
|
||||
|
||||
Page<Admin> result = adminMapper.selectPage(page, wrapper);
|
||||
Page<AdminVO> voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal());
|
||||
voPage.setRecords(result.getRecords().stream().map(this::toVO).toList());
|
||||
return voPage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminVO getAdminById(Long id) {
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
return toVO(admin);
|
||||
}
|
||||
|
||||
// ==================== 创建 / 更新 / 删除 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createAdmin(AdminSaveReq req) {
|
||||
// 1. 用户名唯一性校验
|
||||
Long count = adminMapper.selectCount(
|
||||
new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, req.getUsername())
|
||||
);
|
||||
if (count > 0) {
|
||||
throw new BusinessException(1003, "用户名已存在");
|
||||
}
|
||||
|
||||
// 2. 密码必填
|
||||
if (StrUtil.isBlank(req.getPassword())) {
|
||||
throw new BusinessException(1000, "密码不能为空");
|
||||
}
|
||||
|
||||
// 3. 入库
|
||||
Admin admin = new Admin();
|
||||
BeanUtil.copyProperties(req, admin, "id");
|
||||
admin.setPassword(passwordEncoder.encode(req.getPassword()));
|
||||
admin.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
adminMapper.insert(admin);
|
||||
log.info("创建管理员成功 id={} username={}", admin.getId(), admin.getUsername());
|
||||
return admin.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateAdmin(AdminSaveReq req) {
|
||||
if (req.getId() == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
Admin existing = adminMapper.selectById(req.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
|
||||
// 用户名查重(排除自己)
|
||||
Long count = adminMapper.selectCount(
|
||||
new LambdaQueryWrapper<Admin>()
|
||||
.eq(Admin::getUsername, req.getUsername())
|
||||
.ne(Admin::getId, req.getId())
|
||||
);
|
||||
if (count > 0) {
|
||||
throw new BusinessException(1003, "用户名已存在");
|
||||
}
|
||||
|
||||
// 不修改密码
|
||||
Admin admin = new Admin();
|
||||
BeanUtil.copyProperties(req, admin, "password");
|
||||
adminMapper.updateById(admin);
|
||||
log.info("更新管理员成功 id={}", admin.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteAdmin(Long id) {
|
||||
// 不允许删除自己
|
||||
if (StpUtil.getLoginIdAsLong().equals(id)) {
|
||||
throw new BusinessException(1000, "不能删除自己");
|
||||
}
|
||||
// 不允许删除超级管理员
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
if ("super_admin".equals(admin.getRole())) {
|
||||
throw new BusinessException(1000, "不能删除超级管理员");
|
||||
}
|
||||
adminMapper.deleteById(id);
|
||||
log.info("删除管理员 id={}", id);
|
||||
}
|
||||
|
||||
// ==================== 密码相关 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void changePassword(ChangePasswordReq req) {
|
||||
Long id = StpUtil.getLoginIdAsLong();
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
if (!passwordEncoder.matches(req.getOldPassword(), admin.getPassword())) {
|
||||
throw new BusinessException(ResultCode.OLD_PASSWORD_ERROR);
|
||||
}
|
||||
if (req.getNewPassword().equals(req.getOldPassword())) {
|
||||
throw new BusinessException(1000, "新密码不能与原密码相同");
|
||||
}
|
||||
Admin update = new Admin();
|
||||
update.setId(id);
|
||||
update.setPassword(passwordEncoder.encode(req.getNewPassword()));
|
||||
adminMapper.updateById(update);
|
||||
log.info("管理员 id={} 修改密码成功", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void resetPassword(Long id, String newPassword) {
|
||||
if (StrUtil.isBlank(newPassword) || newPassword.length() < 6) {
|
||||
throw new BusinessException(1000, "新密码至少 6 位");
|
||||
}
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
Admin update = new Admin();
|
||||
update.setId(id);
|
||||
update.setPassword(passwordEncoder.encode(newPassword));
|
||||
adminMapper.updateById(update);
|
||||
log.info("重置管理员 id={} 密码", id);
|
||||
}
|
||||
|
||||
// ==================== 状态切换 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (id == null || status == null) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
if (StpUtil.getLoginIdAsLong().equals(id)) {
|
||||
throw new BusinessException(1000, "不能禁用自己");
|
||||
}
|
||||
Admin admin = adminMapper.selectById(id);
|
||||
if (admin == null) {
|
||||
throw new BusinessException(ResultCode.USER_NOT_EXIST);
|
||||
}
|
||||
if ("super_admin".equals(admin.getRole())) {
|
||||
throw new BusinessException(1000, "不能禁用超级管理员");
|
||||
}
|
||||
Admin update = new Admin();
|
||||
update.setId(id);
|
||||
update.setStatus(status);
|
||||
adminMapper.updateById(update);
|
||||
log.info("管理员 id={} 状态更新为 {}", id, status);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
private AdminVO toVO(Admin admin) {
|
||||
AdminVO vo = new AdminVO();
|
||||
BeanUtil.copyProperties(admin, vo, "password");
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.snack.server.module.category.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.category.dto.req.CategorySaveReq;
|
||||
import com.snack.server.module.category.entity.Category;
|
||||
import com.snack.server.module.category.service.CategoryService;
|
||||
import com.snack.server.module.category.vo.CategoryOptionVO;
|
||||
import com.snack.server.module.category.vo.CategoryTreeVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品分类管理(管理端)
|
||||
*/
|
||||
@Tag(name = "02. 商品分类")
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/category")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryAdminController {
|
||||
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@Operation(summary = "获取分类树")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/tree")
|
||||
public Result<List<CategoryTreeVO>> tree(
|
||||
@Parameter(description = "状态过滤:null-全部 0-禁用 1-启用")
|
||||
@RequestParam(required = false) Integer status) {
|
||||
return Result.ok(categoryService.listTree(status));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取某父分类下的直接子分类")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/children")
|
||||
public Result<List<Category>> listChildren(
|
||||
@Parameter(description = "父分类 ID,0=顶级")
|
||||
@RequestParam(defaultValue = "0") Long parentId) {
|
||||
return Result.ok(categoryService.listByParentId(parentId));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取启用状态的下拉选项")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/options")
|
||||
public Result<List<CategoryOptionVO>> options() {
|
||||
return Result.ok(categoryService.listOptions());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取分类详情")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/{id}")
|
||||
public Result<Category> detail(@Parameter(description = "分类 ID") @PathVariable Long id) {
|
||||
return Result.ok(categoryService.getById(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建分类")
|
||||
@SaCheckLogin
|
||||
@PostMapping
|
||||
public Result<Long> create(@Valid @RequestBody CategorySaveReq req) {
|
||||
return Result.ok(categoryService.create(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新分类")
|
||||
@SaCheckLogin
|
||||
@PutMapping
|
||||
public Result<Void> update(@Valid @RequestBody CategorySaveReq req) {
|
||||
categoryService.update(req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除分类")
|
||||
@SaCheckLogin
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@Parameter(description = "分类 ID") @PathVariable Long id) {
|
||||
categoryService.delete(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "启用 / 禁用分类")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/{id}/status/{status}")
|
||||
public Result<Void> updateStatus(
|
||||
@Parameter(description = "分类 ID") @PathVariable Long id,
|
||||
@Parameter(description = "状态:0-禁用 1-启用") @PathVariable Integer status) {
|
||||
categoryService.updateStatus(id, status);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "调整排序")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/{id}/sort/{sort}")
|
||||
public Result<Void> updateSort(
|
||||
@Parameter(description = "分类 ID") @PathVariable Long id,
|
||||
@Parameter(description = "排序号") @PathVariable Integer sort) {
|
||||
categoryService.updateSort(id, sort);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.snack.server.module.category.controller;
|
||||
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.category.service.CategoryService;
|
||||
import com.snack.server.module.category.vo.CategoryTreeVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品分类(用户端 / 公开接口)
|
||||
*/
|
||||
@Tag(name = "商品分类(公开)")
|
||||
@RestController
|
||||
@RequestMapping("/api/category")
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryPublicController {
|
||||
|
||||
private final CategoryService categoryService;
|
||||
|
||||
@Operation(summary = "获取启用的分类树(用户端首页)")
|
||||
@GetMapping("/tree")
|
||||
public Result<List<CategoryTreeVO>> tree() {
|
||||
return Result.ok(categoryService.listTree(1));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.snack.server.module.category.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.category.entity.Category;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品分类 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface CategoryMapper extends BaseMapper<Category> {
|
||||
|
||||
/**
|
||||
* 查询所有子分类 ID(递归,包含自身)
|
||||
* 用于"删除分类时级联检查商品 / 子分类"
|
||||
*/
|
||||
List<Long> selectAllChildIds(@Param("parentId") Long parentId);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.snack.server.module.category.service;
|
||||
|
||||
import com.snack.server.module.category.dto.req.CategorySaveReq;
|
||||
import com.snack.server.module.category.entity.Category;
|
||||
import com.snack.server.module.category.vo.CategoryOptionVO;
|
||||
import com.snack.server.module.category.vo.CategoryTreeVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品分类业务接口
|
||||
*/
|
||||
public interface CategoryService {
|
||||
|
||||
/**
|
||||
* 获取分类树(全量)
|
||||
*
|
||||
* @param statusFilter null-全部 0-禁用 1-启用
|
||||
*/
|
||||
List<CategoryTreeVO> listTree(Integer statusFilter);
|
||||
|
||||
/**
|
||||
* 获取子分类(按父 ID 查直接子节点)
|
||||
*/
|
||||
List<Category> listByParentId(Long parentId);
|
||||
|
||||
/**
|
||||
* 获取下拉选项(仅启用状态)
|
||||
*/
|
||||
List<CategoryOptionVO> listOptions();
|
||||
|
||||
/**
|
||||
* 获取分类详情
|
||||
*/
|
||||
Category getById(Long id);
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
*/
|
||||
Long create(CategorySaveReq req);
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
*/
|
||||
void update(CategorySaveReq req);
|
||||
|
||||
/**
|
||||
* 删除分类(级联校验:有商品/子分类则禁止删除)
|
||||
*/
|
||||
void delete(Long id);
|
||||
|
||||
/**
|
||||
* 启用 / 禁用
|
||||
*/
|
||||
void updateStatus(Long id, Integer status);
|
||||
|
||||
/**
|
||||
* 调整排序
|
||||
*/
|
||||
void updateSort(Long id, Integer sort);
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
package com.snack.server.module.category.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.snack.server.common.ResultCode;
|
||||
import com.snack.server.exception.BusinessException;
|
||||
import com.snack.server.module.category.dto.req.CategorySaveReq;
|
||||
import com.snack.server.module.category.entity.Category;
|
||||
import com.snack.server.module.category.enums.CategoryLevelEnum;
|
||||
import com.snack.server.module.category.enums.CategoryStatusEnum;
|
||||
import com.snack.server.module.category.mapper.CategoryMapper;
|
||||
import com.snack.server.module.category.service.CategoryService;
|
||||
import com.snack.server.module.category.vo.CategoryOptionVO;
|
||||
import com.snack.server.module.category.vo.CategoryTreeVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 商品分类业务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryServiceImpl implements CategoryService {
|
||||
|
||||
/** 最大层级 */
|
||||
private static final int MAX_LEVEL = 3;
|
||||
/** 默认父分类 ID(顶级) */
|
||||
private static final Long ROOT_PARENT_ID = 0L;
|
||||
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
@Override
|
||||
public List<CategoryTreeVO> listTree(Integer statusFilter) {
|
||||
// 1. 查询全量分类
|
||||
LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<Category>()
|
||||
.orderByAsc(Category::getSort)
|
||||
.orderByAsc(Category::getId);
|
||||
if (statusFilter != null) {
|
||||
wrapper.eq(Category::getStatus, statusFilter);
|
||||
}
|
||||
List<Category> all = categoryMapper.selectList(wrapper);
|
||||
|
||||
// 2. 转 VO
|
||||
List<CategoryTreeVO> vos = all.stream().map(this::toTreeVO).toList();
|
||||
|
||||
// 3. 组装树
|
||||
return buildTree(vos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Category> listByParentId(Long parentId) {
|
||||
return categoryMapper.selectList(
|
||||
new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getParentId, parentId)
|
||||
.orderByAsc(Category::getSort)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CategoryOptionVO> listOptions() {
|
||||
return categoryMapper.selectList(
|
||||
new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getStatus, CategoryStatusEnum.ENABLED.getCode())
|
||||
.orderByAsc(Category::getSort)
|
||||
).stream().map(c -> {
|
||||
CategoryOptionVO vo = new CategoryOptionVO();
|
||||
BeanUtil.copyProperties(c, vo);
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Category getById(Long id) {
|
||||
Category category = categoryMapper.selectById(id);
|
||||
if (category == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
// ==================== 创建 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(CategorySaveReq req) {
|
||||
// 1. 校验父分类
|
||||
validateParent(req.getParentId(), req.getLevel());
|
||||
|
||||
// 2. 同级下名称不能重复
|
||||
Long sameNameCount = categoryMapper.selectCount(
|
||||
new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getParentId, req.getParentId())
|
||||
.eq(Category::getName, req.getName())
|
||||
);
|
||||
if (sameNameCount > 0) {
|
||||
throw new BusinessException(1003, "同级分类下已存在同名分类");
|
||||
}
|
||||
|
||||
// 3. 入库
|
||||
Category category = new Category();
|
||||
BeanUtil.copyProperties(req, category, "id");
|
||||
if (category.getStatus() == null) {
|
||||
category.setStatus(CategoryStatusEnum.ENABLED.getCode());
|
||||
}
|
||||
categoryMapper.insert(category);
|
||||
log.info("创建分类 id={} name={} level={}", category.getId(), category.getName(), category.getLevel());
|
||||
return category.getId();
|
||||
}
|
||||
|
||||
// ==================== 更新 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(CategorySaveReq req) {
|
||||
if (req.getId() == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
Category existing = categoryMapper.selectById(req.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
|
||||
// 不允许修改层级(层级变化会破坏树形结构)
|
||||
if (!existing.getLevel().equals(req.getLevel())) {
|
||||
throw new BusinessException(1000, "不允许修改层级");
|
||||
}
|
||||
|
||||
// 校验父分类
|
||||
validateParent(req.getParentId(), req.getLevel());
|
||||
// 不能把自己设为自己的父分类
|
||||
if (req.getId().equals(req.getParentId())) {
|
||||
throw new BusinessException(1000, "不能将自己设为父分类");
|
||||
}
|
||||
|
||||
// 同级下名称查重(排除自身)
|
||||
Long sameNameCount = categoryMapper.selectCount(
|
||||
new LambdaQueryWrapper<Category>()
|
||||
.eq(Category::getParentId, req.getParentId())
|
||||
.eq(Category::getName, req.getName())
|
||||
.ne(Category::getId, req.getId())
|
||||
);
|
||||
if (sameNameCount > 0) {
|
||||
throw new BusinessException(1003, "同级分类下已存在同名分类");
|
||||
}
|
||||
|
||||
Category category = new Category();
|
||||
BeanUtil.copyProperties(req, category);
|
||||
categoryMapper.updateById(category);
|
||||
log.info("更新分类 id={}", category.getId());
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
Category existing = categoryMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
|
||||
// 1. 校验是否有子分类
|
||||
Long childCount = categoryMapper.selectCount(
|
||||
new LambdaQueryWrapper<Category>().eq(Category::getParentId, id)
|
||||
);
|
||||
if (childCount > 0) {
|
||||
throw new BusinessException(1000,
|
||||
"存在 " + childCount + " 个子分类,请先删除子分类");
|
||||
}
|
||||
|
||||
// 2. 校验是否有关联商品(通过 product 表的 category_id 判断)
|
||||
Long productCount = categoryMapper.selectCount(
|
||||
new LambdaQueryWrapper<Category>().eq(Category::getId, id)
|
||||
// 这里只是占位,真实业务应注入 ProductMapper 检查 product.category_id
|
||||
);
|
||||
// 简化处理:实际应查 product 表,TODO: 注入 ProductService 检查
|
||||
// if (productService.countByCategoryId(id) > 0) {
|
||||
// throw new BusinessException("该分类下存在商品,无法删除");
|
||||
// }
|
||||
|
||||
categoryMapper.deleteById(id);
|
||||
log.info("删除分类 id={} name={}", id, existing.getName());
|
||||
}
|
||||
|
||||
// ==================== 状态切换 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (id == null || status == null) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
Category existing = categoryMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
if (existing.getStatus().equals(status)) {
|
||||
return; // 状态未变
|
||||
}
|
||||
Category update = new Category();
|
||||
update.setId(id);
|
||||
update.setStatus(status);
|
||||
categoryMapper.updateById(update);
|
||||
log.info("分类 id={} 状态更新为 {}", id, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateSort(Long id, Integer sort) {
|
||||
if (id == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
Category existing = categoryMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(1010, "分类不存在");
|
||||
}
|
||||
Category update = new Category();
|
||||
update.setId(id);
|
||||
update.setSort(sort == null ? 0 : sort);
|
||||
categoryMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 校验父分类合法性
|
||||
*/
|
||||
private void validateParent(Long parentId, Integer level) {
|
||||
if (parentId == null) {
|
||||
parentId = ROOT_PARENT_ID;
|
||||
}
|
||||
|
||||
// 一级分类:parentId 必须为 0
|
||||
if (level == null || level < 1 || level > MAX_LEVEL) {
|
||||
throw new BusinessException(1000, "层级必须在 1~" + MAX_LEVEL + " 之间");
|
||||
}
|
||||
|
||||
if (level == 1) {
|
||||
if (!ROOT_PARENT_ID.equals(parentId)) {
|
||||
throw new BusinessException(1000, "一级分类的父分类必须为 0");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 二级 / 三级:父分类必须存在
|
||||
Category parent = categoryMapper.selectById(parentId);
|
||||
if (parent == null) {
|
||||
throw new BusinessException(1000, "父分类不存在");
|
||||
}
|
||||
// 父分类的 level 必须 = 当前 level - 1
|
||||
if (!parent.getLevel().equals(level - 1)) {
|
||||
throw new BusinessException(1000,
|
||||
"父分类层级不匹配:当前层级=" + level + ",父分类层级=" + parent.getLevel());
|
||||
}
|
||||
// 父分类必须启用
|
||||
if (parent.getStatus() != null && parent.getStatus() == 0) {
|
||||
throw new BusinessException(1000, "父分类已禁用,无法添加子分类");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装树形结构
|
||||
*/
|
||||
private List<CategoryTreeVO> buildTree(List<CategoryTreeVO> all) {
|
||||
if (CollUtil.isEmpty(all)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
// 按 id 建索引
|
||||
Map<Long, CategoryTreeVO> map = all.stream()
|
||||
.collect(Collectors.toMap(CategoryTreeVO::getId, v -> v, (a, b) -> a));
|
||||
|
||||
List<CategoryTreeVO> roots = new ArrayList<>();
|
||||
for (CategoryTreeVO vo : all) {
|
||||
if (vo.getParentId() == null || vo.getParentId() == 0L) {
|
||||
roots.add(vo);
|
||||
continue;
|
||||
}
|
||||
CategoryTreeVO parent = map.get(vo.getParentId());
|
||||
if (parent != null) {
|
||||
if (parent.getChildren() == null) {
|
||||
parent.setChildren(new ArrayList<>());
|
||||
}
|
||||
parent.getChildren().add(vo);
|
||||
} else {
|
||||
// 孤儿节点(父分类被删除),按顶级处理
|
||||
roots.add(vo);
|
||||
}
|
||||
}
|
||||
// 排序
|
||||
sortTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
private void sortTree(List<CategoryTreeVO> list) {
|
||||
if (CollUtil.isEmpty(list)) return;
|
||||
list.sort(Comparator.comparing(CategoryTreeVO::getSort,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(CategoryTreeVO::getId));
|
||||
for (CategoryTreeVO vo : list) {
|
||||
sortTree(vo.getChildren());
|
||||
}
|
||||
}
|
||||
|
||||
private CategoryTreeVO toTreeVO(Category c) {
|
||||
CategoryTreeVO vo = new CategoryTreeVO();
|
||||
BeanUtil.copyProperties(c, vo);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<CategoryTreeVO> children;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.snack.server.module.product.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.product.dto.req.ProductPageReq;
|
||||
import com.snack.server.module.product.dto.req.ProductSaveReq;
|
||||
import com.snack.server.module.product.dto.req.StockAdjustReq;
|
||||
import com.snack.server.module.product.service.ProductService;
|
||||
import com.snack.server.module.product.vo.ProductDetailVO;
|
||||
import com.snack.server.module.product.vo.ProductListVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 商品管理(管理端)
|
||||
*/
|
||||
@Tag(name = "03. 商品管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/product")
|
||||
@RequiredArgsConstructor
|
||||
public class ProductAdminController {
|
||||
|
||||
private final ProductService productService;
|
||||
|
||||
@Operation(summary = "分页查询商品")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/page")
|
||||
public Result<Page<ProductListVO>> page(ProductPageReq req) {
|
||||
return Result.ok(productService.pageProducts(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "商品详情(含 SKU)")
|
||||
@SaCheckLogin
|
||||
@GetMapping("/{id}")
|
||||
public Result<ProductDetailVO> detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
|
||||
return Result.ok(productService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "发布商品(SPU + SKU)")
|
||||
@SaCheckLogin
|
||||
@PostMapping
|
||||
public Result<Long> create(@Valid @RequestBody ProductSaveReq req) {
|
||||
return Result.ok(productService.createProduct(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "编辑商品(SPU + SKU)")
|
||||
@SaCheckLogin
|
||||
@PutMapping
|
||||
public Result<Void> update(@Valid @RequestBody ProductSaveReq req) {
|
||||
productService.updateProduct(req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除商品(级联删除 SKU)")
|
||||
@SaCheckLogin
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@Parameter(description = "商品 ID") @PathVariable Long id) {
|
||||
productService.deleteProduct(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "上下架切换")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/{id}/status/{status}")
|
||||
public Result<Void> updateStatus(
|
||||
@Parameter(description = "商品 ID") @PathVariable Long id,
|
||||
@Parameter(description = "状态:0-下架 1-上架") @PathVariable Integer status) {
|
||||
productService.updateStatus(id, status);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "设置 / 取消热门")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/{id}/hot/{isHot}")
|
||||
public Result<Void> updateHot(
|
||||
@PathVariable Long id,
|
||||
@Parameter(description = "0-否 1-是") @PathVariable Integer isHot) {
|
||||
productService.updateHot(id, isHot);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "设置 / 取消新品")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/{id}/new/{isNew}")
|
||||
public Result<Void> updateNew(
|
||||
@PathVariable Long id,
|
||||
@Parameter(description = "0-否 1-是") @PathVariable Integer isNew) {
|
||||
productService.updateNew(id, isNew);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "调整 SKU 库存")
|
||||
@SaCheckLogin
|
||||
@PutMapping("/stock/adjust")
|
||||
public Result<Void> adjustStock(@Valid @RequestBody StockAdjustReq req) {
|
||||
productService.adjustStock(req);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.snack.server.module.product.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.snack.server.common.Result;
|
||||
import com.snack.server.module.product.service.ProductService;
|
||||
import com.snack.server.module.product.vo.ProductDetailVO;
|
||||
import com.snack.server.module.product.vo.ProductListVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品(用户端 / 公开)
|
||||
*/
|
||||
@Tag(name = "商品(公开)")
|
||||
@RestController
|
||||
@RequestMapping("/api/product")
|
||||
@RequiredArgsConstructor
|
||||
public class ProductPublicController {
|
||||
|
||||
private final ProductService productService;
|
||||
|
||||
@Operation(summary = "商品详情(用户端)")
|
||||
@GetMapping("/{id}")
|
||||
public Result<ProductDetailVO> detail(@Parameter(description = "商品 ID") @PathVariable Long id) {
|
||||
// 增加浏览量
|
||||
productService.incrViewCount(id);
|
||||
return Result.ok(productService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "分类下的商品列表(用户端分类页)")
|
||||
@GetMapping("/list")
|
||||
public Result<List<ProductListVO>> list(
|
||||
@Parameter(description = "分类 ID(多个用逗号分隔)") @RequestParam String categoryIds,
|
||||
@Parameter(description = "排序:sales_desc / price_asc / price_desc / create_desc")
|
||||
@RequestParam(required = false) String orderBy,
|
||||
@Parameter(description = "返回数量限制") @RequestParam(defaultValue = "20") Integer limit) {
|
||||
List<Long> ids = Arrays.stream(categoryIds.split(","))
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.map(Long::valueOf)
|
||||
.toList();
|
||||
return Result.ok(productService.listByCategoryIds(ids, orderBy, limit));
|
||||
}
|
||||
|
||||
@Operation(summary = "热门商品")
|
||||
@GetMapping("/hot")
|
||||
public Result<List<ProductListVO>> hot(@RequestParam(defaultValue = "10") Integer limit) {
|
||||
return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "sales_desc", limit));
|
||||
}
|
||||
|
||||
@Operation(summary = "新品上市")
|
||||
@GetMapping("/new")
|
||||
public Result<List<ProductListVO>> newProducts(@RequestParam(defaultValue = "10") Integer limit) {
|
||||
return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "create_desc", limit));
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.snack.server.module.product.dto.req;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 发布/更新商品请求(含 SKU 列表)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "发布/更新商品请求")
|
||||
public class ProductSaveReq implements Serializable {
|
||||
|
||||
@Schema(description = "商品 ID(更新时必填)")
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "商品名称不能为空")
|
||||
@Size(max = 200, message = "商品名称最长 200 字")
|
||||
@Schema(description = "商品名称", example = "原味夏威夷果 500g")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "分类不能为空")
|
||||
@Schema(description = "分类 ID", example = "2")
|
||||
private Long categoryId;
|
||||
|
||||
@Schema(description = "品牌")
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "主图 URL")
|
||||
private String mainImage;
|
||||
|
||||
@Schema(description = "副图 JSON 数组字符串")
|
||||
private String subImages;
|
||||
|
||||
@Schema(description = "富文本详情(HTML)")
|
||||
private String detail;
|
||||
|
||||
@Schema(description = "原价 / 划线价")
|
||||
private BigDecimal originPrice;
|
||||
|
||||
@Schema(description = "是否热门:0-否 1-是")
|
||||
private Integer isHot = 0;
|
||||
|
||||
@Schema(description = "是否新品:0-否 1-是")
|
||||
private Integer isNew = 0;
|
||||
|
||||
@Schema(description = "上架状态:0-下架 1-上架", example = "1")
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* SKU 列表(创建时必填至少 1 个)
|
||||
* id 为空 = 新增;非空 = 更新
|
||||
*/
|
||||
@NotNull(message = "SKU 列表不能为空")
|
||||
@Size(min = 1, message = "至少需要一个 SKU 规格")
|
||||
@Valid
|
||||
@Schema(description = "SKU 列表")
|
||||
private List<ProductSkuReq> skuList;
|
||||
|
||||
@Data
|
||||
public static class ProductSkuReq implements Serializable {
|
||||
|
||||
@Schema(description = "SKU ID(更新时必填)")
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "规格名称不能为空")
|
||||
@Schema(description = "规格名称", example = "原味/500g")
|
||||
private String skuName;
|
||||
|
||||
@Schema(description = "规格图片")
|
||||
private String image;
|
||||
|
||||
@NotNull(message = "价格不能为空")
|
||||
@DecimalMin(value = "0.01", message = "价格必须大于 0")
|
||||
@Schema(description = "售价", example = "79.00")
|
||||
private BigDecimal price;
|
||||
|
||||
@NotNull(message = "库存不能为空")
|
||||
@Min(value = 0, message = "库存不能为负数")
|
||||
@Schema(description = "库存", example = "500")
|
||||
private Integer stock;
|
||||
|
||||
@Schema(description = "重量(克)")
|
||||
private Integer weight;
|
||||
|
||||
@Schema(description = "排序号")
|
||||
private Integer sort = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.snack.server.module.product.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.product.entity.Product;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 商品 SPU Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface ProductMapper extends BaseMapper<Product> {
|
||||
|
||||
/**
|
||||
* 批量查询商品的最低 SKU 价格(用于列表展示"起售价")
|
||||
*/
|
||||
List<Map<String, Object>> selectMinPriceByProductIds(@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.snack.server.module.product.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.snack.server.module.product.entity.ProductSku;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/**
|
||||
* 商品 SKU Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface ProductSkuMapper extends BaseMapper<ProductSku> {
|
||||
|
||||
/**
|
||||
* 扣减库存(原子操作,乐观锁兜底)
|
||||
* @return 受影响行数;0 表示库存不足
|
||||
*/
|
||||
@Update("UPDATE product_sku SET stock = stock - #{quantity}, sales = sales + #{quantity} " +
|
||||
"WHERE id = #{skuId} AND stock >= #{quantity}")
|
||||
int decrStock(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);
|
||||
|
||||
/**
|
||||
* 释放库存(取消订单时调用)
|
||||
*/
|
||||
@Update("UPDATE product_sku SET stock = stock + #{quantity}, sales = sales - #{quantity} " +
|
||||
"WHERE id = #{skuId} AND sales >= #{quantity}")
|
||||
int incrStock(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package com.snack.server.module.product.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.module.product.dto.req.ProductPageReq;
|
||||
import com.snack.server.module.product.dto.req.ProductSaveReq;
|
||||
import com.snack.server.module.product.dto.req.StockAdjustReq;
|
||||
import com.snack.server.module.product.entity.ProductSku;
|
||||
import com.snack.server.module.product.vo.ProductDetailVO;
|
||||
import com.snack.server.module.product.vo.ProductListVO;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品业务接口
|
||||
*/
|
||||
public interface ProductService {
|
||||
|
||||
/**
|
||||
* 分页查询(管理端)
|
||||
*/
|
||||
Page<ProductListVO> pageProducts(ProductPageReq req);
|
||||
|
||||
/**
|
||||
* 商品详情(含 SKU)
|
||||
*/
|
||||
ProductDetailVO getDetail(Long id);
|
||||
|
||||
/**
|
||||
* 根据 ID 获取商品(不含 SKU)
|
||||
*/
|
||||
ProductDetailVO getById(Long id);
|
||||
|
||||
/**
|
||||
* 创建商品(SPU + SKU 一起保存)
|
||||
*/
|
||||
Long createProduct(ProductSaveReq req);
|
||||
|
||||
/**
|
||||
* 更新商品(SPU + SKU 一起保存,SKU 增量更新)
|
||||
*/
|
||||
void updateProduct(ProductSaveReq req);
|
||||
|
||||
/**
|
||||
* 删除商品(级联删除 SKU)
|
||||
*/
|
||||
void deleteProduct(Long id);
|
||||
|
||||
/**
|
||||
* 上下架切换
|
||||
*/
|
||||
void updateStatus(Long id, Integer status);
|
||||
|
||||
/**
|
||||
* 设置 / 取消热门
|
||||
*/
|
||||
void updateHot(Long id, Integer isHot);
|
||||
|
||||
/**
|
||||
* 设置 / 取消新品
|
||||
*/
|
||||
void updateNew(Long id, Integer isNew);
|
||||
|
||||
/**
|
||||
* 调整 SKU 库存
|
||||
*/
|
||||
void adjustStock(StockAdjustReq req);
|
||||
|
||||
/**
|
||||
* 扣减库存(订单 / 抢购用)
|
||||
* @return true 成功,false 库存不足
|
||||
*/
|
||||
boolean decrStock(Long skuId, Integer quantity);
|
||||
|
||||
/**
|
||||
* 释放库存(取消订单 / 退款用)
|
||||
*/
|
||||
void incrStock(Long skuId, Integer quantity);
|
||||
|
||||
/**
|
||||
* 增加浏览量
|
||||
*/
|
||||
void incrViewCount(Long id);
|
||||
|
||||
/**
|
||||
* 根据分类 ID 列表查询商品(用户端首页 / 分类页)
|
||||
*/
|
||||
List<ProductListVO> listByCategoryIds(Collection<Long> categoryIds, String orderBy, Integer limit);
|
||||
|
||||
/**
|
||||
* 根据 SPU ID 列表获取所有 SKU
|
||||
*/
|
||||
List<ProductSku> listSkusByProductIds(List<Long> productIds);
|
||||
}
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
package com.snack.server.module.product.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.snack.server.common.ResultCode;
|
||||
import com.snack.server.exception.BusinessException;
|
||||
import com.snack.server.module.category.entity.Category;
|
||||
import com.snack.server.module.category.mapper.CategoryMapper;
|
||||
import com.snack.server.module.product.dto.req.ProductPageReq;
|
||||
import com.snack.server.module.product.dto.req.ProductSaveReq;
|
||||
import com.snack.server.module.product.dto.req.StockAdjustReq;
|
||||
import com.snack.server.module.product.entity.Product;
|
||||
import com.snack.server.module.product.entity.ProductSku;
|
||||
import com.snack.server.module.product.enums.ProductStatusEnum;
|
||||
import com.snack.server.module.product.mapper.ProductMapper;
|
||||
import com.snack.server.module.product.mapper.ProductSkuMapper;
|
||||
import com.snack.server.module.product.service.ProductService;
|
||||
import com.snack.server.module.product.vo.ProductDetailVO;
|
||||
import com.snack.server.module.product.vo.ProductListVO;
|
||||
import com.snack.server.module.product.vo.ProductSkuVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 商品业务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProductServiceImpl implements ProductService {
|
||||
|
||||
private final ProductMapper productMapper;
|
||||
private final ProductSkuMapper productSkuMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
|
||||
// ==================== 分页查询 ====================
|
||||
|
||||
@Override
|
||||
public Page<ProductListVO> pageProducts(ProductPageReq req) {
|
||||
Page<Product> page = new Page<>(req.getCurrent(), req.getSize());
|
||||
LambdaQueryWrapper<Product> wrapper = buildQueryWrapper(req)
|
||||
.orderByDesc(Product::getId);
|
||||
|
||||
Page<Product> result = productMapper.selectPage(page, wrapper);
|
||||
return toListVOPage(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductListVO> listByCategoryIds(Collection<Long> categoryIds, String orderBy, Integer limit) {
|
||||
if (CollUtil.isEmpty(categoryIds)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
ProductPageReq req = new ProductPageReq();
|
||||
req.setCurrent(1L);
|
||||
req.setSize((long) (limit == null ? 10 : limit));
|
||||
req.setStatus(ProductStatusEnum.ON_SHELF.getCode());
|
||||
req.setOrderBy(orderBy);
|
||||
|
||||
LambdaQueryWrapper<Product> wrapper = buildQueryWrapper(req)
|
||||
.in(Product::getCategoryId, categoryIds);
|
||||
applyOrderBy(wrapper, orderBy);
|
||||
|
||||
List<Product> products = productMapper.selectList(wrapper);
|
||||
return enrichListVO(products);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Product> buildQueryWrapper(ProductPageReq req) {
|
||||
return new LambdaQueryWrapper<Product>()
|
||||
.and(StrUtil.isNotBlank(req.getKeyword()), w -> w
|
||||
.like(Product::getName, req.getKeyword())
|
||||
.or().like(Product::getBrand, req.getKeyword())
|
||||
)
|
||||
.eq(req.getCategoryId() != null, Product::getCategoryId, req.getCategoryId())
|
||||
.eq(req.getStatus() != null, Product::getStatus, req.getStatus())
|
||||
.eq(req.getIsHot() != null, Product::getIsHot, req.getIsHot())
|
||||
.eq(req.getIsNew() != null, Product::getIsNew, req.getIsNew());
|
||||
}
|
||||
|
||||
private void applyOrderBy(LambdaQueryWrapper<Product> wrapper, String orderBy) {
|
||||
if (StrUtil.isBlank(orderBy)) {
|
||||
wrapper.orderByDesc(Product::getId);
|
||||
return;
|
||||
}
|
||||
switch (orderBy) {
|
||||
case "sales_desc" -> wrapper.orderByDesc(Product::getSales);
|
||||
case "price_asc" -> wrapper.orderByAsc(Product::getId); // 简化:用子查询会更准
|
||||
case "price_desc" -> wrapper.orderByDesc(Product::getId);
|
||||
case "create_desc" -> wrapper.orderByDesc(Product::getId);
|
||||
default -> wrapper.orderByDesc(Product::getId);
|
||||
}
|
||||
}
|
||||
|
||||
private Page<ProductListVO> toListVOPage(Page<Product> page) {
|
||||
Page<ProductListVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
|
||||
voPage.setRecords(enrichListVO(page.getRecords()));
|
||||
return voPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 Product 列表 → ProductListVO(含分类名、起售价、库存等)
|
||||
*/
|
||||
private List<ProductListVO> enrichListVO(List<Product> products) {
|
||||
if (CollUtil.isEmpty(products)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
// 1. 批量取分类名
|
||||
Set<Long> categoryIds = products.stream()
|
||||
.map(Product::getCategoryId).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, String> categoryNameMap = new HashMap<>();
|
||||
if (!categoryIds.isEmpty()) {
|
||||
categoryMapper.selectBatchIds(categoryIds)
|
||||
.forEach(c -> categoryNameMap.put(c.getId(), c.getName()));
|
||||
}
|
||||
|
||||
// 2. 批量取起售价
|
||||
List<Long> productIds = products.stream().map(Product::getId).toList();
|
||||
Map<Long, BigDecimal> minPriceMap = new HashMap<>();
|
||||
Map<Long, Integer> stockMap = new HashMap<>();
|
||||
if (!productIds.isEmpty()) {
|
||||
try {
|
||||
List<Map<String, Object>> priceList = productMapper.selectMinPriceByProductIds(productIds);
|
||||
for (Map<String, Object> row : priceList) {
|
||||
Long pid = ((Number) row.get("productId")).longValue();
|
||||
Object minPrice = row.get("minPrice");
|
||||
Object totalStock = row.get("totalStock");
|
||||
if (minPrice != null) minPriceMap.put(pid, new BigDecimal(minPrice.toString()));
|
||||
if (totalStock != null) stockMap.put(pid, ((Number) totalStock).intValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("查询起售价失败,降级为单条查询:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 组装
|
||||
return products.stream().map(p -> {
|
||||
ProductListVO vo = new ProductListVO();
|
||||
BeanUtil.copyProperties(p, vo);
|
||||
vo.setCategoryName(categoryNameMap.get(p.getCategoryId()));
|
||||
vo.setMinPrice(minPriceMap.get(p.getId()));
|
||||
vo.setTotalStock(stockMap.getOrDefault(p.getId(), 0));
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// ==================== 详情 ====================
|
||||
|
||||
@Override
|
||||
public ProductDetailVO getDetail(Long id) {
|
||||
Product product = productMapper.selectById(id);
|
||||
if (product == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
return enrichDetailVO(product, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProductDetailVO getById(Long id) {
|
||||
Product product = productMapper.selectById(id);
|
||||
if (product == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
return enrichDetailVO(product, false);
|
||||
}
|
||||
|
||||
private ProductDetailVO enrichDetailVO(Product p, boolean withSku) {
|
||||
ProductDetailVO vo = new ProductDetailVO();
|
||||
BeanUtil.copyProperties(p, vo, "subImages");
|
||||
// 分类名
|
||||
if (p.getCategoryId() != null) {
|
||||
Category c = categoryMapper.selectById(p.getCategoryId());
|
||||
if (c != null) vo.setCategoryName(c.getName());
|
||||
}
|
||||
// 副图 JSON 数组 → List
|
||||
if (StrUtil.isNotBlank(p.getSubImages())) {
|
||||
try {
|
||||
vo.setSubImages(com.alibaba.fastjson2.JSON.parseArray(p.getSubImages(), String.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("解析副图 JSON 失败:{}", p.getSubImages());
|
||||
}
|
||||
}
|
||||
// SKU
|
||||
if (withSku) {
|
||||
List<ProductSku> skus = productSkuMapper.selectList(
|
||||
new LambdaQueryWrapper<ProductSku>()
|
||||
.eq(ProductSku::getProductId, p.getId())
|
||||
.orderByAsc(ProductSku::getSort)
|
||||
);
|
||||
vo.setSkuList(skus.stream().map(s -> {
|
||||
ProductSkuVO skuVO = new ProductSkuVO();
|
||||
BeanUtil.copyProperties(s, skuVO);
|
||||
return skuVO;
|
||||
}).toList());
|
||||
// 起售价
|
||||
if (CollUtil.isNotEmpty(skus)) {
|
||||
vo.setMinPrice(skus.stream()
|
||||
.map(ProductSku::getPrice)
|
||||
.filter(Objects::nonNull)
|
||||
.min(BigDecimal::compareTo)
|
||||
.orElse(null));
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProductSku> listSkusByProductIds(List<Long> productIds) {
|
||||
if (CollUtil.isEmpty(productIds)) return new ArrayList<>();
|
||||
return productSkuMapper.selectList(
|
||||
new LambdaQueryWrapper<ProductSku>().in(ProductSku::getProductId, productIds)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 创建 / 更新 / 删除 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createProduct(ProductSaveReq req) {
|
||||
// 1. 校验分类
|
||||
validateCategory(req.getCategoryId());
|
||||
|
||||
// 2. SKU 名称去重
|
||||
validateSkuNames(req.getSkuList());
|
||||
|
||||
// 3. 保存 SPU
|
||||
Product product = new Product();
|
||||
BeanUtil.copyProperties(req, product, "id", "skuList");
|
||||
if (product.getSales() == null) product.setSales(0);
|
||||
if (product.getViewCount() == null) product.setViewCount(0);
|
||||
if (product.getStatus() == null) product.setStatus(ProductStatusEnum.ON_SHELF.getCode());
|
||||
productMapper.insert(product);
|
||||
log.info("创建商品 id={} name={}", product.getId(), product.getName());
|
||||
|
||||
// 4. 批量保存 SKU
|
||||
saveSkus(product.getId(), req.getSkuList());
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateProduct(ProductSaveReq req) {
|
||||
if (req.getId() == null) {
|
||||
throw new BusinessException(1000, "ID 不能为空");
|
||||
}
|
||||
Product existing = productMapper.selectById(req.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
validateCategory(req.getCategoryId());
|
||||
validateSkuNames(req.getSkuList());
|
||||
|
||||
// 1. 更新 SPU
|
||||
Product product = new Product();
|
||||
BeanUtil.copyProperties(req, product, "skuList");
|
||||
productMapper.updateById(product);
|
||||
|
||||
// 2. SKU 增量更新:删除本次请求中不存在的旧 SKU,新增的插入,已存在的更新
|
||||
List<ProductSku> oldSkus = productSkuMapper.selectList(
|
||||
new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, req.getId())
|
||||
);
|
||||
Set<Long> reqSkuIds = req.getSkuList().stream()
|
||||
.map(ProductSaveReq.ProductSkuReq::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
|
||||
// 删除:旧 SKU 中不在本次请求的
|
||||
List<Long> toDelete = oldSkus.stream()
|
||||
.filter(s -> !reqSkuIds.contains(s.getId()))
|
||||
.map(ProductSku::getId).toList();
|
||||
if (!toDelete.isEmpty()) {
|
||||
// 检查是否有未支付订单引用(这里简化为直接删,业务上应加状态校验)
|
||||
productSkuMapper.deleteByIds(toDelete);
|
||||
log.info("删除 SKU ids={}", toDelete);
|
||||
}
|
||||
// 新增 / 更新
|
||||
saveSkus(req.getId(), req.getSkuList());
|
||||
log.info("更新商品 id={}", req.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteProduct(Long id) {
|
||||
Product existing = productMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
// 1. 删除所有 SKU
|
||||
productSkuMapper.delete(
|
||||
new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, id)
|
||||
);
|
||||
// 2. 删除 SPU
|
||||
productMapper.deleteById(id);
|
||||
log.info("删除商品 id={}", id);
|
||||
}
|
||||
|
||||
// ==================== 状态切换 ====================
|
||||
|
||||
@Override
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (id == null || status == null) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
Product existing = productMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
if (existing.getStatus() != null && existing.getStatus().equals(status)) {
|
||||
return; // 状态未变
|
||||
}
|
||||
Product update = new Product();
|
||||
update.setId(id);
|
||||
update.setStatus(status);
|
||||
productMapper.updateById(update);
|
||||
log.info("商品 id={} 状态更新为 {}", id, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateHot(Long id, Integer isHot) {
|
||||
updateFlag(id, isHot, "isHot");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateNew(Long id, Integer isNew) {
|
||||
updateFlag(id, isNew, "isNew");
|
||||
}
|
||||
|
||||
private void updateFlag(Long id, Integer value, String field) {
|
||||
if (id == null || value == null) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
Product existing = productMapper.selectById(id);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(ResultCode.PRODUCT_NOT_EXIST);
|
||||
}
|
||||
Product update = new Product();
|
||||
update.setId(id);
|
||||
switch (field) {
|
||||
case "isHot" -> update.setIsHot(value);
|
||||
case "isNew" -> update.setIsNew(value);
|
||||
}
|
||||
productMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 库存 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void adjustStock(StockAdjustReq req) {
|
||||
if (req.getSkuId() == null) {
|
||||
throw new BusinessException(1000, "SKU ID 不能为空");
|
||||
}
|
||||
ProductSku sku = productSkuMapper.selectById(req.getSkuId());
|
||||
if (sku == null) {
|
||||
throw new BusinessException(1010, "SKU 不存在");
|
||||
}
|
||||
|
||||
int newStock;
|
||||
if (req.getDelta() != null) {
|
||||
newStock = sku.getStock() + req.getDelta();
|
||||
} else if (req.getTargetStock() != null) {
|
||||
newStock = req.getTargetStock();
|
||||
} else {
|
||||
throw new BusinessException(1000, "delta 与 targetStock 至少填一个");
|
||||
}
|
||||
if (newStock < 0) {
|
||||
throw new BusinessException(1012, "调整后库存不能小于 0");
|
||||
}
|
||||
|
||||
ProductSku update = new ProductSku();
|
||||
update.setId(sku.getId());
|
||||
update.setStock(newStock);
|
||||
productSkuMapper.updateById(update);
|
||||
log.info("SKU id={} 库存调整为 {}", req.getSkuId(), newStock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean decrStock(Long skuId, Integer quantity) {
|
||||
if (skuId == null || quantity == null || quantity <= 0) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
int affected = productSkuMapper.decrStock(skuId, quantity);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrStock(Long skuId, Integer quantity) {
|
||||
if (skuId == null || quantity == null || quantity <= 0) {
|
||||
throw new BusinessException(1000, "参数错误");
|
||||
}
|
||||
int affected = productSkuMapper.incrStock(skuId, quantity);
|
||||
if (affected == 0) {
|
||||
log.warn("释放库存失败 skuId={} quantity={}", skuId, quantity);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 浏览量 ====================
|
||||
|
||||
@Override
|
||||
public void incrViewCount(Long id) {
|
||||
// 简单实现:直接 UPDATE;高并发可改为 Redis INCR + 定时落库
|
||||
Product p = productMapper.selectById(id);
|
||||
if (p == null) return;
|
||||
Product update = new Product();
|
||||
update.setId(id);
|
||||
update.setViewCount(p.getViewCount() == null ? 1 : p.getViewCount() + 1);
|
||||
productMapper.updateById(update);
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
private void saveSkus(Long productId, List<ProductSaveReq.ProductSkuReq> skuReqs) {
|
||||
for (ProductSaveReq.ProductSkuReq req : skuReqs) {
|
||||
ProductSku sku = new ProductSku();
|
||||
BeanUtil.copyProperties(req, sku, "id");
|
||||
sku.setProductId(productId);
|
||||
if (sku.getStock() == null) sku.setStock(0);
|
||||
if (sku.getSales() == null) sku.setSales(0);
|
||||
if (sku.getSort() == null) sku.setSort(0);
|
||||
if (sku.getId() == null) {
|
||||
productSkuMapper.insert(sku);
|
||||
} else {
|
||||
productSkuMapper.updateById(sku);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCategory(Long categoryId) {
|
||||
if (categoryId == null) {
|
||||
throw new BusinessException(1000, "分类不能为空");
|
||||
}
|
||||
Category category = categoryMapper.selectById(categoryId);
|
||||
if (category == null) {
|
||||
throw new BusinessException(ResultCode.CATEGORY_NOT_EXIST);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSkuNames(List<ProductSaveReq.ProductSkuReq> skus) {
|
||||
if (CollUtil.isEmpty(skus)) {
|
||||
throw new BusinessException(1000, "至少需要一个 SKU");
|
||||
}
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ProductSaveReq.ProductSkuReq sku : skus) {
|
||||
if (!names.add(sku.getSkuName())) {
|
||||
throw new BusinessException(1003, "SKU 名称重复:" + sku.getSkuName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.snack.server.module.product.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品详情 VO(包含 SKU 列表)
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "商品详情")
|
||||
public class ProductDetailVO implements Serializable {
|
||||
|
||||
@Schema(description = "商品 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "商品名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分类 ID")
|
||||
private Long categoryId;
|
||||
|
||||
@Schema(description = "分类名称")
|
||||
private String categoryName;
|
||||
|
||||
@Schema(description = "品牌")
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "主图")
|
||||
private String mainImage;
|
||||
|
||||
@Schema(description = "副图列表")
|
||||
private List<String> subImages;
|
||||
|
||||
@Schema(description = "富文本详情")
|
||||
private String detail;
|
||||
|
||||
@Schema(description = "原价")
|
||||
private BigDecimal originPrice;
|
||||
|
||||
@Schema(description = "起售价")
|
||||
private BigDecimal minPrice;
|
||||
|
||||
@Schema(description = "总销量")
|
||||
private Integer sales;
|
||||
|
||||
@Schema(description = "浏览量")
|
||||
private Integer viewCount;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "是否热门")
|
||||
private Integer isHot;
|
||||
|
||||
@Schema(description = "是否新品")
|
||||
private Integer isNew;
|
||||
|
||||
@Schema(description = "SKU 列表")
|
||||
private List<ProductSkuVO> skuList;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.snack.server.module.product.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 商品列表 VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "商品列表项")
|
||||
public class ProductListVO implements Serializable {
|
||||
|
||||
@Schema(description = "商品 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "商品名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分类 ID")
|
||||
private Long categoryId;
|
||||
|
||||
@Schema(description = "分类名称")
|
||||
private String categoryName;
|
||||
|
||||
@Schema(description = "品牌")
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "主图")
|
||||
private String mainImage;
|
||||
|
||||
@Schema(description = "起售价")
|
||||
private BigDecimal minPrice;
|
||||
|
||||
@Schema(description = "最高价")
|
||||
private BigDecimal maxPrice;
|
||||
|
||||
@Schema(description = "总库存(所有 SKU 之和)")
|
||||
private Integer totalStock;
|
||||
|
||||
@Schema(description = "累计销量")
|
||||
private Integer sales;
|
||||
|
||||
@Schema(description = "状态:0-下架 1-上架")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "是否热门")
|
||||
private Integer isHot;
|
||||
|
||||
@Schema(description = "是否新品")
|
||||
private Integer isNew;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.snack.server.module.product.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 商品 SKU VO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "商品 SKU")
|
||||
public class ProductSkuVO implements Serializable {
|
||||
|
||||
@Schema(description = "SKU ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "所属 SPU ID")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "规格名称")
|
||||
private String skuName;
|
||||
|
||||
@Schema(description = "规格图片")
|
||||
private String image;
|
||||
|
||||
@Schema(description = "售价")
|
||||
private BigDecimal price;
|
||||
|
||||
@Schema(description = "库存")
|
||||
private Integer stock;
|
||||
|
||||
@Schema(description = "销量")
|
||||
private Integer sales;
|
||||
|
||||
@Schema(description = "重量(克)")
|
||||
private Integer weight;
|
||||
|
||||
@Schema(description = "排序号")
|
||||
private Integer sort;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.snack.server.utils;
|
||||
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
|
||||
/**
|
||||
* BCrypt 密码生成工具(仅用于初始化数据 / 测试)
|
||||
* 使用方法:运行 main 方法,传入明文密码即可打印出 BCrypt hash
|
||||
*/
|
||||
public class PasswordGenerator {
|
||||
|
||||
public static void main(String[] args) {
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
|
||||
String raw = args.length > 0 ? args[0] : "123456";
|
||||
String encoded = encoder.encode(raw);
|
||||
System.out.println("明文: " + raw);
|
||||
System.out.println("BCrypt: " + encoded);
|
||||
System.out.println("校验: " + encoder.matches(raw, encoded));
|
||||
}
|
||||
}
|
||||
|
|
@ -34,8 +34,8 @@ spring:
|
|||
|
||||
# MyBatis-Plus 配置
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:com/snack/server/mapper/**/*.xml
|
||||
type-aliases-package: com.snack.server.entity
|
||||
mapper-locations: classpath*:com/snack/server/module/**/mapper/**/*.xml
|
||||
type-aliases-package: com.snack.server.module.**.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.snack.server.module.category.mapper.CategoryMapper">
|
||||
|
||||
<!-- 通用 ResultMap -->
|
||||
<resultMap id="BaseResultMap" type="com.snack.server.module.category.entity.Category">
|
||||
<id property="id" column="id"/>
|
||||
<result property="name" column="name"/>
|
||||
<result property="parentId" column="parent_id"/>
|
||||
<result property="level" column="level"/>
|
||||
<result property="sort" column="sort"/>
|
||||
<result property="icon" column="icon"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- 递归查询所有子分类 ID(包含自身) -->
|
||||
<select id="selectAllChildIds" resultType="java.lang.Long">
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT id, parent_id
|
||||
FROM category
|
||||
WHERE id = #{parentId}
|
||||
UNION ALL
|
||||
SELECT c.id, c.parent_id
|
||||
FROM category c
|
||||
INNER JOIN category_tree ct ON c.parent_id = ct.id
|
||||
)
|
||||
SELECT id FROM category_tree
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.snack.server.module.product.mapper.ProductMapper">
|
||||
|
||||
<!--
|
||||
批量查询商品的 SKU 起售价(最低价格)
|
||||
用于商品列表"¥XX 起"展示
|
||||
-->
|
||||
<select id="selectMinPriceByProductIds" resultType="java.util.Map">
|
||||
SELECT
|
||||
product_id AS productId,
|
||||
MIN(price) AS minPrice,
|
||||
SUM(stock) AS totalStock
|
||||
FROM product_sku
|
||||
WHERE product_id IN
|
||||
<foreach collection="ids" item="id" open="(" close=")" separator=",">
|
||||
#{id}
|
||||
</foreach>
|
||||
GROUP BY product_id
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
Loading…
Reference in New Issue