Ver Fonte

feat(backend): 方案任务生成优先读 tasks + 正则兜底 + 懒加载回填

iwt há 3 semanas atrás
pai
commit
1a8416d62d

+ 213 - 98
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -29,6 +29,8 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -263,10 +265,17 @@ public class HealthPlanServiceImpl implements HealthPlanService {
      */
     private void generateDailyTasksFromPlan(HealthPlan plan) {
         String content = plan.getPlanContent();
-        if (content == null || content.trim().isEmpty()) return;
-
-        List<String> rawLines = parseTaskLines(content);
-        if (rawLines.isEmpty()) return;
+        List<String> rawLines = new java.util.ArrayList<>();
+        List<TaskDraft> structuredDrafts = buildDraftsFromPlanJson(plan);
+        boolean usedStructured = !structuredDrafts.isEmpty();
+        if (content == null || content.trim().isEmpty()) {
+            if (!usedStructured) return;
+        } else {
+            if (!usedStructured) {
+                rawLines = parseTaskLines(content);
+                if (rawLines.isEmpty()) return;
+            }
+        }
 
         Long familyId = plan.getFamilyId();
         String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
@@ -281,96 +290,200 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
         Date now = new Date();
 
-        for (String raw : rawLines) {
-            String line = raw.trim();
-            if (line.isEmpty()) continue;
+        // 统一生成:structured 分支直接用草稿;regex 分支按行分类
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        if (usedStructured) {
+            drafts = structuredDrafts;
+        } else {
+            for (String raw : rawLines) {
+                String line = raw.trim();
+                if (line.isEmpty()) continue;
+                List<TaskDraft> cls = classifyTaskLine(line);
+                if (cls != null && !cls.isEmpty()) drafts.addAll(cls);
+            }
+        }
 
-            // 任务动作筛选:只生成包含"实际动作"的任务,纯原理描述被过滤
-            // 购买任务额外派生"每日使用/服用"子任务(parentTaskId 关联到购买任务)
-            List<TaskDraft> drafts = classifyTaskLine(line);
-            if (drafts == null || drafts.isEmpty()) continue;
-
-            Long buyTaskId = null;
-            for (TaskDraft draft : drafts) {
-                // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
-                Calendar todayStart = Calendar.getInstance();
-                todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
-                todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
-                Calendar todayEnd = (Calendar) todayStart.clone();
-                todayEnd.add(Calendar.DAY_OF_MONTH, 1);
-
-                boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
-                    .eq("family_id", familyId)
-                    .eq("source_type", "plan")
-                    .eq("source_id", plan.getId())
-                    .eq("title", draft.title)
-                    .ge("deadline", todayStart.getTime())
-                    .lt("deadline", todayEnd.getTime())) > 0;
-                if (exists) {
-                    if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
-                        // 购买任务已存在,尝试查回其ID以关联子任务
-                        Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
-                            .eq("family_id", familyId)
-                            .eq("source_type", "plan")
-                            .eq("source_id", plan.getId())
-                            .eq("title", draft.title)
-                            .eq("category", "购买任务")
-                            .last("LIMIT 1"));
-                        if (existingBuy != null) buyTaskId = existingBuy.getId();
-                    }
-                    continue;
+        // 购买→使用子任务链的父任务ID(一次性购买任务完成后派生每日使用/服用)
+        Long buyTaskId = null;
+
+        for (TaskDraft draft : drafts) {
+            // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
+            Calendar todayStart = Calendar.getInstance();
+            todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
+            todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
+            Calendar todayEnd = (Calendar) todayStart.clone();
+            todayEnd.add(Calendar.DAY_OF_MONTH, 1);
+
+            boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
+                .eq("family_id", familyId)
+                .eq("source_type", "plan")
+                .eq("source_id", plan.getId())
+                .eq("title", draft.title)
+                .ge("deadline", todayStart.getTime())
+                .lt("deadline", todayEnd.getTime())) > 0;
+            if (exists) {
+                if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
+                    Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
+                        .eq("family_id", familyId)
+                        .eq("source_type", "plan")
+                        .eq("source_id", plan.getId())
+                        .eq("title", draft.title)
+                        .eq("category", "购买任务")
+                        .last("LIMIT 1"));
+                    if (existingBuy != null) buyTaskId = existingBuy.getId();
                 }
+                continue;
+            }
 
-                Task task = new Task();
-                task.setFamilyId(familyId);
-                task.setFamilyMemberId(familyMemberId);
-                task.setChildId(childId);
-                task.setExecutorType("child");
-                task.setExecutorId(executorId);
-                task.setCreatorId(executorId != null ? executorId : 0L);
-                task.setTitle(draft.title);
-                task.setDescription("");
-                task.setPoints(10);
-                task.setDeadline(deadline);
-                task.setStatus("pending");
-                task.setSourceType("plan");
-                task.setSourceId(plan.getId());
-                task.setNeedReview(1);
-                task.setCreatedAt(now);
-                task.setUpdatedAt(now);
-
-                if ("购买任务".equals(draft.categoryLabel)) {
-                    // 购买任务:一次性,完成后解锁每日使用子任务
-                    task.setRepeatType("none");
-                    task.setTaskType("onetime");
-                    task.setFrequency(null);
-                    task.setCategory(draft.categoryLabel);
-                    task.setDimensionCode(draft.dimensionCode);
-                    taskMapper.insert(task);
-                    buyTaskId = task.getId();
-                    log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
-                } else if ("使用任务".equals(draft.categoryLabel)) {
-                    // 每日使用/服用子任务:父购买任务完成前不显示;父任务完成时生成/生效
-                    task.setRepeatType("daily");
-                    task.setTaskType("recurring");
-                    task.setFrequency("daily");
-                    task.setCategory(draft.categoryLabel);
-                    task.setDimensionCode(draft.dimensionCode);
-                    task.setParentTaskId(buyTaskId != null ? buyTaskId : draft.parentTaskId);
-                    taskMapper.insert(task);
-                    log.info("方案{}生成每日使用子任务: [{}] {} (parent={})",
-                        plan.getId(), draft.categoryLabel, draft.title, task.getParentTaskId());
-                } else {
-                    // 常规任务:每日重复
-                    task.setRepeatType("daily");
-                    task.setTaskType("recurring");
-                    task.setFrequency("daily");
-                    task.setCategory(draft.categoryLabel);
-                    task.setDimensionCode(draft.dimensionCode);
-                    taskMapper.insert(task);
-                    log.info("方案{}生成任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+            String repeatType = "once".equals(draft.frequency) ? "none" : "daily";
+            String taskType = "once".equals(draft.frequency) ? "onetime" : "recurring";
+            String freq = "once".equals(draft.frequency) ? null : "daily";
+
+            Task task = new Task();
+            task.setFamilyId(familyId);
+            task.setFamilyMemberId(familyMemberId);
+            task.setChildId(childId);
+            task.setExecutorType("child");
+            task.setExecutorId(executorId);
+            task.setCreatorId(executorId != null ? executorId : 0L);
+            task.setTitle(draft.title);
+            task.setDescription("");
+            task.setPoints(10);
+            task.setDeadline(deadline);
+            task.setStatus("pending");
+            task.setSourceType("plan");
+            task.setSourceId(plan.getId());
+            task.setNeedReview(1);
+            task.setCreatedAt(now);
+            task.setUpdatedAt(now);
+
+            if ("购买任务".equals(draft.categoryLabel)) {
+                task.setRepeatType(repeatType);
+                task.setTaskType(taskType);
+                task.setFrequency(freq);
+                task.setCategory(draft.categoryLabel);
+                task.setDimensionCode(draft.dimensionCode);
+                taskMapper.insert(task);
+                buyTaskId = task.getId();
+                log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+            } else if ("使用任务".equals(draft.categoryLabel)) {
+                task.setRepeatType(repeatType);
+                task.setTaskType(taskType);
+                task.setFrequency(freq);
+                task.setCategory(draft.categoryLabel);
+                task.setDimensionCode(draft.dimensionCode);
+                task.setParentTaskId(buyTaskId != null ? buyTaskId : draft.parentTaskId);
+                taskMapper.insert(task);
+                log.info("方案{}生成每日使用子任务: [{}] {} (parent={})",
+                    plan.getId(), draft.categoryLabel, draft.title, task.getParentTaskId());
+            } else {
+                task.setRepeatType(repeatType);
+                task.setTaskType(taskType);
+                task.setFrequency(freq);
+                task.setCategory(draft.categoryLabel);
+                task.setDimensionCode(draft.dimensionCode);
+                taskMapper.insert(task);
+                log.info("方案{}生成任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
+            }
+        }
+    }
+
+    /**
+     * 从 plan_json.sections[].tasks 构建任务草稿。返回 tasks 草稿列表;
+     * 若某 section 无 tasks,则用行分类逻辑兜底并回填 plan_json(懒加载),落库。
+     */
+    private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        String planJsonStr = plan.getPlanJson();
+        if (planJsonStr == null || planJsonStr.trim().isEmpty()) return drafts;
+
+        try {
+            JsonNode root = objectMapper.readTree(planJsonStr);
+            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) {
+                plan.setPlanJson(objectMapper.writeValueAsString(root));
+                healthPlanMapper.updateById(plan);
+                log.info("方案{}懒加载回填 tasks 完成", plan.getId());
+            }
+        } catch (Exception e) {
+            log.warn("解析 plan_json 生成任务失败: {}", e.getMessage());
+        }
+        return drafts;
+    }
+
+    /** 将结构化 action_type/dimension/frequency 映射为 TaskDraft;非法类型返回 null。 */
+    private TaskDraft mapStructuredTask(String actionType, String title, String dimension, String frequency) {
+        String cat;
+        String dim;
+        String freq;
+        switch (actionType == null ? "" : actionType) {
+            case "buy": cat = "购买任务"; dim = "wealth"; freq = "once"; break;
+            case "read": cat = "阅读任务"; dim = "wisdom"; freq = "daily"; break;
+            case "exercise": cat = "运动任务"; dim = "body"; freq = "daily"; break;
+            case "checkin": cat = "打卡任务"; dim = "mind"; freq = "daily"; break;
+            case "diet": cat = "饮食任务"; dim = "body"; freq = "daily"; break;
+            case "activity": cat = "活动任务"; dim = "action"; freq = "daily"; break;
+            default: return null;
+        }
+        // dimension/frequency 显式值优先覆盖默认
+        if (dimension != null && !dimension.isEmpty()) dim = dimension;
+        if ("once".equals(frequency) || "daily".equals(frequency)) freq = frequency;
+        String finalFreq = freq;
+        String finalDim = dim;
+        return new TaskDraft(title, cat, finalDim, finalFreq);
+    }
+
+    /** 将 TaskDraft 的分类中文标签反解为 action_type 枚举(用于懒加载回填补全结构化字段)。 */
+    private String actionTypeFromCategory(String categoryLabel) {
+        if (categoryLabel == null) return "";
+        switch (categoryLabel) {
+            case "购买任务": return "buy";
+            case "阅读任务": return "read";
+            case "运动任务": return "exercise";
+            case "打卡任务": return "checkin";
+            case "饮食任务": return "diet";
+            case "活动任务": return "activity";
+            default: return "";
         }
     }
 
@@ -448,16 +561,18 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return plan;
     }
 
-    // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 父任务ID(购买→使用链)
+    // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
     private static class TaskDraft {
         final String title;
         final String categoryLabel;
         final String dimensionCode;
+        final String frequency; // "once" | "daily"
         Long parentTaskId;
-        TaskDraft(String title, String categoryLabel, String dimensionCode) {
+        TaskDraft(String title, String categoryLabel, String dimensionCode, String frequency) {
             this.title = title;
             this.categoryLabel = categoryLabel;
             this.dimensionCode = dimensionCode;
+            this.frequency = frequency;
         }
     }
 
@@ -480,31 +595,31 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         if (pm.find()) {
             List<TaskDraft> drafts = new java.util.ArrayList<>();
             // ① 一次性购买任务(wealth),完成后解锁每日使用
-            TaskDraft buy = new TaskDraft(title, "购买任务", "wealth");
+            TaskDraft buy = new TaskDraft(title, "购买任务", "wealth", "once");
             drafts.add(buy);
             // ② 每日使用/服用子任务,父任务完成前不可见
             String product = extractPurchaseProduct(title);
             if (product != null && !product.isEmpty()) {
                 String useVerb = matchUseVerb(product);
-                TaskDraft sub = new TaskDraft(useVerb + product, "使用任务", "body");
+                TaskDraft sub = new TaskDraft(useVerb + product, "使用任务", "body", "daily");
                 drafts.add(sub);
             }
             return drafts;
         }
         if (ACTION_READING.matcher(title).find()) {
-            return java.util.Collections.singletonList(new TaskDraft(title, "阅读任务", "wisdom"));
+            return java.util.Collections.singletonList(new TaskDraft(title, "阅读任务", "wisdom", "daily"));
         }
         if (ACTION_EXERCISE.matcher(title).find()) {
-            return java.util.Collections.singletonList(new TaskDraft(title, "运动任务", "body"));
+            return java.util.Collections.singletonList(new TaskDraft(title, "运动任务", "body", "daily"));
         }
         if (ACTION_CHECKIN.matcher(title).find()) {
-            return java.util.Collections.singletonList(new TaskDraft(title, "打卡任务", "mind"));
+            return java.util.Collections.singletonList(new TaskDraft(title, "打卡任务", "mind", "daily"));
         }
         if (ACTION_DIET.matcher(title).find()) {
-            return java.util.Collections.singletonList(new TaskDraft(title, "饮食任务", "body"));
+            return java.util.Collections.singletonList(new TaskDraft(title, "饮食任务", "body", "daily"));
         }
         if (ACTION_ACTIVITY.matcher(title).find()) {
-            return java.util.Collections.singletonList(new TaskDraft(title, "活动任务", "action"));
+            return java.util.Collections.singletonList(new TaskDraft(title, "活动任务", "action", "daily"));
         }
         // 无任何动作词命中:若包含机制/原理语境词,判定为纯原理描述,不生成任务
         if (MECHANISM_PATTERN.matcher(title).find()) {