Просмотр исходного кода

feat(backend): T04 指标收藏后端化(方案A)— GAP-2026-10-30-indicator-fav

文档 specs/2026-10-30-indicator-favorite-backend-design.md 提及的 5 个后端文件
全部缺失,本次按文档口径补齐 T01-T03 后端范围(数据层+服务层+接口层):

- 新增实体 IndicatorFavorite(@TableName indicator_favorite + @TableId AUTO)
- 新增 Mapper IndicatorFavoriteMapper(extends BaseMapper)
- 新增 Service 接口 IndicatorFavoriteService + 实现 IndicatorFavoriteServiceImpl
  (toggle 幂等「存在则删、不存在则插」、list 按 5 维隔离+id 升序、remove 幂等删除)
- 新增 Controller IndicatorFavoriteController:
  /api/indicator/favorite/toggle|list|remove 三个 POST,
  @RequestAttribute 取 userId/familyId 不信任前端传值,checkMemberBelongs 归属校验
- DatabaseInitializer 新增迁移429 createIndicatorFavoriteTable()
  (主线最大编号已为 428 coupon_gift_transfer,故取 428+1=429,幂等建表)
- JwtInterceptor.LOGIN_REQUIRED_PATHS 追加 /api/indicator/favorite(强制登录防匿名污染)
- schema.sql 追加 indicator_favorite 建表 DDL(与 DatabaseInitializer 一致)
- 单测 IndicatorFavoriteServiceTest(6) + IndicatorFavoriteControllerTest(10),
  JwtInterceptorLoginRequiredTest 增补收藏接口无 token 401 两条,共 21 例全绿
- 文档:API_REFERENCE.md 新增 §4.48.2、2026-10-25 设计稿补方案A落点、PROJECT-OVERVIEW 同步

不涉及前端(F1-F5 需基线 dbf13c610,尚未合入 cfclub,按 §12.1 待主理人确认)。
Agnes Agent 1 неделя назад
Родитель
Сommit
bcdd602b75

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3379,6 +3379,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移428: 会员优惠券转赠表 coupon_gift_transfer + family_coupon.is_gift 列
         // (用户可把自己的会员券转赠给推荐下级,用于购买会员,可撤回)
         createCouponGiftTransferTable();
+
+        // 迁移429: 用户关注指标收藏表 indicator_favorite(T04 方案A:后端持久化,跨端同步)
+        createIndicatorFavoriteTable();
     }
 
     /**
@@ -14424,6 +14427,33 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // ==================== 迁移429:用户关注指标收藏(T04 方案A) ====================
+
+    /**
+     * 迁移429: 用户关注指标收藏表(跨端同步;5维+指标唯一)。
+     * 幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
+     */
+    void createIndicatorFavoriteTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `indicator_favorite` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`user_id` BIGINT NOT NULL COMMENT '收藏用户ID(users.id)', "
+                    + "`family_id` BIGINT NOT NULL COMMENT '所属家庭ID', "
+                    + "`member_id` BIGINT NOT NULL COMMENT '观测对象ID(family_members.id)', "
+                    + "`source_type` VARCHAR(50) NOT NULL COMMENT '报告类型', "
+                    + "`definition_id` BIGINT NOT NULL COMMENT '指标定义ID(indicator_definitions.id)', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "UNIQUE KEY `uk_user_family_member_source_def` (`user_id`, `family_id`, `member_id`, `source_type`, `definition_id`), "
+                    + "KEY `idx_member_source` (`member_id`, `source_type`), "
+                    + "KEY `idx_user_created` (`user_id`, `created_at`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏'");
+            log.info("迁移429: 已创建 indicator_favorite 表");
+        } catch (Exception e) {
+            log.warn("迁移429: 创建 indicator_favorite 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
 
 
     void createPpointConsumptionLogTable() {

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -83,7 +83,9 @@ private static final String[] PUBLIC_PATHS = {
         // T29-B2-5:报告代传接口——无 token 时被降级为匿名(userId=-1)放行到 controller,
         // 只能返回业务 500;代传属写数据操作(建草稿/入库),必须登录,无 token 直接 401。
         "/api/health/report/proxy-parse",
-        "/api/health/report/proxy-confirm"
+        "/api/health/report/proxy-confirm",
+        // T04 方案A:指标收藏后端化——收藏是用户私有写数据,匿名(userId=-1)写入会污染数据,必须登录
+        "/api/indicator/favorite"
     };
 
     @Override

+ 94 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorFavoriteController.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.IndicatorFavoriteService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/indicator/favorite")
+public class IndicatorFavoriteController {
+
+    @Resource
+    private IndicatorFavoriteService indicatorFavoriteService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /** 归属校验:memberId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
+    private Result<Void> checkMemberBelongs(Long memberId, Long familyId) {
+        if (memberId == null) {
+            return Result.error(400, "memberId不能为空");
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getId, memberId)
+                .eq(FamilyMember::getFamilyId, familyId)
+                .last("LIMIT 1"));
+        if (member == null) {
+            return Result.error(400, "成员不属于当前家庭");
+        }
+        return null;
+    }
+
+    /** 收藏/取消:{memberId, sourceType, definitionId} → {definitionId, added} */
+    @PostMapping("/toggle")
+    public Result<?> toggle(@RequestBody Map<String, Object> params,
+                            @RequestAttribute("userId") Long userId,
+                            @RequestAttribute("familyId") Long familyId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        Long definitionId = ParamUtils.getLong(params.get("definitionId"));
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        if (memberId == null || definitionId == null || sourceType == null || sourceType.isEmpty()) {
+            return Result.error(400, "memberId/definitionId/sourceType 不能为空");
+        }
+        Result<Void> check = checkMemberBelongs(memberId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorFavoriteService.toggle(userId, familyId, memberId, sourceType, definitionId));
+    }
+
+    /** 查已收藏集合:{memberId, sourceType} → {memberId, sourceType, definitionIds:[]} */
+    @PostMapping("/list")
+    public Result<?> list(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long userId,
+                          @RequestAttribute("familyId") Long familyId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        if (memberId == null || sourceType == null || sourceType.isEmpty()) {
+            return Result.error(400, "memberId/sourceType 不能为空");
+        }
+        Result<Void> check = checkMemberBelongs(memberId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorFavoriteService.list(userId, familyId, memberId, sourceType));
+    }
+
+    /** 显式移除:{memberId, sourceType, definitionId} → {definitionId, removed} */
+    @PostMapping("/remove")
+    public Result<?> remove(@RequestBody Map<String, Object> params,
+                            @RequestAttribute("userId") Long userId,
+                            @RequestAttribute("familyId") Long familyId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"));
+        Long definitionId = ParamUtils.getLong(params.get("definitionId"));
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        if (memberId == null || definitionId == null || sourceType == null || sourceType.isEmpty()) {
+            return Result.error(400, "memberId/definitionId/sourceType 不能为空");
+        }
+        Result<Void> check = checkMemberBelongs(memberId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorFavoriteService.remove(userId, familyId, memberId, sourceType, definitionId));
+    }
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorFavorite.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.util.Date;
+
+@TableName("indicator_favorite")
+public class IndicatorFavorite {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long familyId;
+    private Long memberId;
+    private String sourceType;
+    private Long definitionId;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public Long getFamilyId() { return familyId; }
+    public void setFamilyId(Long familyId) { this.familyId = familyId; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public String getSourceType() { return sourceType; }
+    public void setSourceType(String sourceType) { this.sourceType = sourceType; }
+    public Long getDefinitionId() { return definitionId; }
+    public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorFavoriteMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorFavorite;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface IndicatorFavoriteMapper extends BaseMapper<IndicatorFavorite> {
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorFavoriteService.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface IndicatorFavoriteService {
+    /** 收藏/取消:存在则删、不存在则插;返回 {definitionId, added} */
+    Map<String, Object> toggle(Long userId, Long familyId, Long memberId, String sourceType, Long definitionId);
+    /** 查询已收藏集合:{memberId, sourceType, definitionIds:[]} */
+    Map<String, Object> list(Long userId, Long familyId, Long memberId, String sourceType);
+    /** 显式移除:{definitionId, removed} */
+    Map<String, Object> remove(Long userId, Long familyId, Long memberId, String sourceType, Long definitionId);
+}

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/IndicatorFavoriteServiceImpl.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorFavorite;
+import com.etotem.cfc.mapper.IndicatorFavoriteMapper;
+import com.etotem.cfc.service.IndicatorFavoriteService;
+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;
+
+@Service
+public class IndicatorFavoriteServiceImpl implements IndicatorFavoriteService {
+
+    @Resource
+    private IndicatorFavoriteMapper favoriteMapper;
+
+    @Override
+    public Map<String, Object> toggle(Long userId, Long familyId, Long memberId, String sourceType, Long definitionId) {
+        IndicatorFavorite exist = favoriteMapper.selectOne(new LambdaQueryWrapper<IndicatorFavorite>()
+                .eq(IndicatorFavorite::getUserId, userId)
+                .eq(IndicatorFavorite::getFamilyId, familyId)
+                .eq(IndicatorFavorite::getMemberId, memberId)
+                .eq(IndicatorFavorite::getSourceType, sourceType)
+                .eq(IndicatorFavorite::getDefinitionId, definitionId)
+                .last("LIMIT 1"));
+        Map<String, Object> out = new HashMap<>();
+        if (exist != null) {
+            favoriteMapper.deleteById(exist.getId());
+            out.put("added", false);
+        } else {
+            IndicatorFavorite fav = new IndicatorFavorite();
+            fav.setUserId(userId);
+            fav.setFamilyId(familyId);
+            fav.setMemberId(memberId);
+            fav.setSourceType(sourceType);
+            fav.setDefinitionId(definitionId);
+            favoriteMapper.insert(fav);
+            out.put("added", true);
+        }
+        out.put("definitionId", definitionId);
+        return out;
+    }
+
+    @Override
+    public Map<String, Object> list(Long userId, Long familyId, Long memberId, String sourceType) {
+        List<IndicatorFavorite> rows = favoriteMapper.selectList(new LambdaQueryWrapper<IndicatorFavorite>()
+                .eq(IndicatorFavorite::getUserId, userId)
+                .eq(IndicatorFavorite::getFamilyId, familyId)
+                .eq(IndicatorFavorite::getMemberId, memberId)
+                .eq(IndicatorFavorite::getSourceType, sourceType)
+                .orderByAsc(IndicatorFavorite::getId));
+        List<Long> ids = new ArrayList<>();
+        for (IndicatorFavorite r : rows) {
+            ids.add(r.getDefinitionId());
+        }
+        Map<String, Object> out = new HashMap<>();
+        out.put("memberId", memberId);
+        out.put("sourceType", sourceType);
+        out.put("definitionIds", ids);
+        return out;
+    }
+
+    @Override
+    public Map<String, Object> remove(Long userId, Long familyId, Long memberId, String sourceType, Long definitionId) {
+        favoriteMapper.delete(new LambdaQueryWrapper<IndicatorFavorite>()
+                .eq(IndicatorFavorite::getUserId, userId)
+                .eq(IndicatorFavorite::getFamilyId, familyId)
+                .eq(IndicatorFavorite::getMemberId, memberId)
+                .eq(IndicatorFavorite::getSourceType, sourceType)
+                .eq(IndicatorFavorite::getDefinitionId, definitionId));
+        Map<String, Object> out = new HashMap<>();
+        out.put("definitionId", definitionId);
+        out.put("removed", true);
+        return out;
+    }
+}

+ 16 - 0
cfc-backend/src/main/resources/schema.sql

@@ -6956,3 +6956,19 @@ CREATE TABLE IF NOT EXISTS content_dimension_threshold (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_content_dim (content_type, content_id, dimension_code)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容维度阈值显隐配置';
+
+-- 用户关注指标收藏表(T04 方案A:后端持久化,跨端同步)
+-- 隔离维度:user_id × family_id × member_id × source_type × definition_id
+CREATE TABLE IF NOT EXISTS indicator_favorite (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '收藏用户ID(users.id)',
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    member_id BIGINT NOT NULL COMMENT '观测对象ID(family_members.id)',
+    source_type VARCHAR(50) NOT NULL COMMENT '报告类型',
+    definition_id BIGINT NOT NULL COMMENT '指标定义ID(indicator_definitions.id)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_user_family_member_source_def (user_id, family_id, member_id, source_type, definition_id),
+    KEY idx_member_source (member_id, source_type),
+    KEY idx_user_created (user_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注指标收藏';

+ 25 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorLoginRequiredTest.java

@@ -75,4 +75,29 @@ public class JwtInterceptorLoginRequiredTest {
         assertTrue(result);
         assertEquals(-1L, request.getAttribute("userId"));
     }
+
+    @Test
+    public void testIndicatorFavoriteWithoutTokenIs401() throws Exception {
+        // T04 方案A:指标收藏后端化——收藏写接口强制登录,无 token 直接 401(防匿名 userId=-1 污染)
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/indicator/favorite/toggle");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+    }
+
+    @Test
+    public void testIndicatorFavoriteListWithoutTokenIs401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/indicator/favorite/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+    }
 }

+ 171 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/IndicatorFavoriteControllerTest.java

@@ -0,0 +1,171 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.IndicatorFavoriteService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标收藏控制器单测(T03 · §6),覆盖验收要点:
+ * <ul>
+ *   <li>toggle/list/remove:正常路径透传 Service 结果</li>
+ *   <li>参数缺失(memberId/definitionId/sourceType)→ 400</li>
+ *   <li>memberId 不属当前家庭 → 400(越权拦截)</li>
+ * </ul>
+ */
+class IndicatorFavoriteControllerTest {
+
+    private IndicatorFavoriteController controller;
+    private IndicatorFavoriteService indicatorFavoriteService;
+    private FamilyMemberMapper familyMemberMapper;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 初始化 MP TableInfo 缓存,使 LambdaQueryWrapper 在无 Spring 上下文可用
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+    }
+
+    @BeforeEach
+    void setUp() {
+        controller = new IndicatorFavoriteController();
+        indicatorFavoriteService = mock(IndicatorFavoriteService.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        ReflectionTestUtils.setField(controller, "indicatorFavoriteService", indicatorFavoriteService);
+        ReflectionTestUtils.setField(controller, "familyMemberMapper", familyMemberMapper);
+    }
+
+    private FamilyMember member(Long id, Long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    private Map<String, Object> params(String memberId, String sourceType, String definitionId) {
+        Map<String, Object> p = new HashMap<>();
+        if (memberId != null) p.put("memberId", memberId);
+        if (sourceType != null) p.put("sourceType", sourceType);
+        if (definitionId != null) p.put("definitionId", definitionId);
+        return p;
+    }
+
+    // ==================== toggle ====================
+
+    @Test
+    void toggle_正常() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 9L));
+        Map<String, Object> data = new HashMap<>();
+        data.put("definitionId", 456L);
+        data.put("added", true);
+        when(indicatorFavoriteService.toggle(7L, 9L, 42L, "gut_flora", 456L)).thenReturn(data);
+
+        Result<?> r = controller.toggle(params("42", "gut_flora", "456"), 7L, 9L);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals(456L, ((Map<?, ?>) r.getData()).get("definitionId"));
+    }
+
+    @Test
+    void toggle_缺memberId_400() {
+        Result<?> r = controller.toggle(params(null, "gut_flora", "456"), 7L, 9L);
+        assertEquals(400, r.getCode());
+        verify(indicatorFavoriteService, never()).toggle(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void toggle_缺sourceType_400() {
+        Result<?> r = controller.toggle(params("42", "", "456"), 7L, 9L);
+        assertEquals(400, r.getCode());
+        verify(indicatorFavoriteService, never()).toggle(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void toggle_缺definitionId_400() {
+        Result<?> r = controller.toggle(params("42", "gut_flora", null), 7L, 9L);
+        assertEquals(400, r.getCode());
+        verify(indicatorFavoriteService, never()).toggle(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void toggle_成员不属家庭_400() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        Result<?> r = controller.toggle(params("42", "gut_flora", "456"), 7L, 9L);
+
+        assertEquals(400, r.getCode());
+        verify(indicatorFavoriteService, never()).toggle(any(), any(), any(), any(), any());
+    }
+
+    // ==================== list ====================
+
+    @Test
+    void list_正常() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 9L));
+        Map<String, Object> data = new HashMap<>();
+        data.put("memberId", 42L);
+        data.put("sourceType", "gut_flora");
+        data.put("definitionIds", new long[]{456L, 789L});
+        when(indicatorFavoriteService.list(7L, 9L, 42L, "gut_flora")).thenReturn(data);
+
+        Result<?> r = controller.list(params("42", "gut_flora", null), 7L, 9L);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+    }
+
+    @Test
+    void list_缺sourceType_400() {
+        Result<?> r = controller.list(params("42", null, null), 7L, 9L);
+        assertEquals(400, r.getCode());
+    }
+
+    @Test
+    void list_成员不属家庭_400() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+        Result<?> r = controller.list(params("42", "gut_flora", null), 7L, 9L);
+        assertEquals(400, r.getCode());
+    }
+
+    // ==================== remove ====================
+
+    @Test
+    void remove_正常() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 9L));
+        Map<String, Object> data = new HashMap<>();
+        data.put("definitionId", 456L);
+        data.put("removed", true);
+        when(indicatorFavoriteService.remove(7L, 9L, 42L, "gut_flora", 456L)).thenReturn(data);
+
+        Result<?> r = controller.remove(params("42", "gut_flora", "456"), 7L, 9L);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals(true, ((Map<?, ?>) r.getData()).get("removed"));
+    }
+
+    @Test
+    void remove_缺参_400() {
+        Result<?> r = controller.remove(params("42", "gut_flora", null), 7L, 9L);
+        assertEquals(400, r.getCode());
+    }
+}

+ 144 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorFavoriteServiceTest.java

@@ -0,0 +1,144 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.IndicatorFavorite;
+import com.etotem.cfc.mapper.IndicatorFavoriteMapper;
+import com.etotem.cfc.service.impl.IndicatorFavoriteServiceImpl;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+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;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标收藏服务单测(T02 · §5.2),覆盖验收要点:
+ * <ul>
+ *   <li>toggle:新增(不存在则插)/ 删除(存在则删)两分支,返回 {definitionId, added}</li>
+ *   <li>list:空/非空、按 id 升序、5 维条件隔离</li>
+ *   <li>remove:存在/不存在均幂等返回 {definitionId, removed=true}</li>
+ * </ul>
+ */
+class IndicatorFavoriteServiceTest {
+
+    private IndicatorFavoriteServiceImpl service;
+    private IndicatorFavoriteMapper favoriteMapper;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 初始化 MP TableInfo 缓存,使 LambdaQueryWrapper 在无 Spring 上下文可用
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), IndicatorFavorite.class);
+    }
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorFavoriteServiceImpl();
+        favoriteMapper = mock(IndicatorFavoriteMapper.class);
+        ReflectionTestUtils.setField(service, "favoriteMapper", favoriteMapper);
+    }
+
+    private IndicatorFavorite fav(Long id, Long userId, Long familyId, Long memberId,
+                                  String sourceType, Long definitionId) {
+        IndicatorFavorite f = new IndicatorFavorite();
+        f.setId(id);
+        f.setUserId(userId);
+        f.setFamilyId(familyId);
+        f.setMemberId(memberId);
+        f.setSourceType(sourceType);
+        f.setDefinitionId(definitionId);
+        return f;
+    }
+
+    // ==================== toggle ====================
+
+    @Test
+    void toggle_不存在则插入() {
+        when(favoriteMapper.selectOne(any())).thenReturn(null);
+
+        Map<String, Object> out = service.toggle(7L, 9L, 42L, "gut_flora", 456L);
+
+        assertEquals(456L, out.get("definitionId"));
+        assertEquals(true, out.get("added"));
+        verify(favoriteMapper).insert(any(IndicatorFavorite.class));
+        verify(favoriteMapper, never()).deleteById(any(java.io.Serializable.class));
+    }
+
+    @Test
+    void toggle_存在则删除() {
+        IndicatorFavorite exist = fav(11L, 7L, 9L, 42L, "gut_flora", 456L);
+        when(favoriteMapper.selectOne(any())).thenReturn(exist);
+
+        Map<String, Object> out = service.toggle(7L, 9L, 42L, "gut_flora", 456L);
+
+        assertEquals(456L, out.get("definitionId"));
+        assertEquals(false, out.get("added"));
+        verify(favoriteMapper).deleteById((java.io.Serializable) 11L);
+        verify(favoriteMapper, never()).insert(any(IndicatorFavorite.class));
+    }
+
+    // ==================== list ====================
+
+    @Test
+    void list_空集合() {
+        when(favoriteMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Map<String, Object> out = service.list(7L, 9L, 42L, "gut_flora");
+
+        assertEquals(42L, out.get("memberId"));
+        assertEquals("gut_flora", out.get("sourceType"));
+        assertEquals(Collections.emptyList(), out.get("definitionIds"));
+    }
+
+    @Test
+    void list_非空按id升序() {
+        List<IndicatorFavorite> rows = Arrays.asList(
+                fav(1L, 7L, 9L, 42L, "gut_flora", 456L),
+                fav(2L, 7L, 9L, 42L, "gut_flora", 789L));
+        when(favoriteMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> out = service.list(7L, 9L, 42L, "gut_flora");
+
+        @SuppressWarnings("unchecked")
+        List<Long> ids = (List<Long>) out.get("definitionIds");
+        assertEquals(Arrays.asList(456L, 789L), ids);
+    }
+
+    // ==================== remove ====================
+
+    @Test
+    void remove_存在则删除() {
+        when(favoriteMapper.delete(any())).thenReturn(1);
+
+        Map<String, Object> out = service.remove(7L, 9L, 42L, "gut_flora", 456L);
+
+        assertEquals(456L, out.get("definitionId"));
+        assertEquals(true, out.get("removed"));
+        verify(favoriteMapper).delete(any());
+    }
+
+    @Test
+    void remove_不存在仍幂等() {
+        // delete 无行返回 0,接口仍应返回 removed=true(幂等)
+        when(favoriteMapper.delete(any())).thenReturn(0);
+
+        Map<String, Object> out = service.remove(7L, 9L, 42L, "gut_flora", 456L);
+
+        assertEquals(456L, out.get("definitionId"));
+        assertEquals(true, out.get("removed"));
+        assertTrue(out.containsKey("removed"));
+    }
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 35 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1764,6 +1764,41 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
+### 4.48.2 指标收藏 · 后端接口(`/api/indicator/favorite/*`)
+
+> 本节为 T04「关注指标收藏」后端化(方案A)增量。收藏从 localStorage(方案B)升级为后端持久化,可跨设备同步、跨端一致。**全部 `@PostMapping`**;`JWT Bearer` 鉴权;`@Resource` 注入;`userId`/`familyId` 由 `JwtInterceptor` 注入 `@RequestAttribute`,服务端不信任前端传值。
+
+**① `POST /api/indicator/favorite/toggle`** — 收藏/取消(存在则删、不存在则插,幂等于单次调用)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/indicator/favorite/toggle` | 登录(JWT;已加入 `LOGIN_REQUIRED_PATHS`,无 token 直接 401;`memberId` 须属于当前家庭) | `{memberId, sourceType, definitionId}` | `{definitionId, added}` |
+
+- **入参**:`memberId`/`definitionId`(必填 Long)、`sourceType`(必填 String,非空)。
+- **返回**:`added=true` 本次加入收藏(新插入);`added=false` 本次取消收藏(已删除)。
+- **错误码**:`400`(缺参/成员不属家庭/definitionId 非法)、`401`(未登录)。
+
+**② `POST /api/indicator/favorite/list`** — 查已收藏集合
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/indicator/favorite/list` | 登录(JWT;`memberId` 须属于当前家庭) | `{memberId, sourceType}` | `{memberId, sourceType, definitionIds}` |
+
+- **`definitionIds`**:该成员×该报告类型下已收藏的指标定义 ID 列表(升序,按 `id`);空集合返回 `[]`。
+- 查询范围限定 `user_id = userId AND family_id = familyId AND member_id AND source_type`(家庭/用户隔离)。
+
+**③ `POST /api/indicator/favorite/remove`** — 移除收藏(显式删除)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/indicator/favorite/remove` | 登录(JWT;`memberId` 须属于当前家庭) | `{memberId, sourceType, definitionId}` | `{definitionId, removed}` |
+
+- **`removed=true`**:该条收藏已删除(不存在则视作已删除,幂等)。供前端「清空对比/切换成员」等需显式删除而非 toggle 语义的场景使用。
+
+*文档更新:2026-10-30(T04 指标收藏后端化,见 `specs/2026-10-30-indicator-favorite-backend-design.md`)*
+
+---
+
 ### 4.49 健康方案删除(`/api/health/plan/delete`、`/api/admin/health-plan/delete`)
 
 已生成的健康方案可删除,**级联删除其产生的全部任务**(含每日实例)。

+ 2 - 0
docs/superpowers/specs/2026-10-25-indicator-timeline-display-design.md

@@ -45,6 +45,8 @@
 > ① **对照与「查看趋势」入口必须同批上** —— 否则用户看到变化却无处下钻。
 > ② **灰度阀门用 D1 开关,不用"首周只对菌群报告开启"** —— 菌群是主力且流量最大的报告类型,限定它等于把风险放在最敏感位置。D1 已裁定**默认开**;若需保守放量,改为首周默认关、用户可自开,放量只改一个默认值,零额外代码。
 
+> **⚠️ 关注指标收藏:已从方案B(localStorage)升级为方案A(后端持久化)** —— 收藏改为后端持久化后可跨设备同步、跨端一致,前端保留 localStorage 仅作降级读。接口契约见 `specs/2026-10-30-indicator-favorite-backend-design.md` §4 与 `api/API_REFERENCE.md` §4.48.2(`/api/indicator/favorite/toggle|list|remove`,全 POST + JWT 强制登录)。
+
 > **⚠️ 本节曾经的错误(已闭环)**:本节标题曾为「主理人已拍板」且内容写死"第一段 = 时间轴页",与 PM 定稿标签冲突。**教训:文档里"已拍板"这类状态标签必须与存疑项显式关联,不能孤立存在;选项字母(A/B/C)在不同轮次文档中含义可能漂移,跨轮引用时必须核对定稿标签。**
 
 **分拆的收益(三种结论下均成立)**:

Некоторые файлы не были показаны из-за большого количества измененных файлов