Explorar o código

feat: 产品订单退款系统 - 余额不足待退款队列、管理员中止、提现/商品双路退款

- PendingRefund实体新增orderType/orderNo字段支持商品退款
- PendingRefundService新增overloaded createPendingRefund、cancelPendingRefund、移除markFailed最大重试限制
- ProductOrderService在approveRefund中拦截NOT_ENOUGH余额不足异常,自动加入待退款队列
- RefundScheduledTasks新增processProductRefund按orderType路由提现/商品退款
- AdminCommissionController新增POST /refunds/cancel管理员中止接口
- schema.sql和DatabaseInitializer迁移78同步order_type/order_no列
- cfc-web新增PendingRefund.vue管理页面和admin.js API函数
Xiaogang Liao hai 2 meses
pai
achega
844a30be83

+ 70 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -4375,6 +4375,72 @@ try {
         } catch (Exception e) {
             log.warn("创建butler_sessions表可能已存在: {}", e.getMessage());
         }
+
+        // 迁移 79: 创建 tianpan_daily_fortune 表(家庭每日运势)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_daily_fortune (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+                    "date VARCHAR(20) NOT NULL COMMENT '日期 YYYY-MM-DD', " +
+                    "lunar_date VARCHAR(50) COMMENT '农历日期', " +
+                    "day_ganzhi VARCHAR(10) COMMENT '日柱', " +
+                    "five_elements_main VARCHAR(20) COMMENT '当日五行主气', " +
+                    "five_elements_detail VARCHAR(100) COMMENT '五行详情', " +
+                    "auspicious TEXT COMMENT '宜做的事(JSON数组)', " +
+                    "inauspicious TEXT COMMENT '忌做的事(JSON数组)', " +
+                    "family_advice TEXT COMMENT '家庭建议', " +
+                    "focus_member_name VARCHAR(50) COMMENT '关注成员', " +
+                    "focus_member_tip TEXT COMMENT '成员提示', " +
+                    "wuxing_tip TEXT COMMENT '五行提示', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_family_date (family_id, date), " +
+                    "INDEX idx_family_id (family_id), " +
+                    "INDEX idx_date (date)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭每日运势表'");
+            log.info("已创建 tianpan_daily_fortune 表");
+        } catch (Exception e) {
+            log.warn("创建 tianpan_daily_fortune 表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移 80: 创建 tianpan_member_annual_energy 表(成员年度能量)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "member_id BIGINT NOT NULL COMMENT '成员ID', " +
+                    "year INT NOT NULL COMMENT '年份', " +
+                    "dimensions_body INT DEFAULT 0 COMMENT '身能量', " +
+                    "dimensions_mind INT DEFAULT 0 COMMENT '心能量', " +
+                    "dimensions_wisdom INT DEFAULT 0 COMMENT '智能量', " +
+                    "dimensions_action INT DEFAULT 0 COMMENT '行能量', " +
+                    "dimensions_wealth INT DEFAULT 0 COMMENT '富能量', " +
+                    "jupiter_phase VARCHAR(50) COMMENT '木星相位', " +
+                    "saturn_phase VARCHAR(50) COMMENT '土星相位', " +
+                    "highlights TEXT COMMENT '年度亮点(JSON数组)', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_member_year (member_id, year), " +
+                    "INDEX idx_member_id (member_id), " +
+                    "INDEX idx_year (year)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员年度能量表'");
+            log.info("已创建 tianpan_member_annual_energy 表");
+        } catch (Exception e) {
+            log.warn("创建 tianpan_member_annual_energy 表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移 81: family_member_attributes 添加西方星座字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_member_attributes ADD COLUMN western_zodiac VARCHAR(20) COMMENT '西方星座代码'");
+            log.info("已添加 western_zodiac 列到 family_member_attributes 表");
+        } catch (Exception e) {
+            log.warn("添加 western_zodiac 列可能已存在: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE family_member_attributes ADD COLUMN western_zodiac_name VARCHAR(50) COMMENT '西方星座名称'");
+            log.info("已添加 western_zodiac_name 列到 family_member_attributes 表");
+        } catch (Exception e) {
+            log.warn("添加 western_zodiac_name 列可能已存在: {}", e.getMessage());
+        }
     }
 
     private void insertSysConfigSeed(String key, String value, String desc) {
@@ -5109,5 +5175,9 @@ try {
         } catch (Exception e) {
             log.warn("创建pending_refund表失败: {}", e.getMessage());
         }
+
+        // 迁移78: product_orders添加consignee_id字段(OrderTimeoutCancelTask查询需要)
+        ensureColumn("product_orders", "consignee_id", "BIGINT COMMENT '收货人ID'");
+
         }
 }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java

@@ -92,4 +92,16 @@ public class AdminCommissionController {
         summary.put("totalPendingAmount", pendingRefundService.getTotalPendingAmount());
         return Result.success(summary);
     }
+
+    @PostMapping("/refunds/cancel")
+    public Result<String> cancelRefund(@RequestBody Map<String, Object> params) {
+        Long refundId = params.get("refundId") != null
+                ? Long.valueOf(params.get("refundId").toString())
+                : null;
+        if (refundId == null) {
+            return Result.error("refundId不能为空");
+        }
+        pendingRefundService.cancelPendingRefund(refundId);
+        return Result.success("已中止退款");
+    }
 }

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PendingRefund.java

@@ -25,6 +25,12 @@ public class PendingRefund implements Serializable {
     /** 提现申请 ID */
     private Long withdrawalRequestId;
 
+    /** 订单类型:withdrawal(提现) / product(商品订单) */
+    private String orderType;
+
+    /** 订单编号(商品订单号) */
+    private String orderNo;
+
     /** 退款金额 (分) */
     private Integer amount;
 

+ 27 - 15
cfc-backend/src/main/java/com/etotem/cfc/service/PendingRefundService.java

@@ -36,13 +36,20 @@ public class PendingRefundService {
      */
     @Transactional
     public PendingRefund createPendingRefund(Long userId, Long withdrawalRequestId, Integer amount) {
+        return createPendingRefund(userId, withdrawalRequestId, amount, "withdrawal", null);
+    }
+
+    @Transactional
+    public PendingRefund createPendingRefund(Long userId, Long withdrawalRequestId, Integer amount, String orderType, String orderNo) {
         PendingRefund pendingRefund = new PendingRefund();
         pendingRefund.setUserId(userId);
         pendingRefund.setWithdrawalRequestId(withdrawalRequestId);
+        pendingRefund.setOrderType(orderType);
+        pendingRefund.setOrderNo(orderNo);
         pendingRefund.setAmount(amount);
         pendingRefund.setAttemptCount(0);
-        pendingRefund.setMaxAttempts(10); // 最多重试 10 次
-        pendingRefund.setNextRetryTime(new Date()); // 立即重试
+        pendingRefund.setMaxAttempts(999);
+        pendingRefund.setNextRetryTime(new Date());
         pendingRefund.setStatus("pending");
         pendingRefund.setCreatedAt(new Date());
         pendingRefund.setUpdatedAt(new Date());
@@ -106,19 +113,24 @@ public class PendingRefundService {
         if (refund != null) {
             refund.setAttemptCount(refund.getAttemptCount() + 1);
             refund.setFailureReason(reason);
-            
-            // 检查是否超过最大重试次数
-            if (refund.getAttemptCount() >= refund.getMaxAttempts()) {
-                refund.setStatus("failed");
-                refund.setRemark("超过最大重试次数:" + refund.getMaxAttempts());
-            } else {
-                refund.setStatus("pending");
-                // 指数退避策略:第 1 次 5 分钟,第 2 次 10 分钟,第 3 次 20 分钟...
-                Calendar cal = Calendar.getInstance();
-                cal.add(Calendar.MINUTE, retryDelayMinutes);
-                refund.setNextRetryTime(cal.getTime());
-            }
-            
+            refund.setStatus("pending");
+            Calendar cal = Calendar.getInstance();
+            cal.add(Calendar.MINUTE, retryDelayMinutes);
+            refund.setNextRetryTime(cal.getTime());
+            refund.setUpdatedAt(new Date());
+            pendingRefundMapper.updateById(refund);
+        }
+    }
+
+    /**
+     * 中止退款,回到待处理状态
+     */
+    @Transactional
+    public void cancelPendingRefund(Long refundId) {
+        PendingRefund refund = pendingRefundMapper.selectById(refundId);
+        if (refund != null) {
+            refund.setStatus("cancelled");
+            refund.setRemark("管理员中止退款");
             refund.setUpdatedAt(new Date());
             pendingRefundMapper.updateById(refund);
         }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -76,6 +76,9 @@ public class ProductOrderService {
     @Resource
     private AssessmentQuotaService assessmentQuotaService;
 
+    @Resource
+    private PendingRefundService pendingRefundService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
         if (buyerId == null) {
             return Result.error("请先登录");
@@ -408,6 +411,14 @@ public class ProductOrderService {
         try {
             paymentService.refund(orderNo, order.getTotalAmount(), order.getRefundReason());
         } catch (Exception e) {
+            if (e.getMessage() != null && e.getMessage().contains("NOT_ENOUGH")) {
+                pendingRefundService.createPendingRefund(order.getBuyerId(), null, order.getTotalAmount(), "product", orderNo);
+                order.setStatus("refunding");
+                order.setUpdatedAt(new Date());
+                orderMapper.updateById(order);
+                log.info("微信退款余额不足,已加入待退款队列: orderNo={}", orderNo);
+                return Result.success("余额不足,退款已加入等待队列,到账后将自动退款");
+            }
             log.error("微信退款失败: orderNo={}, error={}", orderNo, e.getMessage());
             return Result.error("退款失败: " + e.getMessage());
         }

+ 44 - 6
cfc-backend/src/main/java/com/etotem/cfc/task/RefundScheduledTasks.java

@@ -2,11 +2,14 @@ package com.etotem.cfc.task;
 
 import com.etotem.cfc.entity.CommissionRecord;
 import com.etotem.cfc.entity.PendingRefund;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CommissionRecordMapper;
 import com.etotem.cfc.mapper.PendingRefundMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.CommissionService;
+import com.etotem.cfc.service.PaymentService;
 import com.etotem.cfc.service.PendingRefundService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
@@ -38,6 +41,12 @@ public class RefundScheduledTasks {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private PaymentService paymentService;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
     /**
      * 每 5 分钟执行一次
      * 处理待退款队列
@@ -80,20 +89,25 @@ public class RefundScheduledTasks {
      * 处理单个退款记录
      */
     private void processSingleRefund(PendingRefund pendingRefund) {
+        if ("product".equals(pendingRefund.getOrderType())) {
+            processProductRefund(pendingRefund);
+        } else {
+            processWithdrawalRefund(pendingRefund);
+        }
+    }
+
+    private void processWithdrawalRefund(PendingRefund pendingRefund) {
         Long userId = pendingRefund.getUserId();
         Integer amount = pendingRefund.getAmount();
 
-        // 检查用户当前可提现余额
         Integer availableAmount = commissionService.getAvailableAmount(userId);
         
         if (availableAmount < amount) {
             throw new RuntimeException("余额不足,当前可提现:" + availableAmount + ",需要:" + amount);
         }
 
-        // 标记为处理中
         pendingRefundService.markProcessing(pendingRefund.getId());
 
-        // 将该用户所有已结算的佣金记录标记为已提现
         List<CommissionRecord> settledRecords = commissionRecordMapper.selectList(
                 new LambdaQueryWrapper<CommissionRecord>()
                         .eq(CommissionRecord::getReferrerId, userId)
@@ -111,7 +125,6 @@ public class RefundScheduledTasks {
             int recordAmount = record.getCommissionAmount() != null ? record.getCommissionAmount() : 0;
             int refundAmount = Math.min(recordAmount, remainingAmount);
 
-            // 标记佣金记录为已提现
             record.setStatus("withdrawn");
             record.setWithdrawnAt(new Date());
             commissionRecordMapper.updateById(record);
@@ -120,13 +133,38 @@ public class RefundScheduledTasks {
             actualRefundedAmount += refundAmount;
         }
 
-        // 更新退款记录为成功
         pendingRefundService.markSuccess(pendingRefund.getId(), actualRefundedAmount);
 
-        log.info("退款处理成功:userId={}, amount={}, actualRefunded={}", 
+        log.info("提现退款处理成功:userId={}, amount={}, actualRefunded={}", 
                 userId, amount, actualRefundedAmount);
     }
 
+    private void processProductRefund(PendingRefund pendingRefund) {
+        Long userId = pendingRefund.getUserId();
+        String orderNo = pendingRefund.getOrderNo();
+        Integer amount = pendingRefund.getAmount();
+
+        pendingRefundService.markProcessing(pendingRefund.getId());
+
+        paymentService.refund(orderNo, amount, "商品退款(待退款队列)");
+
+        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(ProductOrder::getOrderNo, orderNo);
+        ProductOrder order = productOrderMapper.selectOne(wrapper);
+        if (order != null) {
+            order.setRefundStatus(2);
+            order.setRefundAmount(amount);
+            order.setRefundTime(new Date());
+            order.setStatus("refunded");
+            order.setUpdatedAt(new Date());
+            productOrderMapper.updateById(order);
+        }
+
+        pendingRefundService.markSuccess(pendingRefund.getId(), amount);
+
+        log.info("商品退款处理成功:orderNo={}, userId={}, amount={}", orderNo, userId, amount);
+    }
+
     /**
      * 处理余额不足的情况
      */

+ 25 - 0
cfc-web/src/api/admin.js

@@ -1340,6 +1340,31 @@ export function updatePromotionTierConfig(data) {
   })
 }
 
+// ========== 待退款管理 API ==========
+
+export function getPendingRefundList(params) {
+  return request({
+    url: '/api/admin/commission/refunds/pending',
+    method: 'post',
+    data: params
+  })
+}
+
+export function getPendingRefundSummary() {
+  return request({
+    url: '/api/admin/commission/refunds/summary',
+    method: 'post'
+  })
+}
+
+export function cancelPendingRefund(params) {
+  return request({
+    url: '/api/admin/commission/refunds/cancel',
+    method: 'post',
+    data: params
+  })
+}
+
 // ========== 佣金配置 API ==========
 
 export function getCommissionConfig() {

+ 214 - 0
cfc-web/src/views/admin/PendingRefund.vue

@@ -0,0 +1,214 @@
+<template>
+  <div class="pending-refund admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">待退款列表</h2>
+      <div class="flex items-center gap-sm">
+        <el-select v-model="statusFilter" placeholder="退款状态" @change="loadList">
+          <el-option label="全部状态" value="" />
+          <el-option label="待处理" value="pending" />
+          <el-option label="处理中" value="processing" />
+          <el-option label="已成功" value="success" />
+          <el-option label="已失败" value="failed" />
+        </el-select>
+        <el-select v-model="typeFilter" placeholder="订单类型" @change="loadList">
+          <el-option label="全部类型" value="" />
+          <el-option label="提现退款" value="withdrawal" />
+          <el-option label="商品退款" value="product" />
+        </el-select>
+      </div>
+    </div>
+
+    <div v-if="summary.totalPendingAmount > 0" class="summary-bar">
+      <el-alert
+        :title="'待退款总金额:¥' + (summary.totalPendingAmount / 100).toFixed(2) + '(' + summary.pendingCount + ' 笔待处理)'"
+        type="warning"
+        :closable="false"
+        show-icon
+      />
+    </div>
+
+    <div class="table-scroll-wrap">
+      <el-table :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column label="类型" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.orderType === 'product' ? 'primary' : 'warning'" size="small">
+              {{ row.orderType === 'product' ? '商品退款' : '提现退款' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="订单号" width="180">
+          <template slot-scope="{ row }">
+            {{ row.orderNo || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="提现申请ID" width="100">
+          <template slot-scope="{ row }">
+            {{ row.withdrawalRequestId || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="用户ID" width="80">
+          <template slot-scope="{ row }">
+            {{ row.userId }}
+          </template>
+        </el-table-column>
+        <el-table-column label="退款金额" width="120">
+          <template slot-scope="{ row }">
+            <span class="amount">{{ formatPriceWithSymbol(row.amount) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="实退金额" width="120">
+          <template slot-scope="{ row }">
+            <span class="amount">{{ row.refundedAmount ? formatPriceWithSymbol(row.refundedAmount) : '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="尝试次数" width="80">
+          <template slot-scope="{ row }">
+            {{ row.attemptCount }}/{{ row.maxAttempts }}
+          </template>
+        </el-table-column>
+        <el-table-column label="失败原因" min-width="200" show-overflow-tooltip>
+          <template slot-scope="{ row }">
+            {{ row.failureReason || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" width="160">
+          <template slot-scope="{ row }">
+            {{ formatTime(row.createdAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="退款时间" width="160">
+          <template slot-scope="{ row }">
+            {{ row.refundedAt ? formatTime(row.refundedAt) : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button v-if="row.status === 'pending' || row.status === 'processing'" type="danger" size="mini" @click="handleCancel(row)">
+              中止退款
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <div class="pagination-wrap">
+      <el-pagination
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        :page-size="pageSize"
+        :current-page.sync="currentPage"
+        @current-change="handlePageChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import { getPendingRefundList, getPendingRefundSummary, cancelPendingRefund } from '@/api/admin'
+
+export default {
+  name: 'PendingRefund',
+  data() {
+    return {
+      list: [],
+      loading: false,
+      statusFilter: '',
+      typeFilter: '',
+      currentPage: 1,
+      pageSize: 20,
+      total: 0,
+      summary: {
+        totalPendingAmount: 0,
+        pendingCount: 0
+      }
+    }
+  },
+  created() {
+    this.loadSummary()
+    this.loadList()
+  },
+  methods: {
+    async loadSummary() {
+      try {
+        const res = await getPendingRefundSummary()
+        if (res.code === 200) {
+          this.summary = res.data || { totalPendingAmount: 0 }
+        }
+      } catch (e) {
+        console.error('加载待退款摘要失败', e)
+      }
+    },
+    async loadList() {
+      this.loading = true
+      try {
+        const res = await getPendingRefundList({
+          status: this.statusFilter || undefined,
+          page: this.currentPage,
+          size: this.pageSize
+        })
+        if (res.code === 200) {
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) {
+        console.error('加载待退款列表失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handlePageChange(page) {
+      this.currentPage = page
+      this.loadList()
+    },
+    statusType(status) {
+      const map = { pending: 'warning', processing: 'primary', success: 'success', failed: 'danger', cancelled: 'info' }
+      return map[status] || 'info'
+    },
+    statusLabel(status) {
+      const map = { pending: '待处理', processing: '处理中', success: '已成功', failed: '已失败', cancelled: '已中止' }
+      return map[status] || status
+    },
+    async handleCancel(row) {
+      try {
+        await this.$confirm('确认中止该退款?订单将回到退款审核状态。', '确认中止', { type: 'warning' })
+        const res = await cancelPendingRefund({ refundId: row.id })
+        if (res.code === 200) {
+          this.$message.success('已中止退款')
+          this.loadList()
+          this.loadSummary()
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) {
+        if (e !== 'cancel') {
+          console.error('中止退款失败', e)
+        }
+      }
+    },
+    formatTime(time) {
+      if (!time) return '-'
+      const d = new Date(time)
+      const pad = n => n < 10 ? '0' + n : n
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
+        ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+    },
+    formatPriceWithSymbol(amount) {
+      if (amount == null) return '-'
+      return '¥' + (amount / 100).toFixed(2)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.summary-bar {
+  margin-bottom: 16px;
+}
+</style>