Przeglądaj źródła

feat(article): 知识中心按文章标签快速搜索(取消五维Tab分类)

- ArticleController /list 新增 tag 参数;新增 /tags 返回文章标签Top20
- ArticleService 新增10参 getPublicList(位置4为tag,内存标签过滤);新增 listTopTags 聚合频次TopN
- 旧8参/9参 getPublicList 代理到10参(tag=null),McpToolRegistry 8参调用兼容
- article-center index.vue Tab从五维改为动态文章标签(全部+各标签),参数 dimensionCode→tag
- api.js 新增 getArticleTags
- 修复 ArticleControllerTest 9参stub→10参;GlobalSearchScP82ContractTest 增加8参verify显式断言
Agnes Agent 2 dni temu
rodzic
commit
6e9a511e2

+ 10 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -139,6 +139,7 @@ public class ArticleController {
         Long categoryId = ParamUtils.getLong(body.get("categoryId"));
         String keyword = (String) body.get("keyword");
         String dimensionCode = body.get("dimensionCode") != null ? body.get("dimensionCode").toString() : null;
+        String tag = body.get("tag") != null ? body.get("tag").toString() : null;
         Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
         int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
         int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 10;
@@ -153,11 +154,19 @@ public class ArticleController {
             }
         }
 
-        Page<Article> result = articleService.getPublicList(categoryId, keyword, dimensionCode, page, size,
+        Page<Article> result = articleService.getPublicList(categoryId, keyword, dimensionCode, tag, page, size,
                 userId, familyId, vendorType, memberId);
         return Result.success(result);
     }
 
+    /**
+     * 知识中心动态标签列表:聚合已发布文章的标签,按出现频次降序返回 Top 20。
+     */
+    @PostMapping("/tags")
+    public Result<List<String>> tags() {
+        return Result.success(articleService.listTopTags(20));
+    }
+
     @PostMapping("/detail")
     public Result<Article> detail(@RequestBody Map<String, Object> body,
                                    @RequestAttribute(value = "userId", required = false) Long userId) {

+ 45 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -104,11 +104,21 @@ public class ArticleService {
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
                                         Long userId, Long familyId, String vendorType) {
-        return getPublicList(categoryId, keyword, dimensionCode, page, size, userId, familyId, vendorType, null);
+        return getPublicList(categoryId, keyword, dimensionCode, null, page, size, userId, familyId, vendorType, null);
     }
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
                                         Long userId, Long familyId, String vendorType, Long memberId) {
+        return getPublicList(categoryId, keyword, dimensionCode, null, page, size, userId, familyId, vendorType, memberId);
+    }
+
+    /**
+     * 知识中心文章列表:支持按文章标签筛选(知识中心 Tab 从「五维」改为「文章标签」)。
+     * tag 为空时行为与旧版一致;tag 非空时仅返回 tags 字段包含该标签的文章。
+     * tags 字段为 JSON 数组字符串(如 ["减脂","睡眠"])或逗号分隔,统一由 parseCommaOrJsonTags 解析后内存过滤。
+     */
+    public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, String tag,
+                                        int page, int size, Long userId, Long familyId, String vendorType, Long memberId) {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .orderByDesc(Article::getIsFeatured)
@@ -137,6 +147,14 @@ public class ArticleService {
         //      前端 noMore = 已加载数 >= total 恒为真,导致只显示一页、无法加载更多。
         List<Article> all = articleMapper.selectList(wrapper);
 
+        // 按文章标签筛选(与 parseCommaOrJsonTags 解析语义对齐,兼容 JSON 数组 / 逗号分隔两种存储格式)
+        if (tag != null && !tag.trim().isEmpty()) {
+            String targetTag = tag.trim();
+            all = all.stream()
+                    .filter(a -> parseCommaOrJsonTags(a.getTags()).contains(targetTag))
+                    .collect(Collectors.toList());
+        }
+
         List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
 
         // 过滤已完成阅读且获得CF值的文章(达到阈值且答题得CF后不再出现在文章列表中)
@@ -179,6 +197,32 @@ public class ArticleService {
         DimensionThresholdFilter.filterVisible(visible, Article::getId, "ARTICLE", familyAvg, byId);
     }
 
+    /**
+     * 聚合已发布文章的标签,按出现频次降序返回 Top N(知识中心 Tab 动态标签列表数据源)。
+     * 标签格式兼容 JSON 数组 / 逗号分隔,统一由 parseCommaOrJsonTags 解析拆分去重。
+     */
+    public List<String> listTopTags(int limit) {
+        int n = limit > 0 ? limit : 20;
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .and(w -> w.isNotNull(Article::getTags).or().apply("TRIM(tags) <> ''"));
+        List<Article> articles = articleMapper.selectList(wrapper);
+        Map<String, Integer> freq = new HashMap<>();
+        for (Article a : articles) {
+            for (String t : parseCommaOrJsonTags(a.getTags())) {
+                if (!t.isEmpty()) {
+                    freq.merge(t, 1, Integer::sum);
+                }
+            }
+        }
+        return freq.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed()
+                        .thenComparing(Map.Entry.comparingByKey()))
+                .limit(n)
+                .map(Map.Entry::getKey)
+                .collect(Collectors.toList());
+    }
+
     public List<Article> getFeatured(int size, Long userId, Long familyId, String vendorType, Long memberId) {
         return getFeatured(size, null, userId, familyId, vendorType, memberId);
     }

+ 4 - 4
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ArticleControllerTest.java

@@ -62,7 +62,7 @@ public class ArticleControllerTest {
         Page<Article> mockPage = new Page<>(1, 10, 1);
         mockPage.setRecords(records);
 
-        when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
+        when(articleService.getPublicList(isNull(), isNull(), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
         Result<Page<Article>> result = controller.list(body, null, null);
@@ -82,7 +82,7 @@ public class ArticleControllerTest {
         Page<Article> mockPage = new Page<>(1, 10, 1);
         mockPage.setRecords(records);
 
-        when(articleService.getPublicList(eq(1L), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
+        when(articleService.getPublicList(eq(1L), isNull(), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
         Result<Page<Article>> result = controller.list(body, null, null);
@@ -101,7 +101,7 @@ public class ArticleControllerTest {
         Page<Article> mockPage = new Page<>(1, 10, 1);
         mockPage.setRecords(records);
 
-        when(articleService.getPublicList(isNull(), eq("测试"), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
+        when(articleService.getPublicList(isNull(), eq("测试"), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
         Result<Page<Article>> result = controller.list(body, null, null);
@@ -124,7 +124,7 @@ public class ArticleControllerTest {
         Page<Article> mockPage = new Page<>(1, 10, 1);
         mockPage.setRecords(records);
 
-        when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), eq(1L), eq(100L), eq("planner"), isNull()))
+        when(articleService.getPublicList(isNull(), isNull(), isNull(), isNull(), eq(1), eq(10), eq(1L), eq(100L), eq("planner"), isNull()))
             .thenReturn(mockPage);
 
         Result<Page<Article>> result = controller.list(body, 1L, null);

+ 4 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GlobalSearchScP82ContractTest.java

@@ -128,6 +128,10 @@ class GlobalSearchScP82ContractTest {
         assertNotNull(result);
         assertTrue(summaryText(result).contains("2 篇文章"), "摘要应含文章条数");
         assertEquals(2, ((List<?>) result.get("data")).size());
+        // 明确断言:search_articles 经 McpToolRegistry.handleSearchArticles 真实调用 8 参 getPublicList
+        // (匿名入口 userId=0L;keyword 透传;familyId/vendorType 均空)。若改为 10 参 stub,mock 不执行
+        // 真实 8 参代理体,将命中不到 stub 而返回 null → 本测试会 NPE,故必须保留 8 参断言。
+        verify(articleService).getPublicList(isNull(), eq("运动"), isNull(), anyInt(), anyInt(), eq(0L), isNull(), isNull());
     }
 
     // ==================== 正常流:商品搜索 ====================

+ 20 - 9
cfc-frontend/pages/article-center/index.vue

@@ -137,7 +137,7 @@
 
 <script>
 import { BrandColors } from '../../utils/brand-colors.js'
-import { getArticleList, getFeaturedArticles } from '@/utils/api.js'
+import { getArticleList, getArticleTags, getFeaturedArticles } from '@/utils/api.js'
 import config from '@/config.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 export default {
@@ -146,12 +146,7 @@ export default {
     return {
       whiteColor: BrandColors.white,
       tabList: [
-        { code: '', name: '全部', color: BrandColors.primary },
-        { code: 'body', name: '身', color: BrandColors.body },
-        { code: 'mind', name: '心', color: BrandColors.mind },
-        { code: 'wisdom', name: '智', color: BrandColors.wisdom },
-        { code: 'action', name: '行', color: BrandColors.success },
-        { code: 'wealth', name: '富', color: BrandColors.wealth }
+        { code: '', name: '全部', color: BrandColors.primary }
       ],
       currentTab: '',
       articles: [],
@@ -172,12 +167,15 @@ export default {
     ArticleBookshelf: () => require('@/pages/home-pages/components/ArticleBookshelf.vue')
   },
 onLoad(options) {
-    // 支持从外部传入 dimension 参数,默认选中对应 tab
+    // 兼容旧入口:外部传入 dimension(旧五维 code)时仍设置 currentTab。
+    // 新语义下 currentTab 表示「文章标签」,若外部传的是旧五维 code,
+    // 将命中 tag 为空的降级结果(无可匹配文章),属可接受的降级行为。
     if (options && options.dimension) {
       this.currentTab = options.dimension
     }
     this.isLoggedIn = !!uni.getStorageSync('token')
     this.currentChildId = uni.getStorageSync('currentChildId') || null
+    this.loadTags()
     this.loadArticles()
     this.loadRecommendedArticles()
 },
@@ -185,6 +183,19 @@ onLoad(options) {
     this.isLoggedIn = !!uni.getStorageSync('token')
   },
   methods: {
+    async loadTags() {
+      try {
+        var res = await getArticleTags()
+        if (res && res.code === 200 && res.data && res.data.length) {
+          var addList = res.data.map(function(tag) {
+            return { code: tag, name: tag, color: BrandColors.primary }
+          })
+          this.tabList = [{ code: '', name: '全部', color: BrandColors.primary }].concat(addList)
+        }
+      } catch (e) {
+        // 标签拉取失败时保留「全部」,文章列表仍可正常浏览
+      }
+    },
     async loadArticles(isLoadMore) {
       if (!isLoadMore) {
         if (this.loading) return
@@ -202,7 +213,7 @@ onLoad(options) {
           params.memberId = parseInt(this.currentChildId)
         }
         if (this.currentTab) {
-          params.dimensionCode = this.currentTab
+          params.tag = this.currentTab
         }
         if (this.searchKeyword && this.searchKeyword.trim()) {
           params.keyword = this.searchKeyword.trim()

+ 1 - 0
cfc-frontend/utils/api.js

@@ -2007,6 +2007,7 @@ export const getUnlockStatus = () => {
 // ===== 文章内容系统 =====
 export const getArticleCategories = () => request('/api/articles/categories', 'POST')
 export const getArticleList = (data) => request('/api/articles/list', 'POST', data)
+export const getArticleTags = (data) => request('/api/articles/tags', 'POST', data || {})
 export const getArticleDetail = (data) => request('/api/articles/detail', 'POST', data)
 export const getArticleAudio = (data) => request('/api/articles/audio', 'POST', data)
 export const getArticleRelatedProducts = (data) => request('/api/articles/related-products', 'POST', data)