|
|
@@ -327,6 +327,31 @@ public class AIChatController {
|
|
|
}
|
|
|
Map<String, Object> inputs = buildChatInputs(userId, params);
|
|
|
|
|
|
+ // 3.5 报告可用性门禁(S-cfc-350 / TC-B-04-E)
|
|
|
+ // - draftId 路径:草稿上下文必须已带解析后 payload(reportDetail),否则视为「报告解析中」
|
|
|
+ // - reportId 路径:上下文已带 reportDetail 时按归属校验;缺 reportDetail 视为无报告 → 提示上传
|
|
|
+ if (draftIdStr != null && !draftIdStr.trim().isEmpty()) {
|
|
|
+ Object draftDetail = inputs.get("reportDetail");
|
|
|
+ if (!(draftDetail instanceof Map) || ((Map<?, ?>) draftDetail).isEmpty()) {
|
|
|
+ return Result.error("报告解析中,请稍后重试");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Object detailObj = inputs.get("reportDetail");
|
|
|
+ if (detailObj instanceof Map) {
|
|
|
+ Object reportObj = ((Map<?, ?>) detailObj).get("report");
|
|
|
+ if (reportObj == null) {
|
|
|
+ return Result.error("尚未上传报告,请先上传报告后再发起解读");
|
|
|
+ }
|
|
|
+ Result<Void> accessDenied = checkReportAccess(user, reportObj);
|
|
|
+ if (accessDenied != null) {
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ Result<Map<String, Object>> denied = (Result<Map<String, Object>>) (Result<?>) accessDenied;
|
|
|
+ return denied;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // detailObj 缺失/非 Map(如上下文未注入 reportDetail):保持原有宽容行为,交由 AI 侧兜底回答
|
|
|
+ }
|
|
|
+
|
|
|
// 4. 调 LangGraph(经 aiService.sendMessage 含记忆层+镜像)
|
|
|
Map<String, Object> difyResp = aiService.sendMessage(
|
|
|
query, String.valueOf(userId),
|
|
|
@@ -336,13 +361,228 @@ public class AIChatController {
|
|
|
return Result.error("AI 服务暂不可用,请稍后重试");
|
|
|
}
|
|
|
String answer = (String) difyResp.getOrDefault("answer", "");
|
|
|
- String cleanAnswer = taskParseService.stripTaskMarkers(answer);
|
|
|
+ String cleanAnswer = maskSensitiveInfo(taskParseService.stripTaskMarkers(answer));
|
|
|
+
|
|
|
+ // 5. 结构化三段输出(通俗解读 / 五维关联 / 行动建议)+ 类型一致性提示
|
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
|
result.put("answer", cleanAnswer);
|
|
|
+ result.put("plainSummary", cleanAnswer);
|
|
|
+ result.put("fiveDimAssociations", buildFiveDimAssociations(cleanAnswer));
|
|
|
+ result.put("actionSuggestions", buildActionSuggestions(cleanAnswer));
|
|
|
+ result.put("typeWarning", buildTypeWarning(inputs));
|
|
|
result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
|
|
|
return Result.success(result);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 报告归属校验(TC-B-04-P):报告已带 familyId 且与当前登录用户家庭不一致 → 拒绝。
|
|
|
+ * 报告缺 familyId(历史数据/未归属)时 fail-open 放行,由上游 family 上下文保证隔离。
|
|
|
+ */
|
|
|
+ private Result<Void> checkReportAccess(com.etotem.cfc.entity.User user, Object reportObj) {
|
|
|
+ if (!(reportObj instanceof com.etotem.cfc.entity.HealthReport)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ com.etotem.cfc.entity.HealthReport report = (com.etotem.cfc.entity.HealthReport) reportObj;
|
|
|
+ Long reportFamilyId = report.getFamilyId();
|
|
|
+ if (reportFamilyId == null || user == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Long userFamilyId = user.getFamilyId();
|
|
|
+ if (userFamilyId != null && !reportFamilyId.equals(userFamilyId)) {
|
|
|
+ return Result.forbidden("无权解读该报告,报告不属于当前家庭");
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 五维关键词表(身/心/智/行/富) */
|
|
|
+ private static final String[][] FIVE_DIM_KEYWORDS = {
|
|
|
+ // body 身
|
|
|
+ {"肠道", "菌群", "免疫", "营养", "代谢", "消化", "身体", "体质", "健康", "炎症", "睡眠", "体重"},
|
|
|
+ // mind 心
|
|
|
+ {"情绪", "压力", "焦虑", "心理", "心情", "情感", "抑郁", "心态"},
|
|
|
+ // intellect 智
|
|
|
+ {"认知", "学习", "记忆", "专注", "注意力", "智力", "思维", "大脑"},
|
|
|
+ // action 行
|
|
|
+ {"运动", "锻炼", "作息", "习惯", "行为", "活动", "执行", "坚持"},
|
|
|
+ // wealth 富
|
|
|
+ {"医疗", "保险", "费用", "花费", "支出", "财富", "理财", "预算", "经济"}
|
|
|
+ };
|
|
|
+
|
|
|
+ private static final String[] FIVE_DIM_FALLBACKS = {
|
|
|
+ "身体维度:建议结合本次报告关注身体基础指标,维持均衡饮食与规律作息。",
|
|
|
+ "心理维度:报告未直接覆盖情绪,可结合日常情绪变化观察身体状况对心理的影响。",
|
|
|
+ "智力维度:报告未直接覆盖认知,可关注营养与睡眠对学习专注度的间接支持。",
|
|
|
+ "行动维度:建议把报告中的改善建议转化为可执行的小步骤并持续跟进。",
|
|
|
+ "财富维度:报告未涉及财务信息,可在医疗/营养支出上做合理规划。"
|
|
|
+ };
|
|
|
+
|
|
|
+ private static final String[] ACTION_SUGGESTION_KEYWORDS = {
|
|
|
+ "建议", "应该", "可以", "需要", "推荐", "尽量", "注意", "不妨", "宜"
|
|
|
+ };
|
|
|
+
|
|
|
+ private static final List<String> DEFAULT_ACTION_SUGGESTIONS;
|
|
|
+
|
|
|
+ static {
|
|
|
+ List<String> defaults = new ArrayList<>();
|
|
|
+ defaults.add("建议结合报告结论,把改善动作拆成每天可完成的小步骤并记录执行情况。");
|
|
|
+ defaults.add("建议持续跟踪关键指标变化,必要时复检或咨询专业人士。");
|
|
|
+ DEFAULT_ACTION_SUGGESTIONS = Collections.unmodifiableList(defaults);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把解读文本按中文句读切分为句子 */
|
|
|
+ private List<String> splitSentences(String text) {
|
|
|
+ List<String> sentences = new ArrayList<>();
|
|
|
+ if (text == null || text.trim().isEmpty()) {
|
|
|
+ return sentences;
|
|
|
+ }
|
|
|
+ String[] parts = text.split("[。!?!?;;\n]+");
|
|
|
+ for (String part : parts) {
|
|
|
+ String trimmed = part == null ? "" : part.trim();
|
|
|
+ if (!trimmed.isEmpty()) {
|
|
|
+ sentences.add(trimmed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sentences;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 基于关键词把解读文本映射到身/心/智/行/富五维;命不中的维度用兜底文案,
|
|
|
+ * 保证输出恒定含 body/mind/intellect/action/wealth 五键。
|
|
|
+ */
|
|
|
+ private Map<String, Object> buildFiveDimAssociations(String text) {
|
|
|
+ List<String> sentences = splitSentences(text);
|
|
|
+ String[] dimKeys = {"body", "mind", "intellect", "action", "wealth"};
|
|
|
+ Map<String, Object> dims = new LinkedHashMap<>();
|
|
|
+ for (int i = 0; i < dimKeys.length; i++) {
|
|
|
+ String matched = null;
|
|
|
+ for (String sentence : sentences) {
|
|
|
+ for (String keyword : FIVE_DIM_KEYWORDS[i]) {
|
|
|
+ if (sentence.contains(keyword)) {
|
|
|
+ matched = sentence;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (matched != null) break;
|
|
|
+ }
|
|
|
+ dims.put(dimKeys[i], matched != null ? matched : FIVE_DIM_FALLBACKS[i]);
|
|
|
+ }
|
|
|
+ return dims;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 抽取含建议类关键词的句子作为行动建议;无命中时返回兜底建议 */
|
|
|
+ private List<String> buildActionSuggestions(String text) {
|
|
|
+ List<String> suggestions = new ArrayList<>();
|
|
|
+ for (String sentence : splitSentences(text)) {
|
|
|
+ for (String keyword : ACTION_SUGGESTION_KEYWORDS) {
|
|
|
+ if (sentence.contains(keyword)) {
|
|
|
+ suggestions.add(sentence);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (suggestions.isEmpty()) {
|
|
|
+ return new ArrayList<>(DEFAULT_ACTION_SUGGESTIONS);
|
|
|
+ }
|
|
|
+ return suggestions;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 报告类型一致性提示:类型集合为空 / 含未知类型 / 多于一种类型时视为不可横向比较。
|
|
|
+ */
|
|
|
+ private Map<String, Object> buildTypeWarning(Map<String, Object> inputs) {
|
|
|
+ Set<String> types = new LinkedHashSet<>();
|
|
|
+ Object detailObj = inputs.get("reportDetail");
|
|
|
+ if (detailObj instanceof Map) {
|
|
|
+ Object reportObj = ((Map<?, ?>) detailObj).get("report");
|
|
|
+ if (reportObj instanceof com.etotem.cfc.entity.HealthReport) {
|
|
|
+ String reportType = ((com.etotem.cfc.entity.HealthReport) reportObj).getReportType();
|
|
|
+ if (reportType != null && !reportType.trim().isEmpty()) {
|
|
|
+ types.add(reportType.trim());
|
|
|
+ } else {
|
|
|
+ types.add("");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ boolean mismatch = types.isEmpty() || types.contains("") || types.size() > 1;
|
|
|
+ Map<String, Object> warning = new LinkedHashMap<>();
|
|
|
+ warning.put("mismatch", mismatch);
|
|
|
+ if (mismatch) {
|
|
|
+ warning.put("message", "检测到不同类型的报告,数据口径不一致,不建议直接横向比较");
|
|
|
+ }
|
|
|
+ return warning;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── PII 脱敏(S-cfc-350 / TC-B-04-E 异常流-4) ──
|
|
|
+
|
|
|
+ /** 中国大陆手机号:1[3-9] 开头的 11 位数字,前后不接数字 */
|
|
|
+ private static final Pattern PII_PHONE_PATTERN =
|
|
|
+ Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
|
|
|
+
|
|
|
+ /** 长数字串(身份证/银行卡等 15-18 位)整体遮蔽 */
|
|
|
+ private static final Pattern PII_LONG_DIGIT_PATTERN =
|
|
|
+ Pattern.compile("(?<!\\d)\\d{15,18}(?!\\d)");
|
|
|
+
|
|
|
+ /** 中文姓名:2-3 个汉字后紧跟「的」或称谓类后缀 */
|
|
|
+ private static final Pattern PII_NAME_PATTERN =
|
|
|
+ Pattern.compile("[\\u4e00-\\u9fa5]{2,3}(?=的|先生|女士|小姐|老师|医生|大夫|教授|主任|同学|患者|受检人|受检者|本人)");
|
|
|
+
|
|
|
+ /** 姓名脱敏白名单:常见泛指/亲属/职业/部位词,命中后缀时不应被当成姓名遮蔽 */
|
|
|
+ private static final Set<String> NAME_STOPWORDS;
|
|
|
+
|
|
|
+ static {
|
|
|
+ Set<String> words = new HashSet<>();
|
|
|
+ String[] raw = {
|
|
|
+ "孩子", "家长", "小孩", "宝宝", "儿童", "学生", "大人", "父母", "爸爸", "妈妈",
|
|
|
+ "爷爷", "奶奶", "外公", "外婆", "哥哥", "姐姐", "弟弟", "妹妹", "儿子", "女儿",
|
|
|
+ "老师", "医生", "大夫", "护士", "教授", "主任", "同学", "朋友", "本人", "患者",
|
|
|
+ "受检人", "受检者", "用户", "我们", "他们", "大家", "建议", "关注", "注意",
|
|
|
+ "肠道", "菌群", "免疫", "营养", "代谢", "消化", "睡眠", "体重", "体质",
|
|
|
+ "情绪", "心理", "压力", "焦虑", "认知", "学习", "记忆", "专注", "大脑",
|
|
|
+ "运动", "锻炼", "作息", "习惯", "行为", "医疗", "保险", "费用", "花费",
|
|
|
+ "主治", "主治医生", "可联系主治", "联系", "可以联系", "报告", "数据", "结果",
|
|
|
+ "以上", "以下", "如下", "因此", "所以", "但是", "然而", "如果", "当", "其中",
|
|
|
+ "其他", "其它", "有些", "有的", "所有", "任何", "每个", "某些", "这种", "那种"
|
|
|
+ };
|
|
|
+ for (String w : raw) {
|
|
|
+ words.add(w);
|
|
|
+ }
|
|
|
+ NAME_STOPWORDS = Collections.unmodifiableSet(words);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对解读输出做最小必要脱敏:手机号 → 前 3 + **** + 后 4;长数字串遮蔽;中文姓名 → ***。
|
|
|
+ */
|
|
|
+ private String maskSensitiveInfo(String text) {
|
|
|
+ if (text == null || text.isEmpty()) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ Matcher phoneMatcher = PII_PHONE_PATTERN.matcher(text);
|
|
|
+ StringBuffer phoneBuf = new StringBuffer();
|
|
|
+ while (phoneMatcher.find()) {
|
|
|
+ String full = phoneMatcher.group();
|
|
|
+ phoneMatcher.appendReplacement(phoneBuf,
|
|
|
+ Matcher.quoteReplacement(full.substring(0, 3) + "****" + full.substring(full.length() - 4)));
|
|
|
+ }
|
|
|
+ phoneMatcher.appendTail(phoneBuf);
|
|
|
+ String masked = PII_LONG_DIGIT_PATTERN.matcher(phoneBuf.toString()).replaceAll("********");
|
|
|
+ Matcher nameMatcher = PII_NAME_PATTERN.matcher(masked);
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ int lastEnd = 0;
|
|
|
+ while (nameMatcher.find()) {
|
|
|
+ String token = nameMatcher.group();
|
|
|
+ int start = nameMatcher.start();
|
|
|
+ sb.append(masked, lastEnd, start);
|
|
|
+ if (NAME_STOPWORDS.contains(token)) {
|
|
|
+ sb.append(token);
|
|
|
+ } else {
|
|
|
+ sb.append("***");
|
|
|
+ }
|
|
|
+ lastEnd = nameMatcher.end();
|
|
|
+ }
|
|
|
+ sb.append(masked.substring(lastEnd));
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
@Operation(summary = "获取会话列表")
|
|
|
@PostMapping("/chat/conversations")
|
|
|
public Result<List<Map<String, Object>>> getConversations(
|