|
|
@@ -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()) {
|