Эх сурвалжийг харах

feat(checkin-cf): T5+T6 打卡 CF 调用方补齐(points 误读键修复 + F25/F26 新建接入)

T5-C(P3 必修):MicroActionService 积分误读 health_checkin_points → 改读
micro_checkin_points,并在 DatabaseInitializer 补同键幂等种子(默认 5)。

T6-A/B(F14/F15):HealthWaterController(喝水)/ HealthMealController(饮食)
基线 f5f3b2 起已接入 earnWaterCheckinCf / earnMealCheckinCf(try/catch warn),
本次代码走查复核无遗漏,无需改动。

T6-C(F25 微小行动打卡,新增):
- CF_EARN_RULE_SEEDS 新增 F25(fixed amount=1,ref_type=checkin_micro,
  params_json 含 daily_limit_by_tier + levels{NONE:1,FAMILY:2,PREMIUM:5})
- CF_DAILY_LIMIT_RULES 加入 F25(与 F14/F15/F16 同档日限)
- CfGrowthPathwayEarnService 新增 earnMicroCheckinCf
- MicroActionService.completeAction 末尾接入(try/catch warn,不影响打卡主流程)

T6-D(F26 情绪打卡,新增):
- CF_EARN_RULE_SEEDS 新增 F26(fixed amount=1,ref_type=checkin_emotion,
  params_json 含 daily_limit_by_tier + levels 同上)
- CF_DAILY_LIMIT_RULES 加入 F26
- CfGrowthPathwayEarnService 新增 earnEmotionCheckinCf
- EmotionCheckinService.createCheckin 末尾接入(try/catch warn)

T6-E:代码无 SleepCheckinService/FocusCheckinService(详见回报注明)。

单测:CfGrowthPathwayEarnServiceTest 新增 F25/F26 发放/幂等/日限/ctx 校验
(+6 用例,共 22);相关调用点/种子测试全绿。迁移437 幂等种子说明注释。
iwt 1 долоо хоног өмнө
parent
commit
a327cd36fb

+ 26 - 7
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1096,6 +1096,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             insertSysConfigSeed("activity_checkin_default_points", "5", "活动签到默认积分");
             insertSysConfigSeed("health_checkin_points", "5", "健康打卡积分");
             insertSysConfigSeed("finance_checkin_points", "5", "理财打卡积分");
+            insertSysConfigSeed("micro_checkin_points", "5", "微小行动打卡积分");
             insertSysConfigSeed("trial_enabled", "true", "是否启用会员试用");
             insertSysConfigSeed("trial_days", "7", "试用天数");
             insertSysConfigSeed("trial_features", "[\"free_activities\",\"free_courses\",\"teacher_consultation\"]", "试用期间可用功能");
@@ -3288,7 +3289,7 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移407: 创建 cf_balance / cf_balance_log(个人 CF 值账本,成员维度;F-1)
         createCfBalanceTables();
 
-        // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history + 24 行规则种子(F02–F24;F-1)
+        // 迁移408: 创建 cf_earn_rule / cf_earn_rule_history + 26 行规则种子(F02–F26;F-1)
         createCfEarnRuleTables();
         seedCfEarnRules();
 
@@ -3403,6 +3404,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移436: products 表添加营养数据字段(热量/蛋白质/脂肪/碳水/膳食纤维/营养标签)
         migrateProductNutritionFields();
+
+        // 迁移437: 打卡 CF 调用方补齐(T5+T6)—— 幂等种子三件套:
+        //   ① sys_config 新增 micro_checkin_points(微小行动打卡积分,T5-C 误读键修复配套);
+        //   ② cf_earn_rule 新增 F25 微小行动打卡 / F26 情绪打卡(amount=1,params_json 含
+        //      daily_limit_by_tier + levels 等级金额通道,T6-C/T6-D);
+        //   ③ cf_earn_daily_limit 日限覆盖 F25/F26(与 F14/F15/F16 同档:FREE=1/BASIC=3/...)。
+        // 三处均走既有幂等种子机制(seedCfEarnRules/seedCfEarnDailyLimits/insertSysConfigSeed,
+        // 已存在则跳过),重复启动不重复、不报错。
     }
 
     /**
@@ -13505,7 +13514,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     + "`user_id` BIGINT NULL, "
                     + "`family_id` BIGINT NULL, "
                     + "`type` VARCHAR(20) NOT NULL COMMENT 'earn/spend/freeze/unfreeze/withdraw/adjust/skip', "
-                    + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F24 / migration_*)', "
+                    + "`ref_type` VARCHAR(50) NOT NULL COMMENT '规则编码或迁移标识(F02-F26 / migration_*)', "
                     + "`ref_id` BIGINT NULL, "
                     + "`amount` INT NOT NULL COMMENT '变动数量(正负)', "
                     + "`balance_after` INT NOT NULL COMMENT '变动后可用余额', "
@@ -13570,7 +13579,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     }
 
     /**
-     * 24 行规则种子(F02–F24,F03 拆 F03a/F03b),列序:
+     * 26 行规则种子(F02–F26,F03 拆 F03a/F03b),列序:
      * code, name, category, ruleType, amount, formula, paramsJson, dailyLimit, totalLimit,
      * cooldownSeconds, dedupRefType, sortOrder, remark
      *
@@ -13698,10 +13707,20 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     "",
                     "{\"trigger\":\"referral_register\"}",
                     "10", "", "", "referral_register", "240",
-                    "N8 新增:推荐他人注册即得,建议20CF/人,日限10次"}
+                    "N8 新增:推荐他人注册即得,建议20CF/人,日限10次"},
+            {"F25", "微小行动打卡", "behavior", "fixed", "1",
+                    "",
+                    "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\",\"levels\":{\"NONE\":1,\"FAMILY\":2,\"PREMIUM\":5}}",
+                    "", "", "", "checkin_micro", "250",
+                    "1CF/次;日限按会员等级(N11);等级金额通道 levels(T6-C 新增)"},
+            {"F26", "情绪打卡", "behavior", "fixed", "1",
+                    "",
+                    "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\",\"levels\":{\"NONE\":1,\"FAMILY\":2,\"PREMIUM\":5}}",
+                    "", "", "", "checkin_emotion", "260",
+                    "1CF/次;日限按会员等级(N11);等级金额通道 levels(T6-D 新增)"}
     };
 
-    /** 幂等写入 24 行规则种子(已存在则跳过,不覆盖运营改过的配置) */
+    /** 幂等写入 26 行规则种子(已存在则跳过,不覆盖运营改过的配置) */
     private void seedCfEarnRules() {
         int inserted = 0;
         for (String[] r : CF_EARN_RULE_SEEDS) {
@@ -13794,8 +13813,8 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             {"PREMIUM", "20", "高级会员"}
     };
 
-    /** 按会员等级限次的规则(任务/打卡类):F14 喝水 / F15 饮食 / F16 每日健康 / F22 任务完成 */
-    private static final String[] CF_DAILY_LIMIT_RULES = {"F14", "F15", "F16", "F22"};
+    /** 按会员等级限次的规则(任务/打卡类):F14 喝水 / F15 饮食 / F16 每日健康 / F22 任务完成 / F25 微小行动 / F26 情绪 */
+    private static final String[] CF_DAILY_LIMIT_RULES = {"F14", "F15", "F16", "F22", "F25", "F26"};
 
     private void seedCfEarnDailyLimits() {
         int inserted = 0;

+ 41 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CfGrowthPathwayEarnService.java

@@ -42,6 +42,10 @@ public class CfGrowthPathwayEarnService {
     public static final String RULE_F17 = "F17";
     /** F18 健康启航计划付费(formula floor(amt),与 F02 同口径 1 元 = 100 CF) */
     public static final String RULE_F18 = "F18";
+    /** F25 微小行动打卡(固定 1 CF/次,日限按会员等级 N11,T6-C 新增) */
+    public static final String RULE_F25 = "F25";
+    /** F26 情绪/冥想打卡(固定 1 CF/次,日限按会员等级 N11,T6-D 新增) */
+    public static final String RULE_F26 = "F26";
 
     @Resource
     private CfBalanceService cfBalanceService;
@@ -140,4 +144,40 @@ public class CfGrowthPathwayEarnService {
         ctx.put("remark", "健康启航计划付费奖励");
         return cfBalanceService.earnByRule(memberId, RULE_F18, orderId, ctx);
     }
-}
+
+    // ==================== F25 微小行动打卡 ====================
+
+    /**
+     * F25 微小行动打卡:固定 1 CF/次,日限按会员等级(N11),口径同 F14/F15。
+     *
+     * <p>数量由规则 F25 决定(rule_type=fixed, amount=1,levels 等级金额通道见参数矩阵),
+     * 代码不写死;日限由 F-2 按 {@code cf_earn_daily_limit} 拦截。</p>
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param recordId 微行动打卡记录ID(micro_action_record.id,幂等标识:同一记录只发一次)
+     */
+    public CfEarnResult earnMicroCheckinCf(Long memberId, Long recordId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("checkin_id", recordId);
+        ctx.put("remark", "微小行动打卡");
+        return cfBalanceService.earnByRule(memberId, RULE_F25, recordId, ctx);
+    }
+
+    // ==================== F26 情绪/冥想打卡 ====================
+
+    /**
+     * F26 情绪/冥想打卡:固定 1 CF/次,日限按会员等级(N11),口径同 F14/F15。
+     *
+     * <p>数量由规则 F26 决定(rule_type=fixed, amount=1,levels 等级金额通道见参数矩阵),
+     * 代码不写死;日限由 F-2 按 {@code cf_earn_daily_limit} 拦截。</p>
+     *
+     * @param memberId  家庭成员ID(family_members.id)
+     * @param checkinId 情绪打卡记录ID(emotion_checkin.id,幂等标识:同一记录只发一次)
+     */
+    public CfEarnResult earnEmotionCheckinCf(Long memberId, Long checkinId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("checkin_id", checkinId);
+        ctx.put("remark", "情绪打卡");
+        return cfBalanceService.earnByRule(memberId, RULE_F26, checkinId, ctx);
+    }
+}

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

@@ -43,6 +43,9 @@ public class EmotionCheckinService {
     @Resource
     private ChallengeService challengeService;
 
+    @Resource
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+
     @Transactional
     public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
         FamilyMember child = familyMemberMapper.selectById(dto.getMemberId());
@@ -90,6 +93,14 @@ public class EmotionCheckinService {
             log.warn("同步闯关进度失败,不影响打卡记录: {}", e.getMessage());
         }
 
+        // CF 值发放(F26 统一入口:数量取自 cf_earn_rule F26 固定值,等级金额通道见 params_json.levels;
+        // 幂等 ref_id = 情绪打卡记录ID,同一记录只发一次;失败只 warn,不影响打卡主流程)
+        try {
+            cfGrowthPathwayEarnService.earnEmotionCheckinCf(dto.getMemberId(), checkin.getId());
+        } catch (Exception e) {
+            log.warn("情绪打卡CF值发放失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
+        }
+
         return toVO(checkin, child.getNickname(), false);
     }
 

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MicroActionService.java

@@ -52,6 +52,9 @@ public class MicroActionService {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+
     /**
      * 获取所有启用的微行动列表
      */
@@ -235,7 +238,7 @@ public class MicroActionService {
         // Create task in task system (same pattern as health checkin)
         try {
             int points = 5;
-            String pointsStr = sysConfigService.getValue("health_checkin_points");
+            String pointsStr = sysConfigService.getValue("micro_checkin_points");
             if (pointsStr != null && !pointsStr.isEmpty()) {
                 try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
             }
@@ -303,6 +306,14 @@ public class MicroActionService {
         try { onboardingService.completeTask(userId, "FIRST_CHECKIN"); } catch (Exception e) { /* ignore */ }
         try { growthTaskService.updateProgress(userId, "DAILY_CHECKIN", 1); } catch (Exception e) { log.warn("成长任务签到进度更新失败: userId={}", userId); }
 
+        // CF 值发放(F25 统一入口:数量取自 cf_earn_rule F25 固定值,等级金额通道见 params_json.levels;
+        // 幂等 ref_id = 微行动打卡记录ID,同一记录只发一次;失败只 warn,不影响打卡主流程)
+        try {
+            cfGrowthPathwayEarnService.earnMicroCheckinCf(memberId, record.getId());
+        } catch (Exception e) {
+            log.warn("微小行动打卡CF值发放失败: recordId={}, error={}", record.getId(), e.getMessage());
+        }
+
         return record;
     }
 

+ 117 - 1
cfc-backend/src/test/java/com/etotem/cfc/service/CfGrowthPathwayEarnServiceTest.java

@@ -118,6 +118,12 @@ class CfGrowthPathwayEarnServiceTest {
                 "{\"source\":\"challenge_task.reward_cf\"}", null, null, "challenge_sub_task"));
         when(cfEarnRuleService.getByCode("F18")).thenReturn(rule("F18", "consume", "formula", null, "floor(amt)",
                 "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", 1, 1, "health_start_plan"));
+        when(cfEarnRuleService.getByCode("F25")).thenReturn(rule("F25", "behavior", "fixed", 1, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\",\"levels\":{\"NONE\":1,\"FAMILY\":2,\"PREMIUM\":5}}",
+                null, null, "checkin_micro"));
+        when(cfEarnRuleService.getByCode("F26")).thenReturn(rule("F26", "behavior", "fixed", 1, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\",\"levels\":{\"NONE\":1,\"FAMILY\":2,\"PREMIUM\":5}}",
+                null, null, "checkin_emotion"));
 
         ReflectionTestUtils.setField(cfBalanceService, "cfBalanceMapper", cfBalanceMapper);
         ReflectionTestUtils.setField(cfBalanceService, "cfBalanceLogMapper", cfBalanceLogMapper);
@@ -258,6 +264,104 @@ class CfGrowthPathwayEarnServiceTest {
         assertEquals(10, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
     }
 
+    // ---------- F25 微小行动打卡 ----------
+
+    @Test
+    void F25_微小行动打卡_数量取规则固定1_refType为checkin_micro() {
+        CfEarnResult r = service.earnMicroCheckinCf(MEMBER_ID, 4001L);
+
+        assertTrue(r.getEarned());
+        assertEquals("F25", r.getRuleCode());
+        assertEquals("checkin_micro", r.getRefType());
+        assertEquals(4001L, r.getRefId());
+        // 1 来自 cf_earn_rule F25(fixed),代码不写死
+        assertEquals(1, r.getAmount());
+        assertEquals(1, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("earn", log.getType());
+        assertEquals("checkin_micro", log.getRefType());
+        assertEquals(4001L, log.getRefId());
+        assertEquals(1, log.getAmount());
+        assertEquals(MEMBER_ID, log.getMemberId());
+        assertEquals("微小行动打卡", log.getRemark());
+    }
+
+    @Test
+    void F25_同一打卡记录重复提交_幂等只发一次() {
+        service.earnMicroCheckinCf(MEMBER_ID, 4002L);
+        CfEarnResult second = service.earnMicroCheckinCf(MEMBER_ID, 4002L);
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(1, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void F25_会员等级日限达上限_跳过发放() {
+        CfEarnDailyLimit limit = new CfEarnDailyLimit();
+        limit.setRuleCode("F25");
+        limit.setLevelCode("BASIC");
+        limit.setDailyLimit(3);
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(limit);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(3L);
+
+        CfEarnResult r = service.earnMicroCheckinCf(MEMBER_ID, 4003L);
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("日限"));
+        assertTrue(earnLogs().isEmpty(), "日限命中不得入账");
+    }
+
+    // ---------- F26 情绪打卡 ----------
+
+    @Test
+    void F26_情绪打卡_数量取规则固定1_refType为checkin_emotion() {
+        CfEarnResult r = service.earnEmotionCheckinCf(MEMBER_ID, 5001L);
+
+        assertTrue(r.getEarned());
+        assertEquals("F26", r.getRuleCode());
+        assertEquals("checkin_emotion", r.getRefType());
+        assertEquals(5001L, r.getRefId());
+        // 1 来自 cf_earn_rule F26(fixed),代码不写死
+        assertEquals(1, r.getAmount());
+        assertEquals(1, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("earn", log.getType());
+        assertEquals("checkin_emotion", log.getRefType());
+        assertEquals(5001L, log.getRefId());
+        assertEquals(1, log.getAmount());
+        assertEquals(MEMBER_ID, log.getMemberId());
+        assertEquals("情绪打卡", log.getRemark());
+    }
+
+    @Test
+    void F26_同一打卡记录重复提交_幂等只发一次() {
+        service.earnEmotionCheckinCf(MEMBER_ID, 5002L);
+        CfEarnResult second = service.earnEmotionCheckinCf(MEMBER_ID, 5002L);
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(1, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void F26_会员等级日限达上限_跳过发放() {
+        CfEarnDailyLimit limit = new CfEarnDailyLimit();
+        limit.setRuleCode("F26");
+        limit.setLevelCode("BASIC");
+        limit.setDailyLimit(3);
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(limit);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(3L);
+
+        CfEarnResult r = service.earnEmotionCheckinCf(MEMBER_ID, 5003L);
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("日限"));
+        assertTrue(earnLogs().isEmpty(), "日限命中不得入账");
+    }
+
     // ---------- F17 挑战赛子任务完成 ----------
 
     @Test
@@ -352,7 +456,7 @@ class CfGrowthPathwayEarnServiceTest {
     }
 
     @Test
-    void F14_F15_F16_ctx不携带数量_数量全由规则决定() {
+    void F14_F15_F16_F25_F26_ctx不携带数量_数量全由规则决定() {
         CfGrowthPathwayEarnService s = new CfGrowthPathwayEarnService();
 
         Map<String, Object> ctxF14 = captureCtx(s, MEMBER_ID, "F14", 11L,
@@ -373,6 +477,18 @@ class CfGrowthPathwayEarnServiceTest {
         assertNull(ctxF16.get("amount"), "固定规则 F16 的数量必须来自 cf_earn_rule,代码不得传 amount");
         assertEquals(33L, ctxF16.get("checkin_id"));
         assertEquals("每日健康打卡", ctxF16.get("remark"));
+
+        Map<String, Object> ctxF25 = captureCtx(s, MEMBER_ID, "F25", 44L,
+                () -> s.earnMicroCheckinCf(MEMBER_ID, 44L));
+        assertNull(ctxF25.get("amount"), "固定规则 F25 的数量必须来自 cf_earn_rule,代码不得传 amount");
+        assertEquals(44L, ctxF25.get("checkin_id"));
+        assertEquals("微小行动打卡", ctxF25.get("remark"));
+
+        Map<String, Object> ctxF26 = captureCtx(s, MEMBER_ID, "F26", 55L,
+                () -> s.earnEmotionCheckinCf(MEMBER_ID, 55L));
+        assertNull(ctxF26.get("amount"), "固定规则 F26 的数量必须来自 cf_earn_rule,代码不得传 amount");
+        assertEquals(55L, ctxF26.get("checkin_id"));
+        assertEquals("情绪打卡", ctxF26.get("remark"));
     }
 
     @Test