```
feat(order): 添加订单模块完整功能 - 新增订单数据库表结构,包含支付渠道、交易号、退款信息等字段 - 添加订单相关实体类、枚举、常量定义 - 实现订单提交、查询、支付、取消、退款等核心业务逻辑 - 创建用户端和管理端订单控制器API接口 - 实现支付回调处理功能用于支付宝/微信支付集成 - 添加订单状态机管理和物流处理功能 ```
This commit is contained in:
parent
e5ac034349
commit
7c483840b6
|
|
@ -204,10 +204,16 @@ CREATE TABLE `orders` (
|
|||
`receive_time` DATETIME DEFAULT NULL COMMENT '收货时间',
|
||||
`cancel_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 '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_no` (`order_no`),
|
||||
UNIQUE KEY `uk_pay_trade_no` (`pay_trade_no`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue