فهرست منبع

feat(health-plan): T22 方案→任务回流传真(懒回填当次消费+实例透传来源+级联兜底+任务中心标签)

zxyj-web 2 هفته پیش
والد
کامیت
eb71d0aa63

+ 80 - 59
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -197,6 +197,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         QueryWrapper<Task> wrapper = new QueryWrapper<>();
         wrapper.eq("source_type", "health_plan")
                 .eq("source_id", planId)
+                // 直接任务语义:每日实例带 template_id(由周期模板生成),不属于方案任务列表,
+                // 否则列表会按天膨胀,且与"直接任务"幂等/删除口径不一致
+                .isNull("template_id")
                 .orderByAsc("id");
         return taskMapper.selectList(wrapper);
     }
@@ -213,13 +216,15 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             return null;
         }
 
-        // 1. 查方案直接任务(周期模板 + 一次性任务 + 购买锚点)
+        // 1. 查方案直接任务(周期模板 + 一次性任务 + 购买锚点);排除按日生成的实例(template_id 非空)
         List<Task> planTasks = taskMapper.selectList(new QueryWrapper<Task>()
                 .eq("source_type", "health_plan")
-                .eq("source_id", planId));
+                .eq("source_id", planId)
+                .isNull("template_id"));
         int deleted = 0;
 
-        // 2. 先删每日实例:实例只透传 template_id、不带 source_id,漏删会留孤儿任务且次日继续生成
+        // 2. 先删每日实例:按 template_id 指向的直接任务精确删除(实例已透传 source_type/source_id,
+        //    但 template_id 维度最精准,避免误删其他来源任务)
         if (planTasks != null && !planTasks.isEmpty()) {
             List<Long> taskIds = new java.util.ArrayList<Long>();
             for (Task t : planTasks) {
@@ -230,7 +235,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             }
         }
 
-        // 3. 删直接任务
+        // 3. 删直接任务 + source 维度兜底:任何仍带 health_plan 来源的行(含已透传 source 的实例)一并清掉,
+        //    保证不因透传/模板归属差异留下孤儿任务
         deleted += taskMapper.delete(new QueryWrapper<Task>()
                 .eq("source_type", "health_plan")
                 .eq("source_id", planId));
@@ -475,7 +481,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
      */
     private void generateDailyTasksForMembers(HealthPlan plan, JsonNode root, String[] memberIdArray) {
         Long familyId = plan.getFamilyId();
-        List<TaskDraft> commonDrafts = buildDraftsFromSections(root.path("common").path("sections"));
+        // 懒加载回填标记:common/individual 任一 section 回填了 tasks 即整体落库
+        boolean[] backfilled = new boolean[]{false};
+        List<TaskDraft> commonDrafts = extractDraftsFromSections(root.path("common").path("sections"), backfilled);
         Date now = new Date();
         int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
             ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
@@ -486,28 +494,68 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             Long rawId = Long.valueOf(rawMemberId.trim());
             Long familyMemberId = resolveFamilyMemberId(rawId, familyId);
             List<TaskDraft> memberDrafts = new java.util.ArrayList<>(commonDrafts);
-            memberDrafts.addAll(buildDraftsFromSections(
-                root.path("individual").path(rawMemberId.trim()).path("sections")));
+            memberDrafts.addAll(extractDraftsFromSections(
+                root.path("individual").path(rawMemberId.trim()).path("sections"), backfilled));
             if (memberDrafts.isEmpty()) continue;
             insertTasksForMember(plan, familyId, familyMemberId, memberDrafts, now, planEnd);
         }
+
+        // 多人路径懒回填:回填内容当次落库 plan_json,避免下次发布时再次重复提取
+        if (backfilled[0]) {
+            try {
+                plan.setPlanJson(objectMapper.writeValueAsString(root));
+                healthPlanMapper.updateById(plan);
+                log.info("方案{}多人懒加载回填 tasks 完成", plan.getId());
+            } catch (Exception e) {
+                log.warn("方案{}多人回填 tasks 写库失败,不影响本次任务生成: {}", plan.getId(), e.getMessage());
+            }
+        }
     }
 
-    /** 从 planJson 的 sections 数组提取任务草稿(仅读取 tasks 字段,无懒回填) */
-    private List<TaskDraft> buildDraftsFromSections(JsonNode sections) {
+    /** 从 planJson 的 sections 数组提取任务草稿(唯一读取/回填入口,单人与多人路径共用)。 */
+    private List<TaskDraft> extractDraftsFromSections(JsonNode sections, boolean[] backfilled) {
         List<TaskDraft> drafts = new java.util.ArrayList<>();
         if (sections == null || !sections.isArray()) return drafts;
         for (JsonNode section : sections) {
             JsonNode tasks = section.path("tasks");
-            if (!tasks.isArray()) continue;
-            for (JsonNode t : tasks) {
-                String actionType = t.path("action_type").asText("");
-                String title = t.path("title").asText("");
-                String dimension = t.path("dimension").asText("");
-                String frequency = t.path("frequency").asText("daily");
-                if (title.isEmpty()) continue;
-                TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
-                if (draft != null) drafts.add(draft);
+            if (tasks.isArray() && tasks.size() > 0) {
+                for (JsonNode t : tasks) {
+                    String actionType = t.path("action_type").asText("");
+                    String title = t.path("title").asText("");
+                    String dimension = t.path("dimension").asText("");
+                    String frequency = t.path("frequency").asText("daily");
+                    if (title.isEmpty()) continue;
+                    TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
+                    if (draft != null) drafts.add(draft);
+                }
+                continue;
+            }
+            // 懒加载回填:tasks 缺失/为空/非法时,用正则从 content 提取动作行;
+            // 仅当提取结果非空才写回 section.tasks(避免空回填反复写库),backfilled[0] 置 true 供调用方落库。
+            List<String> lines = parseTaskLines(section.path("content").asText(""));
+            ArrayNode newTasks = objectMapper.createArrayNode();
+            boolean sectionBackfilled = false;
+            for (String raw : lines) {
+                String line = raw.trim();
+                if (line.isEmpty()) continue;
+                List<TaskDraft> cls = classifyTaskLine(line);
+                if (cls == null) continue;
+                for (TaskDraft d : cls) {
+                    ObjectNode node = objectMapper.createObjectNode();
+                    node.put("action_type", actionTypeFromCategory(d.categoryLabel));
+                    node.put("title", d.title);
+                    node.put("dimension", d.dimensionCode);
+                    node.put("frequency", d.frequency);
+                    node.put("notes", "");
+                    newTasks.add(node);
+                    drafts.add(d); // 回填内容当次即消费,不等下次发布
+                }
+                sectionBackfilled = true;
+            }
+            // 仅在有可提取任务时写回;非 ObjectNode 的 section(异常结构)跳过写回
+            if (sectionBackfilled && section instanceof ObjectNode) {
+                ((ObjectNode) section).set("tasks", newTasks);
+                if (backfilled != null) backfilled[0] = true;
             }
         }
         return drafts;
@@ -644,7 +692,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             .eq("source_type", "health_plan")
             .eq("source_id", plan.getId())
             .eq("title", title)
-            .eq("is_template", templateOnly);
+            .eq("is_template", templateOnly)
+            // 幂等口径与直接任务一致:每日实例带 template_id,不得误判为"已存在任务"而跳过生成
+            .isNull("template_id");
         if (category != null) qw.eq("category", category);
         if (familyMemberId != null) qw.eq("family_member_id", familyMemberId);
         return taskMapper.selectOne(qw.last("LIMIT 1"));
@@ -671,6 +721,10 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return task;
     }
 
+    /**
+     * 单人/旧 flat 方案:从 plan_json 的 root.sections 提取任务草稿;
+     * 懒加载回填统一走 extractDraftsFromSections,回填内容既落库也当次返回。
+     */
     private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
         List<TaskDraft> drafts = new java.util.ArrayList<>();
         String planJsonStr = plan.getPlanJson();
@@ -681,45 +735,10 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             JsonNode sections = root.path("sections");
             if (!sections.isArray()) return drafts;
 
-            boolean backfilled = false;
-            for (JsonNode section : sections) {
-                JsonNode tasks = section.path("tasks");
-                if (tasks.isArray()) {
-                    for (JsonNode t : tasks) {
-                        String actionType = t.path("action_type").asText("");
-                        String title = t.path("title").asText("");
-                        String dimension = t.path("dimension").asText("");
-                        String frequency = t.path("frequency").asText("daily");
-                        if (title.isEmpty()) continue;
-                        TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
-                        if (draft != null) drafts.add(draft);
-                    }
-                }
-                // 懒加载回填:section 无 tasks 时用正则从 content 提取并写回
-                if (tasks.isMissingNode() || (tasks.isArray() && tasks.size() == 0)) {
-                    String sectionContent = section.path("content").asText("");
-                    List<String> lines = parseTaskLines(sectionContent);
-                    ArrayNode newTasks = objectMapper.createArrayNode();
-                    for (String raw : lines) {
-                        String line = raw.trim();
-                        if (line.isEmpty()) continue;
-                        List<TaskDraft> cls = classifyTaskLine(line);
-                        if (cls == null) continue;
-                        for (TaskDraft d : cls) {
-                            ObjectNode node = objectMapper.createObjectNode();
-                            node.put("action_type", actionTypeFromCategory(d.categoryLabel));
-                            node.put("title", d.title);
-                            node.put("dimension", d.dimensionCode);
-                            node.put("frequency", d.frequency);
-                            node.put("notes", "");
-                            newTasks.add(node);
-                        }
-                    }
-                    ((ObjectNode) section).set("tasks", newTasks);
-                    backfilled = true;
-                }
-            }
-            if (backfilled) {
+            boolean[] backfilled = new boolean[]{false};
+            drafts.addAll(extractDraftsFromSections(sections, backfilled));
+
+            if (backfilled[0]) {
                 plan.setPlanJson(objectMapper.writeValueAsString(root));
                 healthPlanMapper.updateById(plan);
                 log.info("方案{}懒加载回填 tasks 完成", plan.getId());
@@ -827,9 +846,11 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         int taskCount = 0;
         try {
             generateDailyTasksFromPlan(plan);
+            // 任务计数只统计直接任务(周期模板 + 一次性任务),排除按日生成的实例
             List<Task> generated = taskMapper.selectList(new QueryWrapper<Task>()
                     .eq("source_type", "health_plan")
-                    .eq("source_id", planId));
+                    .eq("source_id", planId)
+                    .isNull("template_id"));
             taskCount = generated == null ? 0 : generated.size();
             taskGenerated = taskCount > 0;
         } catch (Exception e) {

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/RepeatTaskGenerator.java

@@ -285,6 +285,17 @@ newTask.setTemplateId(template.getId()); // 指向模板
 newTask.setIsTemplate(0); // 这是实例,不是模板
 newTask.setMemberOnly(template.getMemberOnly()); // B21: 透传会员标记
 
+// 透传来源与维度(仅 health_plan):实例须携带方案来源(source_type/source_id,供删除级联与任务中心来源标签)、
+// 父任务(parent_task_id,供 filterLockedChildren 隐藏未解锁的依赖实例)与五维维度码(供维度页归位)。
+// 其余来源(如 'plan')不透传:ProblemCompletionService 依赖 source_type='plan' 统计套餐方案完成,
+// 若每日实例也带 'plan',未完成实例会阻塞领域完成奖励发放。
+if ("health_plan".equals(template.getSourceType())) {
+    newTask.setSourceType(template.getSourceType());
+    newTask.setSourceId(template.getSourceId());
+    newTask.setParentTaskId(template.getParentTaskId());
+    newTask.setDimensionCode(template.getDimensionCode());
+}
+
 // TASK-SYS-248: 透传行为调度中心字段(action/填写/两阶段/最短时长)
 newTask.setPrerequisiteTaskId(template.getPrerequisiteTaskId());
 newTask.setActionType(template.getActionType());

+ 31 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanDeleteCascadeTest.java

@@ -125,4 +125,35 @@ class HealthPlanDeleteCascadeTest {
         assertEquals(2, ((Number) r.get("taskCount")).intValue());
         verify(mapper).deleteById(5L);
     }
+
+    @Test
+    void 级联删除_直接任务查询排除实例且source兜底删除无模板过滤() {
+        when(mapper.selectById(5L)).thenReturn(planOf(5L, 10L, "published"));
+        when(taskMapper.selectList(any(Wrapper.class)))
+                .thenReturn(Collections.<Task>singletonList(taskOf(101L)));
+        when(taskMapper.delete(any(Wrapper.class))).thenReturn(1, 2);
+
+        service.deletePlanCascade(5L, 10L);
+
+        // step1 selectList:直接任务查询必须排除每日实例(template_id 非空)
+        ArgumentCaptor<Wrapper> selectCaptor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(taskMapper).selectList(selectCaptor.capture());
+        String selectSql = selectCaptor.getValue().getTargetSql();
+        assertTrue(selectSql.contains("source_type") && selectSql.contains("source_id"),
+                "直接任务查询必须按 source 定位,实际: " + selectSql);
+        assertTrue(selectSql.contains("template_id IS NULL"),
+                "直接任务查询必须排除每日实例,实际: " + selectSql);
+
+        // step2(template_id 删实例)+ step3(source 维度兜底,无 template 过滤)
+        ArgumentCaptor<Wrapper> delCaptor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(taskMapper, times(2)).delete(delCaptor.capture());
+        List<Wrapper> dels = delCaptor.getAllValues();
+        assertTrue(dels.get(0).getTargetSql().contains("template_id"),
+                "第一次删除必须按 template_id 删实例,实际: " + dels.get(0).getTargetSql());
+        String lastSql = dels.get(1).getTargetSql();
+        assertTrue(lastSql.contains("source_type") && lastSql.contains("source_id"),
+                "末次删除必须按 source_type+source_id 兜底,实际: " + lastSql);
+        assertFalse(lastSql.contains("template_id"),
+                "末次 source 兜底删除不得带 template_id 过滤,实际: " + lastSql);
+    }
 }

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/HealthPlanLazyBackfillTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * HealthPlan → tasks 链路:懒加载回填(单/多人)、重复发布幂等、直接任务查询隔离。
+ */
+class HealthPlanLazyBackfillTest {
+
+    private HealthPlanServiceImpl service;
+    private HealthPlanMapper planMapper;
+    private TaskMapper taskMapper;
+    private FamilyMemberMapper familyMemberMapper;
+
+    private final List<Task> inserted = new ArrayList<>();
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new HealthPlanServiceImpl();
+        planMapper = mock(HealthPlanMapper.class);
+        taskMapper = mock(TaskMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+
+        setField("healthPlanMapper", planMapper);
+        setField("taskMapper", taskMapper);
+        setField("familyMemberMapper", familyMemberMapper);
+
+        // 双保险:insert 记入内存列表便于断言;selectOne 默认返回 null(无已存在任务)
+        when(taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
+            Task t = inv.getArgument(0);
+            inserted.add(t);
+            return 1;
+        });
+        when(taskMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+        when(familyMemberMapper.selectById(any())).thenReturn(null);
+        when(familyMemberMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+    }
+
+    /** 解析 plan_json 字符串,检查任一 section.tasks[] 的 title 是否包含关键字 */
+    private boolean anyTaskTitleContains(String planJson, String keyword) {
+        if (planJson == null || planJson.trim().isEmpty()) return false;
+        try {
+            com.fasterxml.jackson.databind.JsonNode root =
+                    new com.fasterxml.jackson.databind.ObjectMapper().readTree(planJson);
+            com.fasterxml.jackson.databind.JsonNode sections = root.path("sections");
+            if (!sections.isArray()) return false;
+            for (com.fasterxml.jackson.databind.JsonNode s : sections) {
+                com.fasterxml.jackson.databind.JsonNode tasks = s.path("tasks");
+                if (!tasks.isArray()) continue;
+                for (com.fasterxml.jackson.databind.JsonNode t : tasks) {
+                    if (t.path("title").asText("").contains(keyword)) return true;
+                }
+            }
+        } catch (Exception ignored) {}
+        return false;
+    }
+
+    private void setField(String name, Object value) throws Exception {
+        Field f = HealthPlanServiceImpl.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private HealthPlan planOf(Long id, Long familyId, String memberIds, String planJson) {
+        HealthPlan p = new HealthPlan();
+        p.setId(id);
+        p.setFamilyId(familyId);
+        p.setMemberIds(memberIds);
+        p.setPlanJson(planJson);
+        p.setTotalDays(14);
+        p.setStatus("published");
+        return p;
+    }
+
+    @Test
+    void 单人懒回填_回填当次即消费并写回planJson() {
+        // section 无 tasks 字段:只读 content 正则提取,懒回填后当次生成任务
+        String planJson = "{\"sections\":[{\"key\":\"nutrition\",\"content\":\"营养目标\\n1. 每日跑步30分钟\\n探测AMPK通路机制\\n\"}]}";
+        HealthPlan plan = planOf(5L, 10L, "101", planJson);
+        when(planMapper.selectById(5L)).thenReturn(plan);
+        when(planMapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        service.confirmPlan(5L);
+
+        // 回填的"每日跑步30分钟"必须当次生成任务(运动任务 daily → 1 条模板)
+        assertEquals(1, inserted.size(), "回填内容必须当次消费并生成任务");
+        assertEquals("运动任务", inserted.get(0).getCategory());
+        assertEquals(Integer.valueOf(1), inserted.get(0).getIsTemplate());
+        // plan_json 必须落库:tasks 数组已回填
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planMapper, atLeastOnce()).updateById(cap.capture());
+        String persisted = cap.getAllValues().stream()
+                .map(p -> p.getPlanJson() == null ? "" : p.getPlanJson())
+                .reduce("", (a, b) -> a + b);
+        assertTrue(persisted.contains("\"tasks\""), "回填后的 plan_json 必须包含 tasks 数组,实际: " + persisted);
+        assertTrue(persisted.contains("每日跑步30分钟"), "回填的 tasks 必须包含提取到的任务标题");
+        // 纯原理行(AMPK通路)不得进入 tasks(只检查 tasks 数组内,content 原文仍保留)
+        assertFalse(anyTaskTitleContains(persisted, "AMPK"),
+                "纯原理描述行不应被回填进 tasks");
+    }
+
+    @Test
+    void 单人回填空提取不写回空tasks() {
+        // content 全为纯原理/无动作行 → 无任务可提取 → 不写回空 tasks(避免每次发布空回填)
+        String planJson = "{\"sections\":[{\"key\":\"nutrition\",\"content\":\"激活AMPK通路、抑制肝糖原异生\\n调节肠道菌群平衡\\n\"}]}";
+        HealthPlan plan = planOf(5L, 10L, "101", planJson);
+        when(planMapper.selectById(5L)).thenReturn(plan);
+
+        service.confirmPlan(5L);
+
+        assertTrue(inserted.isEmpty(), "无可提取动作行时不得生成任务");
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planMapper, atLeastOnce()).updateById(cap.capture());
+        boolean hasEmptyBackfill = cap.getAllValues().stream()
+                .anyMatch(p -> p.getPlanJson() != null && p.getPlanJson().contains("\"tasks\""));
+        assertFalse(hasEmptyBackfill, "空提取时不得写回 tasks 字段,避免无限空回填");
+    }
+
+    @Test
+    void 多人懒回填_common与individual均回填并落planJson() {
+        String planJson = "{"
+                + "\"common\":{\"sections\":[{\"key\":\"exercise\",\"content\":\"共性运动\\n1. 每日跑步30分钟\\n\"}]},"
+                + "\"individual\":{"
+                + "\"101\":{\"sections\":[{\"key\":\"activity\",\"content\":\"成员101活动\\n1. 参加亲子活动\\n\"}]},"
+                + "\"102\":{\"sections\":[{\"key\":\"diet\",\"content\":\"成员102饮食\\n1. 每日吃一份蔬菜\\n\"}]}"
+                + "}}";
+        HealthPlan plan = planOf(6L, 10L, "101,102", planJson);
+        when(planMapper.selectById(6L)).thenReturn(plan);
+
+        service.confirmPlan(6L);
+
+        // 共性任务每人一份(2)+ 个性任务各一条(2)= 4 条模板
+        assertEquals(4, inserted.size(), "多人回填后共性任务每人一份、个性任务各一份");
+        // plan_json 落库:common + 两个 individual 都有 tasks
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planMapper, atLeastOnce()).updateById(cap.capture());
+        String persisted = cap.getAllValues().stream()
+                .map(p -> p.getPlanJson() == null ? "" : p.getPlanJson())
+                .reduce("", (a, b) -> a + b);
+        assertTrue(persisted.contains("\"tasks\""), "多人回填后 plan_json 必须包含 tasks");
+        assertTrue(persisted.contains("每日跑步30分钟") && persisted.contains("参加亲子活动")
+                && persisted.contains("每日吃一份蔬菜"), "common 与 individual 的任务标题必须全部回填");
+    }
+
+    @Test
+    void 重复发布同一方案不重复插入任务() {
+        // 幂等:同一方案 confirm 两次,第二次因 findPlanTask 命中已存在任务而零新增
+        String planJson = "{\"sections\":[{\"key\":\"nutrition\",\"tasks\":["
+                + "{\"action_type\":\"buy\",\"title\":\"购买益生菌\",\"dimension\":\"wealth\",\"frequency\":\"once\"},"
+                + "{\"action_type\":\"checkin\",\"title\":\"每日健康记录\",\"dimension\":\"mind\",\"frequency\":\"daily\"}"
+                + "]}]}";
+        HealthPlan plan = planOf(7L, 10L, "101", planJson);
+        when(planMapper.selectById(7L)).thenReturn(plan);
+        when(planMapper.updateById(any(HealthPlan.class))).thenReturn(1);
+
+        // 第一次 confirm:findPlanTask 返回 null → 全部创建
+        service.confirmPlan(7L);
+        int afterFirst = inserted.size();
+        assertTrue(afterFirst > 0, "首次发布应生成任务");
+
+        // 第二次 confirm:findPlanTask 返回已存在任务 → 零新增
+        when(taskMapper.selectOne(any(Wrapper.class))).thenAnswer(inv -> {
+            Task existing = new Task();
+            existing.setId(999L);
+            return existing;
+        });
+        service.confirmPlan(7L);
+        int afterSecond = inserted.size();
+
+        assertEquals(afterFirst, afterSecond, "重复发布同一方案不得重复插入任务");
+    }
+
+    @Test
+    void getPlanTasks只返回直接任务不含每日实例() {
+        HealthPlan plan = planOf(5L, 10L, "101", null);
+        when(planMapper.selectById(5L)).thenReturn(plan);
+        when(taskMapper.selectList(any(Wrapper.class))).thenReturn(Collections.singletonList(new Task()));
+
+        service.getPlanTasks(5L);
+
+        ArgumentCaptor<Wrapper> cap = ArgumentCaptor.forClass(Wrapper.class);
+        verify(taskMapper).selectList(cap.capture());
+        String sql = cap.getValue().getTargetSql();
+        assertTrue(sql.contains("source_type") && sql.contains("source_id"), "必须按 source 定位方案任务,实际: " + sql);
+        assertTrue(sql.contains("template_id IS NULL"), "方案任务列表必须排除每日实例(template_id 非空),实际: " + sql);
+    }
+}

+ 125 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RepeatTaskGeneratorPassThroughTest.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.task.RepeatTaskGenerator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RepeatTaskGenerator.createTaskFromTemplate:每日实例必须透传方案来源(source_type/source_id)、
+ * 父任务(parent_task_id)与五维维度码(dimension_code)。
+ * 缺失后果:删除方案留孤儿任务、任务中心无来源标签、维度页任务掉出归位。
+ */
+class RepeatTaskGeneratorPassThroughTest {
+
+    private RepeatTaskGenerator generator;
+    private TaskMapper taskMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        generator = new RepeatTaskGenerator();
+        taskMapper = mock(TaskMapper.class);
+        Field f = RepeatTaskGenerator.class.getDeclaredField("taskMapper");
+        f.setAccessible(true);
+        f.set(generator, taskMapper);
+    }
+
+    private Task healthPlanTemplate(Long id) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(10L);
+        t.setFamilyMemberId(101L);
+        t.setCreatorId(1L);
+        t.setExecutorType("child");
+        t.setExecutorId(101L);
+        t.setChildId(101L);
+        t.setTitle("每日服用益生菌");
+        t.setDescription("方案生成的使用任务");
+        t.setPoints(10);
+        t.setCategory("使用任务");
+        t.setNeedReview(1);
+        t.setIsTemplate(1);
+        t.setSourceType("health_plan");
+        t.setSourceId(5L);
+        t.setParentTaskId(100L);
+        t.setPrerequisiteTaskId(100L);
+        t.setDimensionCode("body");
+        // 周期终点在明天:今天生成实例不受 D3 周期边界拦截
+        t.setDeadline(new Date(System.currentTimeMillis() + 86400000L));
+        t.setCreatedAt(new Date());
+        return t;
+    }
+
+    private Task invokeCreate(Task template) throws Exception {
+        Method m = RepeatTaskGenerator.class.getDeclaredMethod(
+                "createTaskFromTemplate", Task.class, Date.class, boolean.class);
+        m.setAccessible(true);
+        m.invoke(generator, template, new Date(), false);
+        ArgumentCaptor<Task> cap = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).insert(cap.capture());
+        return cap.getValue();
+    }
+
+    @Test
+    void 每日实例透传来源父任务与维度码() throws Exception {
+        Task template = healthPlanTemplate(88L);
+        // 前置购买任务不存在 → 实例初始状态 locked
+        when(taskMapper.selectById(100L)).thenReturn(null);
+
+        Task inst = invokeCreate(template);
+
+        assertEquals("health_plan", inst.getSourceType(), "实例必须透传 source_type");
+        assertEquals(Long.valueOf(5L), inst.getSourceId(), "实例必须透传 source_id");
+        assertEquals(Long.valueOf(100L), inst.getParentTaskId(), "实例必须透传 parent_task_id");
+        assertEquals("body", inst.getDimensionCode(), "实例必须透传 dimension_code");
+        // 实例语义不变:指向模板、非模板行、前置未完成 → locked
+        assertEquals(Long.valueOf(88L), inst.getTemplateId());
+        assertEquals(Integer.valueOf(0), inst.getIsTemplate());
+        assertEquals("locked", inst.getStatus());
+    }
+
+    @Test
+    void 非健康方案模板不透传source避免套餐完成统计被实例阻塞() throws Exception {
+        // ProblemCompletionService 依赖 source_type='plan' 统计套餐方案完成;
+        // 若每日实例也带 'plan',未完成实例会阻塞领域完成奖励发放 → 透传仅限 health_plan
+        Task template = healthPlanTemplate(91L);
+        template.setSourceType("plan");
+        template.setSourceId(200L);
+        template.setParentTaskId(null);
+        template.setPrerequisiteTaskId(null);
+        template.setDimensionCode(null);
+
+        Task inst = invokeCreate(template);
+
+        assertEquals("pending", inst.getStatus());
+        assertNull(inst.getSourceType(), "非 health_plan 来源不得透传到实例");
+        assertNull(inst.getSourceId(), "非 health_plan 来源不得透传 source_id");
+    }
+
+    @Test
+    void 无来源无前置的普通模板实例保持pending且不写来源() throws Exception {
+        Task template = healthPlanTemplate(90L);
+        template.setPrerequisiteTaskId(null);
+        template.setParentTaskId(null);
+        template.setSourceType(null);
+        template.setSourceId(null);
+        template.setDimensionCode(null);
+
+        Task inst = invokeCreate(template);
+
+        assertEquals("pending", inst.getStatus());
+        assertNull(inst.getSourceType());
+        assertNull(inst.getSourceId());
+        assertNull(inst.getParentTaskId());
+        assertNull(inst.getDimensionCode());
+    }
+}

+ 60 - 5
cfc-frontend/pages/tasks/tasks.vue

@@ -73,7 +73,7 @@
 
     <!-- 今日任务 -->
     <view class="task-list" v-if="activeTab === 'today'">
-      <view class="task-card" v-for="task in todayTasks" :key="task.id" :class="{ completed: task.status === 'completed', locked: task.status === 'locked' }">
+      <view class="task-card" v-for="task in todayTasks" :key="task.id" :class="{ completed: task.status === 'completed', locked: task.status === 'locked' }" @click="onTaskCardTap(task)">
         <view class="card-accent-line" :class="'line-' + task.status"></view>
         <view class="card-body">
           <view class="card-left">
@@ -87,6 +87,7 @@
               <text class="card-type-icon" v-else-if="task.requireInput === 1">✍️</text>
               <text class="card-title">{{ task.title }}</text>
               <text class="member-pill" v-if="task.memberOnly === 1">会员</text>
+              <text class="src-pill" :class="task._sourceClass" v-if="task._sourceLabel">{{ task._sourceLabel }}</text>
             </view>
             <view class="card-meta">
               <text class="points-pill">+{{ task.points }} CF</text>
@@ -103,7 +104,7 @@
             <text v-if="task.status === 'locked'" class="lock-tip">🔒 需先完成前置任务后解锁</text>
           </view>
           <view class="card-right">
-            <button v-if="task.status === 'pending' || task.status === 'in_progress'" class="btn-complete" @click="handleTaskAction(task)">{{ getActionLabel(task) }}</button>
+            <button v-if="task.status === 'pending' || task.status === 'in_progress'" class="btn-complete" @click.stop="handleTaskAction(task)">{{ getActionLabel(task) }}</button>
             <view v-else-if="task.status === 'completed'" class="badge badge-completed">已完成</view>
             <view v-else-if="task.status === 'pending_review'" class="badge badge-review">待审核</view>
             <view v-else class="badge badge-lock">🔒 未解锁</view>
@@ -119,7 +120,7 @@
 
     <!-- 我创建的任务 -->
     <view class="task-list" v-if="activeTab === 'created'">
-      <view class="task-card" v-for="task in createdTasks" :key="task.id">
+      <view class="task-card" v-for="task in createdTasks" :key="task.id" @click="onTaskCardTap(task)">
         <view class="card-accent-line"></view>
         <view class="card-body card-body-row">
           <view class="card-left">
@@ -133,6 +134,7 @@
               <text class="card-type-icon" v-else-if="task.requireInput === 1">✍️</text>
               <text class="card-title">{{ task.title }}</text>
               <text class="member-pill" v-if="task.memberOnly === 1">会员</text>
+              <text class="src-pill" :class="task._sourceClass" v-if="task._sourceLabel">{{ task._sourceLabel }}</text>
             </view>
             <view class="card-meta">
               <text class="points-pill">+{{ task.points }} CF</text>
@@ -349,6 +351,33 @@ export default {
       if (!task || this.selfMemberId == null || task.familyMemberId == null) return true
       return String(this.selfMemberId) === String(task.familyMemberId)
     },
+    // 方案来源标签装饰:加载后预计算(小程序模板禁止在 :class 内做方法调用)
+    // 只有健康方案有小程序端详情页可跳转;'plan'/'training_plan' 仅打标签不跳转
+    decorateSource(task) {
+      if (!task) return task
+      task._sourceLabel = ''
+      task._sourceClass = ''
+      task._sourceNavigable = false
+      if (task.sourceType === 'health_plan') {
+        task._sourceLabel = '健康方案'
+        task._sourceClass = 'src-health-plan'
+        task._sourceNavigable = true
+      } else if (task.sourceType === 'plan') {
+        task._sourceLabel = '任务方案'
+        task._sourceClass = 'src-plan'
+      } else if (task.sourceType === 'training_plan') {
+        task._sourceLabel = '训练方案'
+        task._sourceClass = 'src-training-plan'
+      }
+      return task
+    },
+    // 点击任务卡片:健康方案来源的任务跳转方案详情页
+    onTaskCardTap(task) {
+      if (!task || !task._sourceNavigable || !task.sourceId) return
+      if (task.sourceType === 'health_plan') {
+        uni.navigateTo({ url: '/pages/health/health-plan-summary?planId=' + task.sourceId })
+      }
+    },
     checkMembership() {
       getMyMembership().then(res => {
         const m = res.data || {}
@@ -397,20 +426,22 @@ export default {
       }
     },
     async loadTodayTasks(memberId) {
+      var self = this
       try {
         // 选中成员 → 传该成员 memberId;选中本人 → 传 selfMemberId(后端亦按 JWT currentMemberId 兜底)
         var targetMemberId = arguments.length > 0 ? memberId : this.selectedMemberId
         const res = await getTodayTasks(targetMemberId)
-        this.todayTasks = res.data || []
+        this.todayTasks = (res.data || []).map(function(t) { return self.decorateSource(t) })
       } catch (e) {
         console.error('加载今日任务失败', e)
       }
     },
     async loadCreatedTasks() {
+      var self = this
       try {
         // 我创建的:家庭今日任务按 creatorId 过滤,只看当前登录用户创建的任务
         const res = await getTodayTasks(null, { familyWide: true })
-        var all = (res && res.data) || []
+        var all = ((res && res.data) || []).map(function(t) { return self.decorateSource(t) })
         var uid = this.currentUserId
         this.createdTasks = all.filter(function(t) {
           return t.creatorId != null && String(t.creatorId) === String(uid)
@@ -959,6 +990,30 @@ export default {
   font-weight: 500;
 }
 
+/* 方案来源药丸:健康方案(可点进详情)/ 任务方案 / 训练方案 */
+.src-pill {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  padding: 4rpx 12rpx;
+  border-radius: 999rpx;
+  font-weight: 500;
+  flex-shrink: 0;
+}
+
+.src-health-plan {
+  background: $status-info-soft;
+  color: $status-info-deep;
+}
+
+.src-plan {
+  background: $facade-fill-gray-100;
+  color: $facade-text-body;
+}
+
+.src-training-plan {
+  background: $dim-wisdom-tint-2;
+  color: $dim-wisdom;
+}
+
 /* 元信息:积分药丸 + 分隔点 + 创建人/执行人/截止 */
 .card-meta {
   display: flex;