Przeglądaj źródła

feat(users): 新增 is_child 孩子身份字段

- users 表新增 is_child TINYINT(1) NULL(迁移443,存量保持NULL走年龄兜底)
- User 实体 + UserUtil.isChild()/fillDefaultIsChild()(年龄<18 视为孩子,阈值18)
- 注册/生日变更/角色赋值时按年龄填充默认值(仅当 isChild 为 null)
- FamilyMemberService.updateMember 支持家庭管理员设置 isChild(落库到 users),非管理员拒绝(403)
- 单测覆盖 默认/兜底/管理员可改/非管理员拒绝
Sisyphus Agent 1 tydzień temu
rodzic
commit
3b9576d4fc

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

@@ -3414,6 +3414,11 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
 
         // 迁移442: 删除旧 bundle 机制明细表(新模型:套餐=独立商品,内容在 product_combo_items,0数据安全删)
         // 迁移442: 删除旧 bundle 机制明细表(新模型:套餐=独立商品,内容在 product_combo_items,0数据安全删)
         dropTableBundleItems();
         dropTableBundleItems();
+
+        // 迁移443: users 表添加 is_child 列(孩子身份定义,NULL=未判定按年龄<18兜底)
+        // 硬性要求:必须新增数据库迁移;存量数据保持 NULL,读取走年龄兜底,不做回填覆盖。
+        ensureColumn("users", "is_child",
+                "TINYINT(1) NULL COMMENT '孩子身份标记: 1=孩子, 0=非孩子, NULL=未判定(按生日推算年龄<18兜底)'");
     }
     }
 
 
     /**
     /**

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -206,10 +206,24 @@ public Result<FamilyMemberVO> updateMember(@RequestBody Map<String, Object> body
  if (body.get("isAdmin") != null) {
  if (body.get("isAdmin") != null) {
    dto.setIsAdmin(Integer.parseInt(body.get("isAdmin").toString()));
    dto.setIsAdmin(Integer.parseInt(body.get("isAdmin").toString()));
  }
  }
+ if (body.get("isChild") != null) {
+   Object raw = body.get("isChild");
+   if (raw instanceof Boolean) {
+     dto.setIsChild(Boolean.TRUE.equals(raw) ? 1 : 0);
+   } else if (raw instanceof Number) {
+     dto.setIsChild(((Number) raw).intValue() == 1 ? 1 : 0);
+   } else {
+     String s = raw.toString();
+     if ("true".equalsIgnoreCase(s) || "1".equals(s)) dto.setIsChild(1);
+     else if ("false".equalsIgnoreCase(s) || "0".equals(s)) dto.setIsChild(0);
+   }
+ }
 
 
  try {
  try {
    FamilyMemberVO vo = familyMemberService.updateMember(userId, memberId, dto);
    FamilyMemberVO vo = familyMemberService.updateMember(userId, memberId, dto);
    return Result.success(vo);
    return Result.success(vo);
+ } catch (org.springframework.security.access.AccessDeniedException e) {
+   return Result.error(403, e.getMessage() != null ? e.getMessage() : "无权限");
  } catch (RuntimeException e) {
  } catch (RuntimeException e) {
    return Result.error(e.getMessage());
    return Result.error(e.getMessage());
  }
  }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/AddFamilyMemberDTO.java

@@ -47,4 +47,7 @@ public class AddFamilyMemberDTO {
     /** 是否家庭管理员(多管理员,仅编辑成员时生效): 0=否, 1=是;null=不修改 */
     /** 是否家庭管理员(多管理员,仅编辑成员时生效): 0=否, 1=是;null=不修改 */
     private Integer isAdmin;
     private Integer isAdmin;
 
 
+    /** 孩子身份标记(仅家庭管理员可设置): 1=孩子, 0=非孩子;null=不修改 */
+    private Integer isChild;
+
 }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -120,6 +120,10 @@ public class User implements Serializable {
     // 家庭管理员标记
     // 家庭管理员标记
     private Boolean isFamilyAdmin;
     private Boolean isFamilyAdmin;
 
 
+    // 孩子身份定义: true=孩子(1), false=非孩子(0), null=未判定(按生日推算年龄<18兜底)
+    @TableField("is_child")
+    private Boolean isChild;
+
     // 服务商资质信息 (JSON)
     // 服务商资质信息 (JSON)
     private String vendorInfo;
     private String vendorInfo;
 
 

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -23,6 +23,7 @@ import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.springframework.security.access.AccessDeniedException;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
@@ -35,6 +36,7 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.FamilyNamingSupport;
 import com.etotem.cfc.util.FamilyNamingSupport;
+import com.etotem.cfc.util.UserUtil;
 
 
 @Service
 @Service
 public class FamilyMemberService {
 public class FamilyMemberService {
@@ -80,6 +82,9 @@ public class FamilyMemberService {
     @Resource
     @Resource
     private FamilyFoodOverviewService familyFoodOverviewService;
     private FamilyFoodOverviewService familyFoodOverviewService;
 
 
+    @Resource
+    private ReportMemberPermissionService reportMemberPermissionService;
+
     /**
     /**
      * 校验成员是否属于指定家庭(用于避免跨家庭读写成员数据)
      * 校验成员是否属于指定家庭(用于避免跨家庭读写成员数据)
      */
      */
@@ -1034,6 +1039,8 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                 member.setIsSpouse(isSpouse);
                 member.setIsSpouse(isSpouse);
             }
             }
         }
         }
+        // 孩子身份标记(仅家庭管理员可设置;落库到 users 表对应记录)
+        applyIsChildFlag(userId, user.getFamilyId(), member.getUserId(), dto.getIsChild());
         member.setUpdatedAt(new Date());
         member.setUpdatedAt(new Date());
         familyMemberMapper.updateById(member);
         familyMemberMapper.updateById(member);
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
@@ -1060,6 +1067,37 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         }
         }
     }
     }
 
 
+    /**
+     * 设置成员的孩子身份标记(users.is_child),落库到对应的 User 记录。
+     *
+     * <p>权限:仅家庭管理员可操作(复用 {@link ReportMemberPermissionService#isFamilyAdmin});
+     * 非管理员抛出 {@link AccessDeniedException},由 controller 层转为 403。</p>
+     *
+     * @param operatorUserId 操作者 userId
+     * @param familyId       操作者所属家庭(目标成员必须同属该家庭)
+     * @param targetUserId   目标成员关联的 userId(family_members.user_id;家庭主账号场景即主账号 userId;null=无关联账号无法设置)
+     * @param isChild        期望值: 1=孩子, 0=非孩子;null=不修改
+     */
+    void applyIsChildFlag(Long operatorUserId, Long familyId, Long targetUserId, Integer isChild) {
+        if (isChild == null) {
+            return;
+        }
+        if (!reportMemberPermissionService.isFamilyAdmin(operatorUserId, familyId)) {
+            throw new AccessDeniedException("仅家庭管理员可设置孩子身份");
+        }
+        if (targetUserId == null) {
+            throw new RuntimeException("该成员未关联登录账号,无法设置孩子身份");
+        }
+        User target = userMapper.selectById(targetUserId);
+        if (target == null || familyId == null || !familyId.equals(target.getFamilyId())) {
+            throw new RuntimeException("成员不存在或不属于您的家庭");
+        }
+        target.setIsChild(isChild == 1);
+        target.setUpdatedAt(new Date());
+        userMapper.updateById(target);
+        log.info("已设置成员孩子身份: userId={}, isChild={}, 操作者={}", targetUserId, isChild, operatorUserId);
+    }
+
     /**
     /**
      * 修改家庭名称(仅家庭管理员:家庭创建者或成员记录 is_admin=1)。
      * 修改家庭名称(仅家庭管理员:家庭创建者或成员记录 is_admin=1)。
      * 返回更新后的家庭名称。
      * 返回更新后的家庭名称。
@@ -1138,6 +1176,8 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                 throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
                 throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
             }
             }
         }
         }
+        // 孩子身份标记(仅家庭管理员可设置;主账号即 memberId 对应的 User 记录)
+        applyIsChildFlag(operator.getId(), operator.getFamilyId(), memberId, dto.getIsChild());
         parent.setUpdatedAt(new Date());
         parent.setUpdatedAt(new Date());
         userMapper.updateById(parent);
         userMapper.updateById(parent);
         logMemberAction(parent.getFamilyId(), parent.getId(), "update", oldNickname, parent.getNickname(), operator.getId());
         logMemberAction(parent.getFamilyId(), parent.getId(), "update", oldNickname, parent.getNickname(), operator.getId());

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -35,6 +35,7 @@ import com.etotem.cfc.service.FamilyJoinRequestService;
 import com.etotem.cfc.entity.Consignee;
 import com.etotem.cfc.entity.Consignee;
 import com.etotem.cfc.mapper.ConsigneeMapper;
 import com.etotem.cfc.mapper.ConsigneeMapper;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.util.UserUtil;
 
 
 
 
 @Slf4j
 @Slf4j
@@ -482,6 +483,8 @@ private FamilyInvitationService familyInvitationService;
                 } catch (Exception e) {
                 } catch (Exception e) {
                     throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
                     throw new RuntimeException("生日格式不正确,应为 yyyy-MM-dd");
                 }
                 }
+                // 生日变更后按年龄兜底孩子身份(仅当 isChild 为 null 时)
+                UserUtil.fillDefaultIsChild(user);
                 break;
                 break;
             case "birthHour":
             case "birthHour":
                 if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
                 if (!"子丑寅卯辰巳午未申酉戌亥".contains(value)) throw new RuntimeException("出生时辰取值非法");
@@ -605,6 +608,7 @@ private FamilyInvitationService familyInvitationService;
             return null;
             return null;
         }
         }
         user.setRole(mode);
         user.setRole(mode);
+        UserUtil.fillDefaultIsChild(user);
         user.setUpdatedAt(new Date());
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         userMapper.updateById(user);
         return mode;
         return mode;
@@ -630,6 +634,7 @@ private FamilyInvitationService familyInvitationService;
             return null;
             return null;
         }
         }
         user.setRole(targetRole);
         user.setRole(targetRole);
+        UserUtil.fillDefaultIsChild(user);
         user.setUpdatedAt(new Date());
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         userMapper.updateById(user);
         return targetRole;
         return targetRole;
@@ -657,6 +662,7 @@ private FamilyInvitationService familyInvitationService;
             throw new RuntimeException("您没有该角色权限");
             throw new RuntimeException("您没有该角色权限");
         }
         }
         user.setRole(role);
         user.setRole(role);
+        UserUtil.fillDefaultIsChild(user);
         user.setUpdatedAt(new Date());
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         userMapper.updateById(user);
         String token = jwtConfig.generateToken(user.getId(), user.getRole());
         String token = jwtConfig.generateToken(user.getId(), user.getRole());
@@ -1474,6 +1480,8 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
      * @return 创建后的 user(含 familyId 和 isFamilyAdmin)
      * @return 创建后的 user(含 familyId 和 isFamilyAdmin)
      */
      */
     private User createUserAndFamily(User user) {
     private User createUserAndFamily(User user) {
+        // 孩子身份默认值:按生日推算年龄(<18)填充,仅当 isChild 为 null 时
+        UserUtil.fillDefaultIsChild(user);
         // 1. 插入 user
         // 1. 插入 user
         userMapper.insert(user);
         userMapper.insert(user);
 
 
@@ -1845,6 +1853,7 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
         }
         }
         // 切换用户role为child,记录原始角色以便切回
         // 切换用户role为child,记录原始角色以便切回
         user.setRole("child");
         user.setRole("child");
+        UserUtil.fillDefaultIsChild(user);
         user.setUpdatedAt(new Date());
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         userMapper.updateById(user);
         return member.getId();
         return member.getId();

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/UserUtil.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.entity.User;
+
+import java.util.Date;
+
+/**
+ * 用户通用工具。
+ *
+ * <p>孩子身份定义(users.is_child):</p>
+ * <ul>
+ *   <li>显式 {@code isChild} 是权威字段,优先返回;</li>
+ *   <li>为 {@code null}(未判定/存量数据)时,按 {@link User#getBirthday()} 推算年龄,
+ *       年龄 {@literal <} {@link #CHILD_AGE_THRESHOLD} 视为孩子(阈值常量 18)。</li>
+ * </ul>
+ *
+ * <p>设计说明:存量数据迁移后 {@code is_child} 保持 {@code NULL},读取走年龄兜底;
+ * 新增/生日变更/角色赋值时通过 {@link #fillDefaultIsChild(User)} 仅在 {@code isChild} 为
+ * {@code null} 时按年龄填充默认值,不覆盖家庭管理员的显式设置。</p>
+ */
+public final class UserUtil {
+
+    /** 孩子年龄阈值:年龄 &lt; 此值视为孩子(含未满18周岁)。 */
+    public static final int CHILD_AGE_THRESHOLD = 18;
+
+    private UserUtil() {
+    }
+
+    /**
+     * 统一判定用户是否孩子身份。
+     * 优先返回显式 {@code isChild};为 {@code null} 时按年龄兜底(年龄 &lt; 18 → 孩子)。
+     *
+     * @param user 用户;null 视为非孩子
+     * @return true=孩子,false=非孩子
+     */
+    public static boolean isChild(User user) {
+        if (user == null) {
+            return false;
+        }
+        if (user.getIsChild() != null) {
+            return user.getIsChild();
+        }
+        Integer age = ageOf(user.getBirthday());
+        return age != null && age < CHILD_AGE_THRESHOLD;
+    }
+
+    /**
+     * 按年龄兜底填充 {@code isChild} 默认值,仅在 {@code isChild} 为 {@code null} 时生效,
+     * 不覆盖已设置(含家庭管理员显式设置)的值。
+     *
+     * <p>调用点:用户注册、生日变更、角色赋值。</p>
+     *
+     * @param user 用户;null 忽略
+     */
+    public static void fillDefaultIsChild(User user) {
+        if (user == null || user.getIsChild() != null) {
+            return;
+        }
+        user.setIsChild(isChild(user));
+    }
+
+    /**
+     * 按生日推算周岁年龄(口径与 {@code FamilyMember#getAge()} 一致)。
+     *
+     * @param birthday 生日;null 返回 null
+     * @return 周岁年龄,无生日返回 null
+     */
+    public static Integer ageOf(Date birthday) {
+        if (birthday == null) {
+            return null;
+        }
+        java.time.LocalDate birth = birthday.toInstant()
+                .atZone(java.time.ZoneId.systemDefault())
+                .toLocalDate();
+        java.time.LocalDate now = java.time.LocalDate.now();
+        return java.time.Period.between(birth, now).getYears();
+    }
+}

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

@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS users (
     birthday DATE,
     birthday DATE,
     gender ENUM('male','female'),
     gender ENUM('male','female'),
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
+    is_child TINYINT(1) DEFAULT NULL COMMENT '孩子身份标记: 1=孩子, 0=非孩子, NULL=未判定(按生日推算年龄<18兜底)',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
     portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     family_inviter_id BIGINT DEFAULT NULL COMMENT '家庭邀请人ID(首次注册时绑定,用于追踪邀请来源)',
     family_inviter_id BIGINT DEFAULT NULL COMMENT '家庭邀请人ID(首次注册时绑定,用于追踪邀请来源)',

+ 110 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyMemberServiceIsChildTest.java

@@ -0,0 +1,110 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 家庭管理员设置成员孩子身份(users.is_child):
+ * - (b) 管理员可设置并落库到对应的 User 记录;
+ * - (c) 非管理员被拒绝(AccessDeniedException → controller 层 403)。
+ */
+class FamilyMemberServiceIsChildTest {
+
+    private FamilyMemberService buildService(ReportMemberPermissionService perm, UserMapper userMapper) {
+        FamilyMemberService svc = new FamilyMemberService();
+        ReflectionTestUtils.setField(svc, "reportMemberPermissionService", perm);
+        ReflectionTestUtils.setField(svc, "userMapper", userMapper);
+        return svc;
+    }
+
+    /** (b) 家庭管理员可设置目标成员 isChild=1 并落库到 User */
+    @Test
+    void 家庭管理员_可设置目标成员isChild并落库() {
+        UserMapper userMapper = mock(UserMapper.class);
+        ReportMemberPermissionService perm = mock(ReportMemberPermissionService.class);
+        when(perm.isFamilyAdmin(1L, 7L)).thenReturn(true);
+        User target = new User();
+        target.setId(2L);
+        target.setFamilyId(7L);
+        when(userMapper.selectById(2L)).thenReturn(target);
+
+        FamilyMemberService svc = buildService(perm, userMapper);
+        svc.applyIsChildFlag(1L, 7L, 2L, 1);
+
+        assertEquals(Boolean.TRUE, target.getIsChild());
+        verify(userMapper).updateById(any());
+    }
+
+    /** (b) 家庭管理员可将 isChild 改回 0(非孩子) */
+    @Test
+    void 家庭管理员_可将isChild改回0() {
+        UserMapper userMapper = mock(UserMapper.class);
+        ReportMemberPermissionService perm = mock(ReportMemberPermissionService.class);
+        when(perm.isFamilyAdmin(1L, 7L)).thenReturn(true);
+        User target = new User();
+        target.setId(2L);
+        target.setFamilyId(7L);
+        target.setIsChild(true);
+        when(userMapper.selectById(2L)).thenReturn(target);
+
+        FamilyMemberService svc = buildService(perm, userMapper);
+        svc.applyIsChildFlag(1L, 7L, 2L, 0);
+
+        assertEquals(Boolean.FALSE, target.getIsChild());
+        verify(userMapper).updateById(any());
+    }
+
+    /** (c) 非管理员设置 isChild 被明确拒绝(403 等效:AccessDeniedException) */
+    @Test
+    void 非管理员_设置isChild被拒绝() {
+        UserMapper userMapper = mock(UserMapper.class);
+        ReportMemberPermissionService perm = mock(ReportMemberPermissionService.class);
+        when(perm.isFamilyAdmin(1L, 7L)).thenReturn(false);
+
+        FamilyMemberService svc = buildService(perm, userMapper);
+        assertThrows(AccessDeniedException.class, () -> svc.applyIsChildFlag(1L, 7L, 2L, 1));
+        verify(userMapper, never()).updateById(any());
+        verify(userMapper, never()).selectById(any());
+    }
+
+    /** 目标用户 familyId 与操作者不一致 → 拒绝 */
+    @Test
+    void 目标用户不属于同家庭_被拒绝() {
+        UserMapper userMapper = mock(UserMapper.class);
+        ReportMemberPermissionService perm = mock(ReportMemberPermissionService.class);
+        when(perm.isFamilyAdmin(1L, 7L)).thenReturn(true);
+        User target = new User();
+        target.setId(2L);
+        target.setFamilyId(999L); // 不同家庭
+        when(userMapper.selectById(2L)).thenReturn(target);
+
+        FamilyMemberService svc = buildService(perm, userMapper);
+        assertThrows(RuntimeException.class, () -> svc.applyIsChildFlag(1L, 7L, 2L, 1));
+        verify(userMapper, never()).updateById(any());
+    }
+
+    /** isChild 为 null(未传)→ 不做任何修改 */
+    @Test
+    void isChild为null_不做任何修改() {
+        UserMapper userMapper = mock(UserMapper.class);
+        ReportMemberPermissionService perm = mock(ReportMemberPermissionService.class);
+
+        FamilyMemberService svc = buildService(perm, userMapper);
+        svc.applyIsChildFlag(1L, 7L, 2L, null);
+
+        verifyNoInteractions(userMapper);
+        verifyNoInteractions(perm);
+    }
+}

+ 87 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/UserUtilTest.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.util;
+
+import com.etotem.cfc.entity.User;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 孩子身份定义(users.is_child):
+ * - 显式 isChild 优先;为 null 时按生日推算年龄,年龄 &lt; 18 视为孩子。
+ * - fillDefaultIsChild 仅在 isChild 为 null 时按年龄兜底填充。
+ */
+class UserUtilTest {
+
+    private static Date birthdayYearsAgo(int years, int extraDays) {
+        LocalDate d = LocalDate.now().minusYears(years).minusDays(extraDays);
+        return Date.from(d.atStartOfDay(ZoneId.systemDefault()).toInstant());
+    }
+
+    /** (a) 生日小于18 → 默认 isChild=true */
+    @Test
+    void 生日小于18_默认isChild为true() {
+        User u = new User();
+        u.setBirthday(birthdayYearsAgo(10, 0));
+        UserUtil.fillDefaultIsChild(u);
+        assertEquals(Boolean.TRUE, u.getIsChild());
+        assertTrue(UserUtil.isChild(u));
+    }
+
+    /** 生日已满18 → 默认 isChild=false */
+    @Test
+    void 生日已满18_默认isChild为false() {
+        User u = new User();
+        u.setBirthday(birthdayYearsAgo(20, 0));
+        UserUtil.fillDefaultIsChild(u);
+        assertEquals(Boolean.FALSE, u.getIsChild());
+        assertFalse(UserUtil.isChild(u));
+    }
+
+    /** 无生日 → 年龄兜底不误判为孩子 */
+    @Test
+    void 无生日_年龄兜底不判为孩子() {
+        User u = new User();
+        assertFalse(UserUtil.isChild(u));
+    }
+
+    /** (d) isChild 为 null → 兜底到年龄;临界 18 岁整不算孩子 */
+    @Test
+    void isChild为null_兜底到年龄_临界18岁不算孩子() {
+        User u = new User();
+        u.setBirthday(birthdayYearsAgo(18, 0));
+        assertFalse(UserUtil.isChild(u));
+
+        u.setBirthday(birthdayYearsAgo(17, 360)); // 尚差几天满18
+        assertTrue(UserUtil.isChild(u));
+    }
+
+    /** 显式 isChild 是权威字段,优先于年龄兜底 */
+    @Test
+    void 显式isChild优先于年龄兜底() {
+        User adultMarkedChild = new User();
+        adultMarkedChild.setBirthday(birthdayYearsAgo(20, 0));
+        adultMarkedChild.setIsChild(true);
+        assertTrue(UserUtil.isChild(adultMarkedChild));
+
+        User minorMarkedAdult = new User();
+        minorMarkedAdult.setBirthday(birthdayYearsAgo(10, 0));
+        minorMarkedAdult.setIsChild(false);
+        assertFalse(UserUtil.isChild(minorMarkedAdult));
+    }
+
+    /** fillDefaultIsChild 仅在 isChild 为 null 时填充,不覆盖已设置值 */
+    @Test
+    void fillDefault_isChild已设置则不覆盖() {
+        User u = new User();
+        u.setBirthday(birthdayYearsAgo(10, 0));
+        u.setIsChild(false);
+        UserUtil.fillDefaultIsChild(u);
+        assertEquals(Boolean.FALSE, u.getIsChild());
+    }
+}

+ 1 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -182,7 +182,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/family/member/kick` | 移除成员 | — |
 | `POST /api/family/member/kick` | 移除成员 | — |
 | `POST /api/family/member/leave` | 主动退出家庭(微信用户;创建者需先转让管理员) | — |
 | `POST /api/family/member/leave` | 主动退出家庭(微信用户;创建者需先转让管理员) | — |
 | `POST /api/family/member/recycle` | 从回收箱回收成员(管理员操作) | — |
 | `POST /api/family/member/recycle` | 从回收箱回收成员(管理员操作) | — |
-| `POST /api/family/member/update` | 更新成员信息 | — |
+| `POST /api/family/member/update` | 更新成员信息;body 可含 `isChild`(1=孩子/0=非孩子,仅家庭管理员可设置,落库到 users.is_child;非管理员返回 403) | — |
 | `POST /api/family/member/rename-family` | 修改家庭名称(仅家庭管理员);body `{name}`,返回新名称 | — |
 | `POST /api/family/member/rename-family` | 修改家庭名称(仅家庭管理员);body `{name}`,返回新名称 | — |
 | `POST /api/family/member/editable` | 检查是否可编辑 | — |
 | `POST /api/family/member/editable` | 检查是否可编辑 | — |
 | `POST /api/family/member/logs` | 成员变更日志 | — |
 | `POST /api/family/member/logs` | 成员变更日志 | — |