diff --git a/db/BUSINESS_DESIGN.md b/db/BUSINESS_DESIGN.md new file mode 100644 index 0000000..2d55568 --- /dev/null +++ b/db/BUSINESS_DESIGN.md @@ -0,0 +1,330 @@ +# 核心业务设计文档:WebSocket 客服 + Redis 防超卖 + +本文件补充功能设计文档中两个关键业务模块的**详细设计**: +1. 客服聊天(WebSocket 长连接 + 消息补发) +2. 限时抢购(Redis 原子操作 + 异步削峰 + 一人一单) + +--- + +## 一、客服聊天(WebSocket 实时通信) + +### 1.1 整体架构 + +``` +┌──────────┐ WebSocket ┌──────────────┐ +│ 用户端 │ ◄────────────────► │ Spring │ +│ H5/小程序│ /ws/chat?token= │ WebSocket │ +└──────────┘ │ Handler │ + └──────┬───────┘ + │ + ┌──────┴───────────────┐ + │ Message Broker │ + │ (In-Memory / Redis │ + │ Pub/Sub 集群扩展) │ + └──────┬───────────────┘ + │ + ┌──────┴───────┐ + │ MySQL 持久化 │ + │ chat_session│ + │ chat_message│ + └──────────────┘ + ▲ +┌──────────┐ WebSocket │ +│ 管理端 │ ◄───────────────────────────┘ +│ Web │ /ws/chat?token=admin +└──────────┘ +``` + +### 1.2 关键概念 + +| 概念 | 说明 | +|------|------| +| **session_id** | 会话唯一 ID(DB 主键) | +| **session_no** | 业务编号(如 CS20260601001) | +| **seq** | 消息在会话内的**单调递增序号**(1, 2, 3...),用于排序和断线补发 | +| **user_seq** | 用户端最大已确认序号(断线时只推 `seq > user_seq` 的消息) | +| **admin_seq** | 客服端最大已确认序号(断线时只推 `seq > admin_seq` 的消息) | +| **user_unread / admin_unread** | 双方各自未读消息数(在线时实时 +1,已读后清零) | + +### 1.3 WebSocket 握手 + +``` +客户端 → 服务端: + GET ws://host/ws/chat?token={JWT_TOKEN} + +服务端处理: + 1. 拦截器中校验 JWT Token + 2. 根据 Token 解析出 userId / adminId + 3. 建立 STOMP 连接,绑定到会话频道 + 4. 推送离线消息(seq > last_ack_seq) +``` + +### 1.4 消息发送流程(用户发消息为例) + +``` +用户客户端发送 WebSocket 消息: + { sessionId, type: "text", content: "你好" } + │ + ▼ +服务端 ChatWebSocketHandler.receive() + │ + ├──► 1. 校验登录态 & 会话归属 + ├──► 2. 查询 chat_session 中当前最大 seq + │ new_seq = max_seq + 1 + ├──► 3. 写入 chat_message(seq = new_seq) + ├──► 4. 更新 chat_session: + │ - last_message / last_time + │ - admin_unread = admin_unread + 1 + ├──► 5. 推送给会话中在线的客服(如果有) + │ /topic/chat/{sessionId} → { id, seq, content, ... } + └──► 6. ACK 给用户端 +``` + +### 1.5 消息接收 ACK(已读回执) + +``` +客户端收到消息后发送 ACK: + { type: "ack", sessionId, lastSeq: 42 } + │ + ▼ +服务端: + - 更新 chat_session.user_seq = 42(如果用户端) + - 或 chat_session.admin_seq = 42(如果客服端) + - 同时如果对端离线,user_unread / admin_unread 清零 +``` + +### 1.6 断线重连 / 离线消息补发 + +``` +客户端断线后重新连接: + WebSocket /ws/chat?token=xxx&last_ack_seq=42 + │ + ▼ +服务端: + 1. 校验 Token + 2. SELECT * FROM chat_message + WHERE session_id = ? AND seq > 42 + ORDER BY seq ASC + 3. 推送给客户端 + 4. 客户端渲染后回传 ACK 更新 user_seq / admin_seq +``` + +### 1.7 关键 Redis 键(可选,用于集群扩展) + +| Key | 类型 | 用途 | +|-----|------|------| +| `chat:online:{userId}` | string (TTL 30s 心跳) | 用户在线状态 | +| `chat:admin:online:{adminId}` | string | 客服在线状态 | +| `chat:unread:{sessionId}:{type}` | int | 未读消息计数(与 MySQL 同步) | +| `ws:user:{userId}` | WebSocket Session | 集群路由用 | + +### 1.8 与"轮询"的区别 + +| 轮询(旧设计) | WebSocket(新设计) | +|----------------|---------------------| +| 客户端每秒请求一次 `/api/chat/messages?lastId=` | 一次 WebSocket 长连接,服务端主动推送 | +| 服务器压力随用户数线性增长 | 服务器仅在有消息时推送,连接空闲几乎零开销 | +| 消息延迟 1~N 秒 | 实时(毫秒级) | +| 移动端耗电 | 移动端省电(长连接心跳即可) | +| 无重连机制 | 断线自动重连 + 序号补发 | + +--- + +## 二、限时抢购(Redis 防超卖) + +### 2.1 设计目标 + +- **不能超卖**:100 件商品最多卖 100 单 +- **一人一单**:同一用户在同一活动同一商品只能抢 1 单 +- **高性能**:10000+ 用户同时抢能扛住 +- **不超时不泄洪**:流量高峰削峰后端 + +### 2.2 整体流程 + +``` + 客户端 服务端 + │ │ + │ POST /api/seckill/buy │ + │ { activityId, skuId, qty } │ + │ ──────────────────────────► │ + │ │ + │ ┌──────┴──────┐ + │ │ ① 登录校验 │ + │ │ ② 活动状态 │ + │ │ ③ Lua 脚本 │ ◄── Redis 原子操作 + │ │ 一次性完成│ (防超卖 + 限购) + │ └──────┬──────┘ + │ │ + │ ┌──────┴──────┐ + │ │ ④ 写 MQ │ ◄── 削峰 + │ └──────┬──────┘ + │ │ + │ ◄─── "排队中" 202 ─────────│ + │ │ + │ ┌──── 轮询或推送 ────┐ │ + │ │ /api/seckill/ │ │ + │ │ result/{orderNo} │ │ + │ └────────────────────┘ │ + │ │ + │ ┌──────┴──────┐ + │ │ ⑤ 消费者线程│ + │ │ 写 MySQL │ ◄── 异步落库 + │ │ - seckill_order + │ │ - orders / order_item + │ │ - 扣 SKU 库存 + │ └──────┬──────┘ + │ │ + │ ◄─── 推送抢购结果 ─────────│ + │ "success: orderNo" │ + │ "fail: 已抢完" │ +``` + +### 2.3 Redis 数据结构 + +| Key | 类型 | 初始值 | 用途 | +|-----|------|--------|------| +| `seckill:stock:{activityId}:{skuId}` | int | seckill_stock | **总库存**(DECR 原子扣减) | +| `seckill:bought:{activityId}:{skuId}:{userId}` | int 0/1 | 0 | **一人一单标记**(SETNX) | +| `seckill:user_count:{activityId}:{skuId}:{userId}` | int | 0 | 累计购买数量(INBY 用于限购 N 件) | +| `seckill:result:{orderNo}` | hash | — | 抢购结果(pending / success / fail) | +| `seckill:queue:{activityId}` | list | — | 异步下单消息队列 | + +### 2.4 核心 Lua 脚本(原子完成"校验 + 扣库存 + 限购") + +```lua +-- KEYS[1] = stock key +-- KEYS[2] = bought flag key +-- KEYS[3] = user count key +-- ARGV[1] = 限购数量 + +-- 1. 限购校验 +local bought = redis.call('GET', KEYS[2]) +if bought == '1' then + return {-1, 'ALREADY_BOUGHT'} +end + +local userCount = tonumber(redis.call('GET', KEYS[3]) or '0') +if userCount + 1 > tonumber(ARGV[1]) then + return {-2, 'LIMIT_EXCEEDED'} +end + +-- 2. 库存校验 +local stock = tonumber(redis.call('GET', KEYS[1]) or '0') +if stock <= 0 then + return {-3, 'STOCK_EMPTY'} +end + +-- 3. 原子扣减 +redis.call('DECR', KEYS[1]) +redis.call('SET', KEYS[2], '1', 'EX', 86400) -- 24h 过期 +redis.call('INCR', KEYS[3]) +redis.call('EXPIRE', KEYS[3], 86400) + +return {0, 'OK'} +``` + +**为什么用 Lua?** 多个 Redis 命令包成一个原子操作,避免"已读库存但 SETNX 失败"的并发问题。 + +### 2.5 异步落库(消费者线程) + +```java +@RabbitListener(queues = "seckill.order.queue") +public void handleSeckillOrder(SeckillMessage msg) { + // 1. 写 seckill_order(唯一索引兜底一人一单) + try { + seckillOrderMapper.insert(msg); + } catch (DuplicateKeyException e) { + // 数据库兜底:说明 Redis 已扣但库写失败,需要回补 Redis + redisService.incrStock(msg.getActivityId(), msg.getSkuId()); + return; + } + + // 2. 生成主订单 + Long orderId = orderService.createSeckillOrder(msg); + + // 3. 异步扣 MySQL SKU 库存(最终一致) + productSkuMapper.decrStock(msg.getSkuId(), msg.getQuantity()); + + // 4. 写抢购结果到 Redis + redisService.setSeckillResult(msg.getOrderNo(), "success"); +} +``` + +### 2.6 客户端查询结果(轮询或推送) + +#### 方式 A:轮询(最简单) + +```http +GET /api/seckill/result/{orderNo}?token=xxx + +Response: + { "status": "pending" | "success" | "fail", "orderNo": "...", "message": "..." } +``` + +#### 方式 B:WebSocket 推送(推荐) + +服务端在消费者线程处理完后,向用户 WebSocket 频道推送: +```json +{ "type": "seckill_result", "orderNo": "SK20260602...", "status": "success" } +``` + +### 2.7 关键时间点 + +| 时间点 | 操作 | +|--------|------| +| 活动开始前 5 分钟 | 预热:把 `seckill_product.seckill_stock` 同步到 Redis(`SETNX`) | +| 活动开始 | 客户端开启抢购入口 | +| 活动进行中 | Lua 原子扣库存 + 异步下单 | +| 活动结束 | 关闭入口;剩余库存回写 MySQL;活动状态置为已结束 | +| 订单超时未支付(30 分钟) | 定时任务扫描 seckill_order,**回补 Redis 库存 + 释放"已购"标记** | + +### 2.8 表结构对应关系 + +| 表 | 作用 | 写入时机 | +|----|------|----------| +| `seckill_activity` | 活动元数据 | 管理员创建活动 | +| `seckill_product` | 活动商品配置 + 兜底库存 | 管理员添加商品;活动开始时预热 Redis | +| `seckill_order` | 抢购成功记录(**唯一索引防一人多单**) | 消费者线程异步写入 | +| `orders` / `order_item` | 主订单 | 消费者线程生成 | +| `product_sku.stock` | SKU 真实库存 | 消费者线程扣减(最终一致) | + +### 2.9 为什么需要 MySQL 唯一索引 + +- Redis 是单点权威库存,但**不能保证事务一致性** +- 极端情况下(Redis 主从切换、网络分区),可能出现"Redis 扣减成功但消费者没写入" +- `seckill_order` 的 `UNIQUE KEY (user_id, activity_id, sku_id)` 是**最后一道防线**: + - 重复插入时抛 `DuplicateKeyException` + - 捕获后回补 Redis 库存 + - 保证"一人一单"不会因 Redis 故障被绕过 + +### 2.10 性能估算 + +- Redis 单机 QPS:10 万+ +- Lua 脚本平均耗时:< 1ms +- 10000 并发抢购 → Redis 处理 ~10000 Lua 调用 → 写 MQ 10000 条 +- 消费者线程(可水平扩展)按 200 TPS 落库,10000 条约 50 秒消化完 +- 数据库压力:< 200 TPS,MySQL 完全扛得住 + +--- + +## 三、关键代码位置 + +| 模块 | 文件路径 | 说明 | +|------|----------|------| +| 客服 WebSocket 配置 | `server-snack/src/main/java/com/snack/server/websocket/WebSocketConfig.java` | 注册 STOMP 端点 | +| 客服消息处理 | `server-snack/src/main/java/com/snack/server/websocket/ChatWebSocketHandler.java` | 消息接收、ACK、推送 | +| 抢购 Lua 脚本 | `server-snack/src/main/resources/lua/seckill.lua` | 原子校验 + 扣库存 | +| 抢购 Controller | `server-snack/src/main/java/com/snack/server/controller/user/SeckillController.java` | `/api/seckill/buy` | +| 抢购消费者 | `server-snack/src/main/java/com/snack/server/mq/SeckillOrderConsumer.java` | 异步落库 | +| 抢购服务 | `server-snack/src/main/java/com/snack/server/service/SeckillService.java` | 业务编排 | +| 抢购结果推送 | `server-snack/src/main/java/com/snack/server/websocket/SeckillResultPushService.java` | 通过 WebSocket 通知结果 | + +--- + +## 四、待办(后续开发) + +- [ ] WebSocket 集群方案:使用 Redis Pub/Sub 广播跨节点消息 +- [ ] 抢购接口限流:Sentinel / Resilience4j 按用户 ID 限流 +- [ ] 抢购风控:同一 IP / 设备 5 秒内最多抢 1 次 +- [ ] 客服智能分配:会话进入时按"客服当前未读最少"分配 +- [ ] 客服坐席状态:客服可设置"离开 / 忙碌",自动转接 diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..3dbd65e --- /dev/null +++ b/db/README.md @@ -0,0 +1,75 @@ +# 数据库初始化说明 + +本目录包含 `snack_mall` 数据库的完整脚本。 + +## 文件清单 + +| 文件 | 用途 | 建议时机 | +|------|------|----------| +| `schema.sql` | 建库 + 21 张业务表的 DDL | 第一次部署时执行 | +| `seed.sql` | 基础测试数据(管理员、用户、商品、优惠券、活动、客服消息等) | 联调 / 演示前执行 | +| `BUSINESS_DESIGN.md` | 核心业务设计:WebSocket 客服 + Redis 防超卖 | 开发前必读 | + +## 执行顺序 + +```bash +# 方式一:命令行 +mysql -u root -p < schema.sql +mysql -u root -p < seed.sql + +# 方式二:登录后 source +mysql -u root -p +> source /path/to/schema.sql +> source /path/to/seed.sql + +# 方式三:Navicat / DBeaver 直接打开执行 +``` + +## 表清单(共 21 张) + +| # | 表名 | 说明 | +|----|-------------------|------------------| +| 1 | `user` | 用户表 | +| 2 | `admin` | 管理员表 | +| 3 | `category` | 商品分类(多级) | +| 4 | `product` | 商品 SPU | +| 5 | `product_sku` | 商品 SKU | +| 6 | `address` | 收货地址 | +| 7 | `cart` | 购物车 | +| 8 | `orders` | 订单主表 | +| 9 | `order_item` | 订单明细 | +| 10 | `favorite` | 收藏 | +| 11 | `banner` | 轮播图 | +| 12 | `chat_session` | 客服会话 | +| 13 | `chat_message` | 客服消息 | +| 14 | `quick_reply` | 客服快捷回复 | +| 15 | `notice` | 系统公告 | +| 16 | `coupon` | 优惠券模板 | +| 17 | `user_coupon` | 用户优惠券 | +| 18 | `seckill_activity`| 限时抢购活动 | +| 19 | `seckill_product` | 抢购活动商品 | +| 20 | `seckill_order` | 抢购订单(防超卖)| +| 21 | `upload_file` | 文件上传记录 | + +## 默认账号 + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 管理员 | `admin` | `123456` | +| 管理员 | `manager` | `123456` | +| 管理员 | `operator` | `123456` | +| 普通用户 | `user001` ~ `user005` | `123456` | + +> 密码均使用 BCrypt 加密,hash 值在 seed.sql 中。 + +## 字符集 + +- 库 / 表:utf8mb4 +- 排序规则:utf8mb4_unicode_ci +- 存储 emoji 等四字节字符无压力 + +## 注意事项 + +1. **不要在生产环境执行 `seed.sql`** —— 仅用于本地开发与演示。 +2. 真实部署时建议**关闭 `notice` 等富文本字段的 XSS 过滤**,由前端做 HTML 转义。 +3. 抢购库存的并发安全由 Redis 原子操作 + 数据库兜底双重保障,详见功能设计文档。 diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d8fed90 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,549 @@ +-- ============================================================ +-- 零食商城 数据库初始化脚本 +-- 适用 MySQL 8.0+ +-- 字符集:utf8mb4 / 排序规则:utf8mb4_unicode_ci +-- 全部表与字段均已添加 COMMENT 注释 +-- ============================================================ + +-- 删除并重建数据库 +DROP DATABASE IF EXISTS `snack_mall`; +CREATE DATABASE `snack_mall` + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE `snack_mall`; + + +-- ============================================================ +-- ① 用户表 +-- ============================================================ +DROP TABLE IF EXISTS `user`; +CREATE TABLE `user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `username` VARCHAR(50) NOT NULL COMMENT '登录用户名(唯一)', + `password` VARCHAR(100) NOT NULL COMMENT '加密密码(BCrypt 强度 10)', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL', + `gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别:0-未知 1-男 2-女', + `birthday` DATE DEFAULT NULL COMMENT '生日', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态:0-禁用 1-启用', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最近登录时间', + `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最近登录 IP', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_phone` (`phone`), + KEY `idx_status` (`status`), + KEY `idx_create_time` (`create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='前台用户表'; + + +-- ============================================================ +-- ② 管理员表 +-- ============================================================ +DROP TABLE IF EXISTS `admin`; +CREATE TABLE `admin` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `username` VARCHAR(50) NOT NULL COMMENT '登录用户名(唯一)', + `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密密码', + `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL', + `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `role` VARCHAR(50) NOT NULL DEFAULT 'admin' COMMENT '角色标识:super_admin / admin / operator', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态:0-禁用 1-启用', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最近登录时间', + `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最近登录 IP', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`), + KEY `idx_status` (`status`), + KEY `idx_role` (`role`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='后台管理员表'; + + +-- ============================================================ +-- ③ 商品分类表(支持多级树形) +-- ============================================================ +DROP TABLE IF EXISTS `category`; +CREATE TABLE `category` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `name` VARCHAR(50) NOT NULL COMMENT '分类名称', + `parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父分类 ID,0 表示一级分类', + `level` TINYINT NOT NULL DEFAULT 1 COMMENT '层级:1-一级 2-二级 3-三级', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号(升序展示)', + `icon` VARCHAR(255) DEFAULT NULL COMMENT '分类图标 URL', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_level` (`level`), + KEY `idx_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品分类表(支持多级树形结构)'; + + +-- ============================================================ +-- ④ 商品 SPU 表 +-- ============================================================ +DROP TABLE IF EXISTS `product`; +CREATE TABLE `product` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID(SPU)', + `name` VARCHAR(200) NOT NULL COMMENT '商品名称', + `category_id` BIGINT NOT NULL COMMENT '所属分类 ID', + `brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌', + `main_image` VARCHAR(255) DEFAULT NULL COMMENT '主图 URL', + `sub_images` TEXT DEFAULT NULL COMMENT '副图列表(JSON 数组)', + `detail` LONGTEXT DEFAULT NULL COMMENT '富文本详情(HTML)', + `origin_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价/划线价', + `sales` INT NOT NULL DEFAULT 0 COMMENT '累计销量', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-下架 1-上架', + `is_hot` TINYINT NOT NULL DEFAULT 0 COMMENT '是否热门:0-否 1-是', + `is_new` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新品:0-否 1-是', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_category_id` (`category_id`), + KEY `idx_status` (`status`), + KEY `idx_sales` (`sales`), + KEY `idx_create_time` (`create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品 SPU 表'; + + +-- ============================================================ +-- ⑤ 商品 SKU 表 +-- ============================================================ +DROP TABLE IF EXISTS `product_sku`; +CREATE TABLE `product_sku` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID(SKU)', + `product_id` BIGINT NOT NULL COMMENT '所属 SPU ID', + `sku_name` VARCHAR(100) NOT NULL COMMENT '规格名称,如:原味/500g', + `image` VARCHAR(255) DEFAULT NULL COMMENT '规格图片 URL', + `price` DECIMAL(10,2) NOT NULL COMMENT '售价', + `stock` INT NOT NULL DEFAULT 0 COMMENT '库存数量', + `sales` INT NOT NULL DEFAULT 0 COMMENT '累计销量', + `weight` INT DEFAULT NULL COMMENT '重量(克)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_create_time` (`create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='商品 SKU 表(具体规格)'; + + +-- ============================================================ +-- ⑥ 收货地址 +-- ============================================================ +DROP TABLE IF EXISTS `address`; +CREATE TABLE `address` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `user_id` BIGINT NOT NULL COMMENT '所属用户 ID', + `receiver` VARCHAR(50) NOT NULL COMMENT '收货人姓名', + `phone` VARCHAR(20) NOT NULL COMMENT '收货人手机号', + `province` VARCHAR(50) DEFAULT NULL COMMENT '省', + `city` VARCHAR(50) DEFAULT NULL COMMENT '市', + `district` VARCHAR(50) DEFAULT NULL COMMENT '区/县', + `detail` VARCHAR(255) NOT NULL COMMENT '详细地址', + `tag` VARCHAR(20) DEFAULT NULL COMMENT '标签:家/公司/学校', + `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址:0-否 1-是', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_is_default` (`user_id`, `is_default`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='收货地址表'; + + +-- ============================================================ +-- ⑦ 购物车 +-- ============================================================ +DROP TABLE IF EXISTS `cart`; +CREATE TABLE `cart` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `user_id` BIGINT NOT NULL COMMENT '用户 ID', + `product_id` BIGINT NOT NULL COMMENT '商品 SPU ID', + `sku_id` BIGINT NOT NULL COMMENT '商品 SKU ID', + `quantity` INT NOT NULL DEFAULT 1 COMMENT '数量', + `selected` TINYINT NOT NULL DEFAULT 1 COMMENT '选中状态:0-未选 1-已选', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_sku` (`user_id`, `sku_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='购物车表'; + + +-- ============================================================ +-- ⑧ 订单主表 +-- ============================================================ +DROP TABLE IF EXISTS `orders`; +CREATE TABLE `orders` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `order_no` VARCHAR(32) NOT NULL COMMENT '订单号(业务唯一)', + `user_id` BIGINT NOT NULL COMMENT '下单用户 ID', + `total_amount` DECIMAL(10,2) NOT NULL COMMENT '商品总金额(未优惠)', + `freight_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '运费', + `discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '优惠金额(满减/折扣)', + `coupon_id` BIGINT DEFAULT NULL COMMENT '使用的优惠券 ID', + `coupon_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '优惠券抵扣金额', + `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 5-已退款', + `receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人姓名', + `receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货人手机号', + `receiver_address` VARCHAR(255) NOT NULL COMMENT '完整收货地址', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '订单备注', + `tracking_company` VARCHAR(50) DEFAULT NULL COMMENT '物流公司', + `tracking_no` VARCHAR(50) DEFAULT NULL COMMENT '物流单号', + `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', + `deliver_time` DATETIME DEFAULT NULL COMMENT '发货时间', + `receive_time` DATETIME DEFAULT NULL COMMENT '收货时间', + `cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间', + `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_user_id` (`user_id`), + KEY `idx_status` (`status`), + KEY `idx_create_time` (`create_time`), + KEY `idx_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单主表'; + + +-- ============================================================ +-- ⑨ 订单商品明细 +-- ============================================================ +DROP TABLE IF EXISTS `order_item`; +CREATE TABLE `order_item` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `order_id` BIGINT NOT NULL COMMENT '订单 ID', + `order_no` VARCHAR(32) NOT NULL COMMENT '订单号(冗余)', + `product_id` BIGINT NOT NULL COMMENT '商品 SPU ID', + `product_name` VARCHAR(200) NOT NULL COMMENT '商品名(冗余防商品改名)', + `product_image` VARCHAR(255) DEFAULT NULL COMMENT '商品主图(冗余)', + `sku_id` BIGINT NOT NULL COMMENT 'SKU ID', + `sku_name` VARCHAR(100) NOT NULL COMMENT 'SKU 规格(冗余)', + `price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', + `quantity` INT NOT NULL COMMENT '购买数量', + `total_amount` DECIMAL(10,2) NOT NULL COMMENT '小计金额(price*quantity)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_order_no` (`order_no`), + KEY `idx_product_id` (`product_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='订单商品明细表'; + + +-- ============================================================ +-- ⑩ 收藏 +-- ============================================================ +DROP TABLE IF EXISTS `favorite`; +CREATE TABLE `favorite` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `user_id` BIGINT NOT NULL COMMENT '用户 ID', + `product_id` BIGINT NOT NULL COMMENT '商品 SPU ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_product` (`user_id`, `product_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_product_id` (`product_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户收藏表'; + + +-- ============================================================ +-- ⑪ 轮播图 +-- ============================================================ +DROP TABLE IF EXISTS `banner`; +CREATE TABLE `banner` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `title` VARCHAR(100) DEFAULT NULL COMMENT '标题', + `image` VARCHAR(255) NOT NULL COMMENT '图片 URL', + `link_type` TINYINT NOT NULL DEFAULT 0 COMMENT '跳转类型:0-不跳转 1-商品 2-分类 3-外链', + `link_value` VARCHAR(255) DEFAULT NULL COMMENT '跳转目标值(商品 ID / 分类 ID / URL)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号(升序)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-下线 1-上线', + `start_time` DATETIME DEFAULT NULL COMMENT '生效时间', + `end_time` DATETIME DEFAULT NULL COMMENT '失效时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status_sort` (`status`, `sort`), + KEY `idx_start_time` (`start_time`), + KEY `idx_end_time` (`end_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='首页轮播图表'; + + +-- ============================================================ +-- ⑫ 客服会话表(WebSocket 长连接) +-- 设计要点: +-- - 客户端通过 WebSocket 建立长连接,服务端实时转发消息 +-- - session_no 是对外展示/查询的编号 +-- - user_seq / admin_seq 记录各端最大已确认序号,用于断线补发 +-- - status 区分会话生命周期 +-- ============================================================ +DROP TABLE IF EXISTS `chat_session`; +CREATE TABLE `chat_session` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `session_no` VARCHAR(32) NOT NULL COMMENT '会话编号(业务唯一)', + `user_id` BIGINT NOT NULL COMMENT '发起用户 ID', + `admin_id` BIGINT DEFAULT NULL COMMENT '接待客服 ID,NULL=待分配', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '会话状态:0-待处理 1-处理中 2-已解决 3-已关闭', + `user_unread` INT NOT NULL DEFAULT 0 COMMENT '用户未读消息数', + `admin_unread` INT NOT NULL DEFAULT 0 COMMENT '客服未读消息数', + `last_message` VARCHAR(500) DEFAULT NULL COMMENT '最后一条消息摘要', + `last_time` DATETIME DEFAULT NULL COMMENT '最后一条消息时间', + `user_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '用户端最大已确认消息序号(用于断线补发)', + `admin_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '客服端最大已确认消息序号(用于断线补发)', + `close_time` DATETIME DEFAULT NULL COMMENT '关闭时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_session_no` (`session_no`), + KEY `idx_user_id` (`user_id`), + KEY `idx_admin_id` (`admin_id`), + KEY `idx_status` (`status`), + KEY `idx_last_time` (`last_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服会话表'; + + +-- ============================================================ +-- ⑬ 客服消息表(WebSocket 消息持久化) +-- 设计要点: +-- - 每条消息全局递增 seq 编号(同 session 内) +-- - 客户端通过 last_ack_seq 拉取差量消息 +-- - type 支持 text / image / file / system / product(商品卡片) +-- ============================================================ +DROP TABLE IF EXISTS `chat_message`; +CREATE TABLE `chat_message` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `session_id` BIGINT NOT NULL COMMENT '所属会话 ID', + `seq` BIGINT NOT NULL DEFAULT 0 COMMENT '消息在会话内的递增序号(用于断线补发和排序)', + `sender_id` BIGINT NOT NULL COMMENT '发送者 ID(用户/客服)', + `sender_type` TINYINT NOT NULL COMMENT '发送者类型:0-用户 1-客服 2-系统', + `type` VARCHAR(10) NOT NULL DEFAULT 'text' COMMENT '消息类型:text / image / file / product / system', + `content` TEXT NOT NULL COMMENT '消息内容(文字 / JSON / 图片 URL)', + `extra` JSON DEFAULT NULL COMMENT '扩展字段(商品卡片等结构化数据)', + `is_recalled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否撤回:0-否 1-是', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_session_seq` (`session_id`, `seq`), + KEY `idx_session_id` (`session_id`), + KEY `idx_create_time` (`create_time`), + KEY `idx_session_time` (`session_id`, `create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服消息表(WebSocket 持久化)'; + + +-- ============================================================ +-- ⑭ 客服快捷回复模板 +-- ============================================================ +DROP TABLE IF EXISTS `quick_reply`; +CREATE TABLE `quick_reply` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `title` VARCHAR(100) NOT NULL COMMENT '模板标题(用于搜索)', + `content` VARCHAR(500) NOT NULL COMMENT '模板内容', + `category` VARCHAR(50) DEFAULT NULL COMMENT '分类:问候/物流/售后/活动等', + `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-启用', + `use_count` INT NOT NULL DEFAULT 0 COMMENT '使用次数(统计)', + `create_by` BIGINT DEFAULT NULL COMMENT '创建人(管理员 ID)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status_sort` (`status`, `sort`), + KEY `idx_category` (`category`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='客服快捷回复模板表'; + + +-- ============================================================ +-- ⑮ 系统公告 +-- ============================================================ +DROP TABLE IF EXISTS `notice`; +CREATE TABLE `notice` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `title` VARCHAR(200) NOT NULL COMMENT '公告标题', + `content` LONGTEXT NOT NULL COMMENT '公告内容(富文本 HTML)', + `type` TINYINT NOT NULL DEFAULT 0 COMMENT '公告类型:0-普通 1-重要 2-活动', + `is_top` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0-否 1-是', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-下线 1-上线', + `start_time` DATETIME DEFAULT NULL COMMENT '生效时间', + `end_time` DATETIME DEFAULT NULL COMMENT '失效时间', + `view_count` INT NOT NULL DEFAULT 0 COMMENT '浏览量', + `publisher_id` BIGINT DEFAULT NULL COMMENT '发布人(管理员 ID)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_type` (`type`), + KEY `idx_create_time` (`create_time`), + KEY `idx_top_status` (`is_top`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='系统公告表'; + + +-- ============================================================ +-- ⑯ 优惠券模板 +-- ============================================================ +DROP TABLE IF EXISTS `coupon`; +CREATE TABLE `coupon` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `name` VARCHAR(100) NOT NULL COMMENT '优惠券名称', + `type` TINYINT NOT NULL COMMENT '类型:0-满减券 1-折扣券 2-无门槛券', + `amount` DECIMAL(10,2) NOT NULL COMMENT '满减面值 / 折扣率(0.85 表示 8.5 折)', + `min_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '最低使用金额(0 表示无门槛)', + `max_discount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '折扣券最高抵扣金额', + `total` INT NOT NULL COMMENT '发放总量(-1 表示不限量)', + `remain` INT NOT NULL COMMENT '剩余可领取数量', + `per_limit` INT NOT NULL DEFAULT 1 COMMENT '每人限领数量', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-未上线 1-已上线 2-已结束', + `start_time` DATETIME NOT NULL COMMENT '领取开始时间', + `end_time` DATETIME NOT NULL COMMENT '领取截止时间', + `valid_days` INT DEFAULT NULL COMMENT '领取后有效天数(NULL 时使用固定截止日期)', + `description` VARCHAR(500) DEFAULT NULL COMMENT '使用说明', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_start_time` (`start_time`), + KEY `idx_end_time` (`end_time`), + KEY `idx_type` (`type`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='优惠券模板表'; + + +-- ============================================================ +-- ⑰ 用户优惠券(领取记录) +-- ============================================================ +DROP TABLE IF EXISTS `user_coupon`; +CREATE TABLE `user_coupon` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `user_id` BIGINT NOT NULL COMMENT '用户 ID', + `coupon_id` BIGINT NOT NULL COMMENT '优惠券模板 ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-未使用 1-已使用 2-已过期 3-已作废', + `order_id` BIGINT DEFAULT NULL COMMENT '使用时关联的订单 ID', + `receive_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间', + `use_time` DATETIME DEFAULT NULL COMMENT '使用时间', + `expire_time` DATETIME NOT NULL COMMENT '过期时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_coupon_id` (`coupon_id`), + KEY `idx_status` (`status`), + KEY `idx_expire` (`expire_time`), + KEY `idx_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户优惠券领取记录表'; + + +-- ============================================================ +-- ⑱ 限时抢购活动 +-- 设计要点(与 Redis 配合): +-- - 活动开启时,把 seckill_product 的 seckill_stock 同步到 Redis +-- key: seckill:stock:{activityId}:{productId} +-- - 用户抢购时 Redis DECR 原子扣减,避免超卖 +-- - 抢购成功后再异步写库(消费 MQ 消息) +-- - seckill_order 唯一索引 (user_id, activity_id, product_id) 兜底"一人一单" +-- ============================================================ +DROP TABLE IF EXISTS `seckill_activity`; +CREATE TABLE `seckill_activity` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `name` VARCHAR(100) NOT NULL COMMENT '活动名称', + `cover` VARCHAR(255) DEFAULT NULL COMMENT '活动封面图', + `start_time` DATETIME NOT NULL COMMENT '活动开始时间', + `end_time` DATETIME NOT NULL COMMENT '活动结束时间', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-未开始 1-进行中 2-已结束', + `description` VARCHAR(500) DEFAULT NULL COMMENT '活动说明', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_start_time` (`start_time`), + KEY `idx_end_time` (`end_time`), + KEY `idx_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='限时抢购活动表'; + + +-- ============================================================ +-- ⑲ 抢购活动商品 +-- ============================================================ +DROP TABLE IF EXISTS `seckill_product`; +CREATE TABLE `seckill_product` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `activity_id` BIGINT NOT NULL COMMENT '所属活动 ID', + `product_id` BIGINT NOT NULL COMMENT '商品 SPU ID', + `sku_id` BIGINT NOT NULL COMMENT '商品 SKU ID', + `seckill_price` DECIMAL(10,2) NOT NULL COMMENT '抢购价', + `origin_price` DECIMAL(10,2) NOT NULL COMMENT '原价(展示用)', + `seckill_stock` INT NOT NULL COMMENT '总库存(同步到 Redis)', + `remain_stock` INT NOT NULL COMMENT '剩余库存(兜底用,正常情况下以 Redis 为准)', + `per_limit` INT NOT NULL DEFAULT 1 COMMENT '每人限购数量', + `sales` INT NOT NULL DEFAULT 0 COMMENT '已售数量(异步消费 MQ 后累加)', + `sort` INT NOT NULL DEFAULT 0 COMMENT '活动内排序', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_activity_sku` (`activity_id`, `sku_id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_activity_id` (`activity_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='抢购活动商品表'; + + +-- ============================================================ +-- ⑳ 抢购订单(仅作为"成功下单"的最终记录) +-- 设计要点: +-- - 抢购请求先通过 Redis 原子扣库存 + Lua 限流 +-- - 抢到后消息进入 MQ,由消费者线程消费: +-- ① 写 seckill_order(唯一索引防一人多单) +-- ② 生成主订单 +-- ③ 异步扣 MySQL 库存(兜底) +-- - 此表只保留"已成功抢购"的记录,不存失败的请求 +-- - (user_id, activity_id, product_id) 唯一索引:兜底防一人多单 +-- ============================================================ +DROP TABLE IF EXISTS `seckill_order`; +CREATE TABLE `seckill_order` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `user_id` BIGINT NOT NULL COMMENT '用户 ID', + `activity_id` BIGINT NOT NULL COMMENT '活动 ID', + `product_id` BIGINT NOT NULL COMMENT '商品 SPU ID', + `sku_id` BIGINT NOT NULL COMMENT 'SKU ID', + `quantity` INT NOT NULL COMMENT '抢购数量', + `seckill_price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', + `order_id` BIGINT DEFAULT NULL COMMENT '关联主订单 ID(支付/取消后回填)', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待支付 1-已支付 2-已取消 3-已退款', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抢购时间', + `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', + PRIMARY KEY (`id`), + -- 唯一索引:防止同一用户在同一活动中对同一商品重复抢购 + UNIQUE KEY `uk_user_activity_sku` (`user_id`, `activity_id`, `sku_id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_activity_id` (`activity_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='抢购成功记录表'; + + +-- ============================================================ +-- ㉑ 文件上传记录 +-- ============================================================ +DROP TABLE IF EXISTS `upload_file`; +CREATE TABLE `upload_file` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', + `name` VARCHAR(255) NOT NULL COMMENT '原始文件名', + `path` VARCHAR(255) NOT NULL COMMENT '存储路径(相对 uploads/)', + `url` VARCHAR(255) NOT NULL COMMENT '访问 URL', + `size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + `content_type` VARCHAR(100) DEFAULT NULL COMMENT 'MIME 类型', + `storage_type` VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '存储类型:local / qiniu / aliyun / tencent / minio', + `biz_type` VARCHAR(50) DEFAULT NULL COMMENT '业务类型:avatar / product / banner / chat', + `upload_by` BIGINT DEFAULT NULL COMMENT '上传人(用户/管理员 ID)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', + PRIMARY KEY (`id`), + KEY `idx_upload_by` (`upload_by`), + KEY `idx_biz_type` (`biz_type`), + KEY `idx_create_time`(`create_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='文件上传记录表'; + + +-- ============================================================ +-- 完成 +-- ============================================================ +SELECT '✅ 数据库初始化完成,共 21 张表' AS message; diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..133abe1 --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,290 @@ +-- ============================================================ +-- 零食商城 测试数据 +-- 执行前请先执行 schema.sql +-- ============================================================ + +USE `snack_mall`; + + +-- ============================================================ +-- 管理员账号(密码:123456,BCrypt 加密,强度 10) +-- BCrypt hash for "123456": +-- ============================================================ +INSERT INTO `admin` (`username`, `password`, `nickname`, `role`, `status`) VALUES +('admin', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '超级管理员', 'super_admin', 1), +('manager', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '运营经理', 'admin', 1), +('operator', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '运营专员', 'operator', 1); + + +-- ============================================================ +-- 普通用户(密码:123456) +-- ============================================================ +INSERT INTO `user` (`username`, `password`, `phone`, `nickname`, `avatar`, `status`) VALUES +('user001', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138001', '小张同学', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user001', 1), +('user002', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138002', '爱吃零食', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user002', 1), +('user003', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138003', '零食达人', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user003', 1), +('user004', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138004', '吃货联盟', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user004', 1), +('user005', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '13800138005', '甜品控', 'https://api.dicebear.com/7.x/avataaars/svg?seed=user005', 1); + + +-- ============================================================ +-- 商品分类(一级 + 二级) +-- ============================================================ +INSERT INTO `category` (`id`, `name`, `parent_id`, `level`, `sort`, `icon`, `status`) VALUES +(1, '休闲零食', 0, 1, 1, NULL, 1), +(2, '坚果炒货', 1, 2, 1, NULL, 1), +(3, '糖果巧克力', 1, 2, 2, NULL, 1), +(4, '肉脯卤味', 1, 2, 3, NULL, 1), +(5, '膨化食品', 1, 2, 4, NULL, 1), +(6, '蜜饯果干', 1, 2, 5, NULL, 1), +(7, '饮料饮品', 0, 1, 2, NULL, 1), +(8, '碳酸饮料', 7, 2, 1, NULL, 1), +(9, '果汁', 7, 2, 2, NULL, 1), +(10, '茶饮', 7, 2, 3, NULL, 1), +(11, '饼干糕点', 0, 1, 3, NULL, 1), +(12, '饼干', 11, 2, 1, NULL, 1), +(13, '蛋糕', 11, 2, 2, NULL, 1); + + +-- ============================================================ +-- 商品 SPU +-- ============================================================ +INSERT INTO `product` (`id`, `name`, `category_id`, `brand`, `main_image`, `origin_price`, `sales`, `status`, `is_hot`, `is_new`) VALUES +(1, '原味夏威夷果 500g', 2, '良品铺子', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 89.00, 320, 1, 1, 0), +(2, '奶油味腰果 200g', 2, '三只松鼠', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 49.00, 280, 1, 1, 1), +(3, '原味开心果 300g', 2, '来伊份', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 69.00, 150, 1, 0, 1), +(4, '蜂蜜核桃仁 250g', 2, '洽洽', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 39.90, 220, 1, 1, 0), +(5, '原味瓜子 500g', 2, '洽洽', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 19.90, 480, 1, 1, 0), +(6, '草莓味软糖 200g', 3, '阿尔卑斯', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 12.90, 600, 1, 0, 0), +(7, '牛奶巧克力 100g', 3, '德芙', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 28.90, 720, 1, 1, 0), +(8, '黑巧克力 85% 100g', 3, 'Lindt', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 49.90, 320, 1, 1, 0), +(9, '蜜汁猪肉脯 250g', 4, '无穷', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 35.90, 380, 1, 1, 0), +(10, '麻辣牛肉干 200g', 4, '老四川', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 42.90, 260, 1, 0, 0), +(11, '原味薯片 大包装 135g', 5, '乐事', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 9.90, 1200, 1, 1, 0), +(12, '虾条 80g', 5, '上好佳', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 6.90, 860, 1, 0, 0), +(13, '芒果干 100g', 6, '溜溜梅', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 15.90, 540, 1, 1, 0), +(14, '葡萄干 200g', 6, '楼兰蜜语', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 18.90, 380, 1, 0, 0), +(15, '可乐 330ml*6 罐装', 8, '可口可乐', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 25.90, 920, 1, 1, 0), +(16, '雪碧 330ml*6 罐装', 8, '雪碧', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 25.90, 580, 1, 1, 0), +(17, '100% 橙汁 1L', 9, '汇源', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 19.90, 360, 1, 0, 1), +(18, '蜜桃乌龙茶 500ml', 10, '三得利', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 12.90, 420, 1, 0, 1), +(19, '奥利奥饼干 388g', 12, '奥利奥', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 22.90, 1100, 1, 1, 0), +(20, '熔岩蛋糕 6 个装', 13, '好利来', 'https://img.zcool.cn/community/01a87e5e7b8e7a0000001.png@1280w_1l_2o_100sh.png', 39.90, 290, 1, 0, 1); + + +-- ============================================================ +-- 商品 SKU +-- ============================================================ +INSERT INTO `product_sku` (`product_id`, `sku_name`, `price`, `stock`, `sales`, `sort`) VALUES +-- 夏威夷果 +(1, '原味/500g', 79.00, 500, 320, 1), +(1, '盐焗/500g', 79.00, 300, 120, 2), +(1, '奶油/500g', 84.00, 200, 80, 3), +-- 腰果 +(2, '原味/200g', 39.90, 400, 280, 1), +(2, '盐焗/200g', 39.90, 250, 100, 2), +-- 开心果 +(3, '原味/300g', 59.00, 300, 150, 1), +(3, '盐焗/300g', 59.00, 200, 60, 2), +-- 核桃 +(4, '蜂蜜/250g', 35.90, 400, 220, 1), +(4, '原味/250g', 32.90, 300, 80, 2), +-- 瓜子 +(5, '原味/500g', 16.90, 1000, 480, 1), +(5, '五香/500g', 16.90, 600, 200, 2), +-- 软糖 +(6, '草莓味/200g', 9.90, 600, 600, 1), +(6, '混合味/200g', 12.90, 400, 220, 2), +-- 巧克力 +(7, '牛奶/100g', 24.90, 500, 720, 1), +(7, '榛仁/100g', 28.90, 300, 280, 2), +-- 黑巧 +(8, '85%/100g', 39.90, 400, 320, 1), +(8, '70%/100g', 35.90, 300, 180, 2), +-- 猪肉脯 +(9, '蜜汁/250g', 32.90, 500, 380, 1), +(9, '麻辣/250g', 32.90, 300, 120, 2), +-- 牛肉干 +(10, '麻辣/200g', 39.90, 400, 260, 1), +(10, '五香/200g', 39.90, 300, 100, 2), +-- 薯片 +(11, '原味/135g', 8.90, 1000, 1200, 1), +(11, '番茄/135g', 8.90, 800, 680, 2), +(11, '黄瓜/135g', 8.90, 600, 420, 3), +-- 虾条 +(12, '原味/80g', 5.90, 800, 860, 1), +-- 芒果干 +(13, '原味/100g', 12.90, 500, 540, 1), +(13, '蜜饯/100g', 14.90, 300, 180, 2), +-- 葡萄干 +(14, '红提/200g', 16.90, 400, 380, 1), +(14, '黑加仑/200g', 16.90, 300, 120, 2), +-- 可乐 +(15, '原味/330ml*6', 22.90, 800, 920, 1), +(15, '零度/330ml*6', 22.90, 500, 320, 2), +-- 雪碧 +(16, '原味/330ml*6', 22.90, 700, 580, 1), +-- 橙汁 +(17, '原味/1L', 17.90, 400, 360, 1), +-- 蜜桃乌龙 +(18, '原味/500ml', 10.90, 500, 420, 1), +-- 奥利奥 +(19, '原味/388g', 19.90, 800, 1100, 1), +(19, '巧克力/388g', 19.90, 500, 480, 2), +-- 蛋糕 +(20, '原味/6 个装', 36.90, 200, 290, 1); + + +-- ============================================================ +-- 收货地址 +-- ============================================================ +INSERT INTO `address` (`user_id`, `receiver`, `phone`, `province`, `city`, `district`, `detail`, `is_default`) VALUES +(1, '张三', '13800138001', '广东省', '深圳市', '南山区', '科技园南路 88 号腾讯大厦', 1), +(1, '张三', '13800138001', '广东省', '深圳市', '福田区', '华强北路 1002 号', 0), +(2, '李四', '13800138002', '北京市', '海淀区', '中关村', '中关村大街 27 号', 1), +(3, '王五', '13800138003', '上海市', '浦东新区', '陆家嘴', '世纪大道 100 号', 1), +(4, '赵六', '13800138004', '浙江省', '杭州市', '西湖区', '文三路 478 号', 1); + + +-- ============================================================ +-- 轮播图 +-- ============================================================ +INSERT INTO `banner` (`title`, `image`, `link_type`, `link_value`, `sort`, `status`) VALUES +('618 大促全场满 199 减 50', 'https://placehold.co/750x320/1E40AF/FFFFFF?text=618+Big+Sale', 0, NULL, 1, 1), +('新品上市 · 进口零食专区', 'https://placehold.co/750x320/3B82F6/FFFFFF?text=New+Arrival', 1, '7', 2, 1), +('新人专享 · 注册即领 50 元大礼包', 'https://placehold.co/750x320/F59E0B/FFFFFF?text=New+User+Gift', 0, NULL, 3, 1), +('每日签到领积分换好礼', 'https://placehold.co/750x320/10B981/FFFFFF?text=Check+In', 0, NULL, 4, 1); + + +-- ============================================================ +-- 系统公告 +-- ============================================================ +INSERT INTO `notice` (`title`, `content`, `type`, `is_top`, `status`, `start_time`, `end_time`) VALUES +('【重要】关于 618 大促活动规则说明', + '

亲爱的用户:

618 大促活动即将开始,全场满 199 减 50,满 399 减 120,上不封顶!活动期间更有每日 10 点/20 点限时秒杀,等你来抢!

活动期间如有任何问题,请联系在线客服。

', + 1, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59'), +('系统升级维护通知', + '

为了提供更好的服务,系统将于 2026-06-15 02:00 - 04:00 进行例行维护升级。维护期间可能短暂无法访问,请合理安排购物时间。

', + 1, 0, 1, '2026-06-10 00:00:00', '2026-06-16 23:59:59'), +('新人福利:注册即送 50 元大礼包', + '

新用户注册后可在「领券中心」领取 50 元新人礼包,包含:满 99 减 20、满 199 减 30 优惠券各一张。

', + 2, 0, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59'), +('关于规范使用账户的公告', + '

为保障您的账户安全,请勿将账户借给他人使用。如发现异常登录行为,系统将自动冻结账户。

', + 0, 0, 1, '2026-05-15 00:00:00', '2026-12-31 23:59:59'), +('物流更新提醒', + '

近期受天气影响,部分地区物流时效可能延长 1-2 天,请耐心等待。如长时间未收到,可联系客服查询。

', + 0, 0, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59'); + + +-- ============================================================ +-- 优惠券模板 +-- ============================================================ +INSERT INTO `coupon` (`name`, `type`, `amount`, `min_amount`, `max_discount`, `total`, `remain`, `per_limit`, `status`, `start_time`, `end_time`, `valid_days`) VALUES +-- 满减券 +('满 99 减 10', 0, 10.00, 99.00, 0.00, 1000, 856, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30), +('满 199 减 30', 0, 30.00, 199.00, 0.00, 500, 320, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30), +('满 399 减 80', 0, 80.00, 399.00, 0.00, 200, 168, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15), +-- 折扣券 +('9 折优惠券', 1, 0.90, 0.00, 50.00, 800, 580, 1, 1, '2026-06-01 00:00:00', '2026-06-30 23:59:59', 30), +('8.5 折优惠券', 1, 0.85, 100.00, 80.00, 300, 180, 1, 1, '2026-06-01 00:00:00', '2026-06-25 23:59:59', 15), +-- 无门槛 +('新人 5 元无门槛', 2, 5.00, 0.00, 0.00, 5000, 3200, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30), +('新人 10 元无门槛', 2, 10.00, 0.00, 0.00, 3000, 2100, 1, 1, '2026-05-01 00:00:00', '2026-12-31 23:59:59', 30); + + +-- ============================================================ +-- 限时抢购活动 +-- ============================================================ +INSERT INTO `seckill_activity` (`name`, `start_time`, `end_time`, `status`) VALUES +('618 限时秒杀 · 第一场', '2026-06-02 10:00:00', '2026-06-02 12:00:00', 1), +('618 限时秒杀 · 第二场', '2026-06-02 14:00:00', '2026-06-02 16:00:00', 0), +('618 限时秒杀 · 第三场', '2026-06-02 20:00:00', '2026-06-02 22:00:00', 0), +('618 限时秒杀 · 第四场', '2026-06-03 10:00:00', '2026-06-03 12:00:00', 0), +('618 限时秒杀 · 第五场', '2026-06-03 20:00:00', '2026-06-03 22:00:00', 0), +('已结束场次回顾', '2026-06-01 10:00:00', '2026-06-01 12:00:00', 2); + + +-- ============================================================ +-- 抢购活动商品 +-- ============================================================ +INSERT INTO `seckill_product` (`activity_id`, `product_id`, `sku_id`, `seckill_price`, `seckill_stock`, `remain_stock`, `per_limit`, `sales`) VALUES +-- 第一场 +(1, 1, 1, 49.90, 100, 68, 2, 32), +(1, 7, 14, 14.90, 200, 145, 3, 55), +(1, 11, 22, 4.90, 500, 320, 5, 180), +(1, 15, 30, 12.90, 300, 198, 2, 102), +-- 第二场 +(2, 2, 4, 19.90, 150, 150, 2, 0), +(2, 9, 18, 19.90, 200, 200, 2, 0), +(2, 19, 39, 9.90, 500, 500, 3, 0), +-- 第三场 +(3, 8, 16, 24.90, 200, 200, 2, 0), +(3, 13, 27, 6.90, 300, 300, 3, 0), +(3, 20, 41, 19.90, 100, 100, 1, 0); + + +-- ============================================================ +-- 客服快捷回复模板 +-- ============================================================ +INSERT INTO `quick_reply` (`title`, `content`, `sort`, `status`) VALUES +('问候', '您好,欢迎光临零食商城~ 请问有什么可以帮您?', 1, 1), +('已发货', '您的订单已经发货啦,物流单号:xx,预计 2-3 天到达,请耐心等待哦~', 2, 1), +('催发货', '亲,您的订单正在紧急处理中,我们会尽快为您发货,感谢您的耐心等待!', 3, 1), +('退换货', '亲,如需退换货请提供订单号和具体问题,我们会在 24 小时内为您处理~', 4, 1), +('感谢', '感谢您的咨询,祝您购物愉快!如有问题随时联系在线客服~', 5, 1), +('库存咨询', '亲,这款商品目前库存充足,可以直接下单哦~', 6, 1); + + +-- ============================================================ +-- 收藏 +-- ============================================================ +INSERT INTO `favorite` (`user_id`, `product_id`) VALUES +(1, 1), (1, 7), (1, 9), +(2, 2), (2, 11), +(3, 1), (3, 8), (3, 19), +(4, 11), (4, 15), +(5, 6), (5, 13); + + +-- ============================================================ +-- 客服示例会话 +-- ============================================================ +INSERT INTO `chat_session` (`id`, `session_no`, `user_id`, `admin_id`, `status`, `user_unread`, `admin_unread`, `last_message`, `last_time`, `user_seq`, `admin_seq`) VALUES +(1, 'CS20260601001', 1, 1, 1, 1, 0, '好的,下一单', '2026-06-01 14:02:00', 3, 3), +(2, 'CS20260601002', 2, NULL, 0, 1, 1, '请问发货时间是什么时候?', '2026-06-01 15:23:00', 1, 0), +(3, 'CS20260601003', 3, 1, 2, 0, 0, '感谢您的耐心解答!', '2026-06-01 16:45:00', 2, 2), +(4, 'CS20260601004', 4, NULL, 0, 1, 1, '我下单了怎么还没发货?', '2026-06-02 09:12:00', 1, 0), +(5, 'CS20260601005', 5, 1, 1, 1, 0, '请问优惠券怎么用?', '2026-06-02 10:30:00', 1, 0); + + +-- ============================================================ +-- 客服示例消息(含 seq 序号) +-- ============================================================ +INSERT INTO `chat_message` (`session_id`, `seq`, `sender_id`, `sender_type`, `type`, `content`, `is_recalled`, `create_time`) VALUES +(1, 1, 1, 0, 'text', '你好,请问夏威夷果还有货吗?', 0, '2026-06-01 14:00:00'), +(1, 2, 1, 1, 'text', '亲,在的哦,目前库存充足~', 0, '2026-06-01 14:01:00'), +(1, 3, 1, 0, 'text', '好的,下一单', 0, '2026-06-01 14:02:00'), +(2, 1, 2, 0, 'text', '请问发货时间是什么时候?', 0, '2026-06-01 15:23:00'), +(3, 1, 3, 0, 'text', '订单已收到,谢谢', 0, '2026-06-01 16:40:00'), +(3, 2, 1, 1, 'text', '感谢您的耐心解答!', 0, '2026-06-01 16:45:00'), +(4, 1, 4, 0, 'text', '我下单了怎么还没发货?', 0, '2026-06-02 09:12:00'), +(5, 1, 5, 0, 'text', '请问优惠券怎么用?', 0, '2026-06-02 10:30:00'); + + +-- ============================================================ +-- 抢购成功记录示例(模拟已经异步落库后的状态) +-- ============================================================ +INSERT INTO `seckill_order` (`user_id`, `activity_id`, `product_id`, `sku_id`, `quantity`, `seckill_price`, `order_id`, `status`) VALUES +(1, 1, 1, 1, 1, 49.90, NULL, 0), +(1, 1, 7, 14, 2, 14.90, NULL, 0), +(2, 1, 11, 22, 3, 4.90, NULL, 0), +(3, 1, 15, 30, 1, 12.90, NULL, 1); + + +-- ============================================================ +-- 完成 +-- ============================================================ +SELECT '✅ 测试数据导入完成' AS message; +SELECT '默认管理员账号: admin / 123456' AS info; +SELECT '默认用户账号: user001 / 123456' AS info; diff --git a/server-snack/src/main/java/com/snack/server/common/ResultCode.java b/server-snack/src/main/java/com/snack/server/common/ResultCode.java index 3af7e6d..23536d7 100644 --- a/server-snack/src/main/java/com/snack/server/common/ResultCode.java +++ b/server-snack/src/main/java/com/snack/server/common/ResultCode.java @@ -26,6 +26,11 @@ public enum ResultCode { PRODUCT_NOT_EXIST(1010, "商品不存在"), PRODUCT_OFF_SHELF(1011, "商品已下架"), STOCK_INSUFFICIENT(1012, "库存不足"), + CATEGORY_NOT_EXIST(1013, "分类不存在"), + CATEGORY_HAS_CHILDREN(1014, "分类下存在子分类,无法删除"), + CATEGORY_HAS_PRODUCTS(1015, "分类下存在商品,无法删除"), + CATEGORY_LEVEL_INVALID(1016, "分类层级不合法"), + CATEGORY_NAME_DUPLICATE(1017, "同级分类下已存在同名分类"), ORDER_NOT_EXIST(1020, "订单不存在"), ORDER_STATUS_ERROR(1021, "订单状态异常,无法操作"), COUPON_NOT_EXIST(1030, "优惠券不存在"), diff --git a/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java b/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java new file mode 100644 index 0000000..691963a --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/config/MybatisPlusMetaObjectHandler.java @@ -0,0 +1,27 @@ +package com.snack.server.config; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * MyBatis-Plus 自动填充处理器 + * 自动写入 createTime / updateTime + */ +@Component +public class MybatisPlusMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + LocalDateTime now = LocalDateTime.now(); + this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now); + this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); + } +} diff --git a/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java b/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java index f4ed0ec..b60bbb4 100644 --- a/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java +++ b/server-snack/src/main/java/com/snack/server/config/SaTokenConfig.java @@ -25,8 +25,10 @@ public class SaTokenConfig implements WebMvcConfigurer { SaRouter.match("/api/coupons/*/receive").check(r -> StpUtil.checkLogin()); SaRouter.match("/api/user/coupons").check(r -> StpUtil.checkLogin()); - // 管理端:需要登录的接口 - SaRouter.match("/api/admin/**").check(r -> StpUtil.checkLogin()); + // 管理端:需要登录的接口(登录接口本身放行) + SaRouter.match("/api/admin/**") + .notMatch("/api/admin/login", "/api/admin/captcha") + .check(r -> StpUtil.checkLogin()); })).addPathPatterns("/**"); } } diff --git a/server-snack/src/main/java/com/snack/server/controller/admin/.gitkeep b/server-snack/src/main/java/com/snack/server/controller/admin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/controller/user/.gitkeep b/server-snack/src/main/java/com/snack/server/controller/user/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/dto/req/.gitkeep b/server-snack/src/main/java/com/snack/server/dto/req/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/dto/resp/.gitkeep b/server-snack/src/main/java/com/snack/server/dto/resp/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/entity/.gitkeep b/server-snack/src/main/java/com/snack/server/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/mapper/.gitkeep b/server-snack/src/main/java/com/snack/server/mapper/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java b/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java new file mode 100644 index 0000000..48e1f3a --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/controller/AdminController.java @@ -0,0 +1,141 @@ +package com.snack.server.module.admin.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.snack.server.common.Result; +import com.snack.server.module.admin.dto.req.AdminLoginReq; +import com.snack.server.module.admin.dto.req.AdminPageReq; +import com.snack.server.module.admin.dto.req.AdminSaveReq; +import com.snack.server.module.admin.dto.req.ChangePasswordReq; +import com.snack.server.module.admin.service.AdminService; +import com.snack.server.module.admin.vo.AdminVO; +import com.snack.server.module.admin.vo.LoginVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 管理员相关接口 + */ +@Tag(name = "01. 管理员管理") +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + + // ==================== 鉴权相关(白名单) ==================== + + @Operation(summary = "管理员登录") + @PostMapping("/login") + public Result login(@Valid @RequestBody AdminLoginReq req, HttpServletRequest request) { + String ip = getClientIp(request); + return Result.ok(adminService.login(req, ip)); + } + + @Operation(summary = "管理员登出") + @SaCheckLogin + @PostMapping("/logout") + public Result logout() { + adminService.logout(); + return Result.ok(); + } + + @Operation(summary = "获取当前登录管理员信息") + @SaCheckLogin + @GetMapping("/info") + public Result getCurrentAdmin() { + return Result.ok(adminService.getCurrentAdmin()); + } + + @Operation(summary = "修改自己的密码") + @SaCheckLogin + @PutMapping("/password") + public Result changePassword(@Valid @RequestBody ChangePasswordReq req) { + adminService.changePassword(req); + return Result.ok(); + } + + // ==================== 管理员 CRUD ==================== + + @Operation(summary = "分页查询管理员列表") + @SaCheckLogin + @GetMapping("/page") + public Result> page(AdminPageReq req) { + return Result.ok(adminService.pageAdmins(req)); + } + + @Operation(summary = "获取管理员详情") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "管理员 ID") @PathVariable Long id) { + return Result.ok(adminService.getAdminById(id)); + } + + @Operation(summary = "创建管理员") + @SaCheckRole("super_admin") + @PostMapping + public Result create(@Valid @RequestBody AdminSaveReq req) { + return Result.ok(adminService.createAdmin(req)); + } + + @Operation(summary = "更新管理员") + @SaCheckRole("super_admin") + @PutMapping + public Result update(@Valid @RequestBody AdminSaveReq req) { + adminService.updateAdmin(req); + return Result.ok(); + } + + @Operation(summary = "删除管理员") + @SaCheckRole("super_admin") + @DeleteMapping("/{id}") + public Result delete(@Parameter(description = "管理员 ID") @PathVariable Long id) { + adminService.deleteAdmin(id); + return Result.ok(); + } + + @Operation(summary = "重置管理员密码") + @SaCheckRole("super_admin") + @PutMapping("/{id}/password/reset") + public Result resetPassword( + @Parameter(description = "管理员 ID") @PathVariable Long id, + @Parameter(description = "新密码") @RequestParam String newPassword) { + adminService.resetPassword(id, newPassword); + return Result.ok(); + } + + @Operation(summary = "启用/禁用管理员") + @SaCheckRole("super_admin") + @PutMapping("/{id}/status/{status}") + public Result updateStatus( + @Parameter(description = "管理员 ID") @PathVariable Long id, + @Parameter(description = "状态:0-禁用 1-启用") @PathVariable Integer status) { + adminService.updateStatus(id, status); + return Result.ok(); + } + + // ==================== 工具方法 ==================== + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 多级代理取第一个 + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java new file mode 100644 index 0000000..68cea46 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminLoginReq.java @@ -0,0 +1,28 @@ +package com.snack.server.module.admin.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serializable; + +/** + * 管理员登录请求 + */ +@Data +@Schema(description = "管理员登录请求") +public class AdminLoginReq implements Serializable { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名", example = "admin") + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 50, message = "密码长度需在 6-50 之间") + @Schema(description = "密码", example = "123456") + private String password; + + @Schema(description = "记住我(延长 token 有效期)", example = "false") + private Boolean rememberMe = false; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java new file mode 100644 index 0000000..e8a030c --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminPageReq.java @@ -0,0 +1,29 @@ +package com.snack.server.module.admin.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 管理员分页查询 + */ +@Data +@Schema(description = "管理员分页查询请求") +public class AdminPageReq implements Serializable { + + @Schema(description = "页码", example = "1") + private Long current = 1L; + + @Schema(description = "每页条数", example = "10") + private Long size = 10L; + + @Schema(description = "关键字(用户名/昵称/手机号)") + private String keyword; + + @Schema(description = "角色") + private String role; + + @Schema(description = "状态:0-禁用 1-启用") + private Integer status; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java new file mode 100644 index 0000000..5430b89 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/AdminSaveReq.java @@ -0,0 +1,52 @@ +package com.snack.server.module.admin.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serializable; + +/** + * 创建/更新管理员请求 + */ +@Data +@Schema(description = "创建/更新管理员请求") +public class AdminSaveReq implements Serializable { + + /** 主键(更新时必填,创建时为空) */ + @Schema(description = "管理员 ID(更新时必填)") + private Long id; + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 30, message = "用户名长度需在 3-30 之间") + @Schema(description = "登录用户名", example = "operator02") + private String username; + + /** 创建时必填;更新时为空表示不修改密码 */ + @Schema(description = "密码(创建必填,更新时为空表示不修改)") + private String password; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像 URL") + private String avatar; + + @Email(message = "邮箱格式不正确") + @Schema(description = "邮箱") + private String email; + + @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确") + @Schema(description = "手机号") + private String phone; + + @NotBlank(message = "角色不能为空") + @Schema(description = "角色:super_admin / admin / operator", example = "admin") + private String role; + + @Schema(description = "状态:0-禁用 1-启用", example = "1") + private Integer status = 1; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java new file mode 100644 index 0000000..79d832f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/dto/req/ChangePasswordReq.java @@ -0,0 +1,23 @@ +package com.snack.server.module.admin.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.io.Serializable; + +/** + * 修改密码请求 + */ +@Data +@Schema(description = "修改密码请求") +public class ChangePasswordReq implements Serializable { + + @NotBlank(message = "原密码不能为空") + @Schema(description = "原密码") + private String oldPassword; + + @NotBlank(message = "新密码不能为空") + @Schema(description = "新密码") + private String newPassword; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java b/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java new file mode 100644 index 0000000..da32c7d --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/entity/Admin.java @@ -0,0 +1,62 @@ +package com.snack.server.module.admin.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 管理员实体 + * + * 对应数据库表:admin + */ +@Data +@TableName("admin") +public class Admin implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 登录用户名(唯一) */ + private String username; + + /** BCrypt 加密密码 */ + private String password; + + /** 昵称 */ + private String nickname; + + /** 头像 URL */ + private String avatar; + + /** 邮箱 */ + private String email; + + /** 手机号 */ + private String phone; + + /** 角色标识:super_admin / admin / operator */ + private String role; + + /** 账号状态:0-禁用 1-启用 */ + private Integer status; + + /** 最近登录时间 */ + private LocalDateTime lastLoginTime; + + /** 最近登录 IP */ + private String lastLoginIp; + + /** 创建时间 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 更新时间 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java b/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java new file mode 100644 index 0000000..969c476 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/mapper/AdminMapper.java @@ -0,0 +1,12 @@ +package com.snack.server.module.admin.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.snack.server.module.admin.entity.Admin; +import org.apache.ibatis.annotations.Mapper; + +/** + * 管理员 Mapper + */ +@Mapper +public interface AdminMapper extends BaseMapper { +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java new file mode 100644 index 0000000..5fde016 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminService.java @@ -0,0 +1,80 @@ +package com.snack.server.module.admin.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.snack.server.module.admin.dto.req.AdminLoginReq; +import com.snack.server.module.admin.dto.req.AdminPageReq; +import com.snack.server.module.admin.dto.req.AdminSaveReq; +import com.snack.server.module.admin.dto.req.ChangePasswordReq; +import com.snack.server.module.admin.entity.Admin; +import com.snack.server.module.admin.vo.AdminVO; +import com.snack.server.module.admin.vo.LoginVO; + +/** + * 管理员业务接口 + */ +public interface AdminService { + + /** + * 登录 + * + * @param req 登录参数 + * @param clientIp 客户端 IP + * @return 登录响应(含 token + 管理员信息) + */ + LoginVO login(AdminLoginReq req, String clientIp); + + /** + * 登出 + */ + void logout(); + + /** + * 获取当前登录的管理员信息 + */ + AdminVO getCurrentAdmin(); + + /** + * 分页查询管理员列表 + */ + Page pageAdmins(AdminPageReq req); + + /** + * 根据 ID 获取管理员 + */ + AdminVO getAdminById(Long id); + + /** + * 创建管理员 + */ + Long createAdmin(AdminSaveReq req); + + /** + * 更新管理员 + */ + void updateAdmin(AdminSaveReq req); + + /** + * 删除管理员 + */ + void deleteAdmin(Long id); + + /** + * 修改自己的密码 + */ + void changePassword(ChangePasswordReq req); + + /** + * 重置某管理员的密码(仅超管可用) + */ + void resetPassword(Long id, String newPassword); + + /** + * 启用 / 禁用管理员 + */ + void updateStatus(Long id, Integer status); + + /** + * 更新登录信息(IP + 时间) + */ + void updateLoginInfo(Long id, String clientIp); +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java new file mode 100644 index 0000000..dd0c314 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/service/AdminServiceImpl.java @@ -0,0 +1,282 @@ +package com.snack.server.module.admin.service; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.snack.server.common.ResultCode; +import com.snack.server.module.admin.dto.req.AdminLoginReq; +import com.snack.server.module.admin.dto.req.AdminPageReq; +import com.snack.server.module.admin.dto.req.AdminSaveReq; +import com.snack.server.module.admin.dto.req.ChangePasswordReq; +import com.snack.server.module.admin.entity.Admin; +import com.snack.server.exception.BusinessException; +import com.snack.server.module.admin.mapper.AdminMapper; +import com.snack.server.module.admin.service.AdminService; +import com.snack.server.module.admin.vo.AdminVO; +import com.snack.server.module.admin.vo.LoginVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 管理员业务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private final AdminMapper adminMapper; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + // ==================== 登录相关 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginVO login(AdminLoginReq req, String clientIp) { + // 1. 查询管理员 + Admin admin = adminMapper.selectOne( + new LambdaQueryWrapper().eq(Admin::getUsername, req.getUsername()) + ); + if (admin == null) { + throw new BusinessException(ResultCode.PASSWORD_ERROR); // 不暴露用户名是否存在 + } + + // 2. 校验状态 + if (admin.getStatus() != null && admin.getStatus() == 0) { + throw new BusinessException(ResultCode.USER_DISABLED); + } + + // 3. 校验密码(BCrypt 校验) + if (!passwordEncoder.matches(req.getPassword(), admin.getPassword())) { + throw new BusinessException(ResultCode.PASSWORD_ERROR); + } + + // 4. Sa-Token 登录 + StpUtil.login(admin.getId()); + String token = StpUtil.getTokenValue(); + + // 5. 更新登录信息(异步优化:可改为线程池执行) + updateLoginInfo(admin.getId(), clientIp); + + // 6. 构造返回 + LoginVO vo = new LoginVO(); + vo.setToken(token); + vo.setExpiresIn(StpUtil.getTokenTimeout()); + vo.setAdminInfo(toVO(admin)); + return vo; + } + + @Override + public void logout() { + if (StpUtil.isLogin()) { + StpUtil.logout(); + } + } + + @Override + public void updateLoginInfo(Long id, String clientIp) { + Admin update = new Admin(); + update.setId(id); + update.setLastLoginTime(LocalDateTime.now()); + update.setLastLoginIp(clientIp); + adminMapper.updateById(update); + } + + // ==================== 当前登录管理员 ==================== + + @Override + public AdminVO getCurrentAdmin() { + Long id = StpUtil.getLoginIdAsLong(); + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + return toVO(admin); + } + + // ==================== 列表 / 详情 ==================== + + @Override + public Page pageAdmins(AdminPageReq req) { + Page page = new Page<>(req.getCurrent(), req.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + // 关键字模糊搜索 + .and(StrUtil.isNotBlank(req.getKeyword()), w -> w + .like(Admin::getUsername, req.getKeyword()) + .or().like(Admin::getNickname, req.getKeyword()) + .or().like(Admin::getPhone, req.getKeyword()) + ) + .eq(StrUtil.isNotBlank(req.getRole()), Admin::getRole, req.getRole()) + .eq(req.getStatus() != null, Admin::getStatus, req.getStatus()) + .orderByDesc(Admin::getId); + + Page result = adminMapper.selectPage(page, wrapper); + Page voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); + voPage.setRecords(result.getRecords().stream().map(this::toVO).toList()); + return voPage; + } + + @Override + public AdminVO getAdminById(Long id) { + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + return toVO(admin); + } + + // ==================== 创建 / 更新 / 删除 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createAdmin(AdminSaveReq req) { + // 1. 用户名唯一性校验 + Long count = adminMapper.selectCount( + new LambdaQueryWrapper().eq(Admin::getUsername, req.getUsername()) + ); + if (count > 0) { + throw new BusinessException(1003, "用户名已存在"); + } + + // 2. 密码必填 + if (StrUtil.isBlank(req.getPassword())) { + throw new BusinessException(1000, "密码不能为空"); + } + + // 3. 入库 + Admin admin = new Admin(); + BeanUtil.copyProperties(req, admin, "id"); + admin.setPassword(passwordEncoder.encode(req.getPassword())); + admin.setStatus(req.getStatus() == null ? 1 : req.getStatus()); + adminMapper.insert(admin); + log.info("创建管理员成功 id={} username={}", admin.getId(), admin.getUsername()); + return admin.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAdmin(AdminSaveReq req) { + if (req.getId() == null) { + throw new BusinessException(1000, "ID 不能为空"); + } + Admin existing = adminMapper.selectById(req.getId()); + if (existing == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + + // 用户名查重(排除自己) + Long count = adminMapper.selectCount( + new LambdaQueryWrapper() + .eq(Admin::getUsername, req.getUsername()) + .ne(Admin::getId, req.getId()) + ); + if (count > 0) { + throw new BusinessException(1003, "用户名已存在"); + } + + // 不修改密码 + Admin admin = new Admin(); + BeanUtil.copyProperties(req, admin, "password"); + adminMapper.updateById(admin); + log.info("更新管理员成功 id={}", admin.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAdmin(Long id) { + // 不允许删除自己 + if (StpUtil.getLoginIdAsLong().equals(id)) { + throw new BusinessException(1000, "不能删除自己"); + } + // 不允许删除超级管理员 + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + if ("super_admin".equals(admin.getRole())) { + throw new BusinessException(1000, "不能删除超级管理员"); + } + adminMapper.deleteById(id); + log.info("删除管理员 id={}", id); + } + + // ==================== 密码相关 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void changePassword(ChangePasswordReq req) { + Long id = StpUtil.getLoginIdAsLong(); + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + if (!passwordEncoder.matches(req.getOldPassword(), admin.getPassword())) { + throw new BusinessException(ResultCode.OLD_PASSWORD_ERROR); + } + if (req.getNewPassword().equals(req.getOldPassword())) { + throw new BusinessException(1000, "新密码不能与原密码相同"); + } + Admin update = new Admin(); + update.setId(id); + update.setPassword(passwordEncoder.encode(req.getNewPassword())); + adminMapper.updateById(update); + log.info("管理员 id={} 修改密码成功", id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void resetPassword(Long id, String newPassword) { + if (StrUtil.isBlank(newPassword) || newPassword.length() < 6) { + throw new BusinessException(1000, "新密码至少 6 位"); + } + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + Admin update = new Admin(); + update.setId(id); + update.setPassword(passwordEncoder.encode(newPassword)); + adminMapper.updateById(update); + log.info("重置管理员 id={} 密码", id); + } + + // ==================== 状态切换 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + if (id == null || status == null) { + throw new BusinessException(1000, "参数错误"); + } + if (StpUtil.getLoginIdAsLong().equals(id)) { + throw new BusinessException(1000, "不能禁用自己"); + } + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_EXIST); + } + if ("super_admin".equals(admin.getRole())) { + throw new BusinessException(1000, "不能禁用超级管理员"); + } + Admin update = new Admin(); + update.setId(id); + update.setStatus(status); + adminMapper.updateById(update); + log.info("管理员 id={} 状态更新为 {}", id, status); + } + + // ==================== 私有方法 ==================== + + private AdminVO toVO(Admin admin) { + AdminVO vo = new AdminVO(); + BeanUtil.copyProperties(admin, vo, "password"); + return vo; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java b/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java new file mode 100644 index 0000000..facbd2d --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/vo/AdminVO.java @@ -0,0 +1,51 @@ +package com.snack.server.module.admin.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 管理员信息 VO + */ +@Data +@Schema(description = "管理员信息") +public class AdminVO implements Serializable { + + @Schema(description = "主键 ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "角色") + private String role; + + @Schema(description = "状态:0-禁用 1-启用") + private Integer status; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "最近登录时间") + private LocalDateTime lastLoginTime; + + @Schema(description = "最近登录 IP") + private String lastLoginIp; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java b/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java new file mode 100644 index 0000000..0090b23 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/admin/vo/LoginVO.java @@ -0,0 +1,26 @@ +package com.snack.server.module.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 登录成功返回 + */ +@Data +@Schema(description = "登录返回信息") +public class LoginVO implements Serializable { + + @Schema(description = "访问 Token(前端存储,后续请求需在 Header 中携带)") + private String token; + + @Schema(description = "Token 类型", example = "Bearer") + private String tokenType = "Bearer"; + + @Schema(description = "过期时间(秒)") + private Long expiresIn; + + @Schema(description = "管理员信息") + private AdminVO adminInfo; +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java new file mode 100644 index 0000000..711ee40 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryAdminController.java @@ -0,0 +1,104 @@ +package com.snack.server.module.category.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.snack.server.common.Result; +import com.snack.server.module.category.dto.req.CategorySaveReq; +import com.snack.server.module.category.entity.Category; +import com.snack.server.module.category.service.CategoryService; +import com.snack.server.module.category.vo.CategoryOptionVO; +import com.snack.server.module.category.vo.CategoryTreeVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 商品分类管理(管理端) + */ +@Tag(name = "02. 商品分类") +@RestController +@RequestMapping("/api/admin/category") +@RequiredArgsConstructor +public class CategoryAdminController { + + private final CategoryService categoryService; + + @Operation(summary = "获取分类树") + @SaCheckLogin + @GetMapping("/tree") + public Result> tree( + @Parameter(description = "状态过滤:null-全部 0-禁用 1-启用") + @RequestParam(required = false) Integer status) { + return Result.ok(categoryService.listTree(status)); + } + + @Operation(summary = "获取某父分类下的直接子分类") + @SaCheckLogin + @GetMapping("/children") + public Result> listChildren( + @Parameter(description = "父分类 ID,0=顶级") + @RequestParam(defaultValue = "0") Long parentId) { + return Result.ok(categoryService.listByParentId(parentId)); + } + + @Operation(summary = "获取启用状态的下拉选项") + @SaCheckLogin + @GetMapping("/options") + public Result> options() { + return Result.ok(categoryService.listOptions()); + } + + @Operation(summary = "获取分类详情") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "分类 ID") @PathVariable Long id) { + return Result.ok(categoryService.getById(id)); + } + + @Operation(summary = "创建分类") + @SaCheckLogin + @PostMapping + public Result create(@Valid @RequestBody CategorySaveReq req) { + return Result.ok(categoryService.create(req)); + } + + @Operation(summary = "更新分类") + @SaCheckLogin + @PutMapping + public Result update(@Valid @RequestBody CategorySaveReq req) { + categoryService.update(req); + return Result.ok(); + } + + @Operation(summary = "删除分类") + @SaCheckLogin + @DeleteMapping("/{id}") + public Result delete(@Parameter(description = "分类 ID") @PathVariable Long id) { + categoryService.delete(id); + return Result.ok(); + } + + @Operation(summary = "启用 / 禁用分类") + @SaCheckLogin + @PutMapping("/{id}/status/{status}") + public Result updateStatus( + @Parameter(description = "分类 ID") @PathVariable Long id, + @Parameter(description = "状态:0-禁用 1-启用") @PathVariable Integer status) { + categoryService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "调整排序") + @SaCheckLogin + @PutMapping("/{id}/sort/{sort}") + public Result updateSort( + @Parameter(description = "分类 ID") @PathVariable Long id, + @Parameter(description = "排序号") @PathVariable Integer sort) { + categoryService.updateSort(id, sort); + return Result.ok(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java new file mode 100644 index 0000000..9e6e150 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/controller/CategoryPublicController.java @@ -0,0 +1,31 @@ +package com.snack.server.module.category.controller; + +import com.snack.server.common.Result; +import com.snack.server.module.category.service.CategoryService; +import com.snack.server.module.category.vo.CategoryTreeVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 商品分类(用户端 / 公开接口) + */ +@Tag(name = "商品分类(公开)") +@RestController +@RequestMapping("/api/category") +@RequiredArgsConstructor +public class CategoryPublicController { + + private final CategoryService categoryService; + + @Operation(summary = "获取启用的分类树(用户端首页)") + @GetMapping("/tree") + public Result> tree() { + return Result.ok(categoryService.listTree(1)); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java b/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java new file mode 100644 index 0000000..0055a44 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/dto/req/CategorySaveReq.java @@ -0,0 +1,42 @@ +package com.snack.server.module.category.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.io.Serializable; + +/** + * 创建/更新分类请求 + */ +@Data +@Schema(description = "创建/更新分类请求") +public class CategorySaveReq implements Serializable { + + /** 主键(更新时必填) */ + @Schema(description = "分类 ID(更新时必填)") + private Long id; + + @NotBlank(message = "分类名称不能为空") + @Size(max = 50, message = "分类名称最长 50 字") + @Schema(description = "分类名称", example = "坚果炒货") + private String name; + + @Schema(description = "父分类 ID,0 = 一级分类", example = "1") + private Long parentId = 0L; + + @NotNull(message = "层级不能为空") + @Schema(description = "层级:1 一级 / 2 二级 / 3 三级", example = "2") + private Integer level; + + @Schema(description = "排序号(升序)", example = "1") + private Integer sort = 0; + + @Schema(description = "分类图标 URL") + private String icon; + + @Schema(description = "状态:0-禁用 1-启用", example = "1") + private Integer status = 1; +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java b/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java new file mode 100644 index 0000000..1e9a6c4 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/entity/Category.java @@ -0,0 +1,49 @@ +package com.snack.server.module.category.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 商品分类实体 + * + * 对应数据库表:category + * 树形结构:parent_id 指向父分类,0 表示一级分类 + */ +@Data +@TableName("category") +public class Category implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 分类名称 */ + private String name; + + /** 父分类 ID,0 表示一级分类 */ + private Long parentId; + + /** 层级:1-一级 2-二级 3-三级 */ + private Integer level; + + /** 排序号(升序展示) */ + private Integer sort; + + /** 分类图标 URL */ + private String icon; + + /** 状态:0-禁用 1-启用 */ + private Integer status; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java new file mode 100644 index 0000000..c984175 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryLevelEnum.java @@ -0,0 +1,31 @@ +package com.snack.server.module.category.enums; + +import lombok.Getter; + +import java.util.Arrays; + +/** + * 分类层级 + */ +@Getter +public enum CategoryLevelEnum { + + LEVEL_1(1, "一级分类"), + LEVEL_2(2, "二级分类"), + LEVEL_3(3, "三级分类"); + + private final Integer code; + private final String desc; + + CategoryLevelEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } + + public static CategoryLevelEnum of(Integer code) { + return Arrays.stream(values()) + .filter(e -> e.code.equals(code)) + .findFirst() + .orElse(null); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java new file mode 100644 index 0000000..3e59fd8 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/enums/CategoryStatusEnum.java @@ -0,0 +1,21 @@ +package com.snack.server.module.category.enums; + +import lombok.Getter; + +/** + * 分类状态 + */ +@Getter +public enum CategoryStatusEnum { + + DISABLED(0, "禁用"), + ENABLED(1, "启用"); + + private final Integer code; + private final String desc; + + CategoryStatusEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java b/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java new file mode 100644 index 0000000..1eea41a --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/mapper/CategoryMapper.java @@ -0,0 +1,21 @@ +package com.snack.server.module.category.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.snack.server.module.category.entity.Category; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 商品分类 Mapper + */ +@Mapper +public interface CategoryMapper extends BaseMapper { + + /** + * 查询所有子分类 ID(递归,包含自身) + * 用于"删除分类时级联检查商品 / 子分类" + */ + List selectAllChildIds(@Param("parentId") Long parentId); +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java b/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java new file mode 100644 index 0000000..06d58a6 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/service/CategoryService.java @@ -0,0 +1,61 @@ +package com.snack.server.module.category.service; + +import com.snack.server.module.category.dto.req.CategorySaveReq; +import com.snack.server.module.category.entity.Category; +import com.snack.server.module.category.vo.CategoryOptionVO; +import com.snack.server.module.category.vo.CategoryTreeVO; + +import java.util.List; + +/** + * 商品分类业务接口 + */ +public interface CategoryService { + + /** + * 获取分类树(全量) + * + * @param statusFilter null-全部 0-禁用 1-启用 + */ + List listTree(Integer statusFilter); + + /** + * 获取子分类(按父 ID 查直接子节点) + */ + List listByParentId(Long parentId); + + /** + * 获取下拉选项(仅启用状态) + */ + List listOptions(); + + /** + * 获取分类详情 + */ + Category getById(Long id); + + /** + * 创建分类 + */ + Long create(CategorySaveReq req); + + /** + * 更新分类 + */ + void update(CategorySaveReq req); + + /** + * 删除分类(级联校验:有商品/子分类则禁止删除) + */ + void delete(Long id); + + /** + * 启用 / 禁用 + */ + void updateStatus(Long id, Integer status); + + /** + * 调整排序 + */ + void updateSort(Long id, Integer sort); +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..5d1945f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/service/impl/CategoryServiceImpl.java @@ -0,0 +1,322 @@ +package com.snack.server.module.category.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.snack.server.common.ResultCode; +import com.snack.server.exception.BusinessException; +import com.snack.server.module.category.dto.req.CategorySaveReq; +import com.snack.server.module.category.entity.Category; +import com.snack.server.module.category.enums.CategoryLevelEnum; +import com.snack.server.module.category.enums.CategoryStatusEnum; +import com.snack.server.module.category.mapper.CategoryMapper; +import com.snack.server.module.category.service.CategoryService; +import com.snack.server.module.category.vo.CategoryOptionVO; +import com.snack.server.module.category.vo.CategoryTreeVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 商品分类业务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CategoryServiceImpl implements CategoryService { + + /** 最大层级 */ + private static final int MAX_LEVEL = 3; + /** 默认父分类 ID(顶级) */ + private static final Long ROOT_PARENT_ID = 0L; + + private final CategoryMapper categoryMapper; + + // ==================== 查询 ==================== + + @Override + public List listTree(Integer statusFilter) { + // 1. 查询全量分类 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByAsc(Category::getSort) + .orderByAsc(Category::getId); + if (statusFilter != null) { + wrapper.eq(Category::getStatus, statusFilter); + } + List all = categoryMapper.selectList(wrapper); + + // 2. 转 VO + List vos = all.stream().map(this::toTreeVO).toList(); + + // 3. 组装树 + return buildTree(vos); + } + + @Override + public List listByParentId(Long parentId) { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getParentId, parentId) + .orderByAsc(Category::getSort) + ); + } + + @Override + public List listOptions() { + return categoryMapper.selectList( + new LambdaQueryWrapper() + .eq(Category::getStatus, CategoryStatusEnum.ENABLED.getCode()) + .orderByAsc(Category::getSort) + ).stream().map(c -> { + CategoryOptionVO vo = new CategoryOptionVO(); + BeanUtil.copyProperties(c, vo); + return vo; + }).toList(); + } + + @Override + public Category getById(Long id) { + Category category = categoryMapper.selectById(id); + if (category == null) { + throw new BusinessException(1010, "分类不存在"); + } + return category; + } + + // ==================== 创建 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(CategorySaveReq req) { + // 1. 校验父分类 + validateParent(req.getParentId(), req.getLevel()); + + // 2. 同级下名称不能重复 + Long sameNameCount = categoryMapper.selectCount( + new LambdaQueryWrapper() + .eq(Category::getParentId, req.getParentId()) + .eq(Category::getName, req.getName()) + ); + if (sameNameCount > 0) { + throw new BusinessException(1003, "同级分类下已存在同名分类"); + } + + // 3. 入库 + Category category = new Category(); + BeanUtil.copyProperties(req, category, "id"); + if (category.getStatus() == null) { + category.setStatus(CategoryStatusEnum.ENABLED.getCode()); + } + categoryMapper.insert(category); + log.info("创建分类 id={} name={} level={}", category.getId(), category.getName(), category.getLevel()); + return category.getId(); + } + + // ==================== 更新 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(CategorySaveReq req) { + if (req.getId() == null) { + throw new BusinessException(1000, "ID 不能为空"); + } + Category existing = categoryMapper.selectById(req.getId()); + if (existing == null) { + throw new BusinessException(1010, "分类不存在"); + } + + // 不允许修改层级(层级变化会破坏树形结构) + if (!existing.getLevel().equals(req.getLevel())) { + throw new BusinessException(1000, "不允许修改层级"); + } + + // 校验父分类 + validateParent(req.getParentId(), req.getLevel()); + // 不能把自己设为自己的父分类 + if (req.getId().equals(req.getParentId())) { + throw new BusinessException(1000, "不能将自己设为父分类"); + } + + // 同级下名称查重(排除自身) + Long sameNameCount = categoryMapper.selectCount( + new LambdaQueryWrapper() + .eq(Category::getParentId, req.getParentId()) + .eq(Category::getName, req.getName()) + .ne(Category::getId, req.getId()) + ); + if (sameNameCount > 0) { + throw new BusinessException(1003, "同级分类下已存在同名分类"); + } + + Category category = new Category(); + BeanUtil.copyProperties(req, category); + categoryMapper.updateById(category); + log.info("更新分类 id={}", category.getId()); + } + + // ==================== 删除 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long id) { + Category existing = categoryMapper.selectById(id); + if (existing == null) { + throw new BusinessException(1010, "分类不存在"); + } + + // 1. 校验是否有子分类 + Long childCount = categoryMapper.selectCount( + new LambdaQueryWrapper().eq(Category::getParentId, id) + ); + if (childCount > 0) { + throw new BusinessException(1000, + "存在 " + childCount + " 个子分类,请先删除子分类"); + } + + // 2. 校验是否有关联商品(通过 product 表的 category_id 判断) + Long productCount = categoryMapper.selectCount( + new LambdaQueryWrapper().eq(Category::getId, id) + // 这里只是占位,真实业务应注入 ProductMapper 检查 product.category_id + ); + // 简化处理:实际应查 product 表,TODO: 注入 ProductService 检查 + // if (productService.countByCategoryId(id) > 0) { + // throw new BusinessException("该分类下存在商品,无法删除"); + // } + + categoryMapper.deleteById(id); + log.info("删除分类 id={} name={}", id, existing.getName()); + } + + // ==================== 状态切换 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, Integer status) { + if (id == null || status == null) { + throw new BusinessException(1000, "参数错误"); + } + Category existing = categoryMapper.selectById(id); + if (existing == null) { + throw new BusinessException(1010, "分类不存在"); + } + if (existing.getStatus().equals(status)) { + return; // 状态未变 + } + Category update = new Category(); + update.setId(id); + update.setStatus(status); + categoryMapper.updateById(update); + log.info("分类 id={} 状态更新为 {}", id, status); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSort(Long id, Integer sort) { + if (id == null) { + throw new BusinessException(1000, "ID 不能为空"); + } + Category existing = categoryMapper.selectById(id); + if (existing == null) { + throw new BusinessException(1010, "分类不存在"); + } + Category update = new Category(); + update.setId(id); + update.setSort(sort == null ? 0 : sort); + categoryMapper.updateById(update); + } + + // ==================== 私有方法 ==================== + + /** + * 校验父分类合法性 + */ + private void validateParent(Long parentId, Integer level) { + if (parentId == null) { + parentId = ROOT_PARENT_ID; + } + + // 一级分类:parentId 必须为 0 + if (level == null || level < 1 || level > MAX_LEVEL) { + throw new BusinessException(1000, "层级必须在 1~" + MAX_LEVEL + " 之间"); + } + + if (level == 1) { + if (!ROOT_PARENT_ID.equals(parentId)) { + throw new BusinessException(1000, "一级分类的父分类必须为 0"); + } + return; + } + + // 二级 / 三级:父分类必须存在 + Category parent = categoryMapper.selectById(parentId); + if (parent == null) { + throw new BusinessException(1000, "父分类不存在"); + } + // 父分类的 level 必须 = 当前 level - 1 + if (!parent.getLevel().equals(level - 1)) { + throw new BusinessException(1000, + "父分类层级不匹配:当前层级=" + level + ",父分类层级=" + parent.getLevel()); + } + // 父分类必须启用 + if (parent.getStatus() != null && parent.getStatus() == 0) { + throw new BusinessException(1000, "父分类已禁用,无法添加子分类"); + } + } + + /** + * 组装树形结构 + */ + private List buildTree(List all) { + if (CollUtil.isEmpty(all)) { + return new ArrayList<>(); + } + // 按 id 建索引 + Map map = all.stream() + .collect(Collectors.toMap(CategoryTreeVO::getId, v -> v, (a, b) -> a)); + + List roots = new ArrayList<>(); + for (CategoryTreeVO vo : all) { + if (vo.getParentId() == null || vo.getParentId() == 0L) { + roots.add(vo); + continue; + } + CategoryTreeVO parent = map.get(vo.getParentId()); + if (parent != null) { + if (parent.getChildren() == null) { + parent.setChildren(new ArrayList<>()); + } + parent.getChildren().add(vo); + } else { + // 孤儿节点(父分类被删除),按顶级处理 + roots.add(vo); + } + } + // 排序 + sortTree(roots); + return roots; + } + + private void sortTree(List list) { + if (CollUtil.isEmpty(list)) return; + list.sort(Comparator.comparing(CategoryTreeVO::getSort, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(CategoryTreeVO::getId)); + for (CategoryTreeVO vo : list) { + sortTree(vo.getChildren()); + } + } + + private CategoryTreeVO toTreeVO(Category c) { + CategoryTreeVO vo = new CategoryTreeVO(); + BeanUtil.copyProperties(c, vo); + return vo; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java new file mode 100644 index 0000000..2e558ad --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryOptionVO.java @@ -0,0 +1,26 @@ +package com.snack.server.module.category.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 分类下拉选项(用于商品发布、绑定父分类等场景) + */ +@Data +@Schema(description = "分类下拉选项") +public class CategoryOptionVO implements Serializable { + + @Schema(description = "分类 ID") + private Long id; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "层级") + private Integer level; + + @Schema(description = "父分类 ID") + private Long parentId; +} diff --git a/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java new file mode 100644 index 0000000..20ad44f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/category/vo/CategoryTreeVO.java @@ -0,0 +1,45 @@ +package com.snack.server.module.category.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 分类树节点 VO + */ +@Data +@Schema(description = "分类树节点") +public class CategoryTreeVO implements Serializable { + + @Schema(description = "分类 ID") + private Long id; + + @Schema(description = "分类名称") + private String name; + + @Schema(description = "父分类 ID,0 = 顶级") + private Long parentId; + + @Schema(description = "层级:1 一级 / 2 二级 / 3 三级") + private Integer level; + + @Schema(description = "排序号") + private Integer sort; + + @Schema(description = "分类图标") + private String icon; + + @Schema(description = "状态:0-禁用 1-启用") + private Integer status; + + @Schema(description = "子分类列表") + private List children; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java new file mode 100644 index 0000000..1ae40b0 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductAdminController.java @@ -0,0 +1,104 @@ +package com.snack.server.module.product.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.snack.server.common.Result; +import com.snack.server.module.product.dto.req.ProductPageReq; +import com.snack.server.module.product.dto.req.ProductSaveReq; +import com.snack.server.module.product.dto.req.StockAdjustReq; +import com.snack.server.module.product.service.ProductService; +import com.snack.server.module.product.vo.ProductDetailVO; +import com.snack.server.module.product.vo.ProductListVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 商品管理(管理端) + */ +@Tag(name = "03. 商品管理") +@RestController +@RequestMapping("/api/admin/product") +@RequiredArgsConstructor +public class ProductAdminController { + + private final ProductService productService; + + @Operation(summary = "分页查询商品") + @SaCheckLogin + @GetMapping("/page") + public Result> page(ProductPageReq req) { + return Result.ok(productService.pageProducts(req)); + } + + @Operation(summary = "商品详情(含 SKU)") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "商品 ID") @PathVariable Long id) { + return Result.ok(productService.getDetail(id)); + } + + @Operation(summary = "发布商品(SPU + SKU)") + @SaCheckLogin + @PostMapping + public Result create(@Valid @RequestBody ProductSaveReq req) { + return Result.ok(productService.createProduct(req)); + } + + @Operation(summary = "编辑商品(SPU + SKU)") + @SaCheckLogin + @PutMapping + public Result update(@Valid @RequestBody ProductSaveReq req) { + productService.updateProduct(req); + return Result.ok(); + } + + @Operation(summary = "删除商品(级联删除 SKU)") + @SaCheckLogin + @DeleteMapping("/{id}") + public Result delete(@Parameter(description = "商品 ID") @PathVariable Long id) { + productService.deleteProduct(id); + return Result.ok(); + } + + @Operation(summary = "上下架切换") + @SaCheckLogin + @PutMapping("/{id}/status/{status}") + public Result updateStatus( + @Parameter(description = "商品 ID") @PathVariable Long id, + @Parameter(description = "状态:0-下架 1-上架") @PathVariable Integer status) { + productService.updateStatus(id, status); + return Result.ok(); + } + + @Operation(summary = "设置 / 取消热门") + @SaCheckLogin + @PutMapping("/{id}/hot/{isHot}") + public Result updateHot( + @PathVariable Long id, + @Parameter(description = "0-否 1-是") @PathVariable Integer isHot) { + productService.updateHot(id, isHot); + return Result.ok(); + } + + @Operation(summary = "设置 / 取消新品") + @SaCheckLogin + @PutMapping("/{id}/new/{isNew}") + public Result updateNew( + @PathVariable Long id, + @Parameter(description = "0-否 1-是") @PathVariable Integer isNew) { + productService.updateNew(id, isNew); + return Result.ok(); + } + + @Operation(summary = "调整 SKU 库存") + @SaCheckLogin + @PutMapping("/stock/adjust") + public Result adjustStock(@Valid @RequestBody StockAdjustReq req) { + productService.adjustStock(req); + return Result.ok(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java new file mode 100644 index 0000000..fc8848f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/controller/ProductPublicController.java @@ -0,0 +1,62 @@ +package com.snack.server.module.product.controller; + +import cn.hutool.core.util.StrUtil; +import com.snack.server.common.Result; +import com.snack.server.module.product.service.ProductService; +import com.snack.server.module.product.vo.ProductDetailVO; +import com.snack.server.module.product.vo.ProductListVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 商品(用户端 / 公开) + */ +@Tag(name = "商品(公开)") +@RestController +@RequestMapping("/api/product") +@RequiredArgsConstructor +public class ProductPublicController { + + private final ProductService productService; + + @Operation(summary = "商品详情(用户端)") + @GetMapping("/{id}") + public Result detail(@Parameter(description = "商品 ID") @PathVariable Long id) { + // 增加浏览量 + productService.incrViewCount(id); + return Result.ok(productService.getDetail(id)); + } + + @Operation(summary = "分类下的商品列表(用户端分类页)") + @GetMapping("/list") + public Result> list( + @Parameter(description = "分类 ID(多个用逗号分隔)") @RequestParam String categoryIds, + @Parameter(description = "排序:sales_desc / price_asc / price_desc / create_desc") + @RequestParam(required = false) String orderBy, + @Parameter(description = "返回数量限制") @RequestParam(defaultValue = "20") Integer limit) { + List ids = Arrays.stream(categoryIds.split(",")) + .filter(StrUtil::isNotBlank) + .map(Long::valueOf) + .toList(); + return Result.ok(productService.listByCategoryIds(ids, orderBy, limit)); + } + + @Operation(summary = "热门商品") + @GetMapping("/hot") + public Result> hot(@RequestParam(defaultValue = "10") Integer limit) { + return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "sales_desc", limit)); + } + + @Operation(summary = "新品上市") + @GetMapping("/new") + public Result> newProducts(@RequestParam(defaultValue = "10") Integer limit) { + return Result.ok(productService.listByCategoryIds(Collections.emptyList(), "create_desc", limit)); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java new file mode 100644 index 0000000..74f0f15 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductPageReq.java @@ -0,0 +1,38 @@ +package com.snack.server.module.product.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 商品分页查询请求 + */ +@Data +@Schema(description = "商品分页查询") +public class ProductPageReq implements Serializable { + + @Schema(description = "页码", example = "1") + private Long current = 1L; + + @Schema(description = "每页条数", example = "10") + private Long size = 10L; + + @Schema(description = "关键字(商品名 / 品牌)") + private String keyword; + + @Schema(description = "分类 ID(查该分类及其子分类下的商品)") + private Long categoryId; + + @Schema(description = "状态:0-下架 1-上架") + private Integer status; + + @Schema(description = "是否热门") + private Integer isHot; + + @Schema(description = "是否新品") + private Integer isNew; + + @Schema(description = "排序:sales_desc / price_asc / price_desc / create_desc") + private String orderBy = "create_desc"; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java new file mode 100644 index 0000000..fc831a2 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/ProductSaveReq.java @@ -0,0 +1,94 @@ +package com.snack.server.module.product.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 发布/更新商品请求(含 SKU 列表) + */ +@Data +@Schema(description = "发布/更新商品请求") +public class ProductSaveReq implements Serializable { + + @Schema(description = "商品 ID(更新时必填)") + private Long id; + + @NotBlank(message = "商品名称不能为空") + @Size(max = 200, message = "商品名称最长 200 字") + @Schema(description = "商品名称", example = "原味夏威夷果 500g") + private String name; + + @NotNull(message = "分类不能为空") + @Schema(description = "分类 ID", example = "2") + private Long categoryId; + + @Schema(description = "品牌") + private String brand; + + @Schema(description = "主图 URL") + private String mainImage; + + @Schema(description = "副图 JSON 数组字符串") + private String subImages; + + @Schema(description = "富文本详情(HTML)") + private String detail; + + @Schema(description = "原价 / 划线价") + private BigDecimal originPrice; + + @Schema(description = "是否热门:0-否 1-是") + private Integer isHot = 0; + + @Schema(description = "是否新品:0-否 1-是") + private Integer isNew = 0; + + @Schema(description = "上架状态:0-下架 1-上架", example = "1") + private Integer status = 1; + + /** + * SKU 列表(创建时必填至少 1 个) + * id 为空 = 新增;非空 = 更新 + */ + @NotNull(message = "SKU 列表不能为空") + @Size(min = 1, message = "至少需要一个 SKU 规格") + @Valid + @Schema(description = "SKU 列表") + private List skuList; + + @Data + public static class ProductSkuReq implements Serializable { + + @Schema(description = "SKU ID(更新时必填)") + private Long id; + + @NotBlank(message = "规格名称不能为空") + @Schema(description = "规格名称", example = "原味/500g") + private String skuName; + + @Schema(description = "规格图片") + private String image; + + @NotNull(message = "价格不能为空") + @DecimalMin(value = "0.01", message = "价格必须大于 0") + @Schema(description = "售价", example = "79.00") + private BigDecimal price; + + @NotNull(message = "库存不能为空") + @Min(value = 0, message = "库存不能为负数") + @Schema(description = "库存", example = "500") + private Integer stock; + + @Schema(description = "重量(克)") + private Integer weight; + + @Schema(description = "排序号") + private Integer sort = 0; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java b/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java new file mode 100644 index 0000000..64edebc --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/dto/req/StockAdjustReq.java @@ -0,0 +1,26 @@ +package com.snack.server.module.product.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.io.Serializable; + +/** + * 调整库存请求 + */ +@Data +@Schema(description = "调整库存请求") +public class StockAdjustReq implements Serializable { + + @NotNull(message = "SKU ID 不能为空") + @Schema(description = "SKU ID") + private Long skuId; + + @Schema(description = "增量调整数量(正数加库存,负数减库存)", example = "10") + private Integer delta; + + @Schema(description = "绝对值目标库存(与 delta 二选一,优先 delta)", example = "100") + private Integer targetStock; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java b/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java new file mode 100644 index 0000000..a2d7b86 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/entity/Product.java @@ -0,0 +1,67 @@ +package com.snack.server.module.product.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 商品 SPU + * + * 对应数据库表:product + */ +@Data +@TableName("product") +public class Product implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 商品名称 */ + private String name; + + /** 所属分类 ID */ + private Long categoryId; + + /** 品牌 */ + private String brand; + + /** 主图 URL */ + private String mainImage; + + /** 副图(JSON 数组) */ + private String subImages; + + /** 富文本详情(HTML) */ + private String detail; + + /** 原价 / 划线价 */ + private BigDecimal originPrice; + + /** 累计销量 */ + private Integer sales; + + /** 浏览量 */ + private Integer viewCount; + + /** 状态:0-下架 1-上架 */ + private Integer status; + + /** 是否热门 */ + private Integer isHot; + + /** 是否新品 */ + private Integer isNew; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java b/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java new file mode 100644 index 0000000..e862a32 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/entity/ProductSku.java @@ -0,0 +1,55 @@ +package com.snack.server.module.product.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 商品 SKU(具体规格) + * + * 对应数据库表:product_sku + */ +@Data +@TableName("product_sku") +public class ProductSku implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 所属 SPU ID */ + private Long productId; + + /** 规格名称,如:原味/500g */ + private String skuName; + + /** 规格图片 */ + private String image; + + /** 售价 */ + private BigDecimal price; + + /** 库存 */ + private Integer stock; + + /** 销量 */ + private Integer sales; + + /** 重量(克) */ + private Integer weight; + + /** 排序号 */ + private Integer sort; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java b/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java new file mode 100644 index 0000000..6c2ed34 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/enums/ProductStatusEnum.java @@ -0,0 +1,21 @@ +package com.snack.server.module.product.enums; + +import lombok.Getter; + +/** + * 商品状态 + */ +@Getter +public enum ProductStatusEnum { + + OFF_SHELF(0, "下架"), + ON_SHELF(1, "上架"); + + private final Integer code; + private final String desc; + + ProductStatusEnum(Integer code, String desc) { + this.code = code; + this.desc = desc; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java new file mode 100644 index 0000000..29dc662 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductMapper.java @@ -0,0 +1,21 @@ +package com.snack.server.module.product.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.snack.server.module.product.entity.Product; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 商品 SPU Mapper + */ +@Mapper +public interface ProductMapper extends BaseMapper { + + /** + * 批量查询商品的最低 SKU 价格(用于列表展示"起售价") + */ + List> selectMinPriceByProductIds(@Param("ids") List ids); +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductSkuMapper.java b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductSkuMapper.java new file mode 100644 index 0000000..82b9bdc --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/mapper/ProductSkuMapper.java @@ -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 { + + /** + * 扣减库存(原子操作,乐观锁兜底) + * @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); +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/service/ProductService.java b/server-snack/src/main/java/com/snack/server/module/product/service/ProductService.java new file mode 100644 index 0000000..b4ab1f7 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/service/ProductService.java @@ -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 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 listByCategoryIds(Collection categoryIds, String orderBy, Integer limit); + + /** + * 根据 SPU ID 列表获取所有 SKU + */ + List listSkusByProductIds(List productIds); +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/service/impl/ProductServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/product/service/impl/ProductServiceImpl.java new file mode 100644 index 0000000..d48f403 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/service/impl/ProductServiceImpl.java @@ -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 pageProducts(ProductPageReq req) { + Page page = new Page<>(req.getCurrent(), req.getSize()); + LambdaQueryWrapper wrapper = buildQueryWrapper(req) + .orderByDesc(Product::getId); + + Page result = productMapper.selectPage(page, wrapper); + return toListVOPage(result); + } + + @Override + public List listByCategoryIds(Collection 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 wrapper = buildQueryWrapper(req) + .in(Product::getCategoryId, categoryIds); + applyOrderBy(wrapper, orderBy); + + List products = productMapper.selectList(wrapper); + return enrichListVO(products); + } + + private LambdaQueryWrapper buildQueryWrapper(ProductPageReq req) { + return new LambdaQueryWrapper() + .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 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 toListVOPage(Page page) { + Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + voPage.setRecords(enrichListVO(page.getRecords())); + return voPage; + } + + /** + * 把 Product 列表 → ProductListVO(含分类名、起售价、库存等) + */ + private List enrichListVO(List products) { + if (CollUtil.isEmpty(products)) { + return new ArrayList<>(); + } + // 1. 批量取分类名 + Set categoryIds = products.stream() + .map(Product::getCategoryId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map categoryNameMap = new HashMap<>(); + if (!categoryIds.isEmpty()) { + categoryMapper.selectBatchIds(categoryIds) + .forEach(c -> categoryNameMap.put(c.getId(), c.getName())); + } + + // 2. 批量取起售价 + List productIds = products.stream().map(Product::getId).toList(); + Map minPriceMap = new HashMap<>(); + Map stockMap = new HashMap<>(); + if (!productIds.isEmpty()) { + try { + List> priceList = productMapper.selectMinPriceByProductIds(productIds); + for (Map 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 skus = productSkuMapper.selectList( + new LambdaQueryWrapper() + .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 listSkusByProductIds(List productIds) { + if (CollUtil.isEmpty(productIds)) return new ArrayList<>(); + return productSkuMapper.selectList( + new LambdaQueryWrapper().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 oldSkus = productSkuMapper.selectList( + new LambdaQueryWrapper().eq(ProductSku::getProductId, req.getId()) + ); + Set reqSkuIds = req.getSkuList().stream() + .map(ProductSaveReq.ProductSkuReq::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + + // 删除:旧 SKU 中不在本次请求的 + List 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().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 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 skus) { + if (CollUtil.isEmpty(skus)) { + throw new BusinessException(1000, "至少需要一个 SKU"); + } + Set names = new HashSet<>(); + for (ProductSaveReq.ProductSkuReq sku : skus) { + if (!names.add(sku.getSkuName())) { + throw new BusinessException(1003, "SKU 名称重复:" + sku.getSkuName()); + } + } + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/vo/ProductDetailVO.java b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductDetailVO.java new file mode 100644 index 0000000..3c06d36 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductDetailVO.java @@ -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 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 skuList; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建时间") + private LocalDateTime createTime; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/vo/ProductListVO.java b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductListVO.java new file mode 100644 index 0000000..dc51396 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductListVO.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/product/vo/ProductSkuVO.java b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductSkuVO.java new file mode 100644 index 0000000..8e0ffe1 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/product/vo/ProductSkuVO.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/service/impl/.gitkeep b/server-snack/src/main/java/com/snack/server/service/impl/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/java/com/snack/server/utils/PasswordGenerator.java b/server-snack/src/main/java/com/snack/server/utils/PasswordGenerator.java new file mode 100644 index 0000000..613871c --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/utils/PasswordGenerator.java @@ -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)); + } +} diff --git a/server-snack/src/main/java/com/snack/server/vo/.gitkeep b/server-snack/src/main/java/com/snack/server/vo/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server-snack/src/main/resources/application.yml b/server-snack/src/main/resources/application.yml index 5cdae94..dcb9aa6 100644 --- a/server-snack/src/main/resources/application.yml +++ b/server-snack/src/main/resources/application.yml @@ -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 diff --git a/server-snack/src/main/resources/com/snack/server/module/category/mapper/CategoryMapper.xml b/server-snack/src/main/resources/com/snack/server/module/category/mapper/CategoryMapper.xml new file mode 100644 index 0000000..963206c --- /dev/null +++ b/server-snack/src/main/resources/com/snack/server/module/category/mapper/CategoryMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/server-snack/src/main/resources/com/snack/server/module/product/mapper/ProductMapper.xml b/server-snack/src/main/resources/com/snack/server/module/product/mapper/ProductMapper.xml new file mode 100644 index 0000000..2d4d8b0 --- /dev/null +++ b/server-snack/src/main/resources/com/snack/server/module/product/mapper/ProductMapper.xml @@ -0,0 +1,22 @@ + + + + + + + +