Kaynağa Gözat

feat: 文章详情相关文章接口(按标签 FIND_IN_SET 匹配,共享数优先)

Xiaogang Liao 6 gün önce
ebeveyn
işleme
77478e2d88

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -72,6 +72,34 @@ public class ArticleController {
         return Result.success(result);
     }
 
+    /**
+     * 相关文章:按标签关联(共享标签数优先,发布先后次之),默认 3 篇;无匹配返回空数组
+     */
+    @PostMapping("/related")
+    public Result<List<Article>> related(@RequestBody Map<String, Object> body,
+                                         @RequestAttribute(value = "userId", required = false) Long userId,
+                                         @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long articleId = ParamUtils.getLong(body.get("articleId"));
+        if (articleId == null) {
+            return Result.error("articleId不能为空");
+        }
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 3;
+        if (size < 1) size = 1;
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        List<Article> list = articleService.getRelatedArticles(articleId, size, userId, familyId, vendorType);
+        return Result.success(list);
+    }
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
                                        @RequestAttribute(value = "userId", required = false) Long userId,

+ 76 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -180,6 +180,82 @@ public class ArticleService {
         return visible.size() > size ? visible.subList(0, size) : visible;
     }
 
+    /**
+     * 相关文章:按标签匹配(FIND_IN_SET 精确匹配整标签),共享标签数降序 → 发布时间降序,
+     * 排除自身,仅取已发布,经可见性过滤后截取前 size 篇。无标签/无匹配返回空。
+     */
+    public List<Article> getRelatedArticles(Long articleId, int size, Long userId, Long familyId, String vendorType) {
+        Article current = articleMapper.selectById(articleId);
+        if (current == null || current.getTags() == null || current.getTags().trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> tags = new ArrayList<>();
+        for (String t : current.getTags().split(",")) {
+            String tag = t.trim();
+            if (!tag.isEmpty() && !tags.contains(tag)) {
+                tags.add(tag);
+            }
+        }
+        if (tags.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .ne(Article::getId, articleId);
+        wrapper.and(w -> {
+            boolean first = true;
+            for (String tag : tags) {
+                if (first) {
+                    w.apply("FIND_IN_SET({0}, tags) > 0", tag);
+                    first = false;
+                } else {
+                    w.or().apply("FIND_IN_SET({0}, tags) > 0", tag);
+                }
+            }
+        });
+        List<Article> candidates = articleMapper.selectList(wrapper);
+
+        // 共享标签计数,剔除零共享(SQL 已限定至少一个标签命中,此处防御)
+        Map<Long, Integer> score = new HashMap<>();
+        List<Article> matched = new ArrayList<>();
+        for (Article a : candidates) {
+            if (a.getId() != null && a.getId().equals(articleId)) {
+                continue;
+            }
+            int s = 0;
+            if (a.getTags() != null) {
+                for (String t : a.getTags().split(",")) {
+                    if (tags.contains(t.trim())) {
+                        s++;
+                    }
+                }
+            }
+            if (s > 0) {
+                matched.add(a);
+                score.put(a.getId(), s);
+            }
+        }
+        matched.sort((x, y) -> {
+            int diff = score.getOrDefault(y.getId(), 0) - score.getOrDefault(x.getId(), 0);
+            if (diff != 0) {
+                return diff;
+            }
+            Date px = x.getPublishedAt();
+            Date py = y.getPublishedAt();
+            if (px == null && py == null) return 0;
+            if (px == null) return 1;
+            if (py == null) return -1;
+            return py.compareTo(px);
+        });
+
+        List<Article> visible = articlePermissionService.filterVisible(matched, userId, familyId, vendorType);
+        if (visible == null) {
+            visible = Collections.emptyList();
+        }
+        return visible.size() > size ? new ArrayList<>(visible.subList(0, size)) : visible;
+    }
+
     public Article getDetail(Long id, Long userId, Long familyId, String vendorType) {
         Article article = articleMapper.selectById(id);
         if (article == null) return null;

+ 128 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ArticleRelatedServiceTest.java

@@ -0,0 +1,128 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.ArticlePermissionService;
+import com.etotem.cfc.service.ArticleService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 相关文章查询单元测试(纯逻辑,mock Mapper + PermissionService,无需 DB)
+ */
+class ArticleRelatedServiceTest {
+
+    private ArticleService service;
+    private ArticleMapper articleMapper;
+    private ArticlePermissionService permissionService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ArticleService();
+        articleMapper = mock(ArticleMapper.class);
+        permissionService = mock(ArticlePermissionService.class);
+        inject("articleMapper", articleMapper);
+        inject("articlePermissionService", permissionService);
+    }
+
+    private void inject(String field, Object value) throws Exception {
+        Field f = ArticleService.class.getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    private Article a(Long id, String tags, Date publishedAt) {
+        Article art = new Article();
+        art.setId(id);
+        art.setTags(tags);
+        art.setPublishedAt(publishedAt);
+        art.setStatus("published");
+        return art;
+    }
+
+    @Test
+    void getRelatedArticles_currentNull_returnsEmpty() {
+        when(articleMapper.selectById(1L)).thenReturn(null);
+        List<Article> r = service.getRelatedArticles(1L, 3, null, null, null);
+        assertTrue(r.isEmpty());
+        verify(articleMapper, never()).selectList(any());
+    }
+
+    @Test
+    void getRelatedArticles_emptyTags_returnsEmpty() {
+        when(articleMapper.selectById(1L)).thenReturn(a(1L, null, new Date()));
+        List<Article> r = service.getRelatedArticles(1L, 3, null, null, null);
+        assertTrue(r.isEmpty());
+        verify(articleMapper, never()).selectList(any());
+    }
+
+    @Test
+    void getRelatedArticles_noMatch_returnsEmpty() {
+        when(articleMapper.selectById(1L)).thenReturn(a(1L, "健康", new Date()));
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(
+                Collections.singletonList(a(2L, "阅读,亲子", new Date())));
+        when(permissionService.filterVisible(any(), any(), any(), any())).thenReturn(Collections.emptyList());
+
+        List<Article> r = service.getRelatedArticles(1L, 3, null, null, null);
+        assertTrue(r.isEmpty());
+    }
+
+    @Test
+    void getRelatedArticles_sharedTag_sortedByScoreThenPublishedAt() {
+        Article current = a(1L, "健康,饮水", new Date(3000L));
+        Article moreShared = a(2L, "健康,饮水,亲子", new Date(2000L)); // shares 2
+        Article lessShared = a(3L, "健康", new Date(4000L));          // shares 1, newer
+        List<Article> candidates = Arrays.asList(moreShared, lessShared, a(4L, "阅读", new Date())); // last no match
+
+        when(articleMapper.selectById(1L)).thenReturn(current);
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(candidates);
+        when(permissionService.filterVisible(any(), any(), any(), any())).thenAnswer(inv -> inv.getArgument(0));
+
+        List<Article> r = service.getRelatedArticles(1L, 3, null, null, null);
+
+        // order: id=2 (2 shared) > id=3 (1 shared) > id=4 filtered out by count=0 but still queried
+        assertEquals(2, r.size()); // size=3 but only 2 match
+        assertEquals(2L, r.get(0).getId());
+        assertEquals(3L, r.get(1).getId());
+    }
+
+    @Test
+    void getRelatedArticles_excludesSelf() {
+        Article current = a(1L, "健康", new Date());
+        Article dup = a(1L, "健康,其他", new Date()); // same id
+        when(articleMapper.selectById(1L)).thenReturn(current);
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(dup));
+        when(permissionService.filterVisible(any(), any(), any(), any())).thenAnswer(inv -> inv.getArgument(0));
+
+        List<Article> r = service.getRelatedArticles(1L, 3, null, null, null);
+        assertTrue(r.isEmpty());
+    }
+
+    @Test
+    void getRelatedArticles_sizeLimit_applied() {
+        Article current = a(1L, "健康", new Date());
+        List<Article> cands = Arrays.asList(a(2L, "健康", new Date(2000L)),
+                                            a(3L, "健康", new Date(3000L)),
+                                            a(4L, "健康", new Date(4000L)));
+        when(articleMapper.selectById(1L)).thenReturn(current);
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(cands);
+        when(permissionService.filterVisible(any(), any(), any(), any())).thenAnswer(inv -> inv.getArgument(0));
+
+        List<Article> r = service.getRelatedArticles(1L, 2, null, null, null);
+
+        assertEquals(2, r.size());
+    }
+
+}