Jelajahi Sumber

feat: 完善健康启航计划(199会员打卡21天)三大核心功能

1. 支付对接完善 (VirtualPayService, PurchaseController, PurchaseServiceImpl)
   - 新增 generatePurchasePayParams() 虚拟支付下单参数生成
   - 新增 HQ 前缀订单发货/退款/iOS退款问询回调处理
   - 新增 /api/purchase/pay-params 接口获取支付参数
   - 支付成功自动发放 300 CF 基础奖励

2. ChallengeService 补充 streakDays 返回
   - getChallengeProgress() 新增返回 streakDays 字段
   - 前端闯关进度页可直接展示连续打卡天数

3. 打卡自动同步闯关进度机制
   - 新增 ChallengeService.syncStreakProgress() 方法
   - 集成到 DailyCheckinService/EmotionCheckinService/FinanceCheckinService
   - 打卡完成自动更新 streak_3days/streak_7days/streak_21days 任务进度
Sisyphus 3 minggu lalu
induk
melakukan
7f51c33350

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PurchaseController.java

@@ -1,8 +1,12 @@
 package com.etotem.cfc.controller;
 package com.etotem.cfc.controller;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.ChallengeService;
 import com.etotem.cfc.service.ChallengeService;
+import com.etotem.cfc.service.VirtualPayService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
@@ -15,6 +19,8 @@ public class PurchaseController {
 
 
     @Autowired private PurchaseService purchaseService;
     @Autowired private PurchaseService purchaseService;
     @Autowired private ChallengeService challengeService;
     @Autowired private ChallengeService challengeService;
+    @Autowired private VirtualPayService virtualPayService;
+    @Autowired private UserMapper userMapper;
 
 
     @PostMapping("/check")
     @PostMapping("/check")
     public Result<Map<String, Object>> checkPurchase(HttpServletRequest request) {
     public Result<Map<String, Object>> checkPurchase(HttpServletRequest request) {
@@ -31,6 +37,29 @@ public class PurchaseController {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    @PostMapping("/pay-params")
+    public Result<Object> getPayParams(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        String orderNo = (String) params.get("orderNo");
+        if (orderNo == null || orderNo.isEmpty()) {
+            return Result.error("缺少orderNo");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error(400, "会话失效,请重新登录");
+        }
+
+        VirtualPayParamsDTO dto = virtualPayService.generatePurchasePayParams(orderNo, user.getSessionKey());
+        if (dto == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(dto);
+    }
+
     @PostMapping("/confirm")
     @PostMapping("/confirm")
     public Result<Map<String, Object>> confirmPayment(@RequestBody Map<String, Object> params, HttpServletRequest request) {
     public Result<Map<String, Object>> confirmPayment(@RequestBody Map<String, Object> params, HttpServletRequest request) {
         String orderNo = (String) params.get("orderNo");
         String orderNo = (String) params.get("orderNo");

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeService.java

@@ -11,4 +11,7 @@ public interface ChallengeService {
     
     
     /** 更新任务进度 */
     /** 更新任务进度 */
     void updateTaskProgress(Long memberId, String taskKey, int currentValue);
     void updateTaskProgress(Long memberId, String taskKey, int currentValue);
+    
+    /** 同步连续打卡进度(打卡完成后调用) */
+    void syncStreakProgress(Long memberId);
 }
 }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java

@@ -26,6 +26,9 @@ public class DailyCheckinService {
     @Resource
     @Resource
     private EmotionCheckinMapper emotionCheckinMapper;
     private EmotionCheckinMapper emotionCheckinMapper;
 
 
+    @Resource
+    private ChallengeService challengeService;
+
     public List<DailyHealthCheckin> getCheckinsByMember(Long memberId) {
     public List<DailyHealthCheckin> getCheckinsByMember(Long memberId) {
         return dailyCheckinMapper.selectList(new LambdaQueryWrapper<DailyHealthCheckin>()
         return dailyCheckinMapper.selectList(new LambdaQueryWrapper<DailyHealthCheckin>()
                 .eq(DailyHealthCheckin::getMemberId, memberId)
                 .eq(DailyHealthCheckin::getMemberId, memberId)
@@ -58,6 +61,13 @@ public class DailyCheckinService {
             log.warn("心情打卡同步情绪日记失败,不影响主流程: {}", e.getMessage());
             log.warn("心情打卡同步情绪日记失败,不影响主流程: {}", e.getMessage());
         }
         }
 
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            challengeService.syncStreakProgress(checkin.getMemberId());
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return checkin;
         return checkin;
     }
     }
 
 

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java

@@ -40,6 +40,9 @@ public class EmotionCheckinService {
     @Resource
     @Resource
     private AIService aiService;
     private AIService aiService;
 
 
+    @Resource
+    private ChallengeService challengeService;
+
     @Transactional
     @Transactional
     public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
     public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
         FamilyMember child = familyMemberMapper.selectById(dto.getMemberId());
         FamilyMember child = familyMemberMapper.selectById(dto.getMemberId());
@@ -77,6 +80,13 @@ public class EmotionCheckinService {
             log.error("情绪风险检测失败: checkinId={}", checkin.getId(), e);
             log.error("情绪风险检测失败: checkinId={}", checkin.getId(), e);
         }
         }
 
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            challengeService.syncStreakProgress(dto.getMemberId());
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return toVO(checkin, child.getNickname(), false);
         return toVO(checkin, child.getNickname(), false);
     }
     }
 
 

+ 16 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java

@@ -36,6 +36,9 @@ public class FinanceCheckinService {
     @Resource
     @Resource
     private SysConfigService sysConfigService;
     private SysConfigService sysConfigService;
 
 
+    @Resource
+    private ChallengeService challengeService;
+
     public List<FinanceCheckin> getCheckins(Long userId, Long memberId, String yearMonth) {
     public List<FinanceCheckin> getCheckins(Long userId, Long memberId, String yearMonth) {
         LambdaQueryWrapper<FinanceCheckin> wrapper = new LambdaQueryWrapper<FinanceCheckin>()
         LambdaQueryWrapper<FinanceCheckin> wrapper = new LambdaQueryWrapper<FinanceCheckin>()
                 .eq(FinanceCheckin::getUserId, userId);
                 .eq(FinanceCheckin::getUserId, userId);
@@ -51,7 +54,7 @@ public class FinanceCheckinService {
         return financeCheckinMapper.selectById(id);
         return financeCheckinMapper.selectById(id);
     }
     }
 
 
-    @Transactional
+@Transactional
     public FinanceCheckin createCheckin(FinanceCheckin checkin) {
     public FinanceCheckin createCheckin(FinanceCheckin checkin) {
         checkin.setCreatedAt(new Date());
         checkin.setCreatedAt(new Date());
         if (checkin.getEarnedEnergy() == null) {
         if (checkin.getEarnedEnergy() == null) {
@@ -86,8 +89,8 @@ public class FinanceCheckinService {
                     }
                     }
                     task.setCreatorId(checkin.getUserId());
                     task.setCreatorId(checkin.getUserId());
                     task.setExecutorType("child");
                     task.setExecutorType("child");
-task.setExecutorId(memberId);
-        task.setChildId(memberId);
+                    task.setExecutorId(memberId);
+                    task.setChildId(memberId);
                     task.setTitle("理财打卡");
                     task.setTitle("理财打卡");
                     task.setDescription("自动生成 - 理财打卡签到");
                     task.setDescription("自动生成 - 理财打卡签到");
                     task.setPoints(points);
                     task.setPoints(points);
@@ -150,6 +153,16 @@ task.setExecutorId(memberId);
             log.warn("理财打卡能量发放失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
             log.warn("理财打卡能量发放失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
         }
         }
 
 
+        // 同步闯关连续打卡进度(健康启航计划)
+        try {
+            Long memberId = checkin.getChildId();
+            if (memberId != null) {
+                challengeService.syncStreakProgress(memberId);
+            }
+        } catch (Exception e) {
+            log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
+        }
+
         return checkin;
         return checkin;
     }
     }
 
 

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

@@ -1,5 +1,6 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
+import com.etotem.cfc.entity.PurchaseOrder;
 import java.util.Map;
 import java.util.Map;
 
 
 public interface PurchaseService {
 public interface PurchaseService {
@@ -17,4 +18,7 @@ public interface PurchaseService {
     
     
     /** 处理续费(真实支付) */
     /** 处理续费(真实支付) */
     Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount);
     Map<String, Object> processRenewal(Long userId, Long familyId, Integer amount);
+    
+    /** 按订单号查询订单 */
+    PurchaseOrder getByOrderNo(String orderNo);
 }
 }

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -7,7 +7,9 @@ import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.entity.PurchaseOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
+import com.etotem.cfc.mapper.PurchaseOrderMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpEntity;
@@ -51,6 +53,12 @@ public class VirtualPayService {
     @Resource
     @Resource
     private MemberSubscriptionService memberSubscriptionService;
     private MemberSubscriptionService memberSubscriptionService;
 
 
+    @Resource
+    private PurchaseService purchaseService;
+
+    @Resource
+    private PurchaseOrderMapper purchaseOrderMapper;
+
     @Resource
     @Resource
     private WechatService wechatService;   // 复用 getAccessToken()(带缓存)
     private WechatService wechatService;   // 复用 getAccessToken()(带缓存)
 
 
@@ -253,6 +261,40 @@ public class VirtualPayService {
         return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
         return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
     }
     }
 
 
+    /**
+     * 健康启航计划购买虚拟支付下单参数编排。
+     * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
+     */
+    public VirtualPayParamsDTO generatePurchasePayParams(String orderNo, String sessionKey) {
+        // 1. 查订单 + 校验 pending
+        PurchaseOrder order = purchaseService.getByOrderNo(orderNo);
+        if (order == null || !"pending".equals(order.getStatus())) {
+            log.warn("生成健康启航计划支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
+            return null;
+        }
+
+        // 2. 查 virtual_goods_config:goodsType=PURCHASE, bizKey=订单orderNo前缀(如HQ)
+        VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("PURCHASE", "HQ");
+        if (config == null) {
+            log.warn("健康启航计划道具未配置或停用");
+            return null;
+        }
+
+        // 3. 金额 = order.amount(服务端已算,不重算),校验 >= 1元
+        if (order.getAmount() == null || order.getAmount() < 100) {
+            log.warn("健康启航计划订单金额异常(<1元): orderNo={}, amount={}", orderNo, order.getAmount());
+            return null;
+        }
+
+        // 4. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
+        String attach = "purchase:" + order.getOrderNo();
+        VirtualPayParamsDTO dto = generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
+        dto.setOrderNo(orderNo);
+        dto.setTotalPeriods(1);
+        dto.setCurrentPeriod(1);
+        return dto;
+    }
+
     /**
     /**
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 按 OutTradeNo 前缀路由:A→测评,ORD→会员,SUB→订阅。
      * 按 OutTradeNo 前缀路由:A→测评,ORD→会员,SUB→订阅。
@@ -294,6 +336,16 @@ public class VirtualPayService {
                 } else {
                 } else {
                     log.warn("虚拟支付发货推送订阅订单不存在: outTradeNo={}", outTradeNo);
                     log.warn("虚拟支付发货推送订阅订单不存在: outTradeNo={}", outTradeNo);
                 }
                 }
+            } else if (outTradeNo.startsWith("HQ")) {
+                // 健康启航计划购买订单:复用 confirmPayment 逻辑(需幂等)
+                PurchaseOrder purchaseOrder = purchaseService.getByOrderNo(outTradeNo);
+                if (purchaseOrder != null && ("paid".equals(purchaseOrder.getStatus()) || "refunded".equals(purchaseOrder.getStatus()))) {
+                    log.info("虚拟支付发货推送重复通知,购买订单已终态: outTradeNo={}, status={}", outTradeNo, purchaseOrder.getStatus());
+                } else if (purchaseOrder != null) {
+                    purchaseService.confirmPayment(outTradeNo, "virtual", transactionId);
+                } else {
+                    log.warn("虚拟支付发货推送购买订单不存在: outTradeNo={}", outTradeNo);
+                }
             } else {
             } else {
                 log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
                 log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
             }
             }
@@ -326,6 +378,14 @@ public class VirtualPayService {
                 memberSubscriptionService.refundByOrderNo(outTradeNo);
                 memberSubscriptionService.refundByOrderNo(outTradeNo);
             } else if (outTradeNo.startsWith("ORD")) {
             } else if (outTradeNo.startsWith("ORD")) {
                 membershipService.refundByOrderNo(outTradeNo);
                 membershipService.refundByOrderNo(outTradeNo);
+            } else if (outTradeNo.startsWith("HQ")) {
+                // 健康启航计划购买订单退款
+                PurchaseOrder purchaseOrder = purchaseService.getByOrderNo(outTradeNo);
+                if (purchaseOrder != null && !"refunded".equals(purchaseOrder.getStatus())) {
+                    purchaseOrder.setStatus("refunded");
+                    purchaseOrderMapper.updateById(purchaseOrder);
+                    log.info("健康启航计划订单退款: outTradeNo={}", outTradeNo);
+                }
             } else {
             } else {
                 log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
                 log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
             }
             }
@@ -380,6 +440,13 @@ public class VirtualPayService {
                     alreadyRefunded = "refunded".equals(order.getStatus());
                     alreadyRefunded = "refunded".equals(order.getStatus());
                     levelInfo = "订阅订单,状态=" + order.getStatus();
                     levelInfo = "订阅订单,状态=" + order.getStatus();
                 }
                 }
+            } else if (payOrderId.startsWith("HQ")) {
+                PurchaseOrder order = purchaseService.getByOrderNo(payOrderId);
+                if (order != null) {
+                    exists = true;
+                    alreadyRefunded = "refunded".equals(order.getStatus());
+                    levelInfo = "健康启航计划订单,状态=" + order.getStatus();
+                }
             }
             }
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("iOS退款问询查单异常: payOrderId={}", payOrderId, e);
             log.error("iOS退款问询查单异常: payOrderId={}", payOrderId, e);

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ChallengeServiceImpl.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.ChallengeService;
 import com.etotem.cfc.service.ChallengeService;
+import com.etotem.cfc.service.PurchaseService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
@@ -14,6 +15,10 @@ public class ChallengeServiceImpl implements ChallengeService {
 
 
     @Autowired private ChallengeProgressMapper progressMapper;
     @Autowired private ChallengeProgressMapper progressMapper;
     @Autowired private FamilyMemberMapper memberMapper;
     @Autowired private FamilyMemberMapper memberMapper;
+    @Autowired private PurchaseService purchaseService;
+    @Autowired private PurchaseOrderMapper purchaseOrderMapper;
+    @Autowired private FamilyMembershipMapper membershipMapper;
+    @Autowired private FamilyMapper familyMapper;
 
 
     @Override
     @Override
     public void initChallengeTasks(Long purchaseId, Long memberId) {
     public void initChallengeTasks(Long purchaseId, Long memberId) {
@@ -101,6 +106,10 @@ public class ChallengeServiceImpl implements ChallengeService {
         result.put("totalCount", progress.size());
         result.put("totalCount", progress.size());
         result.put("progressPercent", progress.size() > 0 ? Math.round((double)completedCount / progress.size() * 100) : 0);
         result.put("progressPercent", progress.size() > 0 ? Math.round((double)completedCount / progress.size() * 100) : 0);
         
         
+        // 获取连续打卡天数
+        FamilyMember member = memberMapper.selectById(memberId);
+        result.put("streakDays", member != null && member.getStreakDays() != null ? member.getStreakDays() : 0);
+        
         return result;
         return result;
     }
     }
 
 
@@ -134,4 +143,51 @@ public class ChallengeServiceImpl implements ChallengeService {
             memberMapper.updateById(member);
             memberMapper.updateById(member);
         }
         }
     }
     }
+
+    @Override
+    public void syncStreakProgress(Long memberId) {
+        // 1. 获取家庭成员信息
+        FamilyMember member = memberMapper.selectById(memberId);
+        if (member == null) {
+            return;
+        }
+        
+        // 2. 获取家庭ID
+        Long familyId = member.getFamilyId();
+        if (familyId == null) {
+            return;
+        }
+        
+        // 3. 查找该家庭的健康启航计划购买记录(状态为paid且在有效期内)
+        // PurchaseOrder 的 userId 是购买者(通常是家庭创建者),familyId 是家庭ID
+        // 需要找到家庭创建者的 userId
+        Family family = familyMapper.selectById(familyId);
+        if (family == null || family.getCreatorId() == null) {
+            return;
+        }
+        
+        Long creatorId = family.getCreatorId();
+        
+        // 查找有效的购买订单
+        LambdaQueryWrapper<PurchaseOrder> qw = new LambdaQueryWrapper<>();
+        qw.eq(PurchaseOrder::getUserId, creatorId)
+          .eq(PurchaseOrder::getFamilyId, familyId)
+          .eq(PurchaseOrder::getStatus, "paid")
+          .gt(PurchaseOrder::getEndDate, new Date())
+          .orderByDesc(PurchaseOrder::getCreatedAt)
+          .last("LIMIT 1");
+        PurchaseOrder purchaseOrder = purchaseOrderMapper.selectOne(qw);
+        if (purchaseOrder == null) {
+            return; // 没有有效的购买记录
+        }
+        
+        // 4. 获取该购买记录下的连续打卡任务进度
+        int streakDays = member.getStreakDays() != null ? member.getStreakDays() : 0;
+        
+        // 更新三个阶段的连续打卡任务
+        String[] streakTaskKeys = {"streak_3days", "streak_7days", "streak_21days"};
+        for (String taskKey : streakTaskKeys) {
+            updateTaskProgress(memberId, taskKey, streakDays);
+        }
+    }
 }
 }

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PurchaseServiceImpl.java

@@ -203,4 +203,11 @@ public class PurchaseServiceImpl implements PurchaseService {
         result.put("membershipValid", true);
         result.put("membershipValid", true);
         return result;
         return result;
     }
     }
+
+    @Override
+    public PurchaseOrder getByOrderNo(String orderNo) {
+        LambdaQueryWrapper<PurchaseOrder> qw = new LambdaQueryWrapper<>();
+        qw.eq(PurchaseOrder::getOrderNo, orderNo);
+        return orderMapper.selectOne(qw);
+    }
 }
 }