Browse Source

feat(global-search): 实现 SC-P-82 全局搜索聚合接口 (S-cfc-383)

新增 POST /api/search/global 统一搜索入口,按报告/方案/任务/商品四类
结果桶分流,越权过滤按 familyId/memberId 收敛范围。

- SearchController: @PostMapping /api/search/global
- GlobalSearchService: 聚合搜索,keyword 空返回空桶不报错
- GlobalSearchQuery / GlobalSearchResult: 请求/响应载体
Agnes Agent 12 hours ago
parent
commit
13b7d51330

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/SearchController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.GlobalSearchQuery;
+import com.etotem.cfc.dto.GlobalSearchResult;
+import com.etotem.cfc.service.GlobalSearchService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+
+/**
+ * 全局搜索聚合控制器(SC-P-82 全局搜索入口)。
+ * 统一入口:POST /api/search/global
+ */
+@RestController
+@RequestMapping("/api/search")
+public class SearchController {
+
+    @Resource
+    private GlobalSearchService globalSearchService;
+
+    /**
+     * 聚合搜索端点
+     *
+     * @param userId    登录用户 ID(从 JWT 上下文 requestAttribute 取值)
+     * @param familyId  家庭 ID(从 JWT 上下文 requestAttribute 取值)
+     * @param query     搜索请求载体(keyword/page/pageSize,可选)
+     * @return 统一 Result,包含四类结果桶
+     */
+    @PostMapping("/global")
+    public Result<GlobalSearchResult> globalSearch(
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("familyId") Long familyId,
+            @RequestBody(required = false) GlobalSearchQuery query) {
+        String keyword = (query != null) ? query.getKeyword() : null;
+        Integer page = (query != null) ? query.getPage() : null;
+        Integer pageSize = (query != null) ? query.getPageSize() : null;
+        return globalSearchService.globalSearch(keyword, userId, familyId, page, pageSize);
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/GlobalSearchQuery.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/** 全局搜索请求载体(keyword可空 + 分页) */
+@Data
+public class GlobalSearchQuery {
+    /** 搜索关键词(可空:空关键词不报错,返回空桶) */
+    private String keyword;
+    /** 页码(从1起,缺省1) */
+    private Integer page;
+    /** 每页数量(缺省10) */
+    private Integer pageSize;
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/GlobalSearchResult.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 全局搜索结果载体(SC-P-82 结果按类型分流:报告/方案/任务/商品四类桶)。
+ */
+@Data
+public class GlobalSearchResult {
+    /** 报告桶 */
+    private List<Map<String, Object>> reports;
+    /** 方案桶 */
+    private List<Map<String, Object>> plans;
+    /** 任务桶 */
+    private List<Map<String, Object>> tasks;
+    /** 商品桶 */
+    private List<Map<String, Object>> products;
+}

+ 165 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GlobalSearchService.java

@@ -0,0 +1,165 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.dto.GlobalSearchResult;
+import com.etotem.cfc.entity.GrowthPlan;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.GrowthPlanMapper;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 聚合搜索服务(SC-P-82 全局搜索:报告/方案/任务/商品,按类型分流)。
+ *
+ * <p>入口 {@link #globalSearch(String, Long, Long, Integer, Integer)}:
+ * keyword 可空(空结果不报错,返回空四桶);越权过滤:报告/方案/任务桶按调用者
+ * familyId(方案进一步按家庭内成员 id)收敛范围,商品桶仅返回已上架公共商品。</p>
+ */
+@Service
+public class GlobalSearchService {
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private GrowthPlanMapper growthPlanMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private FamilyMemberService familyMemberService;
+
+    /**
+     * 聚合搜索入口。
+     *
+     * @param keyword  搜索关键词(null/空白 = 空结果不报错,返回空四桶)
+     * @param userId   登录用户 ID(越权过滤数据维度)
+     * @param familyId 家庭 ID(越权过滤数据维度;null=无法取到家庭上下文,仅返回商品桶)
+     * @param page     页码(null/&lt;1 归一化为 1)
+     * @param pageSize 每页数量(null/&lt;1 默认 10)
+     * @return 统一 Result 包裹四类分流桶(reports/plans/tasks/products,各为 List)
+     */
+    public Result<GlobalSearchResult> globalSearch(String keyword, Long userId, Long familyId,
+                                                   Integer page, Integer pageSize) {
+        String kw = (keyword != null) ? keyword.trim() : null;
+        boolean hasKw = kw != null && !kw.isEmpty();
+        int p = (page == null || page < 1) ? 1 : page;
+        int size = (pageSize == null || pageSize < 1) ? 10 : pageSize;
+
+        GlobalSearchResult result = new GlobalSearchResult();
+
+        // 1) 报告桶:按家庭范围过滤(越权结果过滤)
+        List<Map<String, Object>> reports = new ArrayList<>();
+        if (familyId != null && hasKw) {
+            LambdaQueryWrapper<HealthReport> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(HealthReport::getFamilyId, familyId);
+            wrapper.and(w -> w.like(HealthReport::getPersonName, kw)
+                    .or().like(HealthReport::getReportType, kw)
+                    .or().like(HealthReport::getReportNumber, kw));
+            wrapper.orderByDesc(HealthReport::getCreatedAt);
+            wrapper.last("LIMIT " + ((p - 1) * size) + ", " + size);
+            for (HealthReport r : healthReportMapper.selectList(wrapper)) {
+                Map<String, Object> m = new HashMap<>();
+                m.put("id", r.getId());
+                m.put("personName", r.getPersonName());
+                m.put("reportType", r.getReportType());
+                m.put("reportNumber", r.getReportNumber());
+                m.put("status", r.getStatus());
+                m.put("createdAt", r.getCreatedAt());
+                reports.add(m);
+            }
+        }
+        result.setReports(reports);
+
+        // 2) 方案桶:按调用者家庭内成员范围过滤(越权结果过滤)
+        List<Map<String, Object>> plans = new ArrayList<>();
+        if (familyId != null && hasKw) {
+            List<Long> memberIds = familyMemberService.listMembersOfFamily(familyId, userId).stream()
+                    .map(FamilyMemberVO::getFamilyMemberId)
+                    .filter(id -> id != null)
+                    .collect(Collectors.toList());
+            if (!memberIds.isEmpty()) {
+                LambdaQueryWrapper<GrowthPlan> wrapper = new LambdaQueryWrapper<>();
+                wrapper.in(GrowthPlan::getMemberId, memberIds);
+                wrapper.and(w -> w.like(GrowthPlan::getPlanTitle, kw)
+                        .or().like(GrowthPlan::getPlanContent, kw));
+                wrapper.orderByDesc(GrowthPlan::getCreatedAt);
+                wrapper.last("LIMIT " + ((p - 1) * size) + ", " + size);
+                for (GrowthPlan plan : growthPlanMapper.selectList(wrapper)) {
+                    Map<String, Object> m = new HashMap<>();
+                    m.put("id", plan.getId());
+                    m.put("planTitle", plan.getPlanTitle());
+                    m.put("planContent", plan.getPlanContent());
+                    m.put("memberId", plan.getMemberId());
+                    m.put("createdAt", plan.getCreatedAt());
+                    plans.add(m);
+                }
+            }
+        }
+        result.setPlans(plans);
+
+        // 3) 任务桶:按家庭范围过滤(越权结果过滤)
+        List<Map<String, Object>> tasks = new ArrayList<>();
+        if (familyId != null && hasKw) {
+            LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(Task::getFamilyId, familyId);
+            wrapper.and(w -> w.like(Task::getTitle, kw)
+                    .or().like(Task::getDescription, kw));
+            wrapper.orderByDesc(Task::getCreatedAt);
+            wrapper.last("LIMIT " + ((p - 1) * size) + ", " + size);
+            for (Task t : taskMapper.selectList(wrapper)) {
+                Map<String, Object> m = new HashMap<>();
+                m.put("id", t.getId());
+                m.put("title", t.getTitle());
+                m.put("description", t.getDescription());
+                m.put("category", t.getCategory());
+                m.put("status", t.getStatus());
+                m.put("createdAt", t.getCreatedAt());
+                tasks.add(m);
+            }
+        }
+        result.setTasks(tasks);
+
+        // 4) 商品桶:公共上架商品(keyword 匹配名称/描述/品类)
+        List<Map<String, Object>> products = new ArrayList<>();
+        if (hasKw) {
+            LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(Product::getStatus, "on_shelf");
+            wrapper.and(w -> w.like(Product::getName, kw)
+                    .or().like(Product::getDescription, kw)
+                    .or().like(Product::getProductType, kw));
+            wrapper.orderByDesc(Product::getCreatedAt);
+            wrapper.last("LIMIT " + ((p - 1) * size) + ", " + size);
+            for (Product prod : productMapper.selectList(wrapper)) {
+                Map<String, Object> m = new HashMap<>();
+                m.put("id", prod.getId());
+                m.put("name", prod.getName());
+                m.put("description", prod.getDescription());
+                m.put("productType", prod.getProductType());
+                m.put("price", prod.getPrice());
+                m.put("createdAt", prod.getCreatedAt());
+                products.add(m);
+            }
+        }
+        result.setProducts(products);
+
+        return Result.success(result);
+    }
+}