Преглед изворни кода

fix(knowledge): 知识库列表支持 tagId/dimensionId 过滤与状态容错,新增自动回填接口

- list 增加 tagId/dimensionId 关联过滤(关联表反查 knowledge_id,空结果用 -1 常量避免 in 空集)
- status 参数改为类型容错解析(前端传字符串 "1"/"0" 不再 ClassCastException 500)
- 新增 /api/admin/knowledge-base/backfill-tags:基于标题/内容关键词自动回填标签与维度绑定
- 单元测试 KnowledgeBaseBackfillTest 7 项(标签 2字仅标题/3字+正文、组合标签拆分、维度身智行心映射、关联过滤)

备注:并行 rebase 中本地未推送的 5856eb277 fix(activity) 因改的是旧架构已删除的 isAdmin/isActivityProvider(已被 HEAD 的 visibleTabs/canManageActivity 取代),三方合并无净变化,rebase --skip 跳过。注意 HEAD 的 cfc-frontend/utils/activity-role.js isApprovedActivityProvider 仍用 vendorType+vendorStatus 判定,与 5856eb277 主张的仅凭 activityProviderStatus=approved 不一致,建议 activity 领域后续复核。
Xiaogang Liao пре 5 дана
родитељ
комит
672b21ea5b

+ 23 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -41,10 +41,31 @@ public class KnowledgeBaseController {
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String keyword = (String) params.get("keyword");
-        Integer status = (Integer) params.get("status");
+        Integer status = parseInteger(params.get("status"));
+        Long tagId = parseLong(params.get("tagId"));
+        Long dimensionId = parseLong(params.get("dimensionId"));
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        return Result.success(danKnowledgeBaseService.list(keyword, status, page, size));
+        return Result.success(danKnowledgeBaseService.list(keyword, status, page, size, tagId, dimensionId));
+    }
+
+    private Integer parseInteger(Object o) {
+        if (o == null || o.toString().trim().isEmpty()) {
+            return null;
+        }
+        return Integer.valueOf(o.toString().trim());
+    }
+
+    private Long parseLong(Object o) {
+        if (o == null || o.toString().trim().isEmpty()) {
+            return null;
+        }
+        return Long.valueOf(o.toString().trim());
+    }
+
+    @PostMapping("/backfill-tags")
+    public Result<Map<String, Object>> backfillTags() {
+        return Result.success(danKnowledgeBaseService.backfillTags());
     }
 
     @PostMapping("/get")

+ 166 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -6,19 +6,29 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBaseDimension;
 import com.etotem.cfc.entity.DanKnowledgeBaseTag;
+import com.etotem.cfc.entity.DanKnowledgeTag;
 import com.etotem.cfc.entity.ProductDimensionConfig;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
+import com.etotem.cfc.mapper.DanKnowledgeTagMapper;
 import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
@@ -32,6 +42,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     @Resource
     private DanKnowledgeBaseTagMapper danKnowledgeBaseTagMapper;
 
+    @Resource
+    private DanKnowledgeTagMapper danKnowledgeTagMapper;
+
     @Resource
     private ProductDimensionConfigMapper productDimensionConfigMapper;
 
@@ -39,6 +52,11 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     private LanggraphSyncService langgraphSyncService;
 
     public Map<String, Object> list(String keyword, Integer status, int page, int size) {
+        return list(keyword, status, page, size, null, null);
+    }
+
+    public Map<String, Object> list(String keyword, Integer status, int page, int size,
+                                    Long tagId, Long dimensionId) {
         Page<DanKnowledgeBase> p = new Page<>(page, size);
         LambdaQueryWrapper<DanKnowledgeBase> wrapper = new LambdaQueryWrapper<>();
         if (keyword != null && !keyword.isEmpty()) {
@@ -48,6 +66,17 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         if (status != null) {
             wrapper.eq(DanKnowledgeBase::getStatus, status);
         }
+        // 标签过滤:经关联表反查 knowledge_id;无命中用 -1 常量避免 in 空集导致 SQL 语法错误
+        if (tagId != null) {
+            List<Long> ids = knowledgeIdsByTag(tagId);
+            wrapper.in(DanKnowledgeBase::getId,
+                ids.isEmpty() ? Collections.singletonList(-1L) : ids);
+        }
+        if (dimensionId != null) {
+            List<Long> ids = knowledgeIdsByDimension(dimensionId);
+            wrapper.in(DanKnowledgeBase::getId,
+                ids.isEmpty() ? Collections.singletonList(-1L) : ids);
+        }
         wrapper.orderByAsc(DanKnowledgeBase::getSort);
         this.page(p, wrapper);
 
@@ -60,6 +89,22 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         return data;
     }
 
+    private List<Long> knowledgeIdsByTag(Long tagId) {
+        List<DanKnowledgeBaseTag> rels = danKnowledgeBaseTagMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseTag>()
+                .eq(DanKnowledgeBaseTag::getTagId, tagId));
+        return rels.stream().map(DanKnowledgeBaseTag::getKnowledgeId)
+            .distinct().collect(Collectors.toList());
+    }
+
+    private List<Long> knowledgeIdsByDimension(Long dimensionId) {
+        List<DanKnowledgeBaseDimension> rels = danKnowledgeBaseDimensionMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                .eq(DanKnowledgeBaseDimension::getDimensionId, dimensionId));
+        return rels.stream().map(DanKnowledgeBaseDimension::getKnowledgeId)
+            .distinct().collect(Collectors.toList());
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public DanKnowledgeBase saveKnowledge(DanKnowledgeBase knowledge) {
         if (knowledge.getId() != null) {
@@ -178,4 +223,125 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
             .filter(code -> code != null)
             .collect(Collectors.toList());
     }
+
+    // ==================== 标签/维度自动回填 ====================
+
+    private static final int MAX_TAGS_PER_KB = 12;
+
+    private static final Map<String, List<String>> DIMENSION_KEYWORDS;
+
+    static {
+        Map<String, List<String>> m = new LinkedHashMap<>();
+        m.put("body", Arrays.asList(
+            "饮食", "营养", "膳食", "肠道", "菌群", "微生物", "代谢", "血糖", "血脂", "血压",
+            "尿酸", "痛风", "心脑血管", "血管", "心脏", "肝脏", "肾脏", "免疫", "炎症",
+            "抗炎", "抗氧化", "自由基", "脂肪", "体重", "减脂", "骨骼", "关节", "骨密度",
+            "甲状腺", "内分泌", "消化", "胃", "肠", "过敏", "湿疹", "皮肤", "口腔", "眼睛",
+            "视力", "呼吸", "肺", "饮水", "饮用水", "水质", "矿物质", "钙", "镁", "维生素",
+            "蛋白质", "电解", "杀菌", "消毒", "致病菌", "体质", "代谢综合征"
+        ));
+        m.put("wisdom", Arrays.asList(
+            "认知", "大脑", "记忆", "专注", "注意力", "学习", "儿童", "教育", "测评",
+            "评估", "检测", "智商", "智力", "思维", "多动", "自闭", "菌群检测", "儿童成长"
+        ));
+        m.put("wealth", Arrays.asList(
+            "财富", "理财", "投资", "经济", "消费", "收益", "成本", "价值", "省钱", "创富"
+        ));
+        m.put("action", Arrays.asList(
+            "习惯", "日常", "管理", "计划", "坚持", "打卡", "预防", "干预", "方案", "调理",
+            "改善", "生活方式", "作息", "行动", "自测"
+        ));
+        m.put("mind", Arrays.asList(
+            "情绪", "心理", "压力", "焦虑", "抑郁", "幸福", "亲子", "家庭", "关系", "陪伴",
+            "疗愈", "正念", "冥想", "心态", "大爱", "睡眠"
+        ));
+        DIMENSION_KEYWORDS = Collections.unmodifiableMap(m);
+    }
+
+    /** 标签关键词匹配:2 字词仅标题命中;>=3 字词标题/正文均可;组合标签按分隔符拆分 */
+    public static Set<Long> matchTagIds(String title, String content, List<DanKnowledgeTag> tags) {
+        Set<Long> hit = new LinkedHashSet<>();
+        String t = title == null ? "" : title;
+        String c = content == null ? "" : content;
+        List<DanKnowledgeTag> sorted = new ArrayList<>(tags);
+        sorted.sort(Comparator
+            .comparingInt((DanKnowledgeTag tag) -> tag.getTagName() == null ? 0 : tag.getTagName().length())
+            .reversed());
+        for (DanKnowledgeTag tag : sorted) {
+            if (hit.size() >= MAX_TAGS_PER_KB) {
+                break;
+            }
+            String name = tag.getTagName();
+            if (name == null || name.isEmpty()) {
+                continue;
+            }
+            for (String kw : name.split("[,\uFF0C]")) {
+                kw = kw.trim();
+                if (kw.isEmpty()) {
+                    continue;
+                }
+                boolean inTitle = t.contains(kw);
+                boolean inContent = c.contains(kw);
+                if (inTitle || (kw.length() >= 3 && inContent)) {
+                    hit.add(tag.getId());
+                    break;
+                }
+            }
+        }
+        return hit;
+    }
+
+    /** 维度关键词匹配:命中任一关键词即归入该维度 */
+    public static Set<String> matchDimensionCodes(String title, String content) {
+        String text = (title == null ? "" : title) + "\n" + (content == null ? "" : content);
+        Set<String> hit = new LinkedHashSet<>();
+        for (Map.Entry<String, List<String>> e : DIMENSION_KEYWORDS.entrySet()) {
+            for (String kw : e.getValue()) {
+                if (text.contains(kw)) {
+                    hit.add(e.getKey());
+                    break;
+                }
+            }
+        }
+        return hit;
+    }
+
+    /** 为全部启用知识回填标签与维度绑定,返回统计 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> backfillTags() {
+        List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectList(null);
+        List<DanKnowledgeBase> list = this.list(new LambdaQueryWrapper<DanKnowledgeBase>()
+            .eq(DanKnowledgeBase::getStatus, 1));
+        int tagBound = 0;
+        int dimBound = 0;
+        for (DanKnowledgeBase kb : list) {
+            Set<Long> tagIds = matchTagIds(kb.getTitle(), kb.getContent(), tags);
+            if (!tagIds.isEmpty()) {
+                bindTags(kb.getId(), new ArrayList<>(tagIds));
+                tagBound++;
+            }
+            Set<String> codes = matchDimensionCodes(kb.getTitle(), kb.getContent());
+            List<Long> dimIds = codes.stream()
+                .map(this::dimensionIdByCode)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+            if (!dimIds.isEmpty()) {
+                bindDimensions(kb.getId(), dimIds);
+                dimBound++;
+            }
+        }
+        Map<String, Object> r = new HashMap<>();
+        r.put("knowledgeCount", list.size());
+        r.put("tagBoundCount", tagBound);
+        r.put("dimensionBoundCount", dimBound);
+        return r;
+    }
+
+    private Long dimensionIdByCode(String code) {
+        ProductDimensionConfig cfg = productDimensionConfigMapper.selectOne(
+            new LambdaQueryWrapper<ProductDimensionConfig>()
+                .eq(ProductDimensionConfig::getDimensionCode, code));
+        return cfg != null ? cfg.getId() : null;
+    }
 }

+ 119 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/KnowledgeBaseBackfillTest.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.DanKnowledgeBaseTag;
+import com.etotem.cfc.entity.DanKnowledgeTag;
+import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
+import com.etotem.cfc.service.DanKnowledgeBaseService;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 知识库标签/维度回填与关联过滤单元测试
+ * 覆盖:
+ * 1. 标签匹配:>=3 字词正文/标题命中;2 字词仅标题命中;组合标签按分隔符拆分
+ * 2. 维度匹配:正文关键词映射 身/智/富/行/心
+ * 3. 关联过滤:tagId -> knowledgeId 集合(含空结果兜底)
+ */
+class KnowledgeBaseBackfillTest {
+
+    private static DanKnowledgeTag tag(Long id, String name) {
+        DanKnowledgeTag t = new DanKnowledgeTag();
+        t.setId(id);
+        t.setTagName(name);
+        return t;
+    }
+
+    @Test
+    void matchTagIds_三字以上正文命中_两字仅标题() {
+        List<DanKnowledgeTag> tags = Arrays.asList(
+            tag(6L, "代谢综合征"),
+            tag(1L, "肠道菌群"),
+            tag(100L, "营养")
+        );
+        String content = "代谢综合征与肠道菌群失衡密切相关,建议膳食均衡。";
+        Set<Long> hit = DanKnowledgeBaseService.matchTagIds("", content, tags);
+        assertTrue(hit.contains(6L), "3字词正文应命中");
+        assertTrue(hit.contains(1L), "4字词正文应命中");
+        assertFalse(hit.contains(100L), "2字词仅正文出现不应命中");
+    }
+
+    @Test
+    void matchTagIds_两字词标题命中() {
+        List<DanKnowledgeTag> tags = Collections.singletonList(tag(80L, "饮用水"));
+        Set<Long> hit = DanKnowledgeBaseService.matchTagIds("饮用水安全指南", "正文不含该词", tags);
+        assertTrue(hit.contains(80L), "2字词标题命中应生效");
+    }
+
+    @Test
+    void matchTagIds_组合标签按分隔符拆分() {
+        List<DanKnowledgeTag> tags = Collections.singletonList(tag(115L, "水与茶,泡茶水,小分子团水"));
+        String content = "小分子团水更容易被人体吸收";
+        Set<Long> hit = DanKnowledgeBaseService.matchTagIds("", content, tags);
+        assertTrue(hit.contains(115L), "组合标签含命中词应命中该标签");
+    }
+
+    @Test
+    void matchDimensionCodes_身智命中() {
+        String content = "肠道菌群影响大脑认知发育,建议通过饮食调节菌群改善认知功能。";
+        Set<String> codes = DanKnowledgeBaseService.matchDimensionCodes("", content);
+        assertTrue(codes.contains("body"), "肠道/饮食应映射到身");
+        assertTrue(codes.contains("wisdom"), "认知/大脑应映射到智");
+    }
+
+    @Test
+    void matchDimensionCodes_心与行命中() {
+        String content = "情绪压力与焦虑影响睡眠,建议坚持正念冥想,养成良好作息习惯。";
+        Set<String> codes = DanKnowledgeBaseService.matchDimensionCodes("", content);
+        assertTrue(codes.contains("mind"), "情绪/焦虑/正念应映射到心");
+        assertTrue(codes.contains("action"), "坚持/作息习惯应映射到行");
+    }
+
+    @Test
+    void knowledgeIdsByTag_命中返回知识id() throws Exception {
+        DanKnowledgeBaseService service = new DanKnowledgeBaseService();
+        DanKnowledgeBaseTagMapper mapper = mock(DanKnowledgeBaseTagMapper.class);
+        inject(service, "danKnowledgeBaseTagMapper", mapper);
+        DanKnowledgeBaseTag rel = new DanKnowledgeBaseTag();
+        rel.setKnowledgeId(10L);
+        rel.setTagId(5L);
+        when(mapper.selectList(any())).thenReturn(Collections.singletonList(rel));
+
+        List<Long> ids = callKnowledgeIdsByTag(service, 5L);
+        assertEquals(Collections.singletonList(10L), ids);
+    }
+
+    @Test
+    void knowledgeIdsByTag_无命中返回空() throws Exception {
+        DanKnowledgeBaseService service = new DanKnowledgeBaseService();
+        DanKnowledgeBaseTagMapper mapper = mock(DanKnowledgeBaseTagMapper.class);
+        inject(service, "danKnowledgeBaseTagMapper", mapper);
+        when(mapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<Long> ids = callKnowledgeIdsByTag(service, 99L);
+        assertTrue(ids.isEmpty(), "无绑定时返回空集合,list 的 in 条件不误命中");
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Long> callKnowledgeIdsByTag(DanKnowledgeBaseService service, Long tagId) throws Exception {
+        Method m = DanKnowledgeBaseService.class.getDeclaredMethod("knowledgeIdsByTag", Long.class);
+        m.setAccessible(true);
+        return (List<Long>) m.invoke(service, tagId);
+    }
+
+    private static void inject(Object target, String fieldName, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}