Răsfoiți Sursa

feat: 重构退款功能,支持部分退款与商品级追溯

- 新增 t_refund 和 t_refund_item 独立退款表,退款数据不再仅存于
  t_order 字段,支持按商品明细追溯退款记录
- 退款金额支持三种模式(优先级):选择商品计算 > 自定义金额 > 全额
- 校验退款金额不超过实付金额减去历史累计退款金额
- 后端 PaymentServiceImpl 适配 products/refundAmount 参数,新增
  部分退款重载方法
- RefundApplicationController 重写,基于 t_refund 表查询和审核
- 前端小程序和管理后台新增自定义退款金额输入,商品数据映射修复
- t_order 新增 current_refund_id 关联当前退款记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 săptămâni în urmă
părinte
comite
713dcfe6f0

+ 60 - 14
haha-admin-web/src/views/order/utils/hook.tsx

@@ -15,6 +15,7 @@ import {
   ElForm,
   ElFormItem,
   ElInput,
+  ElInputNumber,
   ElSelect,
   ElOption,
   ElDatePicker,
@@ -459,7 +460,8 @@ export function useOrder(tableRef: Ref) {
       orderDetail: null as any,
       selectedProducts: [] as number[],
       refundQuantities: {} as Record<number, number>,
-      reason: ""
+      reason: "",
+      customAmount: null as number | null
     });
 
     const refundFormRef = ref();
@@ -586,6 +588,33 @@ export function useOrder(tableRef: Ref) {
                 <span style="color: #f56c6c; font-weight: 700; font-size: 18px;">¥{refundTotalAmount.value.toFixed(2)}</span>
               </div>
 
+              <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--el-text-color-primary);">自定义退款金额</div>
+              <div style="margin-bottom: 16px; padding: 12px; background: #fafafa; border-radius: 6px;">
+                {refundState.selectedProducts.length > 0 ? (
+                  <div style="color: #909399; font-size: 13px;">
+                    已选择商品退款,退款金额根据所选商品自动计算。如需自定义金额,请先取消所有商品选择。
+                  </div>
+                ) : (
+                  <div>
+                    <el-input-number
+                      model-value={refundState.customAmount}
+                      min={0.01}
+                      max={refundState.orderDetail?.paidAmount || refundState.orderDetail?.totalAmount || 0}
+                      precision={2}
+                      placeholder="输入自定义退款金额"
+                      style="width: 100%"
+                      controls-position="right"
+                      onUpdate:modelValue={(val: number | null) => { refundState.customAmount = val; }}
+                    />
+                    <div style="color: #909399; font-size: 12px; margin-top: 6px;">
+                      退款金额上限:实付金额 ¥
+                      {((refundState.orderDetail?.paidAmount || refundState.orderDetail?.totalAmount || 0)).toFixed(2)}
+                      ,若不选择商品也未输入金额,则全额退款
+                    </div>
+                  </div>
+                )}
+              </div>
+
               <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--el-text-color-primary);">退款原因</div>
               <ElForm ref={refundFormRef} model={refundState}>
                 <ElFormItem
@@ -605,25 +634,42 @@ export function useOrder(tableRef: Ref) {
         </div>
       ),
       beforeSure: async (done) => {
-        if (refundState.selectedProducts.length === 0) {
-          message("请选择至少一件退款商品", { type: "warning" });
-          return;
+        // 校验:必须选择商品或输入自定义金额(都不选则全额退款)
+        if (refundState.selectedProducts.length === 0 && (!refundState.customAmount || refundState.customAmount <= 0)) {
+          // 未选择商品也未输入金额,走全额退款(确认提示)
+          try {
+            await ElMessageBox.confirm(
+              "未选择商品也未输入自定义金额,将对整单实付金额进行全额退款,是否继续?",
+              "确认全额退款",
+              { confirmButtonText: "确认退款", cancelButtonText: "取消", type: "warning" }
+            );
+          } catch {
+            return;
+          }
         }
         const valid = await refundFormRef.value?.validate().catch(() => false);
         if (!valid) return;
 
-        const products: RefundProductItem[] = refundState.selectedProducts.map((i) => {
-          const p = refundState.orderDetail.products[i];
-          return {
-            productId: p.productId || p.id,
-            productName: p.productName,
-            quantity: refundState.refundQuantities[i] || 1,
-            price: p.price || 0
-          };
-        });
+        // 构建请求体
+        const refundData: any = { reason: refundState.reason };
+
+        if (refundState.selectedProducts.length > 0) {
+          refundData.products = refundState.selectedProducts.map((i) => {
+            const p = refundState.orderDetail.products[i];
+            return {
+              productId: p.productId || p.id,
+              productName: p.productName,
+              quantity: refundState.refundQuantities[i] || 1,
+              price: p.price || 0
+            };
+          });
+        } else if (refundState.customAmount && refundState.customAmount > 0) {
+          refundData.refundAmount = refundState.customAmount;
+        }
+        // 两者都没有 → 全额退款,不传 products 和 refundAmount
 
         try {
-          const res = await refundOrder(row.id, { reason: refundState.reason, products });
+          const res = await refundOrder(row.id, refundData);
           if (res.code === 200 || res.code === 0) {
             message(`订单 ${row.orderNo} 退款成功`, { type: "success" });
             done();

+ 45 - 3
haha-admin/src/main/java/com/haha/admin/controller/OrderController.java

@@ -22,7 +22,13 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
+
+import com.haha.service.payment.PaymentService;
+import com.haha.service.payment.RefundResult;
+import com.haha.service.payment.RefundResult;
 
 /**
  * 订单管理控制器
@@ -41,6 +47,9 @@ public class OrderController {
     @Autowired(required = false)
     private PayScoreStrategy payScoreStrategy;
 
+    @Autowired
+    private PaymentService paymentService;
+
     /**
      * 分页查询订单列表
      */
@@ -96,14 +105,47 @@ public class OrderController {
     }
 
     /**
-     * 订单退款
+     * 订单退款(支持选择商品退款 / 自定义金额退款 / 全额退款)
      */
     @RequirePermission("order:update")
     @Log(module = "订单管理", operation = OperationType.UPDATE, summary = "订单退款")
     @PostMapping("/{id}/refund")
     public Result<Void> refund(@PathVariable Long id, @RequestBody RefundDTO dto) {
-        boolean success = orderService.refund(id, dto.getReason());
-        return success ? Result.success("退款成功", null) : Result.error(500, "退款失败");
+        // 构建退款参数
+        Map<String, Object> params = new HashMap<>();
+        params.put("orderId", id);
+        params.put("reason", dto.getReason() != null ? dto.getReason() : "管理员退款");
+
+        // 如果有商品明细,传入商品列表
+        if (dto.getProducts() != null && !dto.getProducts().isEmpty()) {
+            List<Map<String, Object>> productMaps = dto.getProducts().stream().map(p -> {
+                Map<String, Object> m = new HashMap<>();
+                m.put("productId", p.getProductId());
+                m.put("productName", p.getProductName());
+                m.put("quantity", p.getQuantity());
+                m.put("price", p.getPrice());
+                return m;
+            }).collect(Collectors.toList());
+            params.put("products", productMaps);
+        }
+
+        // 如果有自定义退款金额
+        if (dto.getRefundAmount() != null) {
+            params.put("refundAmount", dto.getRefundAmount());
+        }
+
+        // 调用支付服务执行退款
+        try {
+            RefundResult result = paymentService.refund(params);
+            if (result.isSuccess()) {
+                return Result.success("退款成功", null);
+            } else {
+                return Result.error(500, result.getErrorMsg() != null ? result.getErrorMsg() : "退款失败");
+            }
+        } catch (Exception e) {
+            log.error("退款异常 - orderId: {}, error: {}", id, e.getMessage(), e);
+            return Result.error(500, "退款失败:" + e.getMessage());
+        }
     }
 
     /**

+ 70 - 78
haha-admin/src/main/java/com/haha/admin/controller/RefundApplicationController.java

@@ -1,8 +1,7 @@
 package com.haha.admin.controller;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import cn.dev33.satoken.stp.StpUtil;
 import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.haha.admin.annotation.RequirePermission;
 import com.haha.common.annotation.Log;
 import com.haha.common.enums.OperationType;
@@ -11,12 +10,10 @@ import com.haha.common.vo.PageResult;
 import com.haha.common.vo.RefundApplicationVO;
 import com.haha.common.vo.RefundProductVO;
 import com.haha.common.vo.Result;
-import com.haha.entity.Order;
-import com.haha.entity.OrderGoods;
-import com.haha.service.OrderGoodsService;
+import com.haha.entity.Refund;
+import com.haha.entity.RefundItem;
 import com.haha.service.OrderService;
-import com.haha.service.payment.PaymentService;
-import com.haha.service.payment.RefundResult;
+import com.haha.service.RefundService;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -29,8 +26,7 @@ import java.util.stream.Collectors;
  * 退款申请管理控制器
  *
  * 负责退款申请的查询、详情查看、审核等操作。
- * 审核通过时,同步调用支付渠道退款API(微信/支付宝等),
- * 而非仅更新数据库状态。
+ * 基于独立的 t_refund 和 t_refund_item 表管理退款记录。
  */
 @Slf4j
 @RestController
@@ -38,9 +34,8 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class RefundApplicationController {
 
+    private final RefundService refundService;
     private final OrderService orderService;
-    private final OrderGoodsService orderGoodsService;
-    private final PaymentService paymentService;
 
     /**
      * 分页查询退款申请列表
@@ -58,29 +53,21 @@ public class RefundApplicationController {
         if (page < 1) page = 1;
         if (pageSize > 100) pageSize = 10;
 
-        LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
-                .isNotNull(Order::getRefundStatus)
-                .eq(orderNo != null && !orderNo.isEmpty(), Order::getOrderNo, orderNo)
-                .ge(startDate != null && !startDate.isEmpty(), Order::getCreateTime, startDate)
-                .le(endDate != null && !endDate.isEmpty(), Order::getCreateTime, endDate + " 23:59:59");
-
-        // 状态筛选:0-待审核,1-已通过,2-已拒绝
+        String statusStr = null;
         if (status != null) {
             if (status == 0) {
-                wrapper.and(w -> w.ne(Order::getRefundStatus, RefundStatus.REFUNDED.getCode())
-                        .ne(Order::getRefundStatus, RefundStatus.REJECTED.getCode()));
+                statusStr = RefundStatus.PENDING.getCode();
             } else if (status == 1) {
-                wrapper.eq(Order::getRefundStatus, RefundStatus.REFUNDED.getCode());
+                statusStr = RefundStatus.REFUNDED.getCode();
             } else if (status == 2) {
-                wrapper.eq(Order::getRefundStatus, RefundStatus.REJECTED.getCode());
+                statusStr = RefundStatus.REJECTED.getCode();
             }
         }
 
-        wrapper.orderByDesc(Order::getCreateTime);
-
-        IPage<Order> orderPage = orderService.page(new Page<>(page, pageSize), wrapper);
+        IPage<Refund> refundPage = refundService.getRefundPage(
+                page, pageSize, orderNo, statusStr, startDate, endDate);
 
-        return Result.success("查询成功", PageResult.of(orderPage, this::toRefundApplicationVO));
+        return Result.success("查询成功", PageResult.of(refundPage, this::toRefundApplicationVO));
     }
 
     /**
@@ -89,89 +76,85 @@ public class RefundApplicationController {
     @RequirePermission("order:read")
     @GetMapping("/{id}")
     public Result<RefundApplicationVO> getDetail(@PathVariable Long id) {
-        Order order = orderService.getDetailById(id);
-        if (order == null) {
+        Refund refund = refundService.getDetailById(id);
+        if (refund == null) {
             return Result.error(404, "退款申请不存在");
         }
-        return Result.success("查询成功", toRefundApplicationVO(order));
+        return Result.success("查询成功", toRefundApplicationVO(refund));
     }
 
     /**
      * 审核退款申请
      *
-     * 审核通过时,同步调用 PaymentService.refund() 执行真实的第三方退款操作
-     * (微信退款API / 余额退还等),而非仅更新数据库状态。
-     * 仅当第三方退款成功时,数据库状态才会标记为 REFUNDED。
-     * 审核拒绝时仅更新数据库状态,不涉及第三方调用。
+     * 审核通过时,同步调用第三方退款API执行实际退款操作。
+     * 审核拒绝时仅更新数据库状态。
      */
     @RequirePermission("order:update")
     @Log(module = "退款管理", operation = OperationType.UPDATE, summary = "审核退款申请")
     @PostMapping("/{id}/review")
     public Result<Void> review(@PathVariable Long id, @RequestBody ReviewRefundDTO dto) {
-        Order order = orderService.getById(id);
-        if (order == null) {
-            return Result.error(404, "退款申请不存在");
-        }
+        Long reviewerId = getCurrentAdminId();
 
         if (dto.isApproved()) {
-            // 审核通过:调用真实退款API,PaymentService.refund() 内部会:
-            // 1. 调用微信/支付宝退款接口
-            // 2. 余额支付则退回到用户账户余额
-            // 3. 更新订单退款状态为 REFUNDED
-            log.info("退款申请审核通过,开始执行退款 - orderId: {}, reason: {}", id, dto.getRemark());
-            RefundResult result = paymentService.refund(id, dto.getRemark() != null ? dto.getRemark() : "管理员审核退款");
-
-            if (!result.isSuccess()) {
-                log.error("退款执行失败 - orderId: {}, errorCode: {}, errorMsg: {}",
-                        id, result.getErrorCode(), result.getErrorMsg());
-                return Result.error("退款失败:" + result.getErrorMsg());
+            boolean success = refundService.approveRefund(id, dto.getRemark(), reviewerId);
+            if (!success) {
+                return Result.error(500, "退款执行失败");
             }
-            log.info("退款执行成功 - orderId: {}, refundId: {}", id, result.getRefundId());
+            log.info("退款审核通过并执行成功 - refundId: {}", id);
             return Result.success("退款成功", null);
         } else {
-            // 审核拒绝:仅更新数据库状态
-            order.setRefundStatus(RefundStatus.REJECTED.getCode());
-            order.setRefundReason(dto.getRemark());
-            orderService.updateById(order);
-            log.info("退款申请已拒绝 - orderId: {}, remark: {}", id, dto.getRemark());
+            boolean success = refundService.rejectRefund(id, dto.getRemark(), reviewerId);
+            if (!success) {
+                return Result.error(500, "操作失败");
+            }
+            log.info("退款申请已拒绝 - refundId: {}", id);
             return Result.success("退款申请已拒绝", null);
         }
     }
 
     /**
-     * 将 Order 转换为 RefundApplicationVO
+     * 将 Refund 实体转换为 RefundApplicationVO
      */
-    private RefundApplicationVO toRefundApplicationVO(Order order) {
+    private RefundApplicationVO toRefundApplicationVO(Refund refund) {
         RefundApplicationVO vo = new RefundApplicationVO();
-        vo.setId(order.getId());
-        vo.setApplicationNo("RF" + order.getOrderNo());
-        vo.setOrderId(order.getId());
-        vo.setOrderNo(order.getOrderNo());
-        vo.setUserId(order.getUserId());
-        vo.setRefundAmount(order.getRefundAmount() != null ? order.getRefundAmount() : order.getTotalAmount());
-        vo.setReason(order.getRefundReason() != null ? order.getRefundReason() : "用户申请退款");
-        vo.setCreateTime(order.getCreateTime());
-        vo.setReviewTime(order.getRefundTime());
-
-        // 映射退款状态:REFUNDED->1(已通过), REJECTED->2(已拒绝), 其他->0(待审核)
-        String refundStatus = order.getRefundStatus();
-        if (RefundStatus.REFUNDED.getCode().equals(refundStatus)) {
+        vo.setId(refund.getId());
+        vo.setApplicationNo(refund.getRefundNo());
+        vo.setOrderId(refund.getOrderId());
+        vo.setOrderNo(refund.getOrderNo());
+        vo.setRefundAmount(refund.getRefundAmount());
+        vo.setReason(refund.getReason());
+        vo.setReviewRemark(refund.getRemark());
+        vo.setCreateTime(refund.getCreateTime());
+        vo.setReviewTime(refund.getRefundTime());
+
+        // 状态映射:REFUNDED->1, REJECTED->2, 其他->0
+        if (RefundStatus.REFUNDED.getCode().equals(refund.getStatus())) {
             vo.setStatus(1);
-        } else if (RefundStatus.REJECTED.getCode().equals(refundStatus)) {
+        } else if (RefundStatus.REJECTED.getCode().equals(refund.getStatus())) {
             vo.setStatus(2);
         } else {
             vo.setStatus(0);
         }
 
-        // 查询商品列表(包含图片)
-        List<OrderGoods> goodsList = orderGoodsService.getByOrderId(order.getId());
-        List<RefundProductVO> products = goodsList.stream().map(g -> {
+        // 加载用户ID
+        try {
+            com.haha.entity.Order order = orderService.getById(refund.getOrderId());
+            if (order != null) {
+                vo.setUserId(order.getUserId());
+            }
+        } catch (Exception e) {
+            log.warn("查询订单用户ID失败 - orderId: {}", refund.getOrderId());
+        }
+
+        // 从 t_refund_item 加载实际退款商品明细
+        List<RefundItem> items = refundService.getRefundItems(refund.getId());
+        List<RefundProductVO> products = items.stream().map(item -> {
             RefundProductVO p = new RefundProductVO();
-            p.setProductId(g.getProductId());
-            p.setProductName(g.getProductName());
-            p.setPic(g.getPic());
-            p.setQuantity(g.getProductNum() != null ? g.getProductNum() : 1);
-            p.setPrice(g.getPrice());
+            p.setProductId(item.getProductId());
+            p.setProductName(item.getProductName());
+            p.setPic(item.getPic());
+            p.setQuantity(item.getQuantity());
+            p.setPrice(item.getPrice());
             return p;
         }).collect(Collectors.toList());
         vo.setRefundProducts(products);
@@ -179,6 +162,15 @@ public class RefundApplicationController {
         return vo;
     }
 
+    private Long getCurrentAdminId() {
+        try {
+            return StpUtil.getLoginIdAsLong();
+        } catch (Exception e) {
+            log.warn("获取当前管理员ID失败,使用默认值: {}", e.getMessage());
+            return 0L;
+        }
+    }
+
     @Data
     public static class ReviewRefundDTO {
         private boolean approved;

+ 58 - 0
haha-admin/src/main/resources/db/migration/V5__create_refund_tables.sql

@@ -0,0 +1,58 @@
+-- ============================================
+-- 退款主表
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_refund` (
+  `id` BIGINT NOT NULL COMMENT '退款记录ID',
+  `order_id` BIGINT NOT NULL COMMENT '订单ID',
+  `order_no` VARCHAR(64) NOT NULL COMMENT '订单号',
+  `refund_no` VARCHAR(64) NOT NULL COMMENT '退款单号(系统生成)',
+  `refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '退款金额',
+  `total_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单原始总金额',
+  `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '退款状态:PENDING-待审核, REFUNDED-已退款, REJECTED-已拒绝',
+  `reason` VARCHAR(500) DEFAULT NULL COMMENT '退款原因',
+  `remark` VARCHAR(500) DEFAULT NULL COMMENT '审核备注',
+  `transaction_id` VARCHAR(100) DEFAULT NULL COMMENT '第三方退款交易号',
+  `refund_time` DATETIME DEFAULT NULL COMMENT '退款完成时间',
+  `reviewer_id` BIGINT DEFAULT NULL COMMENT '审核人ID',
+  `review_time` DATETIME DEFAULT NULL COMMENT '审核时间',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_refund_no` (`refund_no`),
+  KEY `idx_order_id` (`order_id`),
+  KEY `idx_order_no` (`order_no`),
+  KEY `idx_status` (`status`),
+  KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款记录表';
+
+-- ============================================
+-- 退款商品明细表
+-- ============================================
+CREATE TABLE IF NOT EXISTS `t_refund_item` (
+  `id` BIGINT NOT NULL COMMENT '退款商品明细ID',
+  `refund_id` BIGINT NOT NULL COMMENT '退款记录ID',
+  `order_goods_id` BIGINT NOT NULL COMMENT '订单商品明细ID(关联 t_order_goods.id)',
+  `product_id` BIGINT DEFAULT NULL COMMENT '商品ID',
+  `product_name` VARCHAR(255) DEFAULT NULL COMMENT '商品名称',
+  `pic` VARCHAR(500) DEFAULT NULL COMMENT '商品图片',
+  `quantity` INT NOT NULL DEFAULT 1 COMMENT '退款数量',
+  `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '商品单价',
+  `refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '退款金额(quantity * price)',
+  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_refund_id` (`refund_id`),
+  KEY `idx_order_goods_id` (`order_goods_id`),
+  KEY `idx_product_id` (`product_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款商品明细表';
+
+-- ============================================
+-- t_order 新增字段:关联当前退款记录
+-- ============================================
+ALTER TABLE `t_order`
+ADD COLUMN `current_refund_id` BIGINT DEFAULT NULL COMMENT '当前退款记录ID(关联 t_refund.id)' AFTER `transaction_id`;
+
+-- ============================================
+-- t_fund_log 新增字段:关联退款记录
+-- ============================================
+ALTER TABLE `t_fund_log`
+ADD COLUMN `refund_id` BIGINT DEFAULT NULL COMMENT '退款记录ID(关联 t_refund.id)' AFTER `order_no`;

+ 3 - 0
haha-entity/src/main/java/com/haha/entity/FundLog.java

@@ -36,6 +36,9 @@ public class FundLog implements Serializable {
 
     private String orderNo;
 
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long refundId;
+
     private String remark;
 
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")

+ 4 - 0
haha-entity/src/main/java/com/haha/entity/Order.java

@@ -57,6 +57,10 @@ public class Order implements Serializable {
     /** 第三方支付平台交易号 */
     private String transactionId;
 
+    /** 当前退款记录ID(关联 t_refund.id) */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long currentRefundId;
+
     private String videoUrl;
 
     private BigDecimal confidence;

+ 67 - 0
haha-entity/src/main/java/com/haha/entity/Refund.java

@@ -0,0 +1,67 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_refund")
+public class Refund implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long orderId;
+
+    private String orderNo;
+
+    private String refundNo;
+
+    private BigDecimal refundAmount;
+
+    private BigDecimal totalAmount;
+
+    private String status;
+
+    private String reason;
+
+    private String remark;
+
+    private String transactionId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime refundTime;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long reviewerId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime reviewTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime updateTime;
+
+    public boolean isPending() {
+        return "PENDING".equals(status);
+    }
+
+    public boolean isRefunded() {
+        return "REFUNDED".equals(status);
+    }
+
+    public boolean isRejected() {
+        return "REJECTED".equals(status);
+    }
+}

+ 43 - 0
haha-entity/src/main/java/com/haha/entity/RefundItem.java

@@ -0,0 +1,43 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_refund_item")
+public class RefundItem implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long refundId;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long orderGoodsId;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long productId;
+
+    private String productName;
+
+    private String pic;
+
+    private Integer quantity;
+
+    private BigDecimal price;
+
+    private BigDecimal refundAmount;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime createTime;
+}

+ 16 - 6
haha-entity/src/main/java/com/haha/entity/dto/RefundDTO.java

@@ -1,15 +1,25 @@
 package com.haha.entity.dto;
 
 import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
 
-/**
- * 订单退款DTO
- */
 @Data
 public class RefundDTO {
 
-    /**
-     * 退款原因
-     */
     private String reason;
+
+    /** 自定义退款金额(当没有选择商品时使用) */
+    private BigDecimal refundAmount;
+
+    /** 退款商品列表(选择商品退款时使用,优先级高于 refundAmount) */
+    private List<RefundProductDTO> products;
+
+    @Data
+    public static class RefundProductDTO {
+        private Long productId;
+        private String productName;
+        private Integer quantity;
+        private BigDecimal price;
+    }
 }

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/RefundItemMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.RefundItem;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface RefundItemMapper extends BaseMapper<RefundItem> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/RefundMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.Refund;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface RefundMapper extends BaseMapper<Refund> {
+}

+ 126 - 17
haha-mp/src/pages/refund/refund.vue

@@ -64,6 +64,29 @@
       </view>
     </view>
 
+    <!-- 自定义退款金额(未选择商品时可用) -->
+    <view class="section-block custom-amount-section">
+      <text class="section-title">自定义退款金额</text>
+      <view v-if="selectedProducts.length > 0" class="custom-amount-display">
+        <text class="calculated-hint">已根据所选商品自动计算退款金额</text>
+        <text class="calculated-amount">¥{{ refundAmount.toFixed(2) }}</text>
+      </view>
+      <view v-else class="custom-amount-input-wrapper">
+        <view class="custom-amount-input">
+          <text class="currency-symbol">¥</text>
+          <input
+            v-model="customRefundAmount"
+            type="digit"
+            placeholder="请输入退款金额"
+            class="amount-input"
+          />
+        </view>
+        <text class="amount-hint">
+          退款金额不能超过实付金额 ¥{{ (orderInfo.paidAmount || orderInfo.totalAmount || 0).toFixed(2) }}
+        </text>
+      </view>
+    </view>
+
     <!-- 退款原因 -->
     <view class="section-block reason-section">
       <text class="section-title">退款原因</text>
@@ -205,6 +228,7 @@ const refundExpireText = computed(() => {
 
 const selectedReason = ref('');
 const refundRemark = ref('');
+const customRefundAmount = ref<string>('');
 
 const refundReasons = [
   { label: '订单扣款错误', value: '1' },
@@ -312,9 +336,18 @@ onLoad((options) => {
 });
 
 const submitRefund = async () => {
+  // 校验:必须选择商品或输入自定义金额
   if (selectedProducts.value.length === 0) {
-    uni.showToast({ title: '请选择要退款的商品', icon: 'none' });
-    return;
+    const amount = parseFloat(customRefundAmount.value);
+    if (!customRefundAmount.value || isNaN(amount) || amount <= 0) {
+      uni.showToast({ title: '请选择退款商品或输入退款金额', icon: 'none' });
+      return;
+    }
+    const maxAmount = orderInfo.value.paidAmount || orderInfo.value.totalAmount || 0;
+    if (amount > maxAmount) {
+      uni.showToast({ title: '退款金额不能超过实付金额', icon: 'none' });
+      return;
+    }
   }
 
   if (!selectedReason.value) {
@@ -340,29 +373,40 @@ const submitRefund = async () => {
     reasonText = `其他:${refundRemark.value.trim()}`;
   }
 
-  const selectedProductIds = selectedProducts.value.map(index => {
-    const product = orderInfo.value.products[index];
-    return {
-      productId: product.id,
-      productName: product.name,
-      quantity: product.refundQuantity || 1,
-      price: product.price
-    };
-  });
+  // 构建请求体
+  const body: Record<string, any> = {
+    orderId: orderId.value,
+    reason: reasonText,
+  };
+
+  if (selectedProducts.value.length > 0) {
+    body.products = selectedProducts.value.map(index => {
+      const product = orderInfo.value.products[index];
+      return {
+        orderGoodsId: product.orderGoodsId || product.id,
+        productId: product.productId,
+        productName: product.name,
+        pic: product.pic || product.image,
+        quantity: product.refundQuantity || 1,
+        price: product.price
+      };
+    });
+  } else {
+    body.refundAmount = parseFloat(customRefundAmount.value);
+  }
 
   try {
     // 支付分订单走支付分退款接口
     if (orderInfo.value.payChannel === 'wechat_payscore') {
+      const refundAmt = selectedProducts.value.length > 0
+        ? refundAmount.value
+        : parseFloat(customRefundAmount.value);
       await refundPayscoreOrder(Number(orderId.value), {
-        refundAmount: refundAmount.value,
+        refundAmount: refundAmt,
         reason: reasonText,
       });
     } else {
-      await post('/payment/refund', {
-        orderId: orderId.value,
-        reason: reasonText,
-        products: selectedProductIds,
-      });
+      await post('/payment/refund', body);
     }
 
     uni.showToast({ title: '退款申请成功', icon: 'success' });
@@ -789,6 +833,71 @@ radio-group {
   font-weight: 500;
 }
 
+/* 自定义金额 */
+.custom-amount-section {
+  padding-bottom: 20rpx;
+}
+
+.custom-amount-display {
+  text-align: center;
+  padding: 24rpx;
+  background-color: #f6ffed;
+  border-radius: 12rpx;
+  border: 1rpx solid #d9f7be;
+}
+
+.calculated-hint {
+  display: block;
+  font-size: 24rpx;
+  color: $color-text-secondary;
+  margin-bottom: 12rpx;
+}
+
+.calculated-amount {
+  font-size: 44rpx;
+  color: $color-error;
+  font-weight: 700;
+}
+
+.custom-amount-input-wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.custom-amount-input {
+  display: flex;
+  align-items: center;
+  background-color: $color-bg-primary;
+  border: 2rpx solid $color-border;
+  border-radius: 12rpx;
+  padding: 20rpx 24rpx;
+
+  &:focus-within {
+    border-color: $color-info;
+  }
+}
+
+.currency-symbol {
+  font-size: 36rpx;
+  color: $color-text-primary;
+  font-weight: 600;
+  margin-right: 12rpx;
+}
+
+.amount-input {
+  flex: 1;
+  font-size: 32rpx;
+  color: $color-text-primary;
+  height: 60rpx;
+}
+
+.amount-hint {
+  font-size: 24rpx;
+  color: $color-text-secondary;
+  line-height: 1.6;
+}
+
 .contact-section {
   text-align: center;
   padding: 20rpx 0 60rpx;

+ 75 - 0
haha-service/src/main/java/com/haha/service/RefundService.java

@@ -0,0 +1,75 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.entity.Refund;
+import com.haha.entity.RefundItem;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+public interface RefundService extends IService<Refund> {
+
+    /**
+     * 创建退款记录(含商品明细)
+     *
+     * @param orderId 订单ID
+     * @param reason 退款原因
+     * @param products 退款商品列表(可选)
+     * @param refundAmount 自定义退款金额(可选)
+     * @return 退款记录
+     */
+    Refund createRefundRecord(Long orderId, String reason, List<Map<String, Object>> products, BigDecimal refundAmount);
+
+    /**
+     * 从Map参数创建退款记录
+     */
+    Refund createRefundRecord(Map<String, Object> params);
+
+    /**
+     * 查询退款详情
+     */
+    Refund getDetailById(Long refundId);
+
+    /**
+     * 查询订单的所有退款记录
+     */
+    List<Refund> getByOrderId(Long orderId);
+
+    /**
+     * 分页查询退款列表
+     */
+    IPage<Refund> getRefundPage(int page, int pageSize, String orderNo,
+                                String status, String startDate, String endDate);
+
+    /**
+     * 查询退款商品明细
+     */
+    List<RefundItem> getRefundItems(Long refundId);
+
+    /**
+     * 根据订单ID查询累计退款金额(已退款状态的记录)
+     */
+    BigDecimal getTotalRefundedAmount(Long orderId);
+
+    /**
+     * 计算退款金额(从商品列表)
+     */
+    BigDecimal calculateRefundAmount(List<Map<String, Object>> products);
+
+    /**
+     * 审核通过退款(执行第三方退款)
+     */
+    boolean approveRefund(Long refundId, String remark, Long reviewerId);
+
+    /**
+     * 拒绝退款
+     */
+    boolean rejectRefund(Long refundId, String remark, Long reviewerId);
+
+    /**
+     * 标记退款已成功
+     */
+    void markRefunded(Long refundId, String transactionId);
+}

+ 7 - 0
haha-service/src/main/java/com/haha/service/impl/OrderServiceImpl.java

@@ -874,10 +874,14 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
         orderMap.put("discountAmount", order.getDiscountAmount() != null ? order.getDiscountAmount() : 0);
         orderMap.put("paidAmount", order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount());
         orderMap.put("payStatus", order.getPayStatus());
+        orderMap.put("payChannel", order.getPayChannel());
         orderMap.put("status", order.getStatus());
         orderMap.put("createTime", order.getCreateTime());
         orderMap.put("payTime", order.getPayTime());
         orderMap.put("confidence", order.getConfidence());
+        orderMap.put("refundStatus", order.getRefundStatus());
+        orderMap.put("refundAmount", order.getRefundAmount());
+        orderMap.put("currentRefundId", order.getCurrentRefundId());
         orderMap.put("products", getProductsFromDatabase(order.getId()));
         return orderMap;
     }
@@ -896,10 +900,13 @@ public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements
                 for (OrderItemVO goods : goodsList) {
                     Map<String, Object> product = new HashMap<>();
                     product.put("id", goods.getId());
+                    product.put("orderGoodsId", goods.getId());
+                    product.put("productId", goods.getProductId());
                     product.put("name", goods.getProductName());
                     product.put("price", goods.getPrice());
                     product.put("quantity", goods.getProductNum());
                     product.put("image", normalizeImageUrl(goods.getPic()));
+                    product.put("pic", goods.getPic());
                     product.put("subtotal", goods.getPrice() != null && goods.getProductNum() != null
                         ? goods.getPrice().multiply(new BigDecimal(goods.getProductNum()))
                         : new BigDecimal(0));

+ 286 - 0
haha-service/src/main/java/com/haha/service/impl/RefundServiceImpl.java

@@ -0,0 +1,286 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.common.enums.RefundStatus;
+import com.haha.common.constant.PaymentConstants;
+import com.haha.entity.Order;
+import com.haha.entity.Refund;
+import com.haha.entity.RefundItem;
+import com.haha.mapper.RefundItemMapper;
+import com.haha.mapper.RefundMapper;
+import com.haha.service.OrderService;
+import com.haha.service.RefundService;
+import com.haha.service.payment.PaymentService;
+import com.haha.service.payment.RefundResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class RefundServiceImpl extends ServiceImpl<RefundMapper, Refund> implements RefundService {
+
+    private final RefundItemMapper refundItemMapper;
+    private final OrderService orderService;
+
+    @Autowired
+    @Lazy
+    private PaymentService paymentService;
+
+    public RefundServiceImpl(RefundItemMapper refundItemMapper, OrderService orderService) {
+        this.refundItemMapper = refundItemMapper;
+        this.orderService = orderService;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Refund createRefundRecord(Long orderId, String reason, List<Map<String, Object>> products, BigDecimal refundAmount) {
+        Order order = orderService.getById(orderId);
+        if (order == null) {
+            throw new RuntimeException("订单不存在");
+        }
+
+        // 校验是否已有待审核的退款申请
+        long pendingCount = lambdaQuery()
+                .eq(Refund::getOrderId, orderId)
+                .eq(Refund::getStatus, RefundStatus.PENDING.getCode())
+                .count();
+        if (pendingCount > 0) {
+            throw new RuntimeException("该订单已有待审核的退款申请");
+        }
+
+        BigDecimal paidAmount = order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount();
+
+        // 确定退款金额:products > refundAmount > 全额
+        BigDecimal finalRefundAmount;
+        if (products != null && !products.isEmpty()) {
+            finalRefundAmount = calculateRefundAmount(products);
+        } else if (refundAmount != null) {
+            finalRefundAmount = refundAmount;
+        } else {
+            finalRefundAmount = paidAmount;
+        }
+
+        // 校验退款金额
+        BigDecimal alreadyRefunded = getTotalRefundedAmount(orderId);
+        BigDecimal availableAmount = paidAmount.subtract(alreadyRefunded);
+        if (finalRefundAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new RuntimeException("退款金额必须大于0");
+        }
+        if (finalRefundAmount.compareTo(availableAmount) > 0) {
+            throw new RuntimeException("退款金额不能超过可退金额" + availableAmount + "元(实付" + paidAmount + "元,已退" + alreadyRefunded + "元)");
+        }
+
+        // 创建退款记录
+        Refund refund = new Refund();
+        refund.setOrderId(orderId);
+        refund.setOrderNo(order.getOrderNo());
+        refund.setRefundNo(generateRefundNo(order.getOrderNo()));
+        refund.setRefundAmount(finalRefundAmount);
+        refund.setTotalAmount(order.getTotalAmount());
+        refund.setStatus(RefundStatus.PENDING.getCode());
+        refund.setReason(reason);
+        refund.setCreateTime(LocalDateTime.now());
+        refund.setUpdateTime(LocalDateTime.now());
+        this.save(refund);
+
+        // 创建退款商品明细
+        if (products != null && !products.isEmpty()) {
+            List<RefundItem> items = new ArrayList<>();
+            for (Map<String, Object> p : products) {
+                RefundItem item = new RefundItem();
+                item.setRefundId(refund.getId());
+                item.setOrderGoodsId(p.get("orderGoodsId") != null
+                        ? Long.valueOf(p.get("orderGoodsId").toString()) : 0L);
+                item.setProductId(p.get("productId") != null
+                        ? Long.valueOf(p.get("productId").toString()) : null);
+                item.setProductName((String) p.getOrDefault("productName", ""));
+                item.setPic((String) p.getOrDefault("pic", null));
+                item.setQuantity(p.get("quantity") != null
+                        ? Integer.valueOf(p.get("quantity").toString()) : 1);
+                item.setPrice(p.get("price") != null
+                        ? new BigDecimal(p.get("price").toString()) : BigDecimal.ZERO);
+                item.setRefundAmount(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
+                item.setCreateTime(LocalDateTime.now());
+                items.add(item);
+            }
+            for (RefundItem item : items) {
+                refundItemMapper.insert(item);
+            }
+        }
+
+        // 更新订单关联
+        order.setCurrentRefundId(refund.getId());
+        order.setRefundStatus(RefundStatus.PENDING.getCode());
+        order.setRefundAmount(finalRefundAmount);
+        order.setRefundReason(reason);
+        orderService.updateById(order);
+
+        log.info("退款记录创建成功 - refundId: {}, orderId: {}, amount: {}", refund.getId(), orderId, finalRefundAmount);
+        return refund;
+    }
+
+    @Override
+    public Refund createRefundRecord(Map<String, Object> params) {
+        Long orderId = Long.valueOf(params.get("orderId").toString());
+        String reason = params.containsKey("reason") ? params.get("reason").toString() : "用户申请退款";
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> products = (List<Map<String, Object>>) params.get("products");
+
+        BigDecimal refundAmount = null;
+        if (params.containsKey("refundAmount") && params.get("refundAmount") != null) {
+            refundAmount = new BigDecimal(params.get("refundAmount").toString());
+        }
+
+        return createRefundRecord(orderId, reason, products, refundAmount);
+    }
+
+    @Override
+    public Refund getDetailById(Long refundId) {
+        return this.getById(refundId);
+    }
+
+    @Override
+    public List<Refund> getByOrderId(Long orderId) {
+        return lambdaQuery()
+                .eq(Refund::getOrderId, orderId)
+                .orderByDesc(Refund::getCreateTime)
+                .list();
+    }
+
+    @Override
+    public IPage<Refund> getRefundPage(int page, int pageSize, String orderNo,
+                                        String status, String startDate, String endDate) {
+        LambdaQueryWrapper<Refund> wrapper = new LambdaQueryWrapper<Refund>()
+                .eq(orderNo != null && !orderNo.isEmpty(), Refund::getOrderNo, orderNo)
+                .eq(status != null && !status.isEmpty(), Refund::getStatus, status)
+                .ge(startDate != null && !startDate.isEmpty(), Refund::getCreateTime, startDate)
+                .le(endDate != null && !endDate.isEmpty(), Refund::getCreateTime, endDate + " 23:59:59")
+                .orderByDesc(Refund::getCreateTime);
+        return this.page(new Page<>(page, pageSize), wrapper);
+    }
+
+    @Override
+    public List<RefundItem> getRefundItems(Long refundId) {
+        return refundItemMapper.selectList(
+                new LambdaQueryWrapper<RefundItem>()
+                        .eq(RefundItem::getRefundId, refundId));
+    }
+
+    @Override
+    public BigDecimal getTotalRefundedAmount(Long orderId) {
+        List<Refund> refunds = lambdaQuery()
+                .eq(Refund::getOrderId, orderId)
+                .eq(Refund::getStatus, RefundStatus.REFUNDED.getCode())
+                .list();
+        BigDecimal total = BigDecimal.ZERO;
+        for (Refund r : refunds) {
+            if (r.getRefundAmount() != null) {
+                total = total.add(r.getRefundAmount());
+            }
+        }
+        return total;
+    }
+
+    @Override
+    public BigDecimal calculateRefundAmount(List<Map<String, Object>> products) {
+        if (products == null || products.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, Object> p : products) {
+            BigDecimal price = p.get("price") != null
+                    ? new BigDecimal(p.get("price").toString()) : BigDecimal.ZERO;
+            int quantity = p.get("quantity") != null
+                    ? Integer.parseInt(p.get("quantity").toString()) : 1;
+            total = total.add(price.multiply(BigDecimal.valueOf(quantity)));
+        }
+        return total;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean approveRefund(Long refundId, String remark, Long reviewerId) {
+        Refund refund = this.getById(refundId);
+        if (refund == null || !refund.isPending()) {
+            log.warn("退款记录不存在或状态不是待审核 - refundId: {}, status: {}",
+                    refundId, refund != null ? refund.getStatus() : "null");
+            return false;
+        }
+
+        // 执行第三方退款
+        RefundResult result = paymentService.refund(refund.getOrderId(), refund.getReason(), refund.getRefundAmount());
+
+        if (result.isSuccess()) {
+            markRefunded(refundId, result.getRefundId());
+            refund.setRemark(remark);
+            refund.setReviewerId(reviewerId);
+            refund.setReviewTime(LocalDateTime.now());
+            refund.setUpdateTime(LocalDateTime.now());
+            this.updateById(refund);
+            log.info("退款审核通过并执行成功 - refundId: {}, transactionId: {}", refundId, result.getRefundId());
+            return true;
+        } else {
+            log.error("退款执行失败 - refundId: {}, error: {}", refundId, result.getErrorMsg());
+            return false;
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean rejectRefund(Long refundId, String remark, Long reviewerId) {
+        Refund refund = this.getById(refundId);
+        if (refund == null || !refund.isPending()) {
+            return false;
+        }
+
+        refund.setStatus(RefundStatus.REJECTED.getCode());
+        refund.setRemark(remark);
+        refund.setReviewerId(reviewerId);
+        refund.setReviewTime(LocalDateTime.now());
+        refund.setUpdateTime(LocalDateTime.now());
+        this.updateById(refund);
+
+        // 清除订单退款状态
+        Order order = orderService.getById(refund.getOrderId());
+        if (order != null) {
+            order.setRefundStatus(null);
+            order.setCurrentRefundId(null);
+            order.setRefundAmount(null);
+            order.setRefundReason(null);
+            orderService.updateById(order);
+        }
+
+        log.info("退款申请已拒绝 - refundId: {}", refundId);
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void markRefunded(Long refundId, String transactionId) {
+        Refund refund = this.getById(refundId);
+        if (refund != null) {
+            refund.setStatus(RefundStatus.REFUNDED.getCode());
+            refund.setTransactionId(transactionId);
+            refund.setRefundTime(LocalDateTime.now());
+            refund.setUpdateTime(LocalDateTime.now());
+            this.updateById(refund);
+        }
+    }
+
+    private String generateRefundNo(String orderNo) {
+        return PaymentConstants.REFUND_ORDER_PREFIX + orderNo + System.currentTimeMillis();
+    }
+}

+ 11 - 1
haha-service/src/main/java/com/haha/service/payment/PaymentService.java

@@ -1,6 +1,7 @@
 package com.haha.service.payment;
 
 import com.haha.common.enums.PaymentChannel;
+import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
 
@@ -34,9 +35,18 @@ public interface PaymentService {
      */
     RefundResult refund(Long orderId, String reason);
 
+    /**
+     * 退款(指定退款金额)
+     * @param orderId 本地订单ID
+     * @param reason 退款原因
+     * @param refundAmount 退款金额(null则全额退款)
+     * @return 退款结果
+     */
+    RefundResult refund(Long orderId, String reason, BigDecimal refundAmount);
+
     /**
      * 退款(从Map params提取orderId和reason)
-     * @param params 包含 orderId、reason(可选)
+     * @param params 包含 orderId、reason(可选)、products(可选)、refundAmount(可选)
      * @return 退款结果
      */
     RefundResult refund(Map<String, Object> params);

+ 94 - 37
haha-service/src/main/java/com/haha/service/payment/impl/PaymentServiceImpl.java

@@ -7,6 +7,7 @@ import com.haha.common.enums.RefundStatus;
 import com.haha.entity.Order;
 import com.haha.service.AccountService;
 import com.haha.service.OrderService;
+import com.haha.service.RefundService;
 import com.haha.service.payment.*;
 import com.haha.service.payment.payscore.PayScoreResult;
 import com.haha.service.payment.payscore.PayScoreService;
@@ -41,6 +42,9 @@ public class PaymentServiceImpl implements PaymentService {
 
     @Autowired(required = false)
     private PayScoreService payScoreService;
+
+    @Autowired
+    private RefundService refundService;
     
     @Override
     public PaymentResult createPayment(Long orderId, PaymentChannel channel, Map<String, String> extra) {
@@ -133,8 +137,14 @@ public class PaymentServiceImpl implements PaymentService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public RefundResult refund(Long orderId, String reason) {
-        log.info("发起退款请求: orderId={}, reason={}", orderId, reason);
-        
+        return refund(orderId, reason, null);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public RefundResult refund(Long orderId, String reason, BigDecimal refundAmount) {
+        log.info("发起退款请求: orderId={}, reason={}, refundAmount={}", orderId, reason, refundAmount);
+
         // 1. 参数校验
         if (orderId == null) {
             log.error("退款失败: 订单ID不能为空");
@@ -144,7 +154,7 @@ public class PaymentServiceImpl implements PaymentService {
                     .errorMsg("订单ID不能为空")
                     .build();
         }
-        
+
         // 2. 查询订单
         Order order = orderService.getById(orderId);
         if (order == null) {
@@ -155,7 +165,7 @@ public class PaymentServiceImpl implements PaymentService {
                     .errorMsg("订单不存在")
                     .build();
         }
-        
+
         // 3. 校验订单状态(必须是已支付)
         if (!OrderConstants.PAY_STATUS_PAID.equals(order.getPayStatus())) {
             log.error("退款失败: 订单状态不正确, orderId={}, payStatus={}", orderId, order.getPayStatus());
@@ -165,12 +175,12 @@ public class PaymentServiceImpl implements PaymentService {
                     .errorMsg("订单状态不正确,只有已支付订单可以退款")
                     .build();
         }
-        
+
         // 4. 校验订单完成时间是否超过7天
         if (order.getPayTime() != null) {
             LocalDateTime now = LocalDateTime.now();
             long daysDiff = java.time.temporal.ChronoUnit.DAYS.between(order.getPayTime(), now);
-            
+
             if (daysDiff > PaymentConstants.REFUND_MAX_DAYS) {
                 log.error("退款失败: 订单已完成超过7天,不允许退款, orderId={}, payTime={}", orderId, order.getPayTime());
                 return RefundResult.builder()
@@ -180,10 +190,14 @@ public class PaymentServiceImpl implements PaymentService {
                         .build();
             }
         }
-        
+
+        // 确定实际退款金额
+        BigDecimal actualRefundAmount = refundAmount != null ? refundAmount
+                : (order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount());
+
         // 5. 根据订单的 payChannel 获取支付渠道
         PaymentChannel channel = PaymentChannel.fromCode(order.getPayChannel());
-        
+
         // 6. 如果 channel 为 WECHAT_PAYSCORE,走支付分退款流程
         if (channel == PaymentChannel.WECHAT_PAYSCORE) {
             if (payScoreService == null) {
@@ -195,12 +209,12 @@ public class PaymentServiceImpl implements PaymentService {
                         .build();
             }
 
-            BigDecimal actualRefundAmount = order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount();
             PayScoreResult payScoreResult = payScoreService.refundPayScoreOrder(
                     orderId, actualRefundAmount, reason != null ? reason : "用户申请退款");
 
             if (payScoreResult.isSuccess()) {
                 log.info("支付分退款成功: orderId={}, refundId={}", orderId, payScoreResult.getPackageStr());
+                updateOrderRefundStatus(order, reason, payScoreResult.getPackageStr(), actualRefundAmount);
                 return RefundResult.builder()
                         .success(true)
                         .refundId(payScoreResult.getPackageStr())
@@ -219,14 +233,14 @@ public class PaymentServiceImpl implements PaymentService {
         // 7. 如果 channel 为 null 或 BALANCE,直接更新退款状态(余额退款不经过第三方)
         if (channel == null || channel == PaymentChannel.BALANCE) {
             log.info("余额退款,直接更新订单状态: orderId={}", orderId);
-            updateOrderRefundStatus(order, reason, null);
+            updateOrderRefundStatus(order, reason, null, actualRefundAmount);
             return RefundResult.builder()
                     .success(true)
                     .refundId("BALANCE_REFUND_" + orderId)
                     .build();
         }
-        
-        // 7. 获取支付策略
+
+        // 8. 获取支付策略
         PaymentStrategy strategy;
         try {
             strategy = channelFactory.getStrategy(channel);
@@ -238,9 +252,8 @@ public class PaymentServiceImpl implements PaymentService {
                     .errorMsg("不支持的支付渠道: " + channel.getCode())
                     .build();
         }
-        
-        // 8. 构建退款请求(使用实付金额 paidAmount,避免多退优惠券部分)
-        BigDecimal actualRefundAmount = order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount();
+
+        // 9. 构建退款请求
         String refundNo = generateRefundNo(order.getOrderNo());
         RefundRequest refundRequest = RefundRequest.builder()
                 .orderId(orderId)
@@ -251,22 +264,22 @@ public class PaymentServiceImpl implements PaymentService {
                 .reason(reason)
                 .refundNo(refundNo)
                 .build();
-        
-        log.info("调用支付策略执行退款: orderNo={}, refundNo={}, amount={}", 
-                order.getOrderNo(), refundNo, order.getTotalAmount());
-        
-        // 9. 调用策略退款
+
+        log.info("调用支付策略执行退款: orderNo={}, refundNo={}, amount={}",
+                order.getOrderNo(), refundNo, actualRefundAmount);
+
+        // 10. 调用策略退款
         RefundResult result = strategy.refund(refundRequest);
-        
-        // 10. 更新订单退款状态
+
+        // 11. 更新订单退款状态
         if (result.isSuccess()) {
-            updateOrderRefundStatus(order, reason, result.getRefundId());
+            updateOrderRefundStatus(order, reason, result.getRefundId(), actualRefundAmount);
             log.info("退款成功: orderId={}, refundId={}", orderId, result.getRefundId());
         } else {
-            log.warn("退款失败: orderId={}, errorCode={}, errorMsg={}", 
+            log.warn("退款失败: orderId={}, errorCode={}, errorMsg={}",
                     orderId, result.getErrorCode(), result.getErrorMsg());
         }
-        
+
         return result;
     }
     
@@ -504,7 +517,50 @@ public class PaymentServiceImpl implements PaymentService {
         Long orderId = Long.valueOf(params.get("orderId").toString());
         String reason = params.containsKey("reason") ? params.get("reason").toString() : "用户申请退款";
 
-        log.info("退款请求 - 订单ID: {}, 原因: {}", orderId, reason);
+        // 提取商品列表和自定义金额
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> products = (List<Map<String, Object>>) params.get("products");
+        BigDecimal customRefundAmount = null;
+        if (params.containsKey("refundAmount") && params.get("refundAmount") != null) {
+            customRefundAmount = new BigDecimal(params.get("refundAmount").toString());
+        }
+
+        // 如果指定了商品或自定义金额,走部分退款流程
+        boolean hasProducts = products != null && !products.isEmpty();
+        boolean hasCustomAmount = customRefundAmount != null;
+        if (hasProducts || hasCustomAmount) {
+            try {
+                // 创建退款记录(PENDING),内部完成金额计算和校验
+                com.haha.entity.Refund refundRecord = refundService.createRefundRecord(orderId, reason, products, customRefundAmount);
+                BigDecimal refundAmount = refundRecord.getRefundAmount();
+
+                // 执行第三方退款
+                RefundResult result = refund(orderId, reason, refundAmount);
+
+                if (result.isSuccess()) {
+                    refundService.markRefunded(refundRecord.getId(), result.getRefundId());
+                    // 更新订单关联的退款记录ID
+                    Order order = orderService.getById(orderId);
+                    if (order != null) {
+                        order.setCurrentRefundId(refundRecord.getId());
+                        orderService.updateById(order);
+                    }
+                    log.info("退款成功(含商品明细) - orderId: {}, refundId: {}, amount: {}, items: {}",
+                            orderId, refundRecord.getId(), refundAmount, products != null ? products.size() : 0);
+                }
+                return result;
+            } catch (Exception e) {
+                log.error("退款失败 - orderId: {}, error: {}", orderId, e.getMessage());
+                return RefundResult.builder()
+                        .success(false)
+                        .errorCode("REFUND_ERROR")
+                        .errorMsg(e.getMessage())
+                        .build();
+            }
+        }
+
+        // 无商品选择、无自定义金额 → 全额退款(兼容旧行为)
+        log.info("退款请求(全额) - 订单ID: {}, 原因: {}", orderId, reason);
         return refund(orderId, reason);
     }
 
@@ -524,34 +580,35 @@ public class PaymentServiceImpl implements PaymentService {
     /**
      * 更新订单退款状态
      *
-     * 处理逻辑:
-     * 1. 如果是余额支付,同步将退款金额退还到用户账户余额
-     * 2. 退款金额使用实付金额 (paidAmount),而非订单总金额 (totalAmount)
-     * 3. 更新订单退款相关字段
+     * @param order 订单
+     * @param reason 退款原因
+     * @param refundId 第三方退款ID
+     * @param refundAmount 退款金额(null则使用实付金额)
      */
-    private void updateOrderRefundStatus(Order order, String reason, String refundId) {
-        BigDecimal refundAmount = order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount();
+    private void updateOrderRefundStatus(Order order, String reason, String refundId, BigDecimal refundAmount) {
+        BigDecimal amount = refundAmount != null ? refundAmount
+                : (order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount());
 
         // 如果是余额支付,将退款金额退还到用户账户
         if (PaymentChannel.BALANCE.getCode().equals(order.getPayChannel())) {
-            boolean balanceUpdated = accountService.updateBalance(order.getUserId(), refundAmount);
+            boolean balanceUpdated = accountService.updateBalance(order.getUserId(), amount);
             if (balanceUpdated) {
                 log.info("余额退款 - 用户 {} 余额已增加: {}元, orderId={}",
-                        order.getUserId(), refundAmount, order.getId());
+                        order.getUserId(), amount, order.getId());
             } else {
                 log.error("余额退款失败 - 用户 {} 余额退还失败: {}元, orderId={}",
-                        order.getUserId(), refundAmount, order.getId());
+                        order.getUserId(), amount, order.getId());
             }
         }
 
         order.setRefundStatus(RefundStatus.REFUNDED.getCode());
-        order.setRefundAmount(refundAmount);
+        order.setRefundAmount(amount);
         order.setRefundTime(LocalDateTime.now());
         order.setRefundReason(reason);
         order.setPayStatus(OrderConstants.PAY_STATUS_REFUND);
         orderService.updateById(order);
         log.info("订单退款状态已更新 - orderId={}, refundAmount={}, payStatus={}",
-                order.getId(), refundAmount, OrderConstants.PAY_STATUS_REFUND);
+                order.getId(), amount, OrderConstants.PAY_STATUS_REFUND);
     }
     
     /**