Prechádzať zdrojové kódy

fix(knowledge): 修复编辑回显并保存时自动绑定标签维度

Xiaogang Liao 2 týždňov pred
rodič
commit
5a2a67f4a8

+ 27 - 8
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -21,6 +21,7 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -77,8 +78,9 @@ public class KnowledgeBaseController {
             id = com.etotem.cfc.util.ParamUtils.getLong(body.get("id"));
         }
         if (id == null) return Result.error("id不能为空");
-        return danKnowledgeBaseService.getWithAssociations(id) != null
-                ? Result.success(danKnowledgeBaseService.getWithAssociations(id))
+        Map<String, Object> detail = danKnowledgeBaseService.getWithAssociations(id);
+        return detail != null
+                ? Result.success(detail)
                 : Result.error("知识条目不存在");
     }
 
@@ -102,8 +104,8 @@ public class KnowledgeBaseController {
         knowledge.setFileName((String) params.get("fileName"));
         knowledge.setSourceUrl((String) params.get("sourceUrl"));
         knowledge.setSourceType((String) params.get("sourceType"));
-        List<Long> dimensionIds = (List<Long>) params.get("dimensionIds");
-        List<Long> tagIds = (List<Long>) params.get("tagIds");
+        List<Long> dimensionIds = toLongList(params.get("dimensionIds"));
+        List<Long> tagIds = toLongList(params.get("tagIds"));
         DanKnowledgeBase saved = danKnowledgeBaseService.saveWithAssociations(knowledge, dimensionIds, tagIds);
         if (saved.getId() != null && StringUtils.hasText(saved.getContent())) {
             try {
@@ -129,8 +131,7 @@ public class KnowledgeBaseController {
     @PostMapping("/bind-dimensions")
     public Result<Void> bindDimensions(@RequestBody Map<String, Object> params) {
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
-        @SuppressWarnings("unchecked")
-        List<Long> dimensionIds = (List<Long>) params.get("dimensionIds");
+        List<Long> dimensionIds = toLongList(params.get("dimensionIds"));
         danKnowledgeBaseService.bindDimensions(knowledgeId, dimensionIds);
         return Result.success(null);
     }
@@ -138,8 +139,7 @@ public class KnowledgeBaseController {
     @PostMapping("/bind-tags")
     public Result<Void> bindTags(@RequestBody Map<String, Object> params) {
         Long knowledgeId = ((Number) params.get("knowledgeId")).longValue();
-        @SuppressWarnings("unchecked")
-        List<Long> tagIds = (List<Long>) params.get("tagIds");
+        List<Long> tagIds = toLongList(params.get("tagIds"));
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);
         return Result.success(null);
     }
@@ -198,6 +198,25 @@ public class KnowledgeBaseController {
         }
     }
 
+    /**
+     * Jackson 将 JSON 数字数组反序列化为 List<Integer>,直接强转 List<Long> 会在使用元素时
+     * 触发 ClassCastException。这里统一转换为 Long,并过滤 null/非法值。
+     */
+    @SuppressWarnings("unchecked")
+    private List<Long> toLongList(Object value) {
+        if (!(value instanceof List)) {
+            return null;
+        }
+        List<Long> result = new ArrayList<>();
+        for (Object item : (List<Object>) value) {
+            Long id = com.etotem.cfc.util.ParamUtils.getLong(item);
+            if (id != null) {
+                result.add(id);
+            }
+        }
+        return result;
+    }
+
     private String extractTitle(String html) {
         int titleStart = html.indexOf("<title");
         if (titleStart == -1) return "";

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanKnowledgeBase.java

@@ -1,12 +1,14 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 
 import java.io.Serializable;
 import java.util.Date;
+import java.util.List;
 
 @Data
 @TableName("dan_knowledge_base")
@@ -36,4 +38,19 @@ public class DanKnowledgeBase implements Serializable {
     private Date createdAt;
 
     private Date updatedAt;
+
+    /**
+     * 关联维度/标签,仅用于接口返回与编辑页回显,不映射数据库列。
+     */
+    @TableField(exist = false)
+    private List<Long> dimensionIds;
+
+    @TableField(exist = false)
+    private List<String> dimensionCodes;
+
+    @TableField(exist = false)
+    private List<Long> tagIds;
+
+    @TableField(exist = false)
+    private List<String> tagNames;
 }

+ 191 - 17
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -91,6 +91,7 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         }
         wrapper.orderByAsc(DanKnowledgeBase::getSort).orderByAsc(DanKnowledgeBase::getId);
         this.page(p, wrapper);
+        fillAssociations(p.getRecords());
 
         SortUtil.applySort(wrapper);
         Map<String, Object> data = new HashMap<>();
@@ -131,17 +132,57 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     public DanKnowledgeBase saveWithAssociations(DanKnowledgeBase knowledge,
                                                   List<Long> dimensionIds,
                                                   List<Long> tagIds) {
+        DanKnowledgeBase existing = knowledge.getId() != null ? this.getById(knowledge.getId()) : null;
         if (knowledge.getId() != null) {
             this.updateById(knowledge);
         } else {
             this.save(knowledge);
         }
-        if (dimensionIds != null) {
-            bindDimensions(knowledge.getId(), dimensionIds);
+
+        List<Long> resolvedDimensionIds = normalizeIds(dimensionIds);
+        List<Long> resolvedTagIds = normalizeIds(tagIds);
+        String matchTitle = knowledge.getTitle() != null
+                ? knowledge.getTitle() : (existing != null ? existing.getTitle() : null);
+        String matchContent = knowledge.getContent() != null
+                ? knowledge.getContent() : (existing != null ? existing.getContent() : null);
+
+        // 未显式选择维度时按标题/正文关键词自动推断;显式传入空数组同样视为“未选择”。
+        if (resolvedDimensionIds.isEmpty()) {
+            Set<String> codes = matchDimensionCodes(matchTitle, matchContent);
+            resolvedDimensionIds = codes.stream()
+                .map(this::dimensionIdByCode)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+            if (!resolvedDimensionIds.isEmpty()) {
+                bindDimensions(knowledge.getId(), resolvedDimensionIds);
+                log.info("知识[{}]自动绑定维度: {}", knowledge.getId(), resolvedDimensionIds);
+            } else if (dimensionIds != null) {
+                // 编辑页显式清空且无关键词命中时,尊重清空动作
+                bindDimensions(knowledge.getId(), Collections.emptyList());
+            }
+        } else {
+            bindDimensions(knowledge.getId(), resolvedDimensionIds);
         }
-        if (tagIds != null) {
-            bindTags(knowledge.getId(), tagIds);
+
+        // 未显式选择标签时按标题/正文关键词自动匹配;显式传入空数组同样视为“未选择”。
+        if (resolvedTagIds.isEmpty()) {
+            List<DanKnowledgeTag> allTags = danKnowledgeTagMapper.selectList(null);
+            resolvedTagIds = new ArrayList<>(matchTagIds(matchTitle, matchContent, allTags));
+            if (!resolvedTagIds.isEmpty()) {
+                bindTags(knowledge.getId(), resolvedTagIds);
+                log.info("知识[{}]自动绑定标签: {}", knowledge.getId(), resolvedTagIds);
+            } else if (tagIds != null) {
+                // 编辑页显式清空且无关键词命中时,尊重清空动作
+                bindTags(knowledge.getId(), Collections.emptyList());
+            }
+        } else {
+            bindTags(knowledge.getId(), resolvedTagIds);
         }
+
+        knowledge.setDimensionIds(resolvedDimensionIds);
+        knowledge.setTagIds(resolvedTagIds);
+        fillAssociationNames(knowledge);
         return knowledge;
     }
 
@@ -150,23 +191,153 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
         if (knowledge == null) {
             return null;
         }
-        List<Long> dimensionIds = danKnowledgeBaseDimensionMapper.selectList(
-            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
-                .eq(DanKnowledgeBaseDimension::getKnowledgeId, id))
-            .stream().map(DanKnowledgeBaseDimension::getDimensionId)
-            .collect(Collectors.toList());
-        List<Long> tagIds = danKnowledgeBaseTagMapper.selectList(
-            new LambdaQueryWrapper<DanKnowledgeBaseTag>()
-                .eq(DanKnowledgeBaseTag::getKnowledgeId, id))
-            .stream().map(DanKnowledgeBaseTag::getTagId)
-            .collect(Collectors.toList());
-        Map<String, Object> result = new HashMap<>();
+        fillAssociations(Collections.singletonList(knowledge));
+        Map<String, Object> result = new LinkedHashMap<>();
         result.put("knowledge", knowledge);
-        result.put("dimensionIds", dimensionIds);
-        result.put("tagIds", tagIds);
+        result.put("dimensionIds", knowledge.getDimensionIds());
+        result.put("tagIds", knowledge.getTagIds());
+        // 编辑弹窗直接读取 res.data,因此同时提供平铺字段;保留 knowledge 嵌套兼容旧调用方。
+        result.put("id", knowledge.getId());
+        result.put("title", knowledge.getTitle());
+        result.put("content", knowledge.getContent());
+        result.put("sort", knowledge.getSort());
+        result.put("status", knowledge.getStatus());
+        result.put("remark", knowledge.getRemark());
+        result.put("fileUrl", knowledge.getFileUrl());
+        result.put("fileName", knowledge.getFileName());
+        result.put("sourceUrl", knowledge.getSourceUrl());
+        result.put("sourceType", knowledge.getSourceType());
+        result.put("createdAt", knowledge.getCreatedAt());
+        result.put("updatedAt", knowledge.getUpdatedAt());
+        result.put("dimensionCodes", knowledge.getDimensionCodes());
+        result.put("tagNames", knowledge.getTagNames());
         return result;
     }
 
+    private List<Long> normalizeIds(List<Long> ids) {
+        if (ids == null) {
+            return Collections.emptyList();
+        }
+        return ids.stream()
+            .filter(Objects::nonNull)
+            .distinct()
+            .collect(Collectors.toList());
+    }
+
+    /**
+     * 批量补齐知识条目的标签/维度,供管理端列表、langgraph 同步复用。
+     * 使用批量查询避免列表/导出时的 N+1。
+     */
+    private void fillAssociations(List<DanKnowledgeBase> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        List<Long> knowledgeIds = records.stream()
+            .map(DanKnowledgeBase::getId)
+            .filter(Objects::nonNull)
+            .distinct()
+            .collect(Collectors.toList());
+        if (knowledgeIds.isEmpty()) {
+            return;
+        }
+
+        List<DanKnowledgeBaseTag> tagRelations = danKnowledgeBaseTagMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseTag>()
+                .in(DanKnowledgeBaseTag::getKnowledgeId, knowledgeIds));
+        List<DanKnowledgeBaseDimension> dimRelations = danKnowledgeBaseDimensionMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                .in(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeIds));
+        if (tagRelations == null) tagRelations = Collections.emptyList();
+        if (dimRelations == null) dimRelations = Collections.emptyList();
+
+        Map<Long, List<Long>> tagIdsByKnowledge = new HashMap<>();
+        Set<Long> allTagIds = new LinkedHashSet<>();
+        for (DanKnowledgeBaseTag rel : tagRelations) {
+            tagIdsByKnowledge.computeIfAbsent(rel.getKnowledgeId(), k -> new ArrayList<>())
+                .add(rel.getTagId());
+            allTagIds.add(rel.getTagId());
+        }
+        Map<Long, List<Long>> dimIdsByKnowledge = new HashMap<>();
+        Set<Long> allDimIds = new LinkedHashSet<>();
+        for (DanKnowledgeBaseDimension rel : dimRelations) {
+            dimIdsByKnowledge.computeIfAbsent(rel.getKnowledgeId(), k -> new ArrayList<>())
+                .add(rel.getDimensionId());
+            allDimIds.add(rel.getDimensionId());
+        }
+
+        Map<Long, String> tagNamesById = new HashMap<>();
+        if (!allTagIds.isEmpty()) {
+            List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectBatchIds(allTagIds);
+            if (tags != null) {
+                for (DanKnowledgeTag tag : tags) {
+                    if (tag.getId() != null && tag.getTagName() != null) {
+                        tagNamesById.put(tag.getId(), tag.getTagName());
+                    }
+                }
+            }
+        }
+        Map<Long, String> dimCodesById = new HashMap<>();
+        if (!allDimIds.isEmpty()) {
+            List<ProductDimensionConfig> configs = productDimensionConfigMapper.selectBatchIds(allDimIds);
+            if (configs != null) {
+                for (ProductDimensionConfig config : configs) {
+                    if (config.getId() != null && config.getDimensionCode() != null) {
+                        dimCodesById.put(config.getId(), config.getDimensionCode());
+                    }
+                }
+            }
+        }
+
+        for (DanKnowledgeBase record : records) {
+            List<Long> tids = tagIdsByKnowledge.getOrDefault(record.getId(), Collections.emptyList());
+            List<Long> dids = dimIdsByKnowledge.getOrDefault(record.getId(), Collections.emptyList());
+            record.setTagIds(tids);
+            record.setDimensionIds(dids);
+            record.setTagNames(tids.stream().map(tagNamesById::get)
+                .filter(Objects::nonNull).collect(Collectors.toList()));
+            record.setDimensionCodes(dids.stream().map(dimCodesById::get)
+                .filter(Objects::nonNull).collect(Collectors.toList()));
+        }
+    }
+
+    private void fillAssociationNames(DanKnowledgeBase knowledge) {
+        if (knowledge == null) {
+            return;
+        }
+        List<Long> tagIds = knowledge.getTagIds();
+        if (tagIds != null && !tagIds.isEmpty()) {
+            List<DanKnowledgeTag> tags = danKnowledgeTagMapper.selectBatchIds(tagIds);
+            Map<Long, String> names = new HashMap<>();
+            if (tags != null) {
+                for (DanKnowledgeTag tag : tags) {
+                    if (tag.getId() != null && tag.getTagName() != null) {
+                        names.put(tag.getId(), tag.getTagName());
+                    }
+                }
+            }
+            knowledge.setTagNames(tagIds.stream().map(names::get)
+                .filter(Objects::nonNull).collect(Collectors.toList()));
+        } else {
+            knowledge.setTagNames(Collections.emptyList());
+        }
+        List<Long> dimIds = knowledge.getDimensionIds();
+        if (dimIds != null && !dimIds.isEmpty()) {
+            List<ProductDimensionConfig> configs = productDimensionConfigMapper.selectBatchIds(dimIds);
+            Map<Long, String> codes = new HashMap<>();
+            if (configs != null) {
+                for (ProductDimensionConfig config : configs) {
+                    if (config.getId() != null && config.getDimensionCode() != null) {
+                        codes.put(config.getId(), config.getDimensionCode());
+                    }
+                }
+            }
+            knowledge.setDimensionCodes(dimIds.stream().map(codes::get)
+                .filter(Objects::nonNull).collect(Collectors.toList()));
+        } else {
+            knowledge.setDimensionCodes(Collections.emptyList());
+        }
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public boolean deleteKnowledge(Long id) {
         // 关联表可能未建/已缺失,删除主记录不允许被关联表异常阻断
@@ -273,6 +444,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     /** 标签关键词匹配:2 字词仅标题命中;>=3 字词标题/正文均可;组合标签按分隔符拆分 */
     public static Set<Long> matchTagIds(String title, String content, List<DanKnowledgeTag> tags) {
         Set<Long> hit = new LinkedHashSet<>();
+        if (tags == null || tags.isEmpty()) {
+            return hit;
+        }
         String t = title == null ? "" : title;
         String c = content == null ? "" : content;
         List<DanKnowledgeTag> sorted = new ArrayList<>(tags);

+ 106 - 3
cfc-backend/src/test/java/com/etotem/cfc/unit/KnowledgeBaseBackfillTest.java

@@ -1,21 +1,32 @@
 package com.etotem.cfc.unit;
 
+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 com.etotem.cfc.service.DanKnowledgeBaseService;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 
 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.Map;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
@@ -79,6 +90,89 @@ class KnowledgeBaseBackfillTest {
         assertTrue(codes.contains("action"), "坚持/作息习惯应映射到行");
     }
 
+    @Test
+    void getWithAssociations_returnsFlatContentAndAssociationFields() throws Exception {
+        DanKnowledgeBaseService service = new DanKnowledgeBaseService();
+        DanKnowledgeBaseMapper kbMapper = mock(DanKnowledgeBaseMapper.class);
+        DanKnowledgeBaseTagMapper tagRelMapper = mock(DanKnowledgeBaseTagMapper.class);
+        DanKnowledgeBaseDimensionMapper dimRelMapper = mock(DanKnowledgeBaseDimensionMapper.class);
+        DanKnowledgeTagMapper tagMapper = mock(DanKnowledgeTagMapper.class);
+        ProductDimensionConfigMapper configMapper = mock(ProductDimensionConfigMapper.class);
+        inject(service, "baseMapper", kbMapper);
+        inject(service, "danKnowledgeBaseTagMapper", tagRelMapper);
+        inject(service, "danKnowledgeBaseDimensionMapper", dimRelMapper);
+        inject(service, "danKnowledgeTagMapper", tagMapper);
+        inject(service, "productDimensionConfigMapper", configMapper);
+
+        DanKnowledgeBase kb = new DanKnowledgeBase();
+        kb.setId(9L);
+        kb.setTitle("肠道健康");
+        kb.setContent("完整正文内容");
+        when(kbMapper.selectById(9L)).thenReturn(kb);
+
+        DanKnowledgeBaseTag tagRel = new DanKnowledgeBaseTag();
+        tagRel.setKnowledgeId(9L);
+        tagRel.setTagId(1L);
+        when(tagRelMapper.selectList(any())).thenReturn(Collections.singletonList(tagRel));
+        DanKnowledgeBaseDimension dimRel = new DanKnowledgeBaseDimension();
+        dimRel.setKnowledgeId(9L);
+        dimRel.setDimensionId(3L);
+        when(dimRelMapper.selectList(any())).thenReturn(Collections.singletonList(dimRel));
+        DanKnowledgeTag tag = tag(1L, "肠道菌群");
+        when(tagMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(tag));
+        ProductDimensionConfig cfg = new ProductDimensionConfig();
+        cfg.setId(3L);
+        cfg.setDimensionCode("body");
+        when(configMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(cfg));
+
+        Map<String, Object> detail = service.getWithAssociations(9L);
+        assertEquals("完整正文内容", detail.get("content"), "编辑页需要平铺 content");
+        assertEquals(9L, detail.get("id"));
+        assertEquals(Collections.singletonList(1L), detail.get("tagIds"));
+        assertEquals(Collections.singletonList(3L), detail.get("dimensionIds"));
+        assertEquals(Collections.singletonList("肠道菌群"), detail.get("tagNames"));
+        assertEquals(Collections.singletonList("body"), detail.get("dimensionCodes"));
+    }
+
+    @Test
+    void saveWithAssociations_emptyAssociations_autoBindsMatchedTagAndDimension() throws Exception {
+        DanKnowledgeBaseService service = new DanKnowledgeBaseService();
+        DanKnowledgeBaseMapper kbMapper = mock(DanKnowledgeBaseMapper.class);
+        DanKnowledgeBaseTagMapper tagRelMapper = mock(DanKnowledgeBaseTagMapper.class);
+        DanKnowledgeBaseDimensionMapper dimRelMapper = mock(DanKnowledgeBaseDimensionMapper.class);
+        DanKnowledgeTagMapper tagMapper = mock(DanKnowledgeTagMapper.class);
+        ProductDimensionConfigMapper configMapper = mock(ProductDimensionConfigMapper.class);
+        inject(service, "baseMapper", kbMapper);
+        inject(service, "danKnowledgeBaseTagMapper", tagRelMapper);
+        inject(service, "danKnowledgeBaseDimensionMapper", dimRelMapper);
+        inject(service, "danKnowledgeTagMapper", tagMapper);
+        inject(service, "productDimensionConfigMapper", configMapper);
+
+        when(kbMapper.insert(any())).thenAnswer(invocation -> {
+            ((DanKnowledgeBase) invocation.getArgument(0)).setId(101L);
+            return 1;
+        });
+        when(tagMapper.selectList(null)).thenReturn(Collections.singletonList(tag(1L, "肠道菌群")));
+        ProductDimensionConfig cfg = new ProductDimensionConfig();
+        cfg.setId(7L);
+        cfg.setDimensionCode("body");
+        when(configMapper.selectOne(any())).thenReturn(cfg);
+
+        DanKnowledgeBase kb = new DanKnowledgeBase();
+        kb.setTitle("肠道健康指南");
+        kb.setContent("肠道菌群与饮食营养密切相关。");
+        service.saveWithAssociations(kb, Collections.emptyList(), Collections.emptyList());
+
+        ArgumentCaptor<DanKnowledgeBaseTag> tagCaptor = ArgumentCaptor.forClass(DanKnowledgeBaseTag.class);
+        verify(tagRelMapper, atLeastOnce()).insert(tagCaptor.capture());
+        assertEquals(1L, tagCaptor.getValue().getTagId(), "正文命中标签应自动绑定");
+        ArgumentCaptor<DanKnowledgeBaseDimension> dimCaptor = ArgumentCaptor.forClass(DanKnowledgeBaseDimension.class);
+        verify(dimRelMapper, atLeastOnce()).insert(dimCaptor.capture());
+        assertEquals(7L, dimCaptor.getValue().getDimensionId(), "正文命中维度应自动绑定");
+        assertEquals(Collections.singletonList(1L), kb.getTagIds());
+        assertEquals(Collections.singletonList(7L), kb.getDimensionIds());
+    }
+
     @Test
     void knowledgeIdsByTag_命中返回知识id() throws Exception {
         DanKnowledgeBaseService service = new DanKnowledgeBaseService();
@@ -112,8 +206,17 @@ class KnowledgeBaseBackfillTest {
     }
 
     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);
+        Class<?> type = target.getClass();
+        while (type != null) {
+            try {
+                Field f = type.getDeclaredField(fieldName);
+                f.setAccessible(true);
+                f.set(target, value);
+                return;
+            } catch (NoSuchFieldException e) {
+                type = type.getSuperclass();
+            }
+        }
+        throw new NoSuchFieldException(fieldName);
     }
 }

+ 3 - 1
cfc-langgraph/app/tasks/knowledge_sync.py

@@ -3,7 +3,7 @@
 治理改造(2026-10-19):
 - article 由「只增量追加」改为「全量重建」:旧实现永不删除 article 旧块,
   导致下架/删除文章残留在向量库、HNSW 索引无限膨胀(曾达 11.8GB)。
-- 三源 metadata 键统一(id/source/post_id/title/category/tags/source_url/pub_date/chunk_index/chunk_total),
+- 三源 metadata 键统一(id/source/post_id/title/category/tags/dimensions/source_url/pub_date/chunk_index/chunk_total),
   消除旧实现 retriever.initialize 的 metadata.type=article 与同步任务 metadata.source=article 双份数据。
 - 显式传入 Chroma doc id(source_itemId_chunkIdx),写入幂等,可安全重试。
 - 每源独立 try/except,一处失败不影响其他源。
@@ -106,6 +106,7 @@ def _build_chunks(splitter, items: list[dict], source: str) -> tuple[list[Docume
                 "title": title,
                 "category": item.get("category", ""),
                 "tags": item.get("tags", ""),
+                "dimensions": item.get("dimensions", ""),
                 "source_url": item.get("source_url", "") or item.get("sourceUrl", ""),
                 "pub_date": item.get("pub_date", "") or item.get("pubDate", ""),
                 "chunk_index": i,
@@ -168,6 +169,7 @@ def _format_article_item(a: dict) -> dict:
         "title": title,
         "content": f"{a.get('summary', '')}\n\n{a.get('content', '')}".strip(),
         "tags": a.get("tags", ""),
+        "dimensions": a.get("dimensions", ""),
         "pub_date": a.get("updatedAt", ""),
     }
 

+ 34 - 17
cfc-langgraph/app/tools/java_client.py

@@ -99,12 +99,38 @@ class JavaClient:
             return data.get("data", {})
         return {}
 
+    @staticmethod
+    def _join_metadata(value) -> str:
+        """把后端返回的标签/维度数组或字符串统一为 Chroma metadata 字符串。"""
+        if value is None:
+            return ""
+        if isinstance(value, str):
+            return value
+        if isinstance(value, (list, tuple, set)):
+            return ",".join(str(item) for item in value if item)
+        return str(value)
+
+    @classmethod
+    def _map_dan_knowledge(cls, record: dict) -> dict:
+        """dan_knowledge_base 字段(camelCase) → loader/_build_chunks 所需结构。"""
+        return {
+            "id": record.get("id"),
+            "title": record.get("title") or "",
+            "content": record.get("content") or "",
+            "category": "",
+            "tags": cls._join_metadata(record.get("tagNames") or record.get("tags")),
+            "dimensions": cls._join_metadata(
+                record.get("dimensionCodes") or record.get("dimensions")),
+            "source_url": record.get("sourceUrl") or "",
+            "pub_date": str(record.get("createdAt") or record.get("updatedAt") or ""),
+        }
+
     async def get_microbiome_articles(self) -> list[dict]:
         """拉取菌群知识库文章(统一数据源 dan_knowledge_base,source_type='article')
 
         2026-10-20 迁移:microbiome_article 表废弃,809 篇菌群知识统一到
         dan_knowledge_base 管理。分页拉 /api/admin/knowledge-base/list,
-        ORDER BY id 稳定分页,避免跨页重复。
+        ORDER BY id 稳定分页,避免跨页重复。标签/维度由后端批量回填。
         """
         client = await self._get_client()
         page = 1
@@ -123,26 +149,15 @@ class JavaClient:
             if page * size >= int(total or 0) or not items:
                 break
             page += 1
-        # dan_knowledge_base 字段(camelCase) → loader/_build_chunks 所需结构
-        mapped = []
-        for r in records:
-            if r.get("id") is None:
-                continue
-            mapped.append({
-                "id": r.get("id"),
-                "title": r.get("title") or "",
-                "content": r.get("content") or "",
-                "category": "",
-                "tags": "",
-                "source_url": r.get("sourceUrl") or "",
-                "pub_date": str(r.get("createdAt") or r.get("updatedAt") or ""),
-            })
-        return mapped
+        return [
+            self._map_dan_knowledge(r) for r in records if r.get("id") is not None
+        ]
 
     async def get_dan_knowledge_base(self) -> list[dict]:
         """拉取案例报告源(dan_knowledge_base 表,status=1,排除 article 菌群文章)
 
         若 article 为 0 条(INSERT 前确实为 0),则该源拉 0 条,正常。
+        返回结构包含 content/tags/dimensions,供 Chroma metadata 使用。
         """
         client = await self._get_client()
         page = 1
@@ -160,7 +175,9 @@ class JavaClient:
             if page * size >= int(total or 0) or not items:
                 break
             page += 1
-        return records
+        return [
+            self._map_dan_knowledge(r) for r in records if r.get("id") is not None
+        ]
 
     async def get_member_reports(self, member_id: int) -> list[dict]:
         """获取某个家庭成员的健康报告列表"""