feat(order): 添加订单模块完整功能

- 新增订单数据库表结构,包含支付渠道、交易号、退款信息等字段
- 添加订单相关实体类、枚举、常量定义
- 实现订单提交、查询、支付、取消、退款等核心业务逻辑
- 创建用户端和管理端订单控制器API接口
- 实现支付回调处理功能用于支付宝/微信支付集成
- 添加订单状态机管理和物流处理功能
```
This commit is contained in:
Yuhang Wu 2026-06-02 17:59:57 +08:00
parent e5ac034349
commit 7c483840b6
18 changed files with 1488 additions and 1 deletions

View File

@ -204,10 +204,16 @@ CREATE TABLE `orders` (
`receive_time` DATETIME DEFAULT NULL COMMENT '收货时间', `receive_time` DATETIME DEFAULT NULL COMMENT '收货时间',
`cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间', `cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间',
`finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间',
`pay_channel` VARCHAR(20) DEFAULT NULL COMMENT '支付渠道alipay / wechat / balance',
`pay_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '第三方支付平台交易号(支付宝/微信返回)',
`refund_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '退款交易号(支付宝/微信返回)',
`refund_time` DATETIME DEFAULT NULL COMMENT '退款时间',
`refund_reason` VARCHAR(255) DEFAULT NULL COMMENT '退款原因',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_order_no` (`order_no`),
UNIQUE KEY `uk_pay_trade_no` (`pay_trade_no`),
KEY `idx_user_id` (`user_id`), KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`), KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`), KEY `idx_create_time` (`create_time`),

View File

@ -0,0 +1,19 @@
package com.snack.server.module.order.constant;
/**
* 订单模块常量
*/
public interface OrderConstant {
/** 订单号前缀SN + yyyyMMdd + 6 位随机数(共 18 位) */
String ORDER_NO_PREFIX = "SN";
/** 默认运费 */
java.math.BigDecimal DEFAULT_FREIGHT = new java.math.BigDecimal("0.00");
/** 订单超时未支付自动取消时间(分钟)—— 定时任务使用 */
int ORDER_AUTO_CANCEL_MINUTES = 30;
/** 订单确认收货默认时间(天)—— 物流签收后 N 天自动确认 */
int ORDER_AUTO_RECEIVE_DAYS = 7;
}

View File

@ -0,0 +1,60 @@
package com.snack.server.module.order.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.order.dto.req.OrderPageReq;
import com.snack.server.module.order.service.OrderService;
import com.snack.server.module.order.vo.OrderDetailVO;
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.*;
/**
* 订单管理管理端
*/
@Tag(name = "订单管理")
@RestController
@RequestMapping("/api/admin/order")
@RequiredArgsConstructor
public class OrderAdminController {
private final OrderService orderService;
@Operation(summary = "订单分页(管理端)")
@SaCheckLogin
@GetMapping("/page")
public Result<Page<OrderDetailVO>> page(OrderPageReq req) {
return Result.ok(orderService.pageAllOrders(req));
}
@Operation(summary = "订单详情(管理端)")
@SaCheckLogin
@GetMapping("/{id}")
public Result<OrderDetailVO> detail(@Parameter(description = "订单 ID") @PathVariable Long id) {
return Result.ok(orderService.getDetailForAdmin(id));
}
@Operation(summary = "订单发货")
@SaCheckLogin
@PostMapping("/{id}/deliver")
public Result<Void> deliver(
@Parameter(description = "订单 ID") @PathVariable Long id,
@Parameter(description = "物流公司") @RequestParam String company,
@Parameter(description = "物流单号") @RequestParam String trackingNo) {
orderService.deliverOrder(id, company, trackingNo);
return Result.ok();
}
@Operation(summary = "管理员取消订单")
@SaCheckLogin
@PostMapping("/{id}/cancel")
public Result<Void> cancel(
@Parameter(description = "订单 ID") @PathVariable Long id,
@Parameter(description = "原因") @RequestParam(required = false, defaultValue = "管理员取消") String reason) {
orderService.adminCancelOrder(id, reason);
return Result.ok();
}
}

View File

@ -0,0 +1,85 @@
package com.snack.server.module.order.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.order.dto.req.OrderPageReq;
import com.snack.server.module.order.dto.req.OrderSubmitReq;
import com.snack.server.module.order.service.OrderService;
import com.snack.server.module.order.vo.OrderDetailVO;
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.Map;
/**
* 订单用户端
*/
@Tag(name = "订单")
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@Operation(summary = "提交订单")
@SaCheckLogin
@PostMapping
public Result<Map<String, String>> submit(@Valid @RequestBody OrderSubmitReq req) {
String orderNo = orderService.submitOrder(req);
return Result.ok(Map.of("orderNo", orderNo));
}
@Operation(summary = "订单详情")
@SaCheckLogin
@GetMapping("/{id}")
public Result<OrderDetailVO> detail(@Parameter(description = "订单 ID") @PathVariable Long id) {
return Result.ok(orderService.getDetailByCurrentUser(id));
}
@Operation(summary = "我的订单(按状态筛选)")
@SaCheckLogin
@GetMapping
public Result<Page<OrderDetailVO>> page(OrderPageReq req) {
return Result.ok(orderService.pageCurrentUserOrders(req));
}
@Operation(summary = "支付订单(测试用模拟支付)")
@SaCheckLogin
@PostMapping("/{id}/pay")
public Result<Void> pay(@Parameter(description = "订单 ID") @PathVariable Long id) {
orderService.payOrder(id);
return Result.ok();
}
@Operation(summary = "取消订单")
@SaCheckLogin
@PostMapping("/{id}/cancel")
public Result<Void> cancel(@Parameter(description = "订单 ID") @PathVariable Long id) {
orderService.cancelOrder(id);
return Result.ok();
}
@Operation(summary = "确认收货")
@SaCheckLogin
@PostMapping("/{id}/receive")
public Result<Void> receive(@Parameter(description = "订单 ID") @PathVariable Long id) {
orderService.confirmReceive(id);
return Result.ok();
}
@Operation(summary = "申请退款(需传入退款原因)")
@SaCheckLogin
@PostMapping("/{id}/refund")
public Result<Void> refund(
@Parameter(description = "订单 ID") @PathVariable Long id,
@Parameter(description = "退款原因") @RequestParam(required = false, defaultValue = "用户申请退款") String reason) {
orderService.applyRefund(id, reason);
return Result.ok();
}
}

View File

@ -0,0 +1,77 @@
package com.snack.server.module.order.controller;
import cn.hutool.core.util.StrUtil;
import com.snack.server.common.Result;
import com.snack.server.module.order.service.OrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 支付宝 / 微信支付回调沙箱版
*
* 真实生产
* 1. 支付宝 POST 异步通知到 /api/payment/alipay/notify
* 2. 校验 sign 签名
* 3. 解析 out_trade_no订单号trade_no支付宝交易号
* 4. 调用 OrderService.handlePaySuccess()
* 5. 返回 "success" / "fail" 给支付宝
*
* 沙箱测试手动调用
* POST /api/payment/alipay/notify
* ?orderId=1&payChannel=alipay&payTradeNo=20260601xxxx
*/
@Slf4j
@Tag(name = "支付回调(沙箱测试)")
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentNotifyController {
private final OrderService orderService;
/**
* 支付宝支付成功异步通知
*/
@Operation(summary = "支付宝支付成功回调(沙箱测试用)")
@PostMapping("/alipay/notify")
public Result<String> alipayNotify(
@RequestParam Long orderId,
@RequestParam(required = false, defaultValue = "alipay") String payChannel,
@RequestParam(required = false) String payTradeNo) {
if (orderId == null) {
return Result.fail(400, "orderId 不能为空");
}
if (StrUtil.isBlank(payTradeNo)) {
payTradeNo = "ALIPAY-MOCK-" + System.currentTimeMillis();
}
log.info("收到支付宝支付回调 orderId={} payTradeNo={}", orderId, payTradeNo);
boolean ok = orderService.handlePaySuccess(orderId, payChannel, payTradeNo);
// 支付宝要求返回纯文本 "success" / "fail" body 输出
return ok ? Result.ok("success") : Result.fail("fail");
}
/**
* 支付宝退款成功异步通知
*/
@Operation(summary = "支付宝退款成功回调(沙箱测试用)")
@PostMapping("/alipay/refund/notify")
public Result<String> alipayRefundNotify(
@RequestParam Long orderId,
@RequestParam(required = false) String refundTradeNo) {
if (orderId == null) {
return Result.fail(400, "orderId 不能为空");
}
if (StrUtil.isBlank(refundTradeNo)) {
refundTradeNo = "RF-MOCK-" + System.currentTimeMillis();
}
log.info("收到支付宝退款回调 orderId={} refundTradeNo={}", orderId, refundTradeNo);
boolean ok = orderService.handleRefundSuccess(orderId, refundTradeNo);
return ok ? Result.ok("success") : Result.fail("fail");
}
}

View File

@ -0,0 +1,29 @@
package com.snack.server.module.order.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 订单分页查询
*/
@Data
@Schema(description = "订单分页查询")
public class OrderPageReq implements Serializable {
@Schema(description = "页码", example = "1")
private Long current = 1L;
@Schema(description = "每页条数", example = "10")
private Long size = 10L;
@Schema(description = "订单状态(用户端 0/1/2/3/4")
private Integer status;
@Schema(description = "订单号关键字(管理端用)")
private String keyword;
@Schema(description = "用户名关键字(管理端用)")
private String username;
}

View File

@ -0,0 +1,44 @@
package com.snack.server.module.order.dto.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 提交订单请求
*/
@Data
@Schema(description = "提交订单请求")
public class OrderSubmitReq implements Serializable {
@NotEmpty(message = "订单项不能为空")
@Valid
@Schema(description = "订单项列表(来自购物车)")
private List<OrderItemReq> items;
@NotNull(message = "收货地址不能为空")
@Schema(description = "收货地址 ID", example = "1")
private Long addressId;
@Schema(description = "优惠券 ID可选")
private Long couponId;
@Schema(description = "订单备注")
private String remark;
@Data
public static class OrderItemReq implements Serializable {
@NotNull(message = "SKU ID 不能为空")
@Schema(description = "SKU ID")
private Long skuId;
@NotNull(message = "数量不能为空")
@Schema(description = "购买数量")
private Integer quantity;
}
}

View File

@ -0,0 +1,106 @@
package com.snack.server.module.order.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;
/**
* 订单主表
*
* 对应数据库表orders
*/
@Data
@TableName("orders")
public class Order implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 订单号(业务唯一) */
private String orderNo;
/** 下单用户 ID */
private Long userId;
/** 商品总金额(未优惠) */
private BigDecimal totalAmount;
/** 运费 */
private BigDecimal freightAmount;
/** 优惠金额(满减/折扣) */
private BigDecimal discountAmount;
/** 使用的优惠券 ID */
private Long couponId;
/** 优惠券抵扣金额 */
private BigDecimal couponAmount;
/** 实付金额 */
private BigDecimal payAmount;
/** 状态0-待付款 1-待发货 2-待收货 3-已完成 4-已取消 5-已退款 */
private Integer status;
/** 收货人 */
private String receiverName;
/** 收货人手机号 */
private String receiverPhone;
/** 完整收货地址 */
private String receiverAddress;
/** 订单备注 */
private String remark;
/** 物流公司 */
private String trackingCompany;
/** 物流单号 */
private String trackingNo;
/** 支付时间 */
private LocalDateTime payTime;
/** 发货时间 */
private LocalDateTime deliverTime;
/** 收货时间 */
private LocalDateTime receiveTime;
/** 取消时间 */
private LocalDateTime cancelTime;
/** 完成时间 */
private LocalDateTime finishTime;
/** 支付渠道alipay / wechat / balance */
private String payChannel;
/** 第三方支付平台交易号(支付宝/微信返回) */
private String payTradeNo;
/** 退款交易号(支付宝/微信返回) */
private String refundTradeNo;
/** 退款时间 */
private LocalDateTime refundTime;
/** 退款原因 */
private String refundReason;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,59 @@
package com.snack.server.module.order.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;
/**
* 订单商品明细
*
* 对应数据库表order_item
* 关键设计商品信息冗余防商品改名 / 改价
*/
@Data
@TableName("order_item")
public class OrderItem implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 订单 ID */
private Long orderId;
/** 订单号(冗余) */
private String orderNo;
/** 商品 SPU ID */
private Long productId;
/** 商品名(冗余) */
private String productName;
/** 商品主图(冗余) */
private String productImage;
/** SKU ID */
private Long skuId;
/** SKU 规格(冗余) */
private String skuName;
/** 成交单价 */
private BigDecimal price;
/** 购买数量 */
private Integer quantity;
/** 小计金额 */
private BigDecimal totalAmount;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@ -0,0 +1,68 @@
package com.snack.server.module.order.enums;
import lombok.Getter;
import java.util.Set;
/**
* 订单状态枚举订单状态机
*
* 状态流转
* 0 待付款 1 待发货 2 待收货 3 已完成
*
* 4 已取消 3 已完成
* 5 已退款
* 5 已退款可选流程
*/
@Getter
public enum OrderStatusEnum {
PENDING_PAY(0, "待付款"),
PENDING_SHIP(1, "待发货"),
PENDING_RECEIVE(2, "待收货"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消"),
REFUNDED(5, "已退款");
private final Integer code;
private final String desc;
OrderStatusEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
/**
* 判断是否可以从 fromStatus 流转到 toStatus
*/
public static boolean canTransition(Integer from, Integer to) {
if (from == null || to == null) return false;
// 已完成 / 已取消 / 已退款 是终态
Set<Integer> terminal = Set.of(COMPLETED.code, CANCELLED.code, REFUNDED.code);
if (terminal.contains(from)) return false;
switch (from) {
case 0: // 待付款
return to == 1 || to == 4 || to == 5;
case 1: // 待发货
return to == 2 || to == 5; // 商家可主动取消变已退款
case 2: // 待收货
return to == 3 || to == 5;
default:
return false;
}
}
/**
* 状态标签颜色Element Plus 风格
*/
public String tagType() {
return switch (this) {
case PENDING_PAY -> "warning";
case PENDING_SHIP -> "primary";
case PENDING_RECEIVE -> "success";
case COMPLETED -> "success";
case CANCELLED -> "info";
case REFUNDED -> "danger";
};
}
}

View File

@ -0,0 +1,30 @@
package com.snack.server.module.order.enums;
import lombok.Getter;
/**
* 支付渠道
*/
@Getter
public enum PayChannelEnum {
ALIPAY("alipay", "支付宝"),
WECHAT("wechat", "微信支付"),
BALANCE("balance", "余额支付");
private final String code;
private final String desc;
PayChannelEnum(String code, String desc) {
this.code = code;
this.desc = desc;
}
public static PayChannelEnum of(String code) {
if (code == null) return null;
for (PayChannelEnum e : values()) {
if (e.code.equalsIgnoreCase(code)) return e;
}
return null;
}
}

View File

@ -0,0 +1,22 @@
package com.snack.server.module.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.order.entity.OrderItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface OrderItemMapper extends BaseMapper<OrderItem> {
/**
* 根据订单 ID 列表批量查询订单项
*/
List<OrderItem> selectByOrderIds(@Param("orderIds") List<Long> orderIds);
/**
* 批量插入MyBatis-Plus saveBatch 在大数量时较慢这里提供手写批量
*/
int insertBatch(@Param("list") List<OrderItem> items);
}

View File

@ -0,0 +1,61 @@
package com.snack.server.module.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.snack.server.module.order.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 订单主表 Mapper
*/
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* 更新订单状态条件更新防止状态错乱
* @return 受影响行数0 表示状态已被其他流程修改
*/
@Update("UPDATE orders SET status = #{toStatus}, " +
" pay_time = IF(#{toStatus} = 1, NOW(), pay_time), " +
" deliver_time = IF(#{toStatus} = 2, NOW(), deliver_time), " +
" receive_time = IF(#{toStatus} = 3, NOW(), receive_time), " +
" cancel_time = IF(#{toStatus} = 4, NOW(), cancel_time), " +
" finish_time = IF(#{toStatus} = 3, NOW(), finish_time) " +
"WHERE id = #{id} AND status = #{fromStatus}")
int updateStatusIfMatch(@Param("id") Long id,
@Param("fromStatus") Integer fromStatus,
@Param("toStatus") Integer toStatus);
/**
* 填写物流信息
*/
@Update("UPDATE orders SET tracking_company = #{company}, tracking_no = #{trackingNo}, " +
" status = 2, deliver_time = NOW() " +
"WHERE id = #{id} AND status = 1")
int deliverOrder(@Param("id") Long id,
@Param("company") String company,
@Param("trackingNo") String trackingNo);
/**
* 支付成功回调写入支付信息 + 状态切到待发货
* 仅在 status = 0 (待付款) 时生效
*/
@Update("UPDATE orders SET status = 1, pay_channel = #{payChannel}, pay_trade_no = #{payTradeNo}, " +
" pay_time = NOW() " +
"WHERE id = #{id} AND status = 0")
int markAsPaid(@Param("id") Long id,
@Param("payChannel") String payChannel,
@Param("payTradeNo") String payTradeNo);
/**
* 退款成功回调写入退款信息 + 状态切到已退款
* 仅在 status IN (1, 2, 3) 时生效
*/
@Update("UPDATE orders SET status = 5, refund_trade_no = #{refundTradeNo}, " +
" refund_time = NOW(), refund_reason = #{reason} " +
"WHERE id = #{id} AND status IN (1, 2, 3)")
int markAsRefunded(@Param("id") Long id,
@Param("refundTradeNo") String refundTradeNo,
@Param("reason") String reason);
}

View File

@ -0,0 +1,93 @@
package com.snack.server.module.order.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.snack.server.module.order.dto.req.OrderPageReq;
import com.snack.server.module.order.dto.req.OrderSubmitReq;
import com.snack.server.module.order.vo.OrderDetailVO;
import java.util.List;
/**
* 订单业务接口
*/
public interface OrderService {
/**
* 提交订单事务校验库存 扣库存 创建订单 删购物车
* @return 创建成功的订单号
*/
String submitOrder(OrderSubmitReq req);
/**
* 订单详情用户端
*/
OrderDetailVO getDetailByCurrentUser(Long orderId);
/**
* 订单详情管理端
*/
OrderDetailVO getDetailForAdmin(Long orderId);
/**
* 当前用户订单分页
*/
Page<OrderDetailVO> pageCurrentUserOrders(OrderPageReq req);
/**
* 管理端订单分页
*/
Page<OrderDetailVO> pageAllOrders(OrderPageReq req);
/**
* 模拟支付测试用对接真实支付时改为接入微信 / 支付宝回调
*/
void payOrder(Long orderId);
/**
* 支付宝 / 微信支付成功回调异步通知时调用
* @param orderId 订单 ID
* @param payChannel 支付渠道
* @param payTradeNo 第三方交易号
* @return true 成功
*/
boolean handlePaySuccess(Long orderId, String payChannel, String payTradeNo);
/**
* 取消订单用户端
* - 待付款 已取消
* - 已付款但未发货 申请退款
*/
void cancelOrder(Long orderId);
/**
* 确认收货
*/
void confirmReceive(Long orderId);
/**
* 申请退款用户主动申请需商家审核
* @param orderId 订单 ID
* @param reason 退款原因
*/
void applyRefund(Long orderId, String reason);
/**
* 支付宝 / 微信退款成功回调
*/
boolean handleRefundSuccess(Long orderId, String refundTradeNo);
/**
* 订单发货管理端
*/
void deliverOrder(Long orderId, String company, String trackingNo);
/**
* 管理员取消订单强制取消
*/
void adminCancelOrder(Long orderId, String reason);
/**
* 后台统计某状态订单数
*/
long countByStatus(Integer status);
}

View File

@ -0,0 +1,536 @@
package com.snack.server.module.order.service.impl;
import cn.dev33.satoken.stp.StpUtil;
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.address.entity.Address;
import com.snack.server.module.address.service.AddressService;
import com.snack.server.module.cart.entity.Cart;
import com.snack.server.module.cart.service.CartService;
import com.snack.server.module.order.constant.OrderConstant;
import com.snack.server.module.order.dto.req.OrderPageReq;
import com.snack.server.module.order.dto.req.OrderSubmitReq;
import com.snack.server.module.order.entity.Order;
import com.snack.server.module.order.entity.OrderItem;
import com.snack.server.module.order.enums.OrderStatusEnum;
import com.snack.server.module.order.enums.PayChannelEnum;
import com.snack.server.module.order.mapper.OrderItemMapper;
import com.snack.server.module.order.mapper.OrderMapper;
import com.snack.server.module.order.service.OrderService;
import com.snack.server.module.order.vo.OrderDetailVO;
import com.snack.server.module.order.vo.OrderItemVO;
import com.snack.server.module.product.entity.Product;
import com.snack.server.module.product.entity.ProductSku;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
* 订单业务实现
*
* 关键设计
* 1. 状态机所有状态流转都校验 OrderStatusEnum.canTransition
* 2. 状态条件更新UPDATE ... WHERE status = #{fromStatus}防止并发覆盖
* 3. 事务一致性扣库存 / 写订单 / 删购物车必须全部成功或全部回滚
* 4. 商品信息快照订单项冗余商品名/图片/价格防商品改名
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final OrderItemMapper orderItemMapper;
private final ProductMapper productMapper;
private final ProductSkuMapper productSkuMapper;
private final ProductService productService;
private final CartService cartService;
private final AddressService addressService;
// ==================== 提交订单 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public String submitOrder(OrderSubmitReq req) {
Long userId = StpUtil.getLoginIdAsLong();
// 1. 校验收货地址
Address address = addressService.getEntityById(req.getAddressId());
if (address == null || !address.getUserId().equals(userId)) {
throw new BusinessException(1000, "收货地址无效");
}
// 2. 查询 SKU / 商品一次性查完避免循环查询
List<Long> skuIds = req.getItems().stream()
.map(OrderSubmitReq.OrderItemReq::getSkuId)
.toList();
List<ProductSku> skus = productSkuMapper.selectBatchIds(skuIds);
Map<Long, ProductSku> skuMap = skus.stream()
.collect(Collectors.toMap(ProductSku::getId, s -> s));
if (skuMap.size() != skuIds.size()) {
throw new BusinessException(1000, "部分 SKU 不存在");
}
Set<Long> productIds = skus.stream().map(ProductSku::getProductId).collect(Collectors.toSet());
Map<Long, Product> productMap = productMapper.selectBatchIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
// 3. 计算金额 + 构造 OrderItem 列表
BigDecimal totalAmount = BigDecimal.ZERO;
List<OrderItem> orderItems = new ArrayList<>();
for (OrderSubmitReq.OrderItemReq itemReq : req.getItems()) {
ProductSku sku = skuMap.get(itemReq.getSkuId());
Product product = productMap.get(sku.getProductId());
// 校验商品状态
if (product == null || product.getStatus() == null || product.getStatus() == 0) {
throw new BusinessException(ResultCode.PRODUCT_OFF_SHELF,
"商品【" + (product == null ? "?" : product.getName()) + "】已下架");
}
// 校验库存
if (sku.getStock() == null || sku.getStock() < itemReq.getQuantity()) {
throw new BusinessException(ResultCode.STOCK_INSUFFICIENT,
"商品【" + product.getName() + " / " + sku.getSkuName() + "】库存不足");
}
// 校验数量
if (itemReq.getQuantity() <= 0) {
throw new BusinessException(1000, "购买数量必须大于 0");
}
BigDecimal subtotal = sku.getPrice().multiply(BigDecimal.valueOf(itemReq.getQuantity()));
totalAmount = totalAmount.add(subtotal);
// 快照防商品改名 / 改价
OrderItem item = new OrderItem();
item.setProductId(product.getId());
item.setProductName(product.getName());
item.setProductImage(product.getMainImage());
item.setSkuId(sku.getId());
item.setSkuName(sku.getSkuName());
item.setPrice(sku.getPrice());
item.setQuantity(itemReq.getQuantity());
item.setTotalAmount(subtotal);
orderItems.add(item);
}
// 4. 运费 N 元包邮可作为配置项
BigDecimal freight = totalAmount.compareTo(new BigDecimal("99")) >= 0
? BigDecimal.ZERO : OrderConstant.DEFAULT_FREIGHT;
BigDecimal payAmount = totalAmount.add(freight);
// 5. 生成订单号
String orderNo = generateOrderNo();
// 6. 创建订单主表
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setTotalAmount(totalAmount);
order.setFreightAmount(freight);
order.setDiscountAmount(BigDecimal.ZERO);
order.setCouponId(null);
order.setCouponAmount(BigDecimal.ZERO);
order.setPayAmount(payAmount);
order.setStatus(OrderStatusEnum.PENDING_PAY.getCode());
order.setReceiverName(address.getReceiver());
order.setReceiverPhone(address.getPhone());
order.setReceiverAddress(buildFullAddress(address));
order.setRemark(req.getRemark());
orderMapper.insert(order);
log.info("创建订单 orderId={} orderNo={} userId={} payAmount={}",
order.getId(), orderNo, userId, payAmount);
// 7. 写入订单项
for (OrderItem it : orderItems) {
it.setOrderId(order.getId());
it.setOrderNo(orderNo);
}
orderItemMapper.insertBatch(orderItems);
// 8. 扣减库存原子操作失败会抛异常回滚整个事务
for (OrderItem it : orderItems) {
boolean ok = productService.decrStock(it.getSkuId(), it.getQuantity());
if (!ok) {
throw new BusinessException(ResultCode.STOCK_INSUFFICIENT,
"库存扣减失败:" + it.getProductName() + " / " + it.getSkuName());
}
}
// 9. 删除已下单的购物车项 SKU ID
cartService.deleteItems(skuIds);
return orderNo;
}
// ==================== 订单详情 ====================
@Override
public OrderDetailVO getDetailByCurrentUser(Long orderId) {
Long userId = StpUtil.getLoginIdAsLong();
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getId, orderId)
.eq(Order::getUserId, userId)
);
if (order == null) {
throw new BusinessException(1000, "订单不存在或无权访问");
}
return enrichDetailVO(order);
}
@Override
public OrderDetailVO getDetailForAdmin(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException(1000, "订单不存在");
}
return enrichDetailVO(order);
}
private OrderDetailVO enrichDetailVO(Order order) {
OrderDetailVO vo = new OrderDetailVO();
BeanUtil.copyProperties(order, vo);
// 状态文本 / 颜色
OrderStatusEnum statusEnum = OrderStatusEnum.values()[order.getStatus()];
vo.setStatusText(statusEnum.getDesc());
vo.setStatusTagType(statusEnum.tagType());
// 支付渠道文本
if (StrUtil.isNotBlank(order.getPayChannel())) {
PayChannelEnum ch = PayChannelEnum.of(order.getPayChannel());
vo.setPayChannelText(ch == null ? order.getPayChannel() : ch.getDesc());
}
// 订单项
List<OrderItem> items = orderItemMapper.selectList(
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, order.getId())
);
List<OrderItemVO> itemVOs = items.stream().map(it -> {
OrderItemVO itemVO = new OrderItemVO();
BeanUtil.copyProperties(it, itemVO);
return itemVO;
}).toList();
vo.setItems(itemVOs);
vo.setTotalQuantity(items.stream().mapToInt(OrderItem::getQuantity).sum());
return vo;
}
// ==================== 分页 ====================
@Override
public Page<OrderDetailVO> pageCurrentUserOrders(OrderPageReq req) {
Long userId = StpUtil.getLoginIdAsLong();
Page<Order> page = new Page<>(req.getCurrent(), req.getSize());
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.eq(req.getStatus() != null, Order::getStatus, req.getStatus())
.orderByDesc(Order::getId);
Page<Order> result = orderMapper.selectPage(page, wrapper);
return toVOPage(result);
}
@Override
public Page<OrderDetailVO> pageAllOrders(OrderPageReq req) {
Page<Order> page = new Page<>(req.getCurrent(), req.getSize());
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.like(StrUtil.isNotBlank(req.getKeyword()), Order::getOrderNo, req.getKeyword())
.orderByDesc(Order::getId);
Page<Order> result = orderMapper.selectPage(page, wrapper);
return toVOPage(result);
}
private Page<OrderDetailVO> toVOPage(Page<Order> page) {
Page<OrderDetailVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
if (CollUtil.isEmpty(page.getRecords())) {
voPage.setRecords(new ArrayList<>());
return voPage;
}
// 批量查订单项
List<Long> orderIds = page.getRecords().stream().map(Order::getId).toList();
Map<Long, List<OrderItem>> itemMap = orderItemMapper.selectByOrderIds(orderIds).stream()
.collect(Collectors.groupingBy(OrderItem::getOrderId));
voPage.setRecords(page.getRecords().stream().map(o -> {
OrderDetailVO vo = new OrderDetailVO();
BeanUtil.copyProperties(o, vo);
OrderStatusEnum statusEnum = OrderStatusEnum.values()[o.getStatus()];
vo.setStatusText(statusEnum.getDesc());
vo.setStatusTagType(statusEnum.tagType());
List<OrderItem> items = itemMap.getOrDefault(o.getId(), new ArrayList<>());
vo.setItems(items.stream().map(it -> {
OrderItemVO itemVO = new OrderItemVO();
BeanUtil.copyProperties(it, itemVO);
return itemVO;
}).toList());
vo.setTotalQuantity(items.stream().mapToInt(OrderItem::getQuantity).sum());
return vo;
}).toList());
return voPage;
}
// ==================== 状态流转 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
// 兼容旧接口默认走支付宝沙箱实际生产由前端跳转 /api/orders/{id}/pay-url
handlePaySuccess(orderId, PayChannelEnum.ALIPAY.getCode(), "MOCK-" + System.currentTimeMillis());
}
/**
* 支付成功回调支付宝/微信异步通知时调用
*
* 注意真实生产中支付宝回调要求校验 sign 签名防伪造
* 这里只做状态切换和字段写入签名校验请在 AlipayNotifyController 入口做
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handlePaySuccess(Long orderId, String payChannel, String payTradeNo) {
if (orderId == null || payTradeNo == null) {
return false;
}
int affected = orderMapper.markAsPaid(orderId, payChannel, payTradeNo);
if (affected == 0) {
log.warn("订单 {} 支付回调失败:订单不存在或状态非待付款", orderId);
return false;
}
log.info("订单 {} 支付成功 payChannel={} tradeNo={}", orderId, payChannel, payTradeNo);
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelOrder(Long orderId) {
Long userId = StpUtil.getLoginIdAsLong();
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getId, orderId)
.eq(Order::getUserId, userId)
);
if (order == null) {
throw new BusinessException(1000, "订单不存在或无权访问");
}
if (order.getStatus() == null
|| !OrderStatusEnum.canTransition(order.getStatus(), OrderStatusEnum.CANCELLED.getCode())) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "当前状态不允许取消");
}
Integer fromStatus = order.getStatus();
Integer toStatus = OrderStatusEnum.CANCELLED.getCode();
int affected = orderMapper.updateStatusIfMatch(orderId, fromStatus, toStatus);
if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
}
// 释放库存只有真正占用库存的订单需要释放
if (fromStatus != OrderStatusEnum.PENDING_PAY.getCode()) {
// 待发货 / 待收货 / 已完成的取消 = 退款流程本示例不释放库存
// 业务上应记录退款单据财务流程后再释放
log.warn("订单 {} 状态 {} 取消,需走退款流程释放库存", order.getOrderNo(), fromStatus);
} else {
// 待付款取消 释放库存
List<OrderItem> items = orderItemMapper.selectList(
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
log.info("订单 {} 取消,库存已释放", order.getOrderNo());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void confirmReceive(Long orderId) {
Long userId = StpUtil.getLoginIdAsLong();
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getId, orderId)
.eq(Order::getUserId, userId)
);
if (order == null) {
throw new BusinessException(1000, "订单不存在或无权访问");
}
if (!OrderStatusEnum.canTransition(order.getStatus(), OrderStatusEnum.COMPLETED.getCode())) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "当前状态不允许确认收货");
}
int affected = orderMapper.updateStatusIfMatch(
orderId,
OrderStatusEnum.PENDING_RECEIVE.getCode(),
OrderStatusEnum.COMPLETED.getCode()
);
if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
}
log.info("订单 {} 确认收货", order.getOrderNo());
}
/**
* 申请退款同步调用支付宝退款
*
* 真实生产中
* 1. 用户点退款 服务端调用 alipay.trade.refund 发起退款
* 2. 支付宝返回异步通知 handleRefundSuccess() 更新状态
*
* 沙箱演示版简化直接同步标记为已退款同时记录原因 + 模拟退款号
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void applyRefund(Long orderId, String reason) {
Long userId = StpUtil.getLoginIdAsLong();
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>()
.eq(Order::getId, orderId)
.eq(Order::getUserId, userId)
);
if (order == null) {
throw new BusinessException(1000, "订单不存在或无权访问");
}
if (order.getStatus() == null
|| !OrderStatusEnum.canTransition(order.getStatus(), OrderStatusEnum.REFUNDED.getCode())) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "当前状态不允许申请退款");
}
if (StrUtil.isBlank(reason)) {
reason = "用户申请退款";
}
// 模拟支付宝退款号沙箱
String mockRefundNo = "RF" + System.currentTimeMillis();
int affected = orderMapper.markAsRefunded(orderId, mockRefundNo, reason);
if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
}
// 释放库存
List<OrderItem> items = orderItemMapper.selectList(
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
log.info("订单 {} 退款成功 refundNo={} reason={}", order.getOrderNo(), mockRefundNo, reason);
}
/**
* 支付宝 / 微信退款成功回调
* 仅在 status IN (1, 2, 3) 时生效用于"已发货""已完成"订单的异步退款
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handleRefundSuccess(Long orderId, String refundTradeNo) {
if (orderId == null || refundTradeNo == null) {
return false;
}
int affected = orderMapper.markAsRefunded(orderId, refundTradeNo, "支付平台回调退款");
if (affected == 0) {
log.warn("订单 {} 退款回调失败:状态不符合", orderId);
return false;
}
// 释放库存
List<OrderItem> items = orderItemMapper.selectList(
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
log.info("订单 {} 收到退款回调 refundNo={}", orderId, refundTradeNo);
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deliverOrder(Long orderId, String company, String trackingNo) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException(1000, "订单不存在");
}
if (order.getStatus() == null
|| !OrderStatusEnum.canTransition(order.getStatus(), OrderStatusEnum.PENDING_RECEIVE.getCode())) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "只有待发货订单可以发货");
}
if (StrUtil.isBlank(company) || StrUtil.isBlank(trackingNo)) {
throw new BusinessException(1000, "物流公司和单号不能为空");
}
int affected = orderMapper.deliverOrder(orderId, company, trackingNo);
if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
}
log.info("订单 {} 已发货:{} {}", order.getOrderNo(), company, trackingNo);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void adminCancelOrder(Long orderId, String reason) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BusinessException(1000, "订单不存在");
}
if (order.getStatus() == null
|| !OrderStatusEnum.canTransition(order.getStatus(), OrderStatusEnum.CANCELLED.getCode())) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "当前状态不允许取消");
}
int affected = orderMapper.updateStatusIfMatch(
orderId, order.getStatus(), OrderStatusEnum.CANCELLED.getCode()
);
if (affected == 0) {
throw new BusinessException(ResultCode.ORDER_STATUS_ERROR, "订单状态已变更,请刷新");
}
// 释放库存仅在订单已扣库存的状态下
Integer fromStatus = order.getStatus();
if (fromStatus != OrderStatusEnum.PENDING_PAY.getCode()) {
// 已支付订单的取消走退款流程
orderMapper.updateStatusIfMatch(orderId, OrderStatusEnum.CANCELLED.getCode(), OrderStatusEnum.REFUNDED.getCode());
}
// 释放库存
List<OrderItem> items = orderItemMapper.selectList(
new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)
);
for (OrderItem item : items) {
productService.incrStock(item.getSkuId(), item.getQuantity());
}
log.info("管理员取消订单 {} 原因:{}", order.getOrderNo(), reason);
}
// ==================== 工具方法 ====================
@Override
public long countByStatus(Integer status) {
return orderMapper.selectCount(
new LambdaQueryWrapper<Order>().eq(status != null, Order::getStatus, status)
);
}
/**
* 生成订单号SN + yyyyMMdd + 6 位随机数 = 18
*/
private String generateOrderNo() {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
int rand = ThreadLocalRandom.current().nextInt(100000, 999999);
return OrderConstant.ORDER_NO_PREFIX + date + rand;
}
/**
* 拼接完整收货地址
*/
private String buildFullAddress(Address a) {
StringBuilder sb = new StringBuilder();
if (StrUtil.isNotBlank(a.getProvince())) sb.append(a.getProvince());
if (StrUtil.isNotBlank(a.getCity())) sb.append(a.getCity());
if (StrUtil.isNotBlank(a.getDistrict())) sb.append(a.getDistrict());
if (StrUtil.isNotBlank(a.getDetail())) sb.append(a.getDetail());
return sb.toString();
}
}

View File

@ -0,0 +1,117 @@
package com.snack.server.module.order.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
*/
@Data
@Schema(description = "订单详情")
public class OrderDetailVO implements Serializable {
@Schema(description = "订单 ID")
private Long id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "用户 ID")
private Long userId;
@Schema(description = "用户名(管理端用)")
private String username;
@Schema(description = "商品总金额")
private BigDecimal totalAmount;
@Schema(description = "运费")
private BigDecimal freightAmount;
@Schema(description = "优惠金额")
private BigDecimal discountAmount;
@Schema(description = "优惠券抵扣")
private BigDecimal couponAmount;
@Schema(description = "实付金额")
private BigDecimal payAmount;
@Schema(description = "状态码")
private Integer status;
@Schema(description = "状态文本")
private String statusText;
@Schema(description = "状态标签类型Element Plus")
private String statusTagType;
@Schema(description = "收货人")
private String receiverName;
@Schema(description = "收货人手机号")
private String receiverPhone;
@Schema(description = "收货地址")
private String receiverAddress;
@Schema(description = "订单备注")
private String remark;
@Schema(description = "物流公司")
private String trackingCompany;
@Schema(description = "物流单号")
private String trackingNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "支付时间")
private LocalDateTime payTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "发货时间")
private LocalDateTime deliverTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "收货时间")
private LocalDateTime receiveTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "取消时间")
private LocalDateTime cancelTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "支付渠道alipay / wechat / balance")
private String payChannel;
@Schema(description = "支付渠道文本")
private String payChannelText;
@Schema(description = "第三方支付平台交易号")
private String payTradeNo;
@Schema(description = "退款交易号")
private String refundTradeNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "退款时间")
private LocalDateTime refundTime;
@Schema(description = "退款原因")
private String refundReason;
@Schema(description = "订单项列表")
private List<OrderItemVO> items;
@Schema(description = "商品总件数")
private Integer totalQuantity;
}

View File

@ -0,0 +1,44 @@
package com.snack.server.module.order.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 OrderItemVO implements Serializable {
@Schema(description = "订单项 ID")
private Long id;
@Schema(description = "商品 SPU ID")
private Long productId;
@Schema(description = "商品名")
private String productName;
@Schema(description = "商品主图")
private String productImage;
@Schema(description = "SKU ID")
private Long skuId;
@Schema(description = "SKU 规格")
private String skuName;
@Schema(description = "成交单价")
private BigDecimal price;
@Schema(description = "购买数量")
private Integer quantity;
@Schema(description = "小计金额")
private BigDecimal totalAmount;
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.snack.server.module.order.mapper.OrderItemMapper">
<!-- 批量插入foreach + VALUES 语法) -->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO order_item
(order_id, order_no, product_id, product_name, product_image,
sku_id, sku_name, price, quantity, total_amount, create_time)
VALUES
<foreach collection="list" item="it" separator=",">
(#{it.orderId}, #{it.orderNo}, #{it.productId}, #{it.productName}, #{it.productImage},
#{it.skuId}, #{it.skuName}, #{it.price}, #{it.quantity}, #{it.totalAmount}, NOW())
</foreach>
</insert>
<!-- 按订单 ID 批量查询 -->
<select id="selectByOrderIds" resultType="com.snack.server.module.order.entity.OrderItem">
SELECT id, order_id AS orderId, order_no AS orderNo, product_id AS productId,
product_name AS productName, product_image AS productImage,
sku_id AS skuId, sku_name AS skuName, price, quantity, total_amount AS totalAmount,
create_time AS createTime
FROM order_item
WHERE order_id IN
<foreach collection="orderIds" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
ORDER BY id ASC
</select>
</mapper>