Jelajahi Sumber

feat(api): 专项调研重设计 v2 - 统一问卷契约 + 内置默认问卷 (survey-rework)

- 新增 POST /api/problem-survey/questions:按问题域 code 返回问卷(未绑定模板时返回内置默认问卷,选项带 id/tag 供 resultTags 聚合)
- submit 改读 surveyTemplateId(兼容旧 templateId),answers 统一 [{questionId, optionIds}](兼容旧 Map/[{questionText, optionTexts}])
- ProblemDomainController.list 补平铺 surveyTemplateId 字段(修复 flow-intro 透传断链)
- 迁移248:survey_templates 内置 3 个问题域问卷种子(空表才插入)+ problem_domains 未绑定才绑定,幂等

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 1 bulan lalu
induk
melakukan
e2cb80b575

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9211,6 +9211,36 @@ private void runMigration100() {
 			log.warn("补充notification_templates公众号通道失败: " + e.getMessage());
 		}
 
+		// 迁移248: 专项调研内置问卷种子(survey_templates 表为空才插入;problem_domains 未绑定模板才绑定,幂等)
+		// 与 ProblemSurveyController.DEFAULT_QUESTIONS 结构一致(选项带 id/tag 用于 resultTags 聚合)
+		try {
+			Integer tplCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM survey_templates", Integer.class);
+			if (tplCount == null || tplCount == 0) {
+				String weightLossQ = "[{\"id\":\"q1\",\"text\":\"目前孩子的体重情况如何?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"偏瘦\",\"tag\":\"体重偏瘦\"},{\"id\":\"o2\",\"text\":\"正常\",\"tag\":\"\"},{\"id\":\"o3\",\"text\":\"偏胖\",\"tag\":\"体重偏胖\"}]},{\"id\":\"q2\",\"text\":\"孩子的饮食结构如何?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"零食饮料多\",\"tag\":\"零食饮料\"},{\"id\":\"o2\",\"text\":\"主食过量\",\"tag\":\"主食过量\"},{\"id\":\"o3\",\"text\":\"蔬菜水果少\",\"tag\":\"蔬果不足\"},{\"id\":\"o4\",\"text\":\"三餐规律\",\"tag\":\"\"}]},{\"id\":\"q3\",\"text\":\"孩子每天运动情况如何?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"很少运动\",\"tag\":\"运动不足\"},{\"id\":\"o2\",\"text\":\"每周1-2次\",\"tag\":\"运动不足\"},{\"id\":\"o3\",\"text\":\"每周3次以上\",\"tag\":\"运动良好\"}]},{\"id\":\"q4\",\"text\":\"你最想改善的方面是什么?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"控制体重\",\"tag\":\"减重\"},{\"id\":\"o2\",\"text\":\"培养饮食习惯\",\"tag\":\"饮食管理\"},{\"id\":\"o3\",\"text\":\"增加运动\",\"tag\":\"运动习惯\"}]}]";
+				String sleepQ = "[{\"id\":\"q1\",\"text\":\"孩子每晚睡眠时长大约多少?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"不足8小时\",\"tag\":\"睡眠不足\"},{\"id\":\"o2\",\"text\":\"8-10小时\",\"tag\":\"睡眠正常\"},{\"id\":\"o3\",\"text\":\"超过10小时\",\"tag\":\"睡眠过多\"}]},{\"id\":\"q2\",\"text\":\"入睡情况如何?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"入睡困难\",\"tag\":\"入睡困难\"},{\"id\":\"o2\",\"text\":\"容易惊醒\",\"tag\":\"睡眠浅\"},{\"id\":\"o3\",\"text\":\"睡得很沉\",\"tag\":\"\"}]},{\"id\":\"q3\",\"text\":\"睡前习惯有哪些?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"看电子产品\",\"tag\":\"屏幕暴露\"},{\"id\":\"o2\",\"text\":\"喝含糖饮料\",\"tag\":\"饮食影响睡眠\"},{\"id\":\"o3\",\"text\":\"睡前剧烈运动\",\"tag\":\"睡前兴奋\"},{\"id\":\"o4\",\"text\":\"规律作息\",\"tag\":\"\"}]},{\"id\":\"q4\",\"text\":\"白天精神状态如何?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"白天犯困\",\"tag\":\"白天嗜睡\"},{\"id\":\"o2\",\"text\":\"注意力不集中\",\"tag\":\"注意力不足\"},{\"id\":\"o3\",\"text\":\"情绪烦躁\",\"tag\":\"情绪波动\"},{\"id\":\"o4\",\"text\":\"精力充沛\",\"tag\":\"\"}]}]";
+				String constipationQ = "[{\"id\":\"q1\",\"text\":\"孩子排便频率如何?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"每天1次以上\",\"tag\":\"\"},{\"id\":\"o2\",\"text\":\"2-3天1次\",\"tag\":\"排便频率低\"},{\"id\":\"o3\",\"text\":\"3天以上1次\",\"tag\":\"便秘\"}]},{\"id\":\"q2\",\"text\":\"大便性状如何?\",\"multi\":false,\"options\":[{\"id\":\"o1\",\"text\":\"干硬颗粒状\",\"tag\":\"大便干结\"},{\"id\":\"o2\",\"text\":\"成形但偏干\",\"tag\":\"大便干结\"},{\"id\":\"o3\",\"text\":\"正常软便\",\"tag\":\"\"}]},{\"id\":\"q3\",\"text\":\"饮食习惯如何?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"蔬菜水果少\",\"tag\":\"膳食纤维不足\"},{\"id\":\"o2\",\"text\":\"饮水不足\",\"tag\":\"饮水不足\"},{\"id\":\"o3\",\"text\":\"爱吃油炸烧烤\",\"tag\":\"饮食油腻\"},{\"id\":\"o4\",\"text\":\"规律均衡\",\"tag\":\"\"}]},{\"id\":\"q4\",\"text\":\"是否有以下症状?(可多选)\",\"multi\":true,\"options\":[{\"id\":\"o1\",\"text\":\"腹胀\",\"tag\":\"腹胀\"},{\"id\":\"o2\",\"text\":\"腹痛\",\"tag\":\"腹痛\"},{\"id\":\"o3\",\"text\":\"食欲不振\",\"tag\":\"食欲不振\"},{\"id\":\"o4\",\"text\":\"无\",\"tag\":\"\"}]}]";
+				jdbcTemplate.update("INSERT INTO survey_templates (title, dimension, questions_json, cycle_type, ai_generated, enabled) VALUES "
+						+ "('减肥专项调研', 'body', '" + weightLossQ + "', 'weekly', 0, 1), "
+						+ "('睡眠专项调研', 'body', '" + sleepQ + "', 'weekly', 0, 1), "
+						+ "('便秘专项调研', 'body', '" + constipationQ + "', 'weekly', 0, 1)");
+				log.info("迁移248: survey_templates 内置专项调研问卷种子已插入");
+			} else {
+				log.info("迁移248: survey_templates 已有 {} 条数据,跳过内置问卷种子", tplCount);
+			}
+			// 问题域未绑定模板时,按 code 绑定内置问卷(幂等)
+			jdbcTemplate.update("UPDATE problem_domains d SET d.survey_template_id = "
+					+ "(SELECT t.id FROM survey_templates t WHERE t.title = '减肥专项调研' LIMIT 1) "
+					+ "WHERE d.code = 'weight_loss' AND (d.survey_template_id IS NULL OR d.survey_template_id = 0)");
+			jdbcTemplate.update("UPDATE problem_domains d SET d.survey_template_id = "
+					+ "(SELECT t.id FROM survey_templates t WHERE t.title = '睡眠专项调研' LIMIT 1) "
+					+ "WHERE d.code = 'sleep' AND (d.survey_template_id IS NULL OR d.survey_template_id = 0)");
+			jdbcTemplate.update("UPDATE problem_domains d SET d.survey_template_id = "
+					+ "(SELECT t.id FROM survey_templates t WHERE t.title = '便秘专项调研' LIMIT 1) "
+					+ "WHERE d.code = 'constipation' AND (d.survey_template_id IS NULL OR d.survey_template_id = 0)");
+		} catch (Exception e) {
+			log.warn("迁移248初始化专项调研问卷种子失败: " + e.getMessage());
+		}
+
 	}
 
 	/**

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemDomainController.java

@@ -63,6 +63,7 @@ public class ProblemDomainController {
             item.put("contentTags", domain.getContentTags());
             item.put("rewardCfValue", domain.getRewardCfValue());
             item.put("sortOrder", domain.getSortOrder());
+            item.put("surveyTemplateId", domain.getSurveyTemplateId());
 
             // 问卷模板
             if (domain.getSurveyTemplateId() != null) {

+ 277 - 54
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemSurveyController.java

@@ -32,9 +32,12 @@ import java.util.Set;
 import com.etotem.cfc.util.ParamUtils;
 
 /**
- * 专项调研 — 改版v1(提交调研 → 汇总 resultTags → 推荐产品 → 存快照 → 推进旅程)
+ * 专项调研 — 改版v2(契约统一)
+ * 获取问卷:POST /api/problem-survey/questions(按问题域 code,未绑定模板时返回内置默认问卷)
+ * 提交调研:POST /api/problem-survey/submit(answers 统一 [{questionId, optionIds}],
+ *           聚合 resultTags → 推荐产品 → 存快照 → 推进旅程)
  */
-@Tag(name = "专项调研", description = "问题域专项调研提交与结果推荐")
+@Tag(name = "专项调研", description = "问题域专项调研:获取问卷/提交答案 → 汇总 resultTags → 推荐产品 → 存快照 → 推进旅程")
 @RestController
 @RequestMapping("/api/problem-survey")
 public class ProblemSurveyController {
@@ -53,6 +56,48 @@ public class ProblemSurveyController {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
+    /** 内置默认问卷(问题域未绑定模板时兜底),选项带 tag 用于聚合 resultTags */
+    private static final Map<String, String> DEFAULT_QUESTIONS = buildDefaultQuestions();
+
+    @Operation(summary = "获取专项调研问卷(按问题域 code,未绑定模板返回内置默认问卷)")
+    @PostMapping("/questions")
+    public Result<Map<String, Object>> questions(@RequestAttribute("userId") Long userId,
+                                                 @RequestBody Map<String, Object> params) {
+        String code = params.get("problemDomainCode") != null
+                ? params.get("problemDomainCode").toString() : null;
+        ProblemDomain domain = null;
+        if (code != null && !code.isEmpty()) {
+            domain = problemDomainMapper.selectOne(
+                    new LambdaQueryWrapper<ProblemDomain>()
+                            .eq(ProblemDomain::getCode, code)
+                            .last("LIMIT 1"));
+        }
+        SurveyTemplate template = null;
+        if (domain != null && domain.getSurveyTemplateId() != null) {
+            template = surveyTemplateMapper.selectById(domain.getSurveyTemplateId());
+        }
+        List<Map<String, Object>> questions = new ArrayList<>();
+        Long templateId = null;
+        String title = "专项调研";
+        if (template != null && template.getQuestionsJson() != null
+                && !template.getQuestionsJson().trim().isEmpty()) {
+            questions = normalizeQuestions(template.getQuestionsJson());
+            templateId = template.getId();
+            title = template.getTitle();
+        } else {
+            String defaultJson = DEFAULT_QUESTIONS.get(code);
+            if (defaultJson != null) {
+                questions = normalizeQuestions(defaultJson);
+                title = (domain != null ? domain.getName() : "") + "专项调研";
+            }
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("templateId", templateId);
+        data.put("title", title);
+        data.put("questions", questions);
+        return Result.success(data);
+    }
+
     @Operation(summary = "提交专项调研:聚合 resultTags → 推荐产品 → 存快照 → 旅程推进")
     @PostMapping("/submit")
     @Transactional
@@ -60,37 +105,39 @@ public class ProblemSurveyController {
                                               @RequestAttribute(value = "familyId", required = false) Long familyId,
                                               @RequestBody Map<String, Object> params) {
         Long surveyTemplateId = ParamUtils.getLong(params.get("surveyTemplateId"));
+        // 兼容旧字段名 templateId
+        if (surveyTemplateId == null) {
+            surveyTemplateId = ParamUtils.getLong(params.get("templateId"));
+        }
         String problemDomainCode = params.get("problemDomainCode") != null
                 ? params.get("problemDomainCode").toString() : null;
         Long memberId = ParamUtils.getLong(params.get("memberId"));
         Object answersObj = params.get("answers");
 
-        SurveyTemplate template = surveyTemplateId != null ? surveyTemplateMapper.selectById(surveyTemplateId) : null;
-        if (template == null) {
-            return Result.error("问卷模板不存在");
-        }
-
-        // 1. 聚合 resultTags:从题目选项 tag/resultTag + 问题域 contentTags
-        Set<String> resultTags = new LinkedHashSet<>();
-        if (answersObj != null) {
-            try {
-                Map<String, Object> answers = OBJECT_MAPPER.convertValue(answersObj,
-                        new TypeReference<Map<String, Object>>() {});
-                resultTags.addAll(aggregateTagsFromAnswers(template.getQuestionsJson(), answers));
-            } catch (Exception ignored) {
-            }
-        }
+        // 模板解析:显式传入 > 问题域绑定 > 内置默认
+        SurveyTemplate template = surveyTemplateId != null
+                ? surveyTemplateMapper.selectById(surveyTemplateId) : null;
         ProblemDomain domain = null;
         if (problemDomainCode != null && !problemDomainCode.isEmpty()) {
             domain = problemDomainMapper.selectOne(
                     new LambdaQueryWrapper<ProblemDomain>()
                             .eq(ProblemDomain::getCode, problemDomainCode)
                             .last("LIMIT 1"));
-            if (domain != null && domain.getContentTags() != null) {
-                for (String tag : domain.getContentTags().split(",")) {
-                    if (!tag.trim().isEmpty()) {
-                        resultTags.add(tag.trim());
-                    }
+            if (template == null && domain != null && domain.getSurveyTemplateId() != null) {
+                template = surveyTemplateMapper.selectById(domain.getSurveyTemplateId());
+            }
+        }
+        List<Map<String, Object>> questions = resolveQuestions(template, domain, problemDomainCode);
+
+        // 1. 聚合 resultTags:从题目选项 tag + 问题域 contentTags
+        Set<String> resultTags = new LinkedHashSet<>();
+        if (answersObj != null) {
+            resultTags.addAll(aggregateTagsFromAnswers(questions, answersObj));
+        }
+        if (domain != null && domain.getContentTags() != null) {
+            for (String tag : domain.getContentTags().split(",")) {
+                if (!tag.trim().isEmpty()) {
+                    resultTags.add(tag.trim());
                 }
             }
         }
@@ -99,7 +146,7 @@ public class ProblemSurveyController {
         Map<String, Object> recommendData = new HashMap<>();
         RecommendRequestDTO recReq = new RecommendRequestDTO();
         recReq.setType("product");
-        recReq.setDimensionCode(template.getDimension());
+        recReq.setDimensionCode(template != null ? template.getDimension() : null);
         recReq.setChildId(memberId);
         recReq.setSize(10);
         try {
@@ -116,9 +163,9 @@ public class ProblemSurveyController {
                         .last("LIMIT 1"));
         Date now = new Date();
         Map<String, Object> snapshot = new HashMap<>();
-        snapshot.put("surveyTemplateId", template.getId());
-        snapshot.put("surveyTitle", template.getTitle());
-        snapshot.put("dimension", template.getDimension());
+        snapshot.put("surveyTemplateId", template != null ? template.getId() : null);
+        snapshot.put("surveyTitle", template != null ? template.getTitle() : null);
+        snapshot.put("dimension", template != null ? template.getDimension() : null);
         snapshot.put("resultTags", new ArrayList<>(resultTags));
         snapshot.put("answers", answersObj);
         snapshot.put("submittedAt", now);
@@ -130,14 +177,14 @@ public class ProblemSurveyController {
             intent.setProblemDomainCode(problemDomainCode != null ? problemDomainCode : "");
             intent.setJourneyStage(2);
             intent.setStageUpdatedAt(now);
-            intent.setSurveyTemplateId(template.getId());
+            intent.setSurveyTemplateId(template != null ? template.getId() : null);
             intent.setSurveySubmittedAt(now);
             intent.setSurveyResultJson(toJson(snapshot));
             intent.setCreatedAt(now);
             intent.setUpdatedAt(now);
             userIntentMapper.insert(intent);
         } else {
-            intent.setSurveyTemplateId(template.getId());
+            intent.setSurveyTemplateId(template != null ? template.getId() : null);
             intent.setSurveySubmittedAt(now);
             intent.setSurveyResultJson(toJson(snapshot));
             // 调研完成推进到阶段2(文档状态机:2=调研已提交)
@@ -158,39 +205,117 @@ public class ProblemSurveyController {
         return Result.success(data);
     }
 
-    /** 从题目选项聚合 resultTags(兼容 questionsJson 不同结构) */
-    private Set<String> aggregateTagsFromAnswers(String questionsJson, Map<String, Object> answers) {
-        Set<String> tags = new LinkedHashSet<>();
-        if (questionsJson == null || questionsJson.isEmpty()) {
-            return tags;
+    /** 解析题目列表:模板 JSON > 内置默认 > 空列表 */
+    private List<Map<String, Object>> resolveQuestions(SurveyTemplate template,
+                                                       ProblemDomain domain, String code) {
+        if (template != null && template.getQuestionsJson() != null
+                && !template.getQuestionsJson().trim().isEmpty()) {
+            return normalizeQuestions(template.getQuestionsJson());
+        }
+        String defaultJson = DEFAULT_QUESTIONS.get(code != null ? code
+                : (domain != null ? domain.getCode() : null));
+        if (defaultJson != null) {
+            return normalizeQuestions(defaultJson);
+        }
+        return new ArrayList<>();
+    }
+
+    /** 归一化题目结构:统一 [{id, text, multi, options: [{id, text, tag}]}] */
+    private List<Map<String, Object>> normalizeQuestions(String questionsJson) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (questionsJson == null || questionsJson.trim().isEmpty()) {
+            return result;
         }
         try {
-            List<Map<String, Object>> questions = new ArrayList<>();
             Object parsed = OBJECT_MAPPER.readValue(questionsJson, Object.class);
+            List<Map<String, Object>> raw = new ArrayList<>();
             if (parsed instanceof List) {
-                questions = OBJECT_MAPPER.convertValue(parsed,
+                raw = OBJECT_MAPPER.convertValue(parsed,
                         new TypeReference<List<Map<String, Object>>>() {});
             } else if (parsed instanceof Map) {
                 Object qArr = ((Map<?, ?>) parsed).get("questions");
                 if (qArr instanceof List) {
-                    questions = OBJECT_MAPPER.convertValue(qArr,
+                    raw = OBJECT_MAPPER.convertValue(qArr,
                             new TypeReference<List<Map<String, Object>>>() {});
                 }
             }
-            for (Map<String, Object> question : questions) {
-                Object qId = question.get("id") != null ? question.get("id") : question.get("questionId");
-                Object answer = qId != null ? answers.get(String.valueOf(qId)) : null;
-                if (answer == null) {
-                    continue;
+            int qi = 0;
+            for (Map<String, Object> q : raw) {
+                Map<String, Object> nq = new HashMap<>();
+                nq.put("id", q.get("id") != null ? String.valueOf(q.get("id")) : ("q" + (qi + 1)));
+                nq.put("text", q.get("text") != null ? String.valueOf(q.get("text")) : "");
+                Object multiObj = q.get("multi");
+                nq.put("multi", multiObj != null ? Boolean.parseBoolean(String.valueOf(multiObj)) : Boolean.TRUE);
+                List<Map<String, Object>> options = new ArrayList<>();
+                Object optsObj = q.get("options");
+                if (optsObj instanceof List) {
+                    int oi = 0;
+                    for (Object o : (List<?>) optsObj) {
+                        Map<String, Object> no = new HashMap<>();
+                        if (o instanceof Map) {
+                            Map<?, ?> om = (Map<?, ?>) o;
+                            no.put("id", om.get("id") != null ? String.valueOf(om.get("id")) : ("o" + (oi + 1)));
+                            no.put("text", om.get("text") != null ? String.valueOf(om.get("text")) : "");
+                            Object tag = om.get("tag") != null ? om.get("tag") : om.get("resultTag");
+                            no.put("tag", tag != null ? String.valueOf(tag) : "");
+                        } else {
+                            no.put("id", "o" + (oi + 1));
+                            no.put("text", String.valueOf(o));
+                            no.put("tag", "");
+                        }
+                        options.add(no);
+                        oi++;
+                    }
                 }
-                String answerStr = String.valueOf(answer);
-                // 多选数组答案
-                if (answer instanceof List) {
-                    for (Object a : (List<?>) answer) {
-                        collectOptionTag(question, String.valueOf(a), tags);
+                nq.put("options", options);
+                result.add(nq);
+                qi++;
+            }
+        } catch (Exception ignored) {
+        }
+        return result;
+    }
+
+    /** 从 answers 聚合 resultTags(支持 [{questionId, optionIds}] 与旧 Map<qid, answer> 两种格式) */
+    private Set<String> aggregateTagsFromAnswers(List<Map<String, Object>> questions, Object answersObj) {
+        Set<String> tags = new LinkedHashSet<>();
+        if (answersObj == null || questions.isEmpty()) {
+            return tags;
+        }
+        try {
+            if (answersObj instanceof List) {
+                for (Object a : (List<?>) answersObj) {
+                    if (!(a instanceof Map)) {
+                        continue;
+                    }
+                    Map<?, ?> ans = (Map<?, ?>) a;
+                    Object qid = ans.get("questionId");
+                    Object optIds = ans.get("optionIds");
+                    // 兼容旧格式 [{questionText, optionTexts}]
+                    if (qid == null) qid = ans.get("questionText");
+                    if (optIds == null) optIds = ans.get("optionTexts");
+                    if (qid == null || optIds == null) {
+                        continue;
+                    }
+                    if (optIds instanceof List) {
+                        for (Object oid : (List<?>) optIds) {
+                            collectOptionTag(questions, String.valueOf(qid), String.valueOf(oid), tags);
+                        }
+                    } else {
+                        collectOptionTag(questions, String.valueOf(qid), String.valueOf(optIds), tags);
+                    }
+                }
+            } else if (answersObj instanceof Map) {
+                Map<?, ?> answers = (Map<?, ?>) answersObj;
+                for (Map.Entry<?, ?> e : answers.entrySet()) {
+                    Object answer = e.getValue();
+                    if (answer instanceof List) {
+                        for (Object a : (List<?>) answer) {
+                            collectOptionTag(questions, String.valueOf(e.getKey()), String.valueOf(a), tags);
+                        }
+                    } else {
+                        collectOptionTag(questions, String.valueOf(e.getKey()), String.valueOf(answer), tags);
                     }
-                } else {
-                    collectOptionTag(question, answerStr, tags);
                 }
             }
         } catch (Exception ignored) {
@@ -198,26 +323,62 @@ public class ProblemSurveyController {
         return tags;
     }
 
-    private void collectOptionTag(Map<String, Object> question, String answerValue, Set<String> tags) {
+    private void collectOptionTag(List<Map<String, Object>> questions, String questionKey,
+                                  String answerValue, Set<String> tags) {
+        Map<String, Object> question = findQuestion(questions, questionKey);
+        if (question == null) {
+            return;
+        }
         Object optionsObj = question.get("options");
         if (!(optionsObj instanceof List)) {
             return;
         }
+        // 先按选项 id 匹配,再按选项文本匹配(兼容纯字符串选项)
         for (Object o : (List<?>) optionsObj) {
             if (!(o instanceof Map)) {
                 continue;
             }
             Map<?, ?> option = (Map<?, ?>) o;
-            Object optId = option.get("id") != null ? option.get("id") : option.get("value");
+            Object optId = option.get("id");
             if (optId != null && String.valueOf(optId).equals(answerValue)) {
-                Object tag = option.get("tag") != null ? option.get("tag") : option.get("resultTag");
-                if (tag != null) {
-                    tags.add(String.valueOf(tag));
-                }
+                addTag(option, tags);
+                return;
+            }
+        }
+        for (Object o : (List<?>) optionsObj) {
+            if (!(o instanceof Map)) {
+                continue;
+            }
+            Map<?, ?> option = (Map<?, ?>) o;
+            Object text = option.get("text");
+            if (text != null && String.valueOf(text).equals(answerValue)) {
+                addTag(option, tags);
+                return;
             }
         }
     }
 
+    private void addTag(Map<?, ?> option, Set<String> tags) {
+        Object tag = option.get("tag");
+        if (tag != null && !String.valueOf(tag).trim().isEmpty()) {
+            tags.add(String.valueOf(tag).trim());
+        }
+    }
+
+    private Map<String, Object> findQuestion(List<Map<String, Object>> questions, String key) {
+        for (Map<String, Object> q : questions) {
+            Object id = q.get("id");
+            if (id != null && String.valueOf(id).equals(key)) {
+                return q;
+            }
+            Object text = q.get("text");
+            if (text != null && String.valueOf(text).equals(key)) {
+                return q;
+            }
+        }
+        return null;
+    }
+
     private String toJson(Object obj) {
         try {
             return OBJECT_MAPPER.writeValueAsString(obj);
@@ -225,4 +386,66 @@ public class ProblemSurveyController {
             return "{}";
         }
     }
+
+    /** 内置默认问卷:weight_loss / sleep / constipation(选项带 tag 用于 resultTags 聚合) */
+    private static Map<String, String> buildDefaultQuestions() {
+        Map<String, String> map = new HashMap<>();
+        map.put("weight_loss", "["
+                + "{\"id\":\"q1\",\"text\":\"目前孩子的体重情况如何?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"偏瘦\",\"tag\":\"体重偏瘦\"},"
+                + "{\"id\":\"o2\",\"text\":\"正常\",\"tag\":\"\"},"
+                + "{\"id\":\"o3\",\"text\":\"偏胖\",\"tag\":\"体重偏胖\"}]},"
+                + "{\"id\":\"q2\",\"text\":\"孩子的饮食结构如何?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"零食饮料多\",\"tag\":\"零食饮料\"},"
+                + "{\"id\":\"o2\",\"text\":\"主食过量\",\"tag\":\"主食过量\"},"
+                + "{\"id\":\"o3\",\"text\":\"蔬菜水果少\",\"tag\":\"蔬果不足\"},"
+                + "{\"id\":\"o4\",\"text\":\"三餐规律\",\"tag\":\"\"}]},"
+                + "{\"id\":\"q3\",\"text\":\"孩子每天运动情况如何?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"很少运动\",\"tag\":\"运动不足\"},"
+                + "{\"id\":\"o2\",\"text\":\"每周1-2次\",\"tag\":\"运动不足\"},"
+                + "{\"id\":\"o3\",\"text\":\"每周3次以上\",\"tag\":\"运动良好\"}]},"
+                + "{\"id\":\"q4\",\"text\":\"你最想改善的方面是什么?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"控制体重\",\"tag\":\"减重\"},"
+                + "{\"id\":\"o2\",\"text\":\"培养饮食习惯\",\"tag\":\"饮食管理\"},"
+                + "{\"id\":\"o3\",\"text\":\"增加运动\",\"tag\":\"运动习惯\"}]}]");
+        map.put("sleep", "["
+                + "{\"id\":\"q1\",\"text\":\"孩子每晚睡眠时长大约多少?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"不足8小时\",\"tag\":\"睡眠不足\"},"
+                + "{\"id\":\"o2\",\"text\":\"8-10小时\",\"tag\":\"睡眠正常\"},"
+                + "{\"id\":\"o3\",\"text\":\"超过10小时\",\"tag\":\"睡眠过多\"}]},"
+                + "{\"id\":\"q2\",\"text\":\"入睡情况如何?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"入睡困难\",\"tag\":\"入睡困难\"},"
+                + "{\"id\":\"o2\",\"text\":\"容易惊醒\",\"tag\":\"睡眠浅\"},"
+                + "{\"id\":\"o3\",\"text\":\"睡得很沉\",\"tag\":\"\"}]},"
+                + "{\"id\":\"q3\",\"text\":\"睡前习惯有哪些?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"看电子产品\",\"tag\":\"屏幕暴露\"},"
+                + "{\"id\":\"o2\",\"text\":\"喝含糖饮料\",\"tag\":\"饮食影响睡眠\"},"
+                + "{\"id\":\"o3\",\"text\":\"睡前剧烈运动\",\"tag\":\"睡前兴奋\"},"
+                + "{\"id\":\"o4\",\"text\":\"规律作息\",\"tag\":\"\"}]},"
+                + "{\"id\":\"q4\",\"text\":\"白天精神状态如何?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"白天犯困\",\"tag\":\"白天嗜睡\"},"
+                + "{\"id\":\"o2\",\"text\":\"注意力不集中\",\"tag\":\"注意力不足\"},"
+                + "{\"id\":\"o3\",\"text\":\"情绪烦躁\",\"tag\":\"情绪波动\"},"
+                + "{\"id\":\"o4\",\"text\":\"精力充沛\",\"tag\":\"\"}]}]");
+        map.put("constipation", "["
+                + "{\"id\":\"q1\",\"text\":\"孩子排便频率如何?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"每天1次以上\",\"tag\":\"\"},"
+                + "{\"id\":\"o2\",\"text\":\"2-3天1次\",\"tag\":\"排便频率低\"},"
+                + "{\"id\":\"o3\",\"text\":\"3天以上1次\",\"tag\":\"便秘\"}]},"
+                + "{\"id\":\"q2\",\"text\":\"大便性状如何?\",\"multi\":false,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"干硬颗粒状\",\"tag\":\"大便干结\"},"
+                + "{\"id\":\"o2\",\"text\":\"成形但偏干\",\"tag\":\"大便干结\"},"
+                + "{\"id\":\"o3\",\"text\":\"正常软便\",\"tag\":\"\"}]},"
+                + "{\"id\":\"q3\",\"text\":\"饮食习惯如何?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"蔬菜水果少\",\"tag\":\"膳食纤维不足\"},"
+                + "{\"id\":\"o2\",\"text\":\"饮水不足\",\"tag\":\"饮水不足\"},"
+                + "{\"id\":\"o3\",\"text\":\"爱吃油炸烧烤\",\"tag\":\"饮食油腻\"},"
+                + "{\"id\":\"o4\",\"text\":\"规律均衡\",\"tag\":\"\"}]},"
+                + "{\"id\":\"q4\",\"text\":\"是否有以下症状?(可多选)\",\"multi\":true,"
+                + "\"options\":[{\"id\":\"o1\",\"text\":\"腹胀\",\"tag\":\"腹胀\"},"
+                + "{\"id\":\"o2\",\"text\":\"腹痛\",\"tag\":\"腹痛\"},"
+                + "{\"id\":\"o3\",\"text\":\"食欲不振\",\"tag\":\"食欲不振\"},"
+                + "{\"id\":\"o4\",\"text\":\"无\",\"tag\":\"\"}]}]");
+        return map;
+    }
 }