diff --git a/db/schema.sql b/db/schema.sql index d8fed90..ab29adb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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_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`), diff --git a/server-snack/src/main/java/com/snack/server/module/order/constant/OrderConstant.java b/server-snack/src/main/java/com/snack/server/module/order/constant/OrderConstant.java new file mode 100644 index 0000000..1b36d22 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/constant/OrderConstant.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/controller/OrderAdminController.java b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderAdminController.java new file mode 100644 index 0000000..303c7f5 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderAdminController.java @@ -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(OrderPageReq req) { + return Result.ok(orderService.pageAllOrders(req)); + } + + @Operation(summary = "订单详情(管理端)") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "订单 ID") @PathVariable Long id) { + return Result.ok(orderService.getDetailForAdmin(id)); + } + + @Operation(summary = "订单发货") + @SaCheckLogin + @PostMapping("/{id}/deliver") + public Result 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 cancel( + @Parameter(description = "订单 ID") @PathVariable Long id, + @Parameter(description = "原因") @RequestParam(required = false, defaultValue = "管理员取消") String reason) { + orderService.adminCancelOrder(id, reason); + return Result.ok(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java new file mode 100644 index 0000000..5b5b530 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/controller/OrderController.java @@ -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> submit(@Valid @RequestBody OrderSubmitReq req) { + String orderNo = orderService.submitOrder(req); + return Result.ok(Map.of("orderNo", orderNo)); + } + + @Operation(summary = "订单详情") + @SaCheckLogin + @GetMapping("/{id}") + public Result detail(@Parameter(description = "订单 ID") @PathVariable Long id) { + return Result.ok(orderService.getDetailByCurrentUser(id)); + } + + @Operation(summary = "我的订单(按状态筛选)") + @SaCheckLogin + @GetMapping + public Result> page(OrderPageReq req) { + return Result.ok(orderService.pageCurrentUserOrders(req)); + } + + @Operation(summary = "支付订单(测试用模拟支付)") + @SaCheckLogin + @PostMapping("/{id}/pay") + public Result pay(@Parameter(description = "订单 ID") @PathVariable Long id) { + orderService.payOrder(id); + return Result.ok(); + } + + @Operation(summary = "取消订单") + @SaCheckLogin + @PostMapping("/{id}/cancel") + public Result cancel(@Parameter(description = "订单 ID") @PathVariable Long id) { + orderService.cancelOrder(id); + return Result.ok(); + } + + @Operation(summary = "确认收货") + @SaCheckLogin + @PostMapping("/{id}/receive") + public Result receive(@Parameter(description = "订单 ID") @PathVariable Long id) { + orderService.confirmReceive(id); + return Result.ok(); + } + + @Operation(summary = "申请退款(需传入退款原因)") + @SaCheckLogin + @PostMapping("/{id}/refund") + public Result refund( + @Parameter(description = "订单 ID") @PathVariable Long id, + @Parameter(description = "退款原因") @RequestParam(required = false, defaultValue = "用户申请退款") String reason) { + orderService.applyRefund(id, reason); + return Result.ok(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/controller/PaymentNotifyController.java b/server-snack/src/main/java/com/snack/server/module/order/controller/PaymentNotifyController.java new file mode 100644 index 0000000..6d8f54d --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/controller/PaymentNotifyController.java @@ -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 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 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"); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderPageReq.java b/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderPageReq.java new file mode 100644 index 0000000..fceb1d0 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderPageReq.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderSubmitReq.java b/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderSubmitReq.java new file mode 100644 index 0000000..f7be8d2 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/dto/req/OrderSubmitReq.java @@ -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 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; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/entity/Order.java b/server-snack/src/main/java/com/snack/server/module/order/entity/Order.java new file mode 100644 index 0000000..87ffeae --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/entity/Order.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/entity/OrderItem.java b/server-snack/src/main/java/com/snack/server/module/order/entity/OrderItem.java new file mode 100644 index 0000000..7740508 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/entity/OrderItem.java @@ -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; +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/enums/OrderStatusEnum.java b/server-snack/src/main/java/com/snack/server/module/order/enums/OrderStatusEnum.java new file mode 100644 index 0000000..0e1be7f --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/enums/OrderStatusEnum.java @@ -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 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"; + }; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/enums/PayChannelEnum.java b/server-snack/src/main/java/com/snack/server/module/order/enums/PayChannelEnum.java new file mode 100644 index 0000000..d39dacb --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/enums/PayChannelEnum.java @@ -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; + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderItemMapper.java b/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderItemMapper.java new file mode 100644 index 0000000..26f9530 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderItemMapper.java @@ -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 { + + /** + * 根据订单 ID 列表批量查询订单项 + */ + List selectByOrderIds(@Param("orderIds") List orderIds); + + /** + * 批量插入(MyBatis-Plus saveBatch 在大数量时较慢,这里提供手写批量) + */ + int insertBatch(@Param("list") List items); +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderMapper.java b/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderMapper.java new file mode 100644 index 0000000..743ee86 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/mapper/OrderMapper.java @@ -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 { + + /** + * 更新订单状态(条件更新,防止状态错乱) + * @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); +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java b/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java new file mode 100644 index 0000000..ff97d74 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/service/OrderService.java @@ -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 pageCurrentUserOrders(OrderPageReq req); + + /** + * 管理端订单分页 + */ + Page 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); +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java new file mode 100644 index 0000000..1e57388 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/service/impl/OrderServiceImpl.java @@ -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 skuIds = req.getItems().stream() + .map(OrderSubmitReq.OrderItemReq::getSkuId) + .toList(); + List skus = productSkuMapper.selectBatchIds(skuIds); + Map skuMap = skus.stream() + .collect(Collectors.toMap(ProductSku::getId, s -> s)); + if (skuMap.size() != skuIds.size()) { + throw new BusinessException(1000, "部分 SKU 不存在"); + } + Set productIds = skus.stream().map(ProductSku::getProductId).collect(Collectors.toSet()); + Map productMap = productMapper.selectBatchIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + // 3. 计算金额 + 构造 OrderItem 列表 + BigDecimal totalAmount = BigDecimal.ZERO; + List 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() + .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 items = orderItemMapper.selectList( + new LambdaQueryWrapper().eq(OrderItem::getOrderId, order.getId()) + ); + List 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 pageCurrentUserOrders(OrderPageReq req) { + Long userId = StpUtil.getLoginIdAsLong(); + Page page = new Page<>(req.getCurrent(), req.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Order::getUserId, userId) + .eq(req.getStatus() != null, Order::getStatus, req.getStatus()) + .orderByDesc(Order::getId); + Page result = orderMapper.selectPage(page, wrapper); + return toVOPage(result); + } + + @Override + public Page pageAllOrders(OrderPageReq req) { + Page page = new Page<>(req.getCurrent(), req.getSize()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .like(StrUtil.isNotBlank(req.getKeyword()), Order::getOrderNo, req.getKeyword()) + .orderByDesc(Order::getId); + Page result = orderMapper.selectPage(page, wrapper); + return toVOPage(result); + } + + private Page toVOPage(Page page) { + Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + if (CollUtil.isEmpty(page.getRecords())) { + voPage.setRecords(new ArrayList<>()); + return voPage; + } + // 批量查订单项 + List orderIds = page.getRecords().stream().map(Order::getId).toList(); + Map> 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 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() + .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 items = orderItemMapper.selectList( + new LambdaQueryWrapper().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() + .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() + .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 items = orderItemMapper.selectList( + new LambdaQueryWrapper().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 items = orderItemMapper.selectList( + new LambdaQueryWrapper().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 items = orderItemMapper.selectList( + new LambdaQueryWrapper().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().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(); + } +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/vo/OrderDetailVO.java b/server-snack/src/main/java/com/snack/server/module/order/vo/OrderDetailVO.java new file mode 100644 index 0000000..be8b927 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/vo/OrderDetailVO.java @@ -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 items; + + @Schema(description = "商品总件数") + private Integer totalQuantity; +} diff --git a/server-snack/src/main/java/com/snack/server/module/order/vo/OrderItemVO.java b/server-snack/src/main/java/com/snack/server/module/order/vo/OrderItemVO.java new file mode 100644 index 0000000..5403306 --- /dev/null +++ b/server-snack/src/main/java/com/snack/server/module/order/vo/OrderItemVO.java @@ -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; +} diff --git a/server-snack/src/main/resources/com/snack/server/module/order/mapper/OrderItemMapper.xml b/server-snack/src/main/resources/com/snack/server/module/order/mapper/OrderItemMapper.xml new file mode 100644 index 0000000..e293a16 --- /dev/null +++ b/server-snack/src/main/resources/com/snack/server/module/order/mapper/OrderItemMapper.xml @@ -0,0 +1,31 @@ + + + + + + + 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 + + (#{it.orderId}, #{it.orderNo}, #{it.productId}, #{it.productName}, #{it.productImage}, + #{it.skuId}, #{it.skuName}, #{it.price}, #{it.quantity}, #{it.totalAmount}, NOW()) + + + + + + +