Przeglądaj źródła

feat(coupon): 退款归还券并退还CF值 + 下单即占用券 + 小程序端修复

后端:
- 迁移384: family_platform_exchange_record 加 family_coupon_id,绑定CF兑换与券实例
- 新增 refundCouponCf(): CF兑换券退回按原消耗退还家庭池,平台赠送券不退,幂等+自洽校验
- ProductOrderService.releaseCouponForCancelledOrder(): 取消/退款/延迟退款三处统一释放券+退CF
- CouponService.reserve(): 下单即置 RESERVED 占用券,防pending期间被其他订单重复使用
- OrderTimeoutCancelTask 补齐超时归还券 + 恢复库存 + 入库流水(原只改状态)
- 修正微信退款金额: 改按 money_amount 实付退款(原退 total_amount 会超实收被拒)
- AfterSalesService.create 退款金额同步按实付计算

小程序:
- 修复结算页CF兑换后误把券模板ID当选中券,导致优惠静默失效(改为重拉列表)
- after-sales-apply 退款金额改实付,并展示优惠券/积分抵扣明细
- coupons.vue 从遗留积分兑换切换到CF兑换;wealth首页新增"我的优惠券"入口
- 死代码清理: productOrderRefundApply/getProductCoupons/loadExchangeRecords/重复availableCoupons
Sisyphus 5 dni temu
rodzic
commit
1f4a4c4e26

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

@@ -3100,6 +3100,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移384: water_record 添加 task_id 列(喝水打卡任务回写,与 health_meal_record.task_id 同款)
         ensureColumn("water_record", "task_id", "BIGINT NULL COMMENT '关联任务ID(喝水打卡任务回写)'");
+
+        // 迁移385: family_platform_exchange_record 添加 family_coupon_id 列(把 CF 兑换记录与发出的券实例绑定)
+        // 目的:订单取消/退款时,对用户主动选择使用的券实例能精确判断「是否为 CF 兑换而来」并退还 CF 值;
+        //      平台赠送券(JOIN/POPULATION/MANUAL)无对应兑换记录,退款时不退 CF。
+        ensureColumn("family_platform_exchange_record", "family_coupon_id",
+                "BIGINT DEFAULT NULL COMMENT '兑换发放的优惠券实例ID(family_coupon.id), 退款时据此退还CF值'");
     }
 
     private void migrateHealthDimensionsTable() {

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyPlatformExchangeRecord.java

@@ -21,9 +21,12 @@ public class FamilyPlatformExchangeRecord implements Serializable {
     /** 家庭ID */
     private Long familyId;
 
-    /** 关联优惠券ID */
+    /** 关联优惠券ID(券模板) */
     private Long couponId;
 
+    /** 兑换发放的优惠券实例ID(family_coupon.id),退款时据此判断该券是否由CF兑换而来 */
+    private Long familyCouponId;
+
     /** 优惠券名称 */
     private String couponName;
 

+ 9 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java

@@ -39,7 +39,14 @@ public class AfterSalesService {
         }
         request.setUserId(userId);
         request.setOrderNo(order.getOrderNo());
-        request.setRefundAmount(order.getTotalAmount());
+        // 退款金额按「用户实付现金」计算(money_amount 已扣除优惠券抵扣与积分抵扣),
+        // 与 ProductOrderService.approveRefund 实际向微信申请的退款金额保持一致,
+        // 避免前端展示金额与最终到账金额不一致。
+        Integer refundAmount = order.getMoneyAmount();
+        if (refundAmount == null || refundAmount < 0) {
+            refundAmount = order.getTotalAmount();
+        }
+        request.setRefundAmount(refundAmount);
         request.setStatus("pending");
         request.setCreatedAt(new Date());
         request.setUpdatedAt(new Date());
@@ -48,7 +55,7 @@ public class AfterSalesService {
         order.setStatus("refunding");
         order.setRefundStatus(1);
         order.setRefundReason(request.getReason());
-        order.setRefundAmount(order.getTotalAmount());
+        order.setRefundAmount(refundAmount);
         order.setUpdatedAt(new Date());
         productOrderMapper.updateById(order);
 

+ 52 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java

@@ -234,6 +234,20 @@ public class CouponService {
         }
     }
 
+    /**
+     * 下单时立即占用券实例(pending/timeout 期间拒绝其他订单使用),并关联订单号。
+     * 支付成功后 {@link #markUsed} 改为 USED;取消/超时后 {@link #revert} 回到 AVAILABLE。
+     */
+    @Transactional
+    public void reserve(Long userCouponId, Long orderId) {
+        FamilyCoupon fc = familyCouponMapper.selectById(userCouponId);
+        if (fc != null) {
+            fc.setStatus("RESERVED");
+            fc.setOrderId(orderId);
+            familyCouponMapper.updateById(fc);
+        }
+    }
+
     public List<Coupon> listAll() {
         return couponMapper.selectList(null);
     }
@@ -251,15 +265,51 @@ public class CouponService {
         return couponMapper.selectById(couponId);
     }
 
-    /** 单条发放到家庭(替代旧 issueToUser) */
+    /**
+     * 单条发放到家庭,返回券实例ID(family_coupon.id)。
+     * 返回值供 CF 兑换等需要回溯券来源的场景绑定使用。
+     */
     @Transactional
-    public void issueToFamily(Long couponId, Long familyId) {
+    public Long issueToFamily(Long couponId, Long familyId) {
         FamilyCoupon fc = new FamilyCoupon();
         fc.setCouponId(couponId);
         fc.setFamilyId(familyId);
         fc.setStatus("AVAILABLE");
         fc.setReceivedAt(new Date());
         familyCouponMapper.insert(fc);
+        return fc.getId();
+    }
+
+    /**
+     * 发放单张券到家庭并落流水,返回券实例ID(family_coupon.id)。
+     * 与 {@link #grantFamily} 的区别:固定数量 1 且返回券实例ID,供「CF 兑换」这类需要
+     * 在退款时把券实例反查回兑换记录、退还 CF 值的场景使用。
+     *
+     * @param familyId  家庭ID
+     * @param couponId  券模板ID
+     * @param grantType 发放类型(如 CF_EXCHANGE)
+     * @param source    触发来源
+     * @return 券实例ID;券模板不存在/已停用时返回 null
+     */
+    @Transactional
+    public Long grantFamilyOne(Long familyId, Long couponId, String grantType, String source) {
+        Coupon coupon = couponMapper.selectById(couponId);
+        if (coupon == null) {
+            return null;
+        }
+        if (!"ACTIVE".equals(coupon.getStatus())) {
+            return null;
+        }
+        Long familyCouponId = issueToFamily(couponId, familyId);
+        FamilyCouponGrantLog log = new FamilyCouponGrantLog();
+        log.setFamilyId(familyId);
+        log.setCouponId(couponId);
+        log.setGrantType(grantType);
+        log.setQuantity(1);
+        log.setSource(source);
+        log.setCreatedAt(new Date());
+        familyCouponGrantLogMapper.insert(log);
+        return familyCouponId;
     }
 
     /**

+ 63 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -3,9 +3,11 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.FamilyPlatformBalance;
 import com.etotem.cfc.entity.FamilyPlatformBalanceLog;
 import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.mapper.FamilyPlatformBalanceLogMapper;
 import com.etotem.cfc.mapper.FamilyPlatformBalanceMapper;
 import org.springframework.stereotype.Service;
@@ -21,6 +23,8 @@ import java.util.Calendar;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * 家庭平台积分池服务(CF值家庭维度)
@@ -29,6 +33,8 @@ import java.util.Map;
 @Service
 public class FamilyPlatformPointsService {
 
+    private static final Logger logger = LoggerFactory.getLogger(FamilyPlatformPointsService.class);
+
     private static final String TYPE_EARN = "earn";
     private static final String TYPE_SPEND = "spend";
     private static final String TYPE_FREEZE = "freeze";
@@ -51,6 +57,9 @@ public class FamilyPlatformPointsService {
     @Resource
     private CouponMapper couponMapper;
 
+    @Resource
+    private FamilyCouponMapper familyCouponMapper;
+
     @Resource
     private FamilyPlatformExchangeRecordMapper exchangeRecordMapper;
 
@@ -267,14 +276,18 @@ public class FamilyPlatformPointsService {
         spend(familyId, pointsPrice, "coupon_exchange", couponId,
               "兑换优惠券: " + (coupon.getName() != null ? coupon.getName() : "优惠券"));
 
-        // 5. 发券给家庭
-        couponService.grantFamily(familyId, couponId, "CF_EXCHANGE", null, "family:" + familyId);
+        // 5. 发券给家庭(记录券实例ID,退款时据此退还CF值)
+        Long familyCouponId = couponService.grantFamilyOne(familyId, couponId, "CF_EXCHANGE", "family:" + familyId);
+        if (familyCouponId == null) {
+            throw new RuntimeException("发券失败,兑换未完成");
+        }
 
         // 6. 落兑换记录
         FamilyPlatformExchangeRecord record = new FamilyPlatformExchangeRecord();
         record.setUserId(refUserId);
         record.setFamilyId(familyId);
         record.setCouponId(couponId);
+        record.setFamilyCouponId(familyCouponId);
         record.setCouponName(coupon.getName());
         record.setCfCost(pointsPrice);
         record.setStatus("completed");
@@ -284,6 +297,54 @@ public class FamilyPlatformPointsService {
         exchangeRecordMapper.insert(record);
     }
 
+    /**
+     * 优惠券退回时退还 CF 值:
+     * 券实例在 {@link #exchangeCouponByCf} 中有 completed 兑换记录 → 属「用户 CF 兑换而来」,
+     * 按原消耗 cfCost 退还到家庭池;无兑换记录(平台赠送:JOIN/POPULATION/MANUAL 等)→ 不退。
+     *
+     * 幂等:以 (refType=coupon_refund, refId=券实例ID) 去重,同一张券重复退只生效一次。
+     *
+     * @param familyCouponId 券实例ID(family_coupon.id)
+     * @return 实际退还的 CF 值;0 = 平台赠送券 / 记录缺失 / 已退还过
+     */
+    @Transactional
+    public int refundCouponCf(Long familyCouponId) {
+        if (familyCouponId == null) {
+            return 0;
+        }
+        // 幂等检查:同一券实例只退一次
+        Long dup = logMapper.selectCount(
+                new LambdaQueryWrapper<FamilyPlatformBalanceLog>()
+                        .eq(FamilyPlatformBalanceLog::getRefType, "coupon_refund")
+                        .eq(FamilyPlatformBalanceLog::getRefId, familyCouponId));
+        if (dup != null && dup > 0) {
+            return 0;
+        }
+        FamilyPlatformExchangeRecord rec = exchangeRecordMapper.selectOne(
+                new LambdaQueryWrapper<FamilyPlatformExchangeRecord>()
+                        .eq(FamilyPlatformExchangeRecord::getFamilyCouponId, familyCouponId)
+                        .eq(FamilyPlatformExchangeRecord::getStatus, "completed")
+                        .last("LIMIT 1"));
+        if (rec == null || rec.getCfCost() == null || rec.getCfCost() <= 0) {
+            // 平台赠送的优惠券:不退还CF值
+            return 0;
+        }
+        // 自洽校验:确认 family_coupon_id 指向的确是这套券模板发出的实例。
+        // product_orders.coupon_id 正常存券实例ID,但供应商临时券流程存的是券模板ID,
+        // 两者ID空间不同,兜底校验避免把模板ID误判成某张兑换券而错退 CF。
+        FamilyCoupon fc = familyCouponMapper.selectById(familyCouponId);
+        if (fc == null || rec.getCouponId() == null || !rec.getCouponId().equals(fc.getCouponId())) {
+            logger.warn("CF退还跳过:券实例与兑换记录不匹配: familyCouponId={}, recordCouponId={}",
+                    familyCouponId, rec.getCouponId());
+            return 0;
+        }
+        earn(rec.getFamilyId(), rec.getCfCost(), "coupon_refund", familyCouponId,
+                "优惠券退回返还CF值: " + (rec.getCouponName() != null ? rec.getCouponName() : ""));
+        logger.info("优惠券退回退还CF值: familyId={}, familyCouponId={}, cfCost={}",
+                rec.getFamilyId(), familyCouponId, rec.getCfCost());
+        return rec.getCfCost();
+    }
+
     /** 查询家庭CF兑换记录分页 */
     public Page<FamilyPlatformExchangeRecord> getExchangeRecords(Long familyId, Integer page, Integer size) {
         Page<FamilyPlatformExchangeRecord> pageParam = new Page<>(page, size);

+ 83 - 22
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -305,6 +305,14 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
+        // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
+        if (appliedCouponId != null) {
+            try {
+                couponService.reserve(appliedCouponId, order.getId());
+            } catch (Exception e) {
+                log.error("占用优惠券失败: orderNo={}, couponId={}, error={}", order.getOrderNo(), appliedCouponId, e.getMessage());
+            }
+        }
         // ===== 赠品校验与记录 =====
         Result<Void> giftResult = processGifts(order, dto, product);
         if (giftResult.getCode() != 200) {
@@ -642,6 +650,14 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
+        // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
+        if (appliedCouponId != null) {
+            try {
+                couponService.reserve(appliedCouponId, order.getId());
+            } catch (Exception e) {
+                log.error("占用优惠券失败: orderNo={}, couponId={}, error={}", order.getOrderNo(), appliedCouponId, e.getMessage());
+            }
+        }
         // ===== 赠品校验与记录(仅单商品下单支持赠品) =====
         if (dto.getGiftItemIds() != null && !dto.getGiftItemIds().isEmpty()) {
             if (dto.getItems().size() > 1) {
@@ -1004,14 +1020,8 @@ public class ProductOrderService {
         order.setStatus("cancelled");
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        // 取消订单:归还优惠券
-        if (order.getCouponId() != null) {
-            try {
-                couponService.revert(order.getCouponId());
-            } catch (Exception e) {
-                log.error("取消订单归还优惠券失败: orderNo={}, couponId={}, error={}", orderNo, order.getCouponId(), e.getMessage());
-            }
-        }
+        // 取消订单:归还优惠券;CF 兑换来的券同时退还 CF 值
+        releaseCouponForCancelledOrder(order.getCouponId());
         // Restore stock on cancel
         productService.increaseStock(order.getProductId(), order.getQuantity());
         // 记录退款入库流水
@@ -1020,6 +1030,40 @@ public class ProductOrderService {
         return Result.success("订单已取消");
     }
 
+    /**
+     * 订单取消/退款时的优惠券释放:先退还 CF 值(若该券为用户 CF 兑换而来),再归还券实例。
+     *
+     * 规则:
+     * <ul>
+     *   <li>券实例在 family_platform_exchange_record 有 completed 兑换记录 → 用户用 CF 值兑换而来,
+     *       按原始消耗 cfCost 退还到家庭池(退还幂等,同一券实例只退一次);</li>
+     *   <li>无兑换记录 → 平台赠送券(JOIN 开通会员 / POPULATION 新增成员 / MANUAL 手动发放 /
+     *       供应商临时券等),只归还券,不退还 CF 值。</li>
+     * </ul>
+     *
+     * 券归还与 CF 退还放在同一事务,避免「券已归还但 CF 未退」或相反的不一致。
+     * 与商品主流程解耦:优惠券释放失败只记日志,不阻断订单取消/退款主流程。
+     *
+     * @param userCouponId 券实例ID(family_coupon.id,即下单时保存的 product_orders.coupon_id)
+     */
+    @Transactional
+    public void releaseCouponForCancelledOrder(Long userCouponId) {
+        if (userCouponId == null) {
+            return;
+        }
+        try {
+            // 先退 CF:查询与 family_coupon 状态无关,顺序不影响判断结果
+            int cfRefunded = familyPlatformPointsService.refundCouponCf(userCouponId);
+            // 再归还券实例(USED → AVAILABLE)
+            couponService.revert(userCouponId);
+            if (cfRefunded > 0) {
+                log.info("取消/退款订单:优惠券已归还并退还CF值: familyCouponId={}, cfRefunded={}", userCouponId, cfRefunded);
+            }
+        } catch (Exception e) {
+            log.error("取消/退款订单:释放优惠券失败: familyCouponId={}, error={}", userCouponId, e.getMessage());
+        }
+    }
+
     public Result<String> ship(String orderNo, String trackingNumber, String expressCompany) {
         return ship(orderNo, trackingNumber, expressCompany, null);
     }
@@ -1215,23 +1259,38 @@ public class ProductOrderService {
         if (order.getRefundStatus() == null || order.getRefundStatus() != 1) {
             return Result.error("没有待处理的退款申请");
         }
-        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("refund_pending");
-                order.setRefundStatus(4);
-                order.setUpdatedAt(new Date());
-                orderMapper.updateById(order);
-                log.info("微信退款余额不足,已加入待退款队列: orderNo={}", orderNo);
-                return Result.success("⚠️ 商户余额不足,退款已加入等待队列,到账后将自动退款");
+        // 实际退款金额 = 用户实付现金(money_amount 已扣除优惠券抵扣与积分抵扣)。
+        // 微信侧按订单实收金额退款:退 total_amount 会超过实收金额被微信拒绝,
+        // 且会把已抵扣的优惠券/积分折算成现金再退一次,与「归还优惠券」形成双重获益。
+        // 历史订单 money_amount 为空时退回 total_amount。
+        Integer refundAmount = order.getMoneyAmount();
+        if (refundAmount == null || refundAmount < 0) {
+            refundAmount = order.getTotalAmount();
+        }
+        if (refundAmount != null && refundAmount > 0) {
+            try {
+                paymentService.refund(orderNo, refundAmount, order.getRefundReason());
+            } catch (Exception e) {
+                if (e.getMessage() != null && e.getMessage().contains("NOT_ENOUGH")) {
+                    pendingRefundService.createPendingRefund(order.getBuyerId(), null, refundAmount, "product", orderNo);
+                    order.setStatus("refund_pending");
+                    order.setRefundStatus(4);
+                    order.setUpdatedAt(new Date());
+                    orderMapper.updateById(order);
+                    log.info("微信退款余额不足,已加入待退款队列: orderNo={}", orderNo);
+                    // 注意:此处不归还优惠券/不退 CF —— 钱还没退出去。
+                    // 待 RefundScheduledTasks.processProductRefund 实际退款成功后再释放,避免"券退了钱没退"。
+                    return Result.success("⚠️ 商户余额不足,退款已加入等待队列,到账后将自动退款");
+                }
+                log.error("微信退款失败: orderNo={}, error={}", orderNo, e.getMessage());
+                return Result.error("退款失败: " + e.getMessage());
             }
-            log.error("微信退款失败: orderNo={}, error={}", orderNo, e.getMessage());
-            return Result.error("退款失败: " + e.getMessage());
+        } else {
+            // 优惠券/积分全额抵扣、用户未付现金:直接走完退款流程,无需调微信退款接口
+            log.info("订单实付金额为0(优惠券/积分全额抵扣),跳过微信退款: orderNo={}", orderNo);
         }
         order.setRefundStatus(2);
-        order.setRefundAmount(order.getTotalAmount());
+        order.setRefundAmount(refundAmount);
         order.setRefundTime(new Date());
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
@@ -1239,6 +1298,8 @@ public class ProductOrderService {
         productService.increaseStock(order.getProductId(), order.getQuantity());
         inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
                 "order", order.getId(), adminId, "退款:" + orderNo);
+        // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
+        releaseCouponForCancelledOrder(order.getCouponId());
         syncAfterSalesRequestStatus(orderNo, "approved", adminId);
         log.info("退款完成: orderNo={}, adminId={}", orderNo, adminId);
         return Result.success("退款已处理");

+ 43 - 2
cfc-backend/src/main/java/com/etotem/cfc/task/OrderTimeoutCancelTask.java

@@ -3,11 +3,15 @@ package com.etotem.cfc.task;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.ProductService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.Calendar;
@@ -16,6 +20,10 @@ import java.util.List;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.config.DatabaseInitializer;
 
+/**
+ * 超时未支付订单自动取消任务。
+ * 同时恢复库存并归还券(若该订单使用了优惠券,超时后券应回到 AVAILABLE 状态,避免占坑)。
+ */
 @Component
 public class OrderTimeoutCancelTask {
 
@@ -24,10 +32,20 @@ public class OrderTimeoutCancelTask {
     @Resource
     private ProductOrderMapper orderMapper;
 
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private InventoryService inventoryService;
+
+    @Resource
+    private CouponService couponService;
+
     @Value("${shop.order.payment-timeout-minutes:30}")
     private int paymentTimeoutMinutes;
 
     @Scheduled(fixedRate = 60000) // 每分钟执行一次
+    @Transactional(rollbackFor = Exception.class)
     public void cancelTimeoutOrders() {
         if (!DatabaseInitializer.isMigrationsComplete()) {
             log.info("数据库迁移尚未完成,跳过本轮订单超时检查");
@@ -49,14 +67,37 @@ public class OrderTimeoutCancelTask {
             return;
         }
 
-        // 取消每个超时订单
+        // 取消每个超时订单:释放优惠券 + 恢复库存
         for (ProductOrder order : timeoutOrders) {
             order.setStatus("cancelled");
             order.setUpdatedAt(new Date());
             orderMapper.updateById(order);
+
+            // 归还优惠券(RESERVED → AVAILABLE);CF 兑换来的券同时退还 CF 值
+            if (order.getCouponId() != null) {
+                try {
+                    couponService.revert(order.getCouponId());
+                    log.info("订单 {} 超时取消:归还优惠券 instanceId={}", order.getOrderNo(), order.getCouponId());
+                } catch (Exception e) {
+                    log.error("订单 {} 超时取消:归还优惠券失败: couponId={}, error={}",
+                            order.getOrderNo(), order.getCouponId(), e.getMessage());
+                }
+            }
+
+            // 恢复库存(实物商品)
+            try {
+                productService.increaseStock(order.getProductId(), order.getQuantity());
+                inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                        "order", order.getId(), null, "超时取消:" + order.getOrderNo());
+                log.info("订单 {} 超时取消:恢复库存 done", order.getOrderNo());
+            } catch (Exception e) {
+                log.error("订单 {} 超时取消:恢复库存失败: productId={}, error={}",
+                        order.getOrderNo(), order.getProductId(), e.getMessage());
+            }
+
             log.info("订单 {} 超时未支付,已自动取消", order.getOrderNo());
         }
 
         log.info("本次共取消 {} 个超时订单", timeoutOrders.size());
     }
-}
+}

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

@@ -11,6 +11,7 @@ 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.etotem.cfc.service.ProductOrderService;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -46,6 +47,9 @@ public class RefundScheduledTasks {
     @Resource
     private PaymentService paymentService;
 
+    @Resource
+    private ProductOrderService productOrderService;
+
     @Resource
     private ProductOrderMapper productOrderMapper;
 
@@ -165,6 +169,8 @@ public class RefundScheduledTasks {
             order.setStatus("refunded");
             order.setUpdatedAt(new Date());
             productOrderMapper.updateById(order);
+            // 实际退款到账:归还优惠券;CF 兑换来的券同时退还 CF 值(管理员审批时因余额不足挂起的那一步在此补上)
+            productOrderService.releaseCouponForCancelledOrder(order.getCouponId());
         }
 
         pendingRefundService.markSuccess(pendingRefund.getId(), amount);

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

@@ -4983,6 +4983,7 @@ CREATE TABLE IF NOT EXISTS family_platform_exchange_record (
     user_id       BIGINT       NOT NULL COMMENT '操作用户ID',
     family_id     BIGINT       NOT NULL COMMENT '家庭ID',
     coupon_id     BIGINT       NOT NULL COMMENT '关联优惠券ID',
+    family_coupon_id BIGINT DEFAULT NULL COMMENT '兑换发放的优惠券实例ID(family_coupon.id),退款时据此退还CF值',
     coupon_name   VARCHAR(200) COMMENT '优惠券名称',
     cf_cost       INT          NOT NULL COMMENT '消耗CF值数量',
     status        VARCHAR(20)  NOT NULL DEFAULT 'completed' COMMENT 'completed/failed',

+ 5 - 5
cfc-frontend/pages/profile-extra/coupons.vue

@@ -28,7 +28,7 @@
         >
           <view class="card-left">
             <text class="card-value">{{ item.pointsPrice }}</text>
-            <text class="card-type-label">积分</text>
+            <text class="card-type-label">CF值</text>
           </view>
           <view class="card-right">
             <view class="card-header">
@@ -43,7 +43,7 @@
         <view v-if="!loading && exchangeableList.length === 0" class="empty-state">
           <text class="empty-icon">🎫</text>
           <text class="empty-text">暂无可用兑换的优惠券</text>
-          <text class="empty-hint">积分可以在商城中兑换优惠券</text>
+          <text class="empty-hint">CF值可以在商城中兑换优惠券</text>
         </view>
       </view>
       <view v-else-if="loading" class="loading-wrap">
@@ -89,7 +89,7 @@
 </template>
 
 <script>
-import { getMyCoupons, claimCoupon, getExchangeableCoupons, exchangeCoupon } from '../../utils/api.js'
+import { getMyCoupons, claimCoupon, getExchangeableCoupons, exchangeCouponByCf } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -206,10 +206,10 @@ export default {
       var self = this
       uni.showModal({
         title: '确认兑换',
-        content: '消耗 ' + item.pointsPrice + ' 积分兑换「' + item.name + '」?',
+        content: '消耗 ' + item.pointsPrice + ' CF值兑换「' + item.name + '」?',
         success: (r) => {
           if (r.confirm) {
-            exchangeCoupon({ couponId: item.id }).then(res => {
+            exchangeCouponByCf({ couponId: item.id }).then(res => {
               if (res.code === 200) {
                 uni.showToast({ title: '兑换成功', icon: 'success' })
                 self.loadExchangeable()

+ 29 - 1
cfc-frontend/pages/shop/after-sales-apply/after-sales-apply.vue

@@ -58,6 +58,17 @@
           <text class="info-label">退款金额</text>
           <text class="info-value price">{{ formatPrice(refundAmount) }}</text>
         </view>
+        <view class="info-row" v-if="order.discountAmount > 0">
+          <text class="info-label">优惠券抵扣</text>
+          <text class="info-value">-{{ formatPrice(order.discountAmount) }}</text>
+        </view>
+        <view class="info-row" v-if="order.pointsCost > 0">
+          <text class="info-label">积分抵扣</text>
+          <text class="info-value">-{{ formatPrice(order.pointsCost) }}</text>
+        </view>
+        <view class="refund-note" v-if="order.discountAmount > 0 || order.pointsCost > 0">
+          <text class="refund-note-text">退款金额为实际支付现金;已使用的优惠券与积分将一并退回,可再次使用</text>
+        </view>
       </view>
 
       <view class="submit-wrap">
@@ -123,7 +134,13 @@ export default {
         success: function(res) {
           if (res.data && res.data.code === 200) {
             that.order = res.data.data || {}
-            that.refundAmount = that.order.totalAmount || 0
+            // 退款金额按「用户实付现金」计算(money_amount 已扣除优惠券抵扣与积分抵扣),
+            // 与后端 approveRefund 实际向微信申请的退款金额一致,避免展示金额与到账金额不符
+            var paid = that.order.moneyAmount
+            if (paid === null || paid === undefined || paid < 0) {
+              paid = that.order.totalAmount
+            }
+            that.refundAmount = paid || 0
           }
         }
       })
@@ -295,6 +312,17 @@ export default {
   font-weight: bold;
   font-size: 30rpx;
 }
+.refund-note {
+  margin-top: 16rpx;
+  padding: 16rpx;
+  background: #fff7e6;
+  border-radius: 8rpx;
+}
+.refund-note-text {
+  font-size: 22rpx;
+  color: #B45309;
+  line-height: 1.5;
+}
 .submit-wrap {
   padding: 30rpx;
 }

+ 9 - 23
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -614,19 +614,6 @@ export default {
         return true
       })
     },
-    availableCoupons() {
-      var self = this
-      var currentProductId = (self.items && self.items.length > 0) ? self.items[0].productId : null
-      return this.coupons.filter(function(c) {
-        if (c.status && c.status !== 'AVAILABLE') return false
-        if (c.minSpend && c.minSpend > self.actualAmount) return false
-        if (c.applicableTo === 'PRODUCT' && c.productId != null) {
-          return c.productId === currentProductId
-        }
-        if (c.applicableTo && c.applicableTo !== 'ALL' && c.applicableTo !== 'PRODUCT') return false
-        return true
-      })
-    },
     affordableExchangeCoupons() {
       var self = this
       return (this.exchangeableCoupons || []).filter(function(c) {
@@ -746,20 +733,19 @@ export default {
       coupon.exchanging = true
       uni.showLoading({ title: '兑换中...' })
       try {
+        // 注意:此处 coupon.id 是「券模板ID」(来自 /coupon/checkout-list)
         var res = await exchangeCouponByCf({ couponId: coupon.id })
         uni.hideLoading()
         coupon.exchanging = false
         if (res.code === 200) {
           uni.showToast({ title: '兑换成功', icon: 'success' })
-          // 从可兑换列表移除,加入已拥有列表
-          var idx = this.exchangeableCoupons.findIndex(function(c) { return c.id === coupon.id })
-          if (idx >= 0) {
-            this.exchangeableCoupons.splice(idx, 1)
-          }
-          coupon.isExchangeable = false
-          coupon.status = 'AVAILABLE'
-          this.coupons.push(coupon)
-          // 刷新余额
+          // 兑换成功后有两点必须重新拉列表,不能在本地把 template 券挪进已拥有列表:
+          // 1. /coupon/list 里的 id 是「券实例ID(family_coupon.id)」,而下单 couponId 要传的就是它;
+          //    本地挪进去的 coupon.id 是券模板ID,直接选中会导致后端 familyCouponMapper.selectById 查不到、
+          //    优惠沉默不生效。重新拉列表才能拿到正确的券实例ID。
+          // 2. /coupon/checkout-list 由后端过滤「家庭未拥有」,刷新后才能把刚换的券移出可兑换区。
+          await Promise.all([this.loadCoupons(), this.loadExchangeableCoupons()])
+          // 刷新 CF 余额(刚扣掉一笔)
           this.loadCfBalance()
         } else {
           uni.showToast({ title: res.message || '兑换失败', icon: 'none' })
@@ -771,7 +757,7 @@ export default {
       }
     },
     openCouponPicker() {
-      if (this.availableCoupons.length === 0 && !this.selectedCoupon) {
+      if (this.ownedCoupons.length === 0 && !this.selectedCoupon) {
         uni.showToast({ title: '暂无可用优惠券', icon: 'none' })
         return
       }

+ 6 - 9
cfc-frontend/pages/wealth-cf/index.vue

@@ -96,7 +96,7 @@
 </template>
 
 <script>
-import { getFamilyPlatformBalance, getFamilyPlatformLogs, getExchangeableCoupons, exchangeCouponByCf } from '@/utils/api.js'
+import { getFamilyPlatformBalance, getFamilyPlatformLogs, exchangeCouponByCf } from '@/utils/api.js'
 
 export default {
   data() {
@@ -172,14 +172,6 @@ export default {
         this.loadLogs()
       }
     },
-    async loadExchangeRecords() {
-      try {
-        const res = await getFamilyPlatformLogs({ page: 1, size: 50, type: 'coupon_exchange' })
-        if (res.code === 200) {
-          this.exchangeRecords = (res.data && res.data.records) || []
-        }
-      } catch (e) { console.error(e) }
-    },
     refreshData() {
       this.page = 1
       this.loadBalance()
@@ -209,6 +201,11 @@ export default {
           uni.showToast({ title: '兑换成功', icon: 'success' })
           this.showExchangeModal = false
           this.loadBalance()
+          // 该券已归家庭所有,从可兑换列表移除(下次打开弹窗时后端也会过滤掉)
+          const idx = this.exchangeableCoupons.findIndex(function(c) { return c.id === coupon.id })
+          if (idx >= 0) {
+            this.exchangeableCoupons.splice(idx, 1)
+          }
         } else {
           uni.showToast({ title: res.message || '兑换失败', icon: 'none' })
         }

+ 4 - 0
cfc-frontend/pages/wealth/index.vue

@@ -345,6 +345,7 @@ export default {
         { icon: '📋', label: '我的订单', action: 'goToOrders' },
         { icon: '🔄', label: '售后服务', action: 'goToAfterSales' },
         { icon: '📊', label: '收益中心', action: 'goToIncome' },
+        { icon: '🎫', label: '我的优惠券', action: 'goToMyCoupons' },
         { icon: '💎', label: '家庭CF值', action: 'goToFamilyCf' }
       ],
       children: [],
@@ -796,6 +797,9 @@ export default {
     goToIncome() {
       uni.navigateTo({ url: '/pages/promotion/commission' })
     },
+    goToMyCoupons() {
+      uni.navigateTo({ url: '/pages/profile-extra/coupons' })
+    },
     goToFamilyCf() {
       uni.navigateTo({ url: '/pages/wealth-cf/index' })
     },

+ 0 - 9
cfc-frontend/utils/api.js

@@ -1736,11 +1736,6 @@ export const productOrderReviewUpdate = (params) => {
   return request('/api/product/order/review/update', 'POST', params)
 }
 
-// 用户申请订单退款
-export const productOrderRefundApply = (params) => {
-  return request('/api/product/order/refund/apply', 'POST', params)
-}
-
 // 管理员:商品待审核列表
 export const adminProductPending = (params) => {
   return request('/api/admin/product/pending', 'POST', params)
@@ -2621,10 +2616,6 @@ export const getCouponCheckoutList = (data) => {
   return request('/api/coupon/checkout-list', 'POST', data)
 }
 
-export const getProductCoupons = (data) => {
-  return request('/api/coupon/product', 'POST', data)
-}
-
 export const exchangeCoupon = (data) => {
   return request('/api/coupon/exchange', 'POST', data)
 }