Просмотр исходного кода

fix(activity): P0-2 取消报名支持 approved 并联动退款/名额回滚

- cancel 放宽到 pending/approved,approved 取消时查已支付订单调 PaymentService.refund 原路退款,成功才置 cancelled
- 退款金额按报名人 fee 解析(一单多人按单退),订单状态置 refunded
- 退款失败不置 cancelled,返回错误提示;名额回滚走 releaseQuota 原子减一
- ActivityRegistration 新增 cancelledAt/cancelReason,迁移391 + schema.sql 同步
E2E Test Bot 3 недель назад
Родитель
Сommit
ede98ee9c9

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

@@ -3165,6 +3165,10 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移390: product_orders 添加 special_deal_id 列(限时特价订单关联)
         ensureColumn("product_orders", "special_deal_id", "BIGINT NULL COMMENT '限时特价活动ID(非空=特价单)'");
+
+        // 迁移391: activity_registrations 添加取消时间/原因列(P0-2 取消报名支持 approved + 退款链路,取消时记录)
+        ensureColumn("activity_registrations", "cancelled_at", "DATETIME DEFAULT NULL COMMENT '取消报名时间'");
+        ensureColumn("activity_registrations", "cancel_reason", "VARCHAR(200) DEFAULT NULL COMMENT '取消原因'");
     }
 
     private void migrateHealthDimensionsTable() {

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityRegistration.java

@@ -21,6 +21,10 @@ public class ActivityRegistration implements Serializable {
     private String registrantPhone;
     private String status;
     private String rejectReason;
+    /** 取消时间(取消报名时记录) */
+    private LocalDateTime cancelledAt;
+    /** 取消原因 */
+    private String cancelReason;
     private LocalDateTime registeredAt;
     private LocalDateTime approvedAt;
     private Long approvedBy;

+ 46 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -35,6 +35,8 @@ public class ActivityRegistrationService {
     private ActivityMapper activityMapper;
     @Resource
     private ActivityOrderMapper orderMapper;
+    @Resource
+    private PaymentService paymentService;
 
     @Transactional
     public Result<Map<String, Object>> batchRegister(Long activityId, Long userId, List<Map<String, Object>> registrants) {
@@ -125,12 +127,30 @@ public class ActivityRegistrationService {
                 .last("LIMIT 1"));
         if (reg == null) return Result.error("报名记录不存在");
         String prevStatus = reg.getStatus();
-        reg.setStatus("cancelled"); reg.setUpdatedAt(LocalDateTime.now());
-        registrationMapper.updateById(reg);
-        // 已通过报名取消时回退名额(退款链路见 P0-2)
         if ("approved".equals(prevStatus)) {
+            // 付费活动取消:原路退款(金额取该报名人对应 fee;如一单多人则按单退款)
+            ActivityOrder order = orderMapper.selectOne(new LambdaQueryWrapper<ActivityOrder>()
+                    .eq(ActivityOrder::getActivityId, activityId)
+                    .eq(ActivityOrder::getUserId, userId)
+                    .eq(ActivityOrder::getStatus, "paid")
+                    .last("LIMIT 1"));
+            if (order != null) {
+                int refundFee = resolveRegistrantFee(order, memberId);
+                try {
+                    paymentService.refund(order.getOrderNo(), refundFee > 0 ? refundFee : order.getTotalAmount(), "取消活动报名");
+                } catch (Exception e) {
+                    log.error("活动报名退款失败: orderNo={}, memberId={}", order.getOrderNo(), memberId, e);
+                    return Result.error("退款处理失败,请联系客服");
+                }
+                order.setStatus("refunded");
+                orderMapper.updateById(order);
+            }
             releaseQuota(activityId);
         }
+        reg.setStatus("cancelled");
+        reg.setCancelledAt(LocalDateTime.now());
+        reg.setUpdatedAt(LocalDateTime.now());
+        registrationMapper.updateById(reg);
         return Result.success(null);
     }
 
@@ -419,6 +439,29 @@ public class ActivityRegistrationService {
             .eq(ActivityRegistration::getStatus, "approved").last("LIMIT 1")) != null;
     }
 
+    private int resolveRegistrantFee(ActivityOrder order, Long memberId) {
+        if (order.getRegistrantsJson() == null) return 0;
+        try {
+            com.alibaba.fastjson.TypeReference<java.util.List<java.util.Map<String, Object>>> typeRef
+                    = new com.alibaba.fastjson.TypeReference<java.util.List<java.util.Map<String, Object>>>() {};
+            java.util.List<java.util.Map<String, Object>> list
+                    = com.alibaba.fastjson.JSON.parseObject(order.getRegistrantsJson(), typeRef);
+            if (list != null) {
+                for (java.util.Map<String, Object> item : list) {
+                    Object mid = item.get("memberId");
+                    if (mid != null && memberId != null
+                            && String.valueOf(mid).equals(String.valueOf(memberId))) {
+                        Object fee = item.get("fee");
+                        if (fee instanceof Number) return ((Number) fee).intValue();
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析报名人 fee 失败: orderNo={}, memberId={}", order.getOrderNo(), memberId, e);
+        }
+        return 0;
+    }
+
     private ActivityRegistration findRegistration(Long activityId, Long memberId, Long userId, String status) {
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId)

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -433,6 +433,10 @@ public class PaymentService implements PaymentServiceInterface {
     }
 
     public void refund(String orderNo, Integer amount, String reason) {
+        if (testMode) {
+            log.info("【测试模式】跳过微信退款请求: orderNo={}, amount={}, reason={}", orderNo, amount, reason);
+            return;
+        }
         try {
             PackageOrder order = getOrderStatus(orderNo);
             Integer totalFee = order != null ? order.getPrice() : amount;

+ 2 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2091,6 +2091,8 @@ CREATE TABLE IF NOT EXISTS activity_registrations (
     registrant_phone VARCHAR(20) COMMENT '报名人联系方式',
     status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/approved/rejected/cancelled',
     reject_reason VARCHAR(500) COMMENT '驳回原因',
+    cancelled_at DATETIME DEFAULT NULL COMMENT '取消报名时间',
+    cancel_reason VARCHAR(200) DEFAULT NULL COMMENT '取消原因',
     registered_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '报名时间',
     approved_at DATETIME COMMENT '审核通过时间',
     approved_by BIGINT COMMENT '审核人用户ID',