Переглянути джерело

T29-B2: 报告代传功能修复·第二批——权限越权与安全策略收敛 (B2-1~B2-6)

B2-1 family/member/list 支持 params.familyId + 行级隔离(平台角色任意,非平台需在有效家庭集内,
     否则 code=403);删除 ensureUserFamily 兜底建家庭,无家庭改为返回空列表
B2-2 proxyParsePreview 增加 familyId 绑定校验:非平台角色且 familyId 不在有效家庭集内 → 拒绝,
     校验发生在建草稿/写文件之前,越权不产生副作用
B2-3 auditConfirm 增加草稿归属校验(非 admin 仅能确认自己创建的草稿,admin 不受限)+
     subjectId 归属家庭一致性校验(跨家庭篡改 → 明确错误,不再静默跨家庭绑定)
B2-4 FamilyAccessInterceptor 收紧:有效家庭集为空且请求携带 familyId → 403(此前直接放行);
     平台角色(admin/operator/finance)直通,与 /api/admin 语义一致
     getEffectiveFamilyIds 口径补全:并入 family_members 反查 + guide_families(status=binding) 绑定,
     使「空集」严格等价于「无任何访问渠道」,避免误伤成长规划师等合法用户
B2-5 proxy-parse/proxy-confirm 移入 JwtInterceptor.LOGIN_REQUIRED_PATHS:无 token 直接 401
     (此前降级为匿名放行到 controller,仅返回业务 500)
B2-6 proxyParsePreview 补齐三层校验编排注释:角色校验 / familyId 绑定 / 拦截器兜底及各自返回码

测试:新增/更新 6 个测试类共 37 项全部通过(拦截器 11、有效家庭集 5、成员列表 4、
代传绑定 6、审核确认 8、登录必需 3);另有 5 项失败 + 2 项错误经基线 worktree 复跑确认为
改动前既已存在(DanReportParseServiceTest / FoodRecommendServiceTest / ServiceRoleApproveTest)。

不改前端、不新增 DB 迁移、不改 schema。
Sisyphus Agent 2 тижнів тому
батько
коміт
751145b9d

+ 30 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/FamilyAccessInterceptor.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.config;
 
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.service.UserService;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -57,16 +58,37 @@ public class FamilyAccessInterceptor implements HandlerInterceptor {
             return true;
         }
 
-        Set<Long> effectiveFamilyIds = userService.getEffectiveFamilyIds(userId);
-        if (effectiveFamilyIds == null || effectiveFamilyIds.isEmpty()) {
+        // B2-4 兜底:平台角色(admin/operator/finance)直通 —— 与 /api/admin 跳过语义一致。
+        // 平台角色可合法访问任意家庭(如 /api/family-platform/admin/balance|logs|adjust 等
+        // 非 admin 前缀但带 familyId 的运营端点),即使其有效家庭集为空也不应被 403。
+        String role = (String) request.getAttribute("role");
+        @SuppressWarnings("unchecked")
+        List<String> roles = (List<String>) request.getAttribute("roles");
+        if ("admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles)) {
             return true;
         }
 
+        Set<Long> effectiveFamilyIds = userService.getEffectiveFamilyIds(userId);
+
         List<Long> requestedFamilyIds = collectFamilyIds(request);
         if (requestedFamilyIds.isEmpty()) {
             return true;
         }
 
+        // B2-4 enforcement:effective 集为空/null 且请求携带 familyId → 一律 403。
+        // 修复前空集直接放行(logging 模式)导致任意 familyId 可被探测/越权;
+        // 修复后 getEffectiveFamilyIds 已并入 family_members 反查 + guide_family 绑定,
+        // "空集"即代表该用户没有任何访问渠道,携带 familyId 必为越权。
+        if (effectiveFamilyIds == null || effectiveFamilyIds.isEmpty()) {
+            log.warn("[FamilyAccess] userId={} 有效家庭集为空,拒绝携带 familyId={} 的访问",
+                    userId, requestedFamilyIds);
+            if (isEnforcementMode()) {
+                sendForbidden(response, "无权访问该家庭数据");
+                return false;
+            }
+            return true;
+        }
+
         for (Long requestedFamilyId : requestedFamilyIds) {
             if (!effectiveFamilyIds.contains(requestedFamilyId)) {
                 log.warn("[FamilyAccess] userId={} 越权访问 familyId={},有效范围={}",
@@ -81,7 +103,12 @@ public class FamilyAccessInterceptor implements HandlerInterceptor {
         return true;
     }
 
-    /** 从 query / path / body 三来源收集 familyId */
+    /**
+     * 从 query / path / body 三来源收集 familyId。
+     * 注意:multipart/form-data 请求(如 /api/health/report/proxy-parse)的 familyId 走
+     * query string 或 multipart 字段,均经 request.getParameter("familyId") 读取;
+     * RequestBodyCachingFilter 只缓存 application/json body,multipart 不进入 body 解析分支。
+     */
     private List<Long> collectFamilyIds(HttpServletRequest request) {
         Set<Long> ids = new HashSet<>();
 

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

@@ -79,7 +79,11 @@ private static final String[] PUBLIC_PATHS = {
     // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
     private static final String[] LOGIN_REQUIRED_PATHS = {
         "/api/share/qrcode",        // 生成海报小程序码:anonymous 会反复触发微信 access_token 失效("not latest")
-        "/api/auth/verify-password" // 密码验证:属凭证操作,必须登录(防 /api/auth/verify 前缀碰撞白名单放行)
+        "/api/auth/verify-password", // 密码验证:属凭证操作,必须登录(防 /api/auth/verify 前缀碰撞白名单放行)
+        // T29-B2-5:报告代传接口——无 token 时被降级为匿名(userId=-1)放行到 controller,
+        // 只能返回业务 500;代传属写数据操作(建草稿/入库),必须登录,无 token 直接 401。
+        "/api/health/report/proxy-parse",
+        "/api/health/report/proxy-confirm"
     };
 
     @Override

+ 49 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.DanReportUploadService;
 import com.etotem.cfc.dto.HealthAnalysisResult;
@@ -29,6 +30,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.FamilyMemberNutritionProfileService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodRecommendService;
@@ -171,6 +173,9 @@ public class HealthReportController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private UserService userService;
+
     @Resource
     private ReportBlockService reportBlockService;
 
@@ -2106,7 +2111,9 @@ public class HealthReportController {
     @PostMapping("/report/audit/confirm")
     public Result<Map<String, Object>> auditConfirm(
             @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long auditorId) {
+            @RequestAttribute("userId") Long auditorId,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("roles") List<String> roles) {
         Long draftId = ParamUtils.getLong(params.get("draftId"));
         Long subjectId = ParamUtils.getLong(params.get("subjectId"));
 
@@ -2115,7 +2122,9 @@ public class HealthReportController {
         }
 
         try {
-            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, auditorId);
+            // B2-3:admin 判定——平台角色可确认任意草稿;非平台角色仅能确认自己创建的草稿
+            boolean isAdmin = "admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles);
+            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, auditorId, isAdmin);
             Long confirmedSubjectId = ParamUtils.getLong(result.get("subjectId"));
             if (confirmedSubjectId != null) {
                 try {
@@ -2675,6 +2684,23 @@ public class HealthReportController {
         return false;
     }
 
+    /**
+     * B2-2 / B2-6:代传 familyId 绑定校验(发生在建草稿/写文件之前,越权不产生副作用)。
+     *
+     * <p>口径与 FamilyAccessInterceptor.getEffectiveFamilyIds 一致:平台角色(admin/operator/finance)
+     * 直接放行;否则 familyId 必位于调用者有效家庭集内(own family / teacher_family_ids /
+     * family_members 反查 / guide_family 绑定)。</p>
+     *
+     * @return true=允许代传该家庭;false=无权(controller 层返回 code=500 "无权为该家庭代传报告")
+     */
+    boolean canProxyForFamily(Long userId, String role, List<String> roles, Long familyId) {
+        if ("admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles)) {
+            return true;
+        }
+        Set<Long> effective = userService.getEffectiveFamilyIds(userId);
+        return effective != null && effective.contains(familyId);
+    }
+
     /**
      * 规划师/营养师代用户上传报告(预览模式,仅解析不入库)
      *
@@ -2683,8 +2709,17 @@ public class HealthReportController {
      * <p>链路:storeFile → createDraft → updateNote(备注) → getFilePath → LangGraph 解析
      * (失败回退本地 Java 解析)→ 返回 draftId/payload/成员匹配/提取字段。</p>
      *
-     * <p>权限编排说明:① 角色校验 isProxyUploader;② familyId 非空校验;
-     * ③ 家庭访问拦截器兜底(代传绑定校验由 T29-B2 补充)。</p>
+     * <p><b>T29-B2-6 权限编排(三层校验,各层失败返回码):</b>
+     * <ol>
+     *   <li>① 角色校验 {@link #isProxyUploader}:非 admin/teacher/approved 营养师/approved 规划师
+     *       → <code>Result.error(code=500, "无权限代上传报告,仅超级管理员/规划师/营养师可操作")</code>;</li>
+     *   <li>② familyId 绑定校验 {@link #canProxyForFamily}(B2-2,本方法内):
+     *       非平台角色且 familyId 不在有效家庭集内
+     *       → <code>Result.error(code=500, "无权为该家庭代传报告")</code>(不建草稿、不写文件);</li>
+     *   <li>③ 家庭访问拦截器兜底(FamilyAccessInterceptor,B2-4 enforcement):
+     *       越权 → <code>HTTP 403</code>,body <code>{"code":403,"message":"无权访问该家庭数据"}</code>。
+     *       (拦截器在 controller 之前执行,因此②③不会同时触发;②是对拦截器未覆盖路径的纵深防御。)</li>
+     * </ol></p>
      */
     @Operation(summary = "代上传报告(预览)")
     @PostMapping("/report/proxy-parse")
@@ -2712,6 +2747,12 @@ public class HealthReportController {
             return Result.error("familyId不能为空");
         }
 
+        // B2-2 / B2-6:familyId 绑定校验(发生在建草稿/写文件之前,越权不产生副作用)。
+        // 拦截器(B2-4)未覆盖或未生效时,此处作纵深防御。
+        if (!canProxyForFamily(userId, role, roles, familyId)) {
+            return Result.error("无权为该家庭代传报告");
+        }
+
         String contentType = file.getContentType();
         String originalFilename = file.getOriginalFilename();
         String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
@@ -2844,8 +2885,10 @@ public class HealthReportController {
             HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
             String proxyUploadNote = draft != null ? draft.getRemark() : null;
 
-            // 代上传模式跳过草稿归属校验
-            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
+            // B2-3:草稿归属校验下沉到 auditConfirm——仅创建者本人或平台角色可确认
+            // (原代传链路"跳过草稿归属校验"的口径已废止:代传草稿只能由代传人本人或 admin 确认)
+            boolean isAdmin = "admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles);
+            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId, isAdmin);
             Long reportId = (Long) result.get("reportId");
 
             // 维护"当前食材"推荐(有则更新,无则添加)

+ 43 - 17
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.family;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.dto.AddFamilyMemberDTO;
 import com.etotem.cfc.dto.FamilyMemberVO;
 import com.etotem.cfc.dto.SwitchMemberVO;
@@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "家庭成员管理", description = "手动添加的家庭成员管理接口")
@@ -61,23 +63,47 @@ public Result<FamilyMemberVO> addMember(@RequestBody AddFamilyMemberDTO dto,
 @Operation(summary = "获取家庭成员列表(统一入口)")
 @PostMapping("/list")
 public Result<?> listMembers(@RequestBody(required = false) Map<String, Object> params,
- @RequestAttribute("userId") Long userId) {
- if (userId == null) {
-   return Result.error("请先登录");
- }
- boolean visibleOnly = params != null && Boolean.TRUE.equals(params.get("visibleOnly"));
- boolean includeFamily = params != null && Boolean.TRUE.equals(params.get("includeFamily"));
- if (includeFamily) {
-   userService.ensureUserFamily(userId);
-   return Result.success(familyMemberService.listMembersWithFamily(userId));
- }
- if (visibleOnly) {
-   return Result.success(userService.getVisibleMembers(userId));
- }
- // 与 visibleOnly/includeFamily 一致:无家庭时先兜底创建,避免空列表误伤前端选人
- userService.ensureUserFamily(userId);
- List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
- return Result.success(members);
+                             @RequestAttribute("userId") Long userId,
+                             @RequestAttribute("role") String role,
+                             @RequestAttribute("roles") List<String> roles) {
+    if (userId == null) {
+        return Result.error("请先登录");
+    }
+
+    // B2-1:显式指定 familyId 时,按该家庭返回成员(teacher 可查绑定家庭;平台角色任意家庭)。
+    // 越权(familyId 不在调用者有效家庭集内)直接 403,与 FamilyAccessInterceptor 口径一致。
+    Long requestedFamilyId = params != null ? ParamUtils.getLong(params.get("familyId")) : null;
+    if (requestedFamilyId != null) {
+        if (!canAccessFamily(requestedFamilyId, userId, role, roles)) {
+            return Result.error(403, "无权访问该家庭数据");
+        }
+        return Result.success(familyMemberService.listMembersOfFamily(requestedFamilyId, userId));
+    }
+
+    boolean visibleOnly = params != null && Boolean.TRUE.equals(params.get("visibleOnly"));
+    boolean includeFamily = params != null && Boolean.TRUE.equals(params.get("includeFamily"));
+    if (includeFamily) {
+        // 不再 ensureUserFamily:无家庭时返回空列表,绝不静默建家庭
+        return Result.success(familyMemberService.listMembersWithFamily(userId));
+    }
+    if (visibleOnly) {
+        return Result.success(userService.getVisibleMembers(userId));
+    }
+    List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+    return Result.success(members);
+}
+
+/**
+ * B2-1 行级隔离口径:平台角色(admin/operator/finance)任意家庭;
+ * 否则 familyId 必在用户有效家庭集内(own family / teacher_family_ids / family_members 反查 / guide_family 绑定)。
+ * 与 FamilyAccessInterceptor.getEffectiveFamilyIds 保持一致,避免两套口径导致越权放行或误伤。
+ */
+private boolean canAccessFamily(Long familyId, Long userId, String role, List<String> roles) {
+    if ("admin".equals(role) || AdminRoles.hasAnyPlatformRole(roles)) {
+        return true;
+    }
+    Set<Long> effective = userService.getEffectiveFamilyIds(userId);
+    return effective != null && effective.contains(familyId);
 }
 
 @Operation(summary = "切换到家庭成员视图")

+ 16 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -235,7 +235,20 @@ public class FamilyMemberService {
         if (user == null || user.getFamilyId() == null) {
             return new ArrayList<>();
         }
-        Long familyId = user.getFamilyId();
+        return listMembersOfFamily(user.getFamilyId(), userId);
+    }
+
+    /**
+     * 获取指定家庭的成员列表(B2-1 新增)。
+     * <p>按 familyId 直接查询(成长规划师查看绑定家庭、管理端查看任意家庭时使用);
+     * 调用方需先校验访问权(getEffectiveFamilyIds / 平台角色直通)。</p>
+     * @param familyId     目标家庭
+     * @param viewerUserId 当前查看者(关系标签/isSelf 视角计算用)
+     */
+    public List<FamilyMemberVO> listMembersOfFamily(Long familyId, Long viewerUserId) {
+        if (familyId == null) {
+            return new ArrayList<>();
+        }
         List<FamilyMemberVO> result = new ArrayList<>();
 
         // 查询家庭创建者,用于逐成员判断其是否管理员(isAdmin 语义:该成员本人是否为家庭创建者)
@@ -294,7 +307,7 @@ public class FamilyMemberService {
         // 查找当前查看者在 family_members 中的记录(用于计算视角标签)
         FamilyMember viewerMember = null;
         for (FamilyMember fm : familyMembers) {
-            if (userId.equals(fm.getUserId())) {
+            if (viewerUserId.equals(fm.getUserId())) {
                 viewerMember = fm;
                 break;
             }
@@ -411,7 +424,7 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             vo.setGeneration(0);
             // 家庭主账号:仅当其为家庭创建者时视为管理员(users 表无 is_admin 列)
             vo.setIsAdmin(creatorId != null && creatorId.equals(p.getId()));
-            vo.setIsSelf(p.getId().equals(userId));
+            vo.setIsSelf(p.getId().equals(viewerUserId));
             // 家庭主账号均为可登录用户,不允许切换视角(与成员段"仅不可登录可切换"一致)
             vo.setIsSwitchable(false); // 含 source='parent' 段
             vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);

+ 48 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -37,6 +37,7 @@ import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Objects;
 import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.util.Map;
@@ -2052,12 +2053,17 @@ public class HealthReportService {
      * 审核确认报告 — 将草稿转为正式报告并绑定到指定成员
      */
     @Transactional(rollbackFor = Exception.class)
-    public Map<String, Object> auditConfirm(Long draftId, Long subjectId, Long auditorId) {
+    public Map<String, Object> auditConfirm(Long draftId, Long subjectId, Long auditorId, boolean isAdmin) {
         HealthReportDraft draft = getActiveDraftOrThrow(draftId);
         if (draft == null) {
             throw new IllegalArgumentException("草稿不存在或已过期");
         }
 
+        // B2-3:草稿只能被创建者本人或平台角色(admin)确认(非 admin 不能确认他人草稿)
+        validateDraftOwnership(draft, auditorId, isAdmin);
+        // B2-3:subjectId 必须属于草稿所属家庭(防跨家庭篡改确认)
+        validateSubjectFamily(draft, subjectId);
+
         try {
             ParsedReportPayload.Payload payload = objectMapper.readValue(
                     draft.getPayloadJson(), ParsedReportPayload.Payload.class);
@@ -2095,6 +2101,47 @@ public class HealthReportService {
         }
     }
 
+    /**
+     * B2-3:草稿归属校验——非 admin 只能确认自己创建的草稿。
+     * @param draft     草稿(必须非 null)
+     * @param auditorId 确认人 userId
+     * @param isAdmin   是否平台角色(admin/operator/finance)
+     */
+    void validateDraftOwnership(HealthReportDraft draft, Long auditorId, boolean isAdmin) {
+        if (!isAdmin && !Objects.equals(draft.getUserId(), auditorId)) {
+            throw new IllegalArgumentException("无权确认他人创建的草稿");
+        }
+    }
+
+    /**
+     * B2-3:subjectId 必须属于草稿所属家庭(防跨家庭篡改:把 A 家庭的报告确认到 B 家庭的成员上)。
+     *
+     * <p>兼容两种 subjectId 口径:
+     * <ul>
+     *   <li>family_members.id:直接校验其 family_id 是否等于 draft.familyId,跨家庭 → 拒绝;</li>
+     *   <li>users.id:按既有 {@link #resolveSubjectToMemberId} 归一化口径解析后,校验最终落点成员的家庭。</li>
+     * </ul></p>
+     */
+    void validateSubjectFamily(HealthReportDraft draft, Long subjectId) {
+        if (subjectId == null || draft.getFamilyId() == null) {
+            return;
+        }
+        FamilyMember direct = familyMemberMapper.selectById(subjectId);
+        if (direct != null && !Objects.equals(direct.getFamilyId(), draft.getFamilyId())) {
+            // subjectId 是 family_members.id 但属于其他家庭 → 明确拒绝
+            throw new IllegalArgumentException("所选成员不属于该草稿所属家庭");
+        }
+        if (direct == null) {
+            // subjectId 是 users.id 或不存在:按既有归一化口径解析后校验最终落点家庭
+            Long resolved = resolveSubjectToMemberId(subjectId, draft.getFamilyId());
+            FamilyMember resolvedMember = resolved != null ? familyMemberMapper.selectById(resolved) : null;
+            if (resolvedMember == null || !Objects.equals(resolvedMember.getFamilyId(), draft.getFamilyId())) {
+                throw new IllegalArgumentException("所选成员不属于该草稿所属家庭");
+            }
+        }
+        // direct != null && direct.familyId == draft.familyId → 通过
+    }
+
     /**
      * 审核丢弃报告 — 删除草稿,不入库
      */

+ 51 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -8,9 +8,11 @@ import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.GuideFamily;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.VerificationCodeService;
 import com.etotem.cfc.entity.DistributionRelation;
@@ -47,6 +49,9 @@ private FamilyMapper familyMapper;
 @Resource
 private FamilyMemberMapper familyMemberMapper;
 
+@Resource
+private GuideFamilyMapper guideFamilyMapper;
+
 @Resource
 private JwtConfig jwtConfig;
 
@@ -825,11 +830,12 @@ private FamilyInvitationService familyInvitationService;
         if (user == null) {
             return new ArrayList<>();
         }
-        if (user.getFamilyId() == null) {
-            // 兜底:无家庭时自动创建,避免"暂无家庭"空态
-            ensureUserHasFamily(user);
-        }
+        // B2-1:不再静默建家庭(原 ensureUserHasFamily 兜底)。无家庭 → 空列表,
+        // 避免"看一眼成员列表"就凭空产生一个空家庭。
         Long familyId = user.getFamilyId();
+        if (familyId == null) {
+            return new ArrayList<>();
+        }
         List<FamilyMemberVO> result = new ArrayList<>();
 
         Family family = familyMapper.selectById(familyId);
@@ -1817,7 +1823,20 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
     }
 
     /**
-     * 获取用户有效的 familyId 集合:自己的 familyId + teacherFamilyIds 中关联的家庭。
+     * 获取用户有效的 familyId 集合(访问权口径,与 JwtInterceptor 家庭解析口径保持一致):
+     * <ol>
+     *   <li>自己的 familyId(users.family_id)</li>
+     *   <li>teacherFamilyIds(教师/规划师被分配的家庭,逗号分隔)</li>
+     *   <li>family_members 反查:users.family_id 为空但有 family_members 行的用户
+     *       (与 JwtInterceptor:157-166 兜底口径一致)</li>
+     *   <li>guide_families 中 status=binding 的绑定家庭(成长规划师绑定家庭,与
+     *       teacherFamilyIds 不同源,B2-4 主理人裁决并入)</li>
+     * </ol>
+     *
+     * <p>T29-B2-4:修复前仅含 1、2,导致 guide_family 绑定但 teacherFamilyIds 为空的规划师、
+     * 以及 family_id 为空但有成员行的用户被拦截器误判为"无访问渠道"而 403。
+     * 补全后"空集"才代表真正没有任何访问渠道(全新账号),拦截器方可将空集 + 携带 familyId 判为越权。
+     *
      * @param userId 用户ID
      * @return familyId 集合,永不为 null(空用户返回空 set)
      */
@@ -1841,6 +1860,33 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
                 }
             }
         }
+        // family_members 反查:users.family_id 为空时,从 family_members 表反查用户所属家庭
+        // (与 JwtInterceptor 兜底口径一致;family_id 非空时以 users 表为准,不再反查)
+        if (user.getFamilyId() == null) {
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .last("LIMIT 20"));
+            if (members != null) {
+                for (FamilyMember fm : members) {
+                    if (fm.getFamilyId() != null) {
+                        ids.add(fm.getFamilyId());
+                    }
+                }
+            }
+        }
+        // guide_families status=binding:成长规划师与家庭的服务绑定关系(与 teacherFamilyIds 不同源)
+        List<GuideFamily> guideBindings = guideFamilyMapper.selectList(
+                new LambdaQueryWrapper<GuideFamily>()
+                        .eq(GuideFamily::getGuideId, userId)
+                        .eq(GuideFamily::getStatus, "binding"));
+        if (guideBindings != null) {
+            for (GuideFamily gf : guideBindings) {
+                if (gf.getFamilyId() != null) {
+                    ids.add(gf.getFamilyId());
+                }
+            }
+        }
         return ids;
     }
 

+ 78 - 1
cfc-backend/src/test/java/com/etotem/cfc/config/FamilyAccessInterceptorTest.java

@@ -140,8 +140,9 @@ public class FamilyAccessInterceptorTest {
 
     @Test
     public void testEmptyEffectiveFamilyIds() throws Exception {
+        // B2-4 enforcement:effective set 为空但请求携带 familyId → 403 拒绝(非平台角色)
         Long userId = 1L;
-        User user = createMockUser(userId, null, "parent");
+        User user = createMockUser(userId, null, "teacher");
         when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.emptySet());
 
         MockHttpServletRequest request = new MockHttpServletRequest();
@@ -150,8 +151,84 @@ public class FamilyAccessInterceptorTest {
         request.setParameter("familyId", "1");
         request.setAttribute("userId", userId);
 
+        when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+    }
+
+    @Test
+    public void testEmptyEffectiveFamilyIdsNoFamilyId() throws Exception {
+        // B2-4:effective set 为空,但请求无 familyId → 正常放行(不携带目标家庭无需拦截)
+        Long userId = 1L;
+        User user = createMockUser(userId, null, "teacher");
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.emptySet());
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/dan-assessment/materials");
+        // 不带 familyId
+        request.setAttribute("userId", userId);
+
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+        assertTrue(result);
+    }
+
+    @Test
+    public void testPlatformRoleAdminBypass() throws Exception {
+        // B2-4 兜底:平台角色(admin/operator/finance)直接放行,不受 effective set 限制
+        Long userId = 1L;
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/dan-assessment/materials");
+        request.setParameter("familyId", "999");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "admin");
+        request.setAttribute("roles", java.util.Arrays.asList("admin"));
+
         boolean result = familyAccessInterceptor.preHandle(request, response, null);
         assertTrue(result);
+        // 平台角色直通发生在 getEffectiveFamilyIds 之前:永远不应触发用户查询
+        verify(userService, never()).getEffectiveFamilyIds(any());
+    }
+
+    @Test
+    public void testPlatformRoleOperatorBypass() throws Exception {
+        Long userId = 1L;
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/family-platform/admin/balance");
+        request.setParameter("familyId", "1");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "operator");
+        request.setAttribute("roles", java.util.Arrays.asList("operator"));
+
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+        assertTrue(result);
+        verify(userService, never()).getEffectiveFamilyIds(any());
+    }
+
+    @Test
+    public void testTeacherWithEmptyEffectiveIsBlocked() throws Exception {
+        // 非平台角色 + effective 集空 + 请求带 familyId → 403
+        Long userId = 2L;
+        User user = createMockUser(userId, null, "teacher");
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.emptySet());
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/dan-assessment/materials");
+        request.setParameter("familyId", "1");
+        request.setAttribute("userId", userId);
+        request.setAttribute("role", "teacher");
+        request.setAttribute("roles", java.util.Arrays.asList("teacher"));
+
+        when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+        boolean result = familyAccessInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
     }
 
     private User createMockUser(Long userId, Long familyId, String role) {

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

@@ -0,0 +1,78 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import javax.servlet.http.HttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * B2-5:代传报告接口(/api/health/report/proxy-parse、proxy-confirm)无 token → 401。
+ *
+ * 修复前:无 token 时被设为匿名(userId=-1)放行到 controller,代传接口返回业务 500;
+ * 修复后:进入 LOGIN_REQUIRED_PATHS,无 token 直接 401(清理前端登录态并引导重新登录)。
+ */
+public class JwtInterceptorLoginRequiredTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    public void testProxyParseWithoutTokenIs401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/proxy-parse");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+
+    @Test
+    public void testProxyConfirmWithoutTokenIs401() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/proxy-confirm");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertFalse(result);
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+
+    @Test
+    public void testNonProxyPathWithoutTokenIsAnonymous() throws Exception {
+        // 对照:非 login-required 的健康报告路径,无 token 仍为匿名放行(现有行为,防误伤)
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/health/report/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+        assertTrue(result);
+        assertEquals(-1L, request.getAttribute("userId"));
+    }
+}

+ 82 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthReportControllerProxyParseBindingTest.java

@@ -0,0 +1,82 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * B2-2:proxyParsePreview familyId 绑定校验(代传)。B2-6 权限编排层 ②。
+ *
+ * canProxyForFamily:平台角色(admin/operator/finance)直接放行;否则 familyId 必位于
+ * 调用者有效家庭集内(getEffectiveFamilyIds:own family / teacher_family_ids /
+ * family_members 反查 / guide_family 绑定)。
+ */
+public class HealthReportControllerProxyParseBindingTest {
+
+    @Mock
+    private UserService userService;
+
+    private HealthReportController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new HealthReportController();
+        ReflectionTestUtils.setField(controller, "userService", userService);
+    }
+
+    @Test
+    public void testAdminAllowedForAnyFamily() {
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.emptySet());
+        assertTrue(controller.canProxyForFamily(1L, "admin", Arrays.asList("admin"), 999L));
+        // 平台角色直通:永远不查 effective set
+        verify(userService, never()).getEffectiveFamilyIds(anyLong());
+    }
+
+    @Test
+    public void testOperatorAllowed() {
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.emptySet());
+        assertTrue(controller.canProxyForFamily(1L, "operator", Arrays.asList("operator"), 999L));
+        verify(userService, never()).getEffectiveFamilyIds(anyLong());
+    }
+
+    @Test
+    public void testTeacherBoundFamilyAllowed() {
+        Long familyId = 5L;
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.singleton(familyId));
+        assertTrue(controller.canProxyForFamily(1L, "teacher", Arrays.asList("teacher"), familyId));
+        verify(userService).getEffectiveFamilyIds(1L);
+    }
+
+    @Test
+    public void testTeacherUnboundFamilyDenied() {
+        Long familyId = 99L;
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.singleton(5L));
+        assertFalse(controller.canProxyForFamily(1L, "teacher", Arrays.asList("teacher"), familyId));
+    }
+
+    @Test
+    public void testTeacherEmptyEffectiveDenied() {
+        // B2-4 B2-2 融合:effective 为空且请求带 familyId → false(拦截器前就拦截,不建草稿)
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.emptySet());
+        assertFalse(controller.canProxyForFamily(1L, "teacher", Arrays.asList("teacher"), 1L));
+    }
+
+    @Test
+    public void testNonProxyRoleDeniedWithoutBinding() {
+        when(userService.getEffectiveFamilyIds(1L)).thenReturn(Collections.singleton(5L));
+        // role 非平台,familyId 在集合内 → allowed(binding 检查只关心集合归属)
+        assertTrue(controller.canProxyForFamily(1L, "parent", Arrays.asList("parent"), 5L));
+    }
+}

+ 147 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/family/FamilyMembersControllerListTest.java

@@ -0,0 +1,147 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.dto.FamilyMemberVO;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyMemberService;
+import com.etotem.cfc.service.UnlockGateService;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * B2-1:family/member/list 支持 params.familyId + 行级隔离 + 删除 ensureUserFamily 兜底建家庭。
+ *
+ * 契约:
+ * - 传 familyId 且调用者在有效家庭集内(own family / teacher_family_ids / guide_families 绑定)→ 返回该家庭全部成员
+ * - 传 familyId 但调用者无权(platform 角色除外)→ Result code=403
+ * - 平台角色(admin/operator/finance)传任意 familyId → 放行
+ * - 不传 familyId → 返回本人家庭成员;绝不静默调用 ensureUserFamily 建家庭
+ */
+public class FamilyMembersControllerListTest {
+
+    @Mock
+    private FamilyMemberService familyMemberService;
+
+    @Mock
+    private UserService userService;
+
+    @Mock
+    private UnlockGateService unlockGateService;
+
+    @Mock
+    private UserMapper userMapper;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        FamilyMembersController controller = new FamilyMembersController();
+        ReflectionTestUtils.setField(controller, "familyMemberService", familyMemberService);
+        ReflectionTestUtils.setField(controller, "unlockGateService", unlockGateService);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    public void listMembers_withFamilyId_andAccess_returnsMembersOfThatFamily() throws Exception {
+        Long userId = 1L;
+        Long familyId = 5L;
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(familyId));
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(11L);
+        when(familyMemberService.listMembersOfFamily(familyId, userId)).thenReturn(Arrays.asList(vo));
+
+        mockMvc.perform(post("/api/family/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"familyId\":5}")
+                        .requestAttr("userId", userId)
+                        .requestAttr("role", "teacher")
+                        .requestAttr("roles", Collections.singletonList("teacher")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].id").value(11));
+
+        verify(familyMemberService).listMembersOfFamily(familyId, userId);
+        verify(userService, never()).ensureUserFamily(any());
+    }
+
+    @Test
+    public void listMembers_withFamilyId_noAccess_returns403() throws Exception {
+        Long userId = 1L;
+        Long targetFamilyId = 99L;
+        when(userService.getEffectiveFamilyIds(userId)).thenReturn(Collections.singleton(5L));
+
+        mockMvc.perform(post("/api/family/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"familyId\":99}")
+                        .requestAttr("userId", userId)
+                        .requestAttr("role", "teacher")
+                        .requestAttr("roles", Collections.singletonList("teacher")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(403))
+                .andExpect(jsonPath("$.message").value("无权访问该家庭数据"));
+
+        verify(familyMemberService, never()).listMembersOfFamily(anyLong(), anyLong());
+    }
+
+    @Test
+    public void listMembers_withPlatformRole_anyFamilyAllowed() throws Exception {
+        Long userId = 1L;
+        Long familyId = 999L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(22L);
+        when(familyMemberService.listMembersOfFamily(familyId, userId)).thenReturn(Arrays.asList(vo));
+
+        mockMvc.perform(post("/api/family/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"familyId\":999}")
+                        .requestAttr("userId", userId)
+                        .requestAttr("role", "admin")
+                        .requestAttr("roles", Collections.singletonList("admin")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].id").value(22));
+
+        verify(familyMemberService).listMembersOfFamily(familyId, userId);
+        verify(userService, never()).getEffectiveFamilyIds(any());
+    }
+
+    @Test
+    public void listMembers_withoutFamilyId_usesOwnList_noAutoCreate() throws Exception {
+        Long userId = 1L;
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(33L);
+        when(familyMemberService.listMembers(userId)).thenReturn(Arrays.asList(vo));
+
+        mockMvc.perform(post("/api/family/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}")
+                        .requestAttr("userId", userId)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].id").value(33));
+
+        verify(familyMemberService).listMembers(userId);
+        verify(userService, never()).ensureUserFamily(any());
+    }
+}

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

@@ -0,0 +1,144 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * B2-3:auditConfirm 草稿归属校验 + subjectId 归属家庭一致性校验。
+ *
+ * 契约:
+ * - 非 admin 只能确认自己创建的草稿(draft.userId == auditorId),否则拒绝;
+ * - admin(平台角色)可确认任意草稿;
+ * - subjectId(family_members.id)必须属于草稿所属家庭,跨家庭 → 明确错误;
+ * - subjectId 为 users.id 时按既有归一化口径解析后校验最终落点家庭。
+ */
+public class HealthReportServiceAuditConfirmTest {
+
+    @Mock
+    private HealthReportDraftMapper draftMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private HealthReportService healthReportService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private HealthReportDraft draft(Long id, Long userId, Long familyId) {
+        HealthReportDraft d = new HealthReportDraft();
+        d.setId(id);
+        d.setUserId(userId);
+        d.setFamilyId(familyId);
+        d.setStatus("pending");
+        d.setExpireAt(new Date(System.currentTimeMillis() + 3600000));
+        return d;
+    }
+
+    // ============ 所有权(创建者本人或 admin) ============
+
+    @Test
+    public void testNonAdminCannotConfirmOthersDraft() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> healthReportService.validateDraftOwnership(d, 99L, false));
+        assertTrue(ex.getMessage().contains("无权确认他人创建的草稿"));
+    }
+
+    @Test
+    public void testCreatorCanConfirmOwnDraft() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        assertDoesNotThrow(() -> healthReportService.validateDraftOwnership(d, 100L, false));
+    }
+
+    @Test
+    public void testAdminCanConfirmAnyDraft() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        assertDoesNotThrow(() -> healthReportService.validateDraftOwnership(d, 99L, true));
+    }
+
+    // ============ subjectId 归属家庭一致性 ============
+
+    @Test
+    public void testSubjectInOtherFamilyRejected() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        FamilyMember otherMember = new FamilyMember();
+        otherMember.setId(500L);
+        otherMember.setFamilyId(99L); // 其他家庭
+        when(familyMemberMapper.selectById(500L)).thenReturn(otherMember);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> healthReportService.validateSubjectFamily(d, 500L));
+        assertTrue(ex.getMessage().contains("不属于该草稿所属家庭"));
+    }
+
+    @Test
+    public void testSubjectInDraftFamilyAllowed() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        FamilyMember member = new FamilyMember();
+        member.setId(500L);
+        member.setFamilyId(10L);
+        when(familyMemberMapper.selectById(500L)).thenReturn(member);
+
+        assertDoesNotThrow(() -> healthReportService.validateSubjectFamily(d, 500L));
+    }
+
+    @Test
+    public void testSubjectAsUserIdResolvedToDraftFamilyAllowed() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        Long subjectUserId = 55L;
+        when(familyMemberMapper.selectById(subjectUserId)).thenReturn(null);
+
+        User u = new User();
+        u.setId(subjectUserId);
+        when(userMapper.selectById(subjectUserId)).thenReturn(u);
+
+        FamilyMember byUser = new FamilyMember();
+        byUser.setId(555L);
+        byUser.setUserId(subjectUserId);
+        byUser.setFamilyId(10L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(byUser);
+        when(familyMemberMapper.selectById(555L)).thenReturn(byUser);
+
+        assertDoesNotThrow(() -> healthReportService.validateSubjectFamily(d, subjectUserId));
+    }
+
+    @Test
+    public void testUnrelatedSubjectIdRejected() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        Long ghostId = 777L;
+        when(familyMemberMapper.selectById(ghostId)).thenReturn(null);
+        when(userMapper.selectById(ghostId)).thenReturn(null);
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> healthReportService.validateSubjectFamily(d, ghostId));
+        assertTrue(ex.getMessage().contains("不属于该草稿所属家庭"));
+    }
+
+    @Test
+    public void testNullSubjectIdAllowed() {
+        HealthReportDraft d = draft(1L, 100L, 10L);
+        assertDoesNotThrow(() -> healthReportService.validateSubjectFamily(d, null));
+    }
+}

+ 122 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/UserServiceEffectiveFamilyIdsTest.java

@@ -0,0 +1,122 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GuideFamilyMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * B2-4:getEffectiveFamilyIds 访问权口径测试。
+ *
+ * 有效家庭集 = users.family_id ∪ teacher_family_ids ∪ family_members 反查(family_id 为空时)
+ *            ∪ guide_families(status=binding) 绑定家庭。
+ * "空集" 必须只代表"真正没有任何访问渠道",否则拦截器 enforcement 会误伤合法用户。
+ */
+@ExtendWith(MockitoExtension.class)
+public class UserServiceEffectiveFamilyIdsTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Mock
+    private GuideFamilyMapper guideFamilyMapper;
+
+    @InjectMocks
+    private UserService userService;
+
+    @Test
+    public void testOwnFamilyAndTeacherFamilies() {
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(10L);
+        user.setTeacherFamilyIds("20, 30");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Set<Long> ids = userService.getEffectiveFamilyIds(1L);
+        assertEquals(new HashSet<>(Arrays.asList(10L, 20L, 30L)), ids);
+    }
+
+    @Test
+    public void testFamilyMembersFallbackWhenFamilyIdNull() {
+        // ② users.family_id = NULL,但 family_members 表有该用户的成员行
+        Long userId = 7L;
+        User user = new User();
+        user.setId(userId);
+        user.setFamilyId(null);
+        when(userMapper.selectById(userId)).thenReturn(user);
+
+        FamilyMember fm = new FamilyMember();
+        fm.setUserId(userId);
+        fm.setFamilyId(88L);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.singletonList(fm));
+
+        Set<Long> ids = userService.getEffectiveFamilyIds(userId);
+        assertEquals(Collections.singleton(88L), ids);
+    }
+
+    @Test
+    public void testGuideBindingFamiliesMerged() {
+        // ① 成长规划师:teacher_family_ids 为空,但 guide_families 有 status=binding 绑定
+        Long userId = 9L;
+        User user = new User();
+        user.setId(userId);
+        user.setFamilyId(null);
+        user.setTeacherFamilyIds(null);
+        when(userMapper.selectById(userId)).thenReturn(user);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        GuideFamily binding = new GuideFamily();
+        binding.setGuideId(userId);
+        binding.setFamilyId(55L);
+        binding.setStatus("binding");
+        when(guideFamilyMapper.selectList(any())).thenReturn(Collections.singletonList(binding));
+
+        Set<Long> ids = userService.getEffectiveFamilyIds(userId);
+        assertTrue(ids.contains(55L), "guide_families 绑定家庭必须并入有效家庭集");
+    }
+
+    @Test
+    public void testBrandNewUserHasEmptySet() {
+        // B2-4 AC 复现路径:全新账号,无 family / 无 teacherFamilyIds / 无成员行 / 无 guide 绑定
+        Long userId = 2009L;
+        User user = new User();
+        user.setId(userId);
+        user.setFamilyId(null);
+        user.setTeacherFamilyIds(null);
+        when(userMapper.selectById(userId)).thenReturn(user);
+        when(familyMemberMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(guideFamilyMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        Set<Long> ids = userService.getEffectiveFamilyIds(userId);
+        assertNotNull(ids);
+        assertTrue(ids.isEmpty(), "全新账号有效家庭集必须为空");
+    }
+
+    @Test
+    public void testUnknownUserReturnsEmptySet() {
+        when(userMapper.selectById(404L)).thenReturn(null);
+
+        Set<Long> ids = userService.getEffectiveFamilyIds(404L);
+        assertNotNull(ids);
+        assertTrue(ids.isEmpty());
+    }
+}