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

feat(child.auth.login): S-cfc-325 使绿——SC-C-17 服务端退出(token黑名单) + SC-C-18 设备/账号绑定校验

- AuthController 新增 POST /logout(token 入黑名单)+ POST /device/verify(登录准入预检)
- 新增 TokenBlacklistService(invalidate/isInvalid/isBlacklisted),JwtInterceptor 与 /api/auth/verify 黑名单命中即 401
- 登录 DTO(Phone/Wechat/WechatPhone)补 deviceId 字段
- 新增 DeviceBind 实体/Mapper + DeviceBindService(新设备登记/正常放行/异常二次验证)
- schema.sql 补 device_bind 建表 + DatabaseInitializer 迁移470(幂等)
- API_REFERENCE 登记新端点;改 ChildAuthLoginContractTest 11/11 绿(6红→0红)
zxyj-langgraph 1 день назад
Родитель
Сommit
370b7b445a

+ 37 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3520,9 +3520,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
             log.warn("迁移469: five_dimension_scores.source_type 注释校准失败(幂等容忍): {}", e.getMessage());
             log.warn("迁移469: five_dimension_scores.source_type 注释校准失败(幂等容忍): {}", e.getMessage());
         }
         }
 
 
-        // 迁移470: 创建模型管理表 mind_model
+// 迁移470: 创建模型管理表 mind_model
         //(mind.model.manage · SC-A-21 舌诊/情绪模型版本/开关:版控/上下线/下线前回退方案)
         //(mind.model.manage · SC-A-21 舌诊/情绪模型版本/开关:版控/上下线/下线前回退方案)
         createMindModelTable();
         createMindModelTable();
+
+        // 迁移471: 创建 device_bind 设备/账号绑定表(S-cfc-325 child.auth.login · SC-C-18 设备/账号绑定校验)
+        createDeviceBindTable();
     }
     }
 
 
     /**
     /**
@@ -15816,6 +15819,39 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         }
     }
     }
 
 
+    /**
+     * 迁移471: 创建设备/账号绑定表 device_bind(S-cfc-325 child.auth.login · SC-C-18 设备/账号绑定校验)。
+     *
+     * <p>承载登录设备维度:登录时前端上报 deviceId,服务端按 (user_id, device_id) 唯一绑定,
+     * 支撑新设备登记 / 正常设备放行 / 异常设备二次验证(TC-C-18 主流程与边界,
+     * child-zone-contract G-04 建议「device_bind 表 + /api/auth/device/verify」)。
+     * 幂等(CREATE TABLE IF NOT EXISTS)。</p>
+     */
+    private void createDeviceBindTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS device_bind ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "user_id BIGINT NOT NULL COMMENT '登录用户ID(users.id,账号归属)', "
+                    + "member_id BIGINT NULL COMMENT '家庭成员ID(family_members.id,可空)', "
+                    + "device_id VARCHAR(128) NOT NULL COMMENT '设备标识(登录时上报)', "
+                    + "device_name VARCHAR(128) NULL COMMENT '设备名称(可选)', "
+                    + "device_model VARCHAR(64) NULL COMMENT '设备型号(可选)', "
+                    + "platform VARCHAR(32) NULL COMMENT '平台: mp(小程序)/web/h5', "
+                    + "status VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '绑定状态: normal(正常)/abnormal(异常)/pending(待验证)', "
+                    + "first_seen_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '首次登录时间', "
+                    + "last_login_time DATETIME NULL COMMENT '最近登录时间', "
+                    + "login_count INT NOT NULL DEFAULT 1 COMMENT '登录次数', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "UNIQUE KEY uk_user_device (user_id, device_id), "
+                    + "INDEX idx_device (device_id) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备/账号绑定记录(SC-C-18 登录设备校验)'");
+            log.info("迁移471: 已创建 device_bind 表");
+        } catch (Exception e) {
+            log.warn("迁移471: 创建 device_bind 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      int sort, String sourceSystem, String nodeKind,
                                      int sort, String sourceSystem, String nodeKind,

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.TokenBlacklistService;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.Claims;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -34,6 +35,9 @@ public class JwtInterceptor implements HandlerInterceptor {
     @Resource
     @Resource
     private FamilyMemberMapper familyMemberMapper;
     private FamilyMemberMapper familyMemberMapper;
 
 
+    @Resource
+    private TokenBlacklistService tokenBlacklistService;
+
 private static final String[] PUBLIC_PATHS = {
 private static final String[] PUBLIC_PATHS = {
          "/api/auth/send-code",
          "/api/auth/send-code",
          "/api/auth/phone-login",
          "/api/auth/phone-login",
@@ -216,6 +220,13 @@ private static final String[] PUBLIC_PATHS = {
         try {
         try {
             // 解析token
             // 解析token
             Claims claims = jwtConfig.parseToken(token);
             Claims claims = jwtConfig.parseToken(token);
+            // 退出登录(SC-C-17)后旧 token 服务端失效:黑名单命中即 401(TC-C-17-E 异常流)。
+            // null 容忍:遗留 @InjectMocks 单测未注入该 Bean 时跳过黑名单校验(等价于空黑名单放行)。
+            if (tokenBlacklistService != null && tokenBlacklistService.isInvalid(token)) {
+                log.warn("Token已退出登录(黑名单), 拒绝请求: path={}", path);
+                writeUnauthorized(response);
+                return false;
+            }
             Long userId = Long.parseLong(claims.getSubject());
             Long userId = Long.parseLong(claims.getSubject());
             String role = (String) claims.get("role");
             String role = (String) claims.get("role");
             @SuppressWarnings("unchecked")
             @SuppressWarnings("unchecked")

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -7,7 +7,9 @@ import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.DeviceBindService;
 import com.etotem.cfc.service.SsoService;
 import com.etotem.cfc.service.SsoService;
+import com.etotem.cfc.service.TokenBlacklistService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.Claims;
@@ -43,6 +45,12 @@ public class AuthController {
     @Resource
     @Resource
     private SsoService ssoService;
     private SsoService ssoService;
 
 
+    @Resource
+    private TokenBlacklistService tokenBlacklistService;
+
+    @Resource
+    private DeviceBindService deviceBindService;
+
     /**
     /**
      * 发送验证码
      * 发送验证码
      */
      */
@@ -230,6 +238,12 @@ public class AuthController {
         try {
         try {
             String token = authHeader.substring(7);
             String token = authHeader.substring(7);
             Claims claims = jwtConfig.parseToken(token);
             Claims claims = jwtConfig.parseToken(token);
+            // 退出登录(SC-C-17)后旧 token 服务端失效:黑名单命中视为无效,返回 401
+            // null 容忍:遗留 @InjectMocks 单测未注入该 Bean 时跳过黑名单校验。
+            if (tokenBlacklistService != null && tokenBlacklistService.isInvalid(token)) {
+                log.warn("/api/auth/verify: token 已退出登录(黑名单),返回 401");
+                return Result.error(401, "Token已失效,请重新登录");
+            }
             Long userId = Long.parseLong(claims.getSubject());
             Long userId = Long.parseLong(claims.getSubject());
             String role = (String) claims.get("role");
             String role = (String) claims.get("role");
 
 
@@ -249,6 +263,34 @@ public class AuthController {
         }
         }
     }
     }
 
 
+    @Operation(summary = "退出登录(服务端 token 失效)")
+    @PostMapping("/logout")
+    public Result<Boolean> logout(HttpServletRequest request) {
+        // SC-C-17 主流程:把当前 Bearer token 记入黑名单,使其服务端失效(TC-C-17-E 异常流:旧 token 再调用应 401)。
+        // 幂等:无 token / 重复退出均视为成功。
+        if (tokenBlacklistService != null) {
+            tokenBlacklistService.invalidate(extractToken(request));
+        }
+        return Result.success(true);
+    }
+
+    @Operation(summary = "设备/账号绑定校验(登录准入预检)")
+    @PostMapping("/device/verify")
+    public Result<DeviceVerifyResultDTO> deviceVerify(@RequestBody DeviceVerifyDTO dto) {
+        // SC-C-18 主流程/边界:登录链路预检——正常设备放行 / 新设备登记 / 异常设备二次验证(needVerify=true)。
+        if (dto == null || dto.getDeviceId() == null || dto.getDeviceId().isEmpty()) {
+            return Result.badRequest("deviceId 不能为空");
+        }
+        if (deviceBindService == null) {
+            return Result.error("设备校验服务不可用");
+        }
+        try {
+            return Result.success(deviceBindService.verifyDevice(dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @Operation(summary = "验证密码")
     @Operation(summary = "验证密码")
     @PostMapping("/verify-password")
     @PostMapping("/verify-password")
     public Result<Boolean> verifyPassword(@RequestBody VerifyPasswordDTO dto,
     public Result<Boolean> verifyPassword(@RequestBody VerifyPasswordDTO dto,
@@ -394,4 +436,13 @@ public class AuthController {
         }
         }
     }
     }
 
 
+    /** 从请求中提取 Bearer token(Authorization 头 → 参数兜底),无则返回 null。 */
+    private String extractToken(HttpServletRequest request) {
+        String header = request.getHeader("Authorization");
+        if (header != null && header.startsWith("Bearer ")) {
+            return header.substring(7);
+        }
+        return request.getParameter("token");
+    }
+
 }
 }

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DeviceVerifyDTO.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 设备/账号绑定校验请求(child.auth.login · SC-C-18 登录准入预检)。
+ *
+ * <p>登录链路预检输入:前端随登录携带 deviceId(及可选设备信息),
+ * 服务端据此判定正常放行 / 新设备登记 / 异常设备二次验证(TC-C-18)。</p>
+ */
+@Data
+public class DeviceVerifyDTO {
+
+    /** 设备标识(必填,小程序端生成) */
+    private String deviceId;
+
+    /** 设备名称(可选) */
+    private String deviceName;
+
+    /** 设备型号(可选) */
+    private String deviceModel;
+
+    /** 平台: mp(小程序)/web/h5(可选) */
+    private String platform;
+
+    /** 登录用户ID(可选,登录后场景回传) */
+    private Long userId;
+
+    /** 家庭成员ID(可选) */
+    private Long memberId;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DeviceVerifyResultDTO.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/**
+ * 设备/账号绑定校验结果(child.auth.login · SC-C-18)。
+ *
+ * <p>needVerify=true 时前端应触发二次验证(异常设备风控提示,TC-C-18 边界;
+ * child-zone-contract G-04 建议端点 /api/auth/device/verify 的返回载体)。</p>
+ */
+@Data
+public class DeviceVerifyResultDTO {
+
+    /** 设备是否已知(存在绑定记录) */
+    private Boolean knownDevice;
+
+    /** 是否需要二次验证(异常设备/风控提示) */
+    private Boolean needVerify;
+
+    /** 提示信息 */
+    private String message;
+
+    /** 绑定状态: normal(正常)/abnormal(异常)/pending(待验证) */
+    private String status;
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/PhoneLoginDTO.java

@@ -12,4 +12,5 @@ public class PhoneLoginDTO {
     private String role; // 角色: parent/child
     private String role; // 角色: parent/child
     private Date birthday;
     private Date birthday;
     private String gender;
     private String gender;
+    private String deviceId; // 设备标识(SC-C-18 登录设备校验输入,登录链路预检)
 }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/WechatLoginDTO.java

@@ -10,4 +10,5 @@ public class WechatLoginDTO {
     private String userInfo;
     private String userInfo;
     private String nickname;
     private String nickname;
     private String avatar;
     private String avatar;
+    private String deviceId;       // 设备标识(SC-C-18 登录设备校验输入,登录链路预检)
 }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/WechatPhoneLoginDTO.java

@@ -8,4 +8,5 @@ public class WechatPhoneLoginDTO {
     private String phoneCode;      // 手机号code,用于获取手机号
     private String phoneCode;      // 手机号code,用于获取手机号
     private String nickname;
     private String nickname;
     private String avatar;
     private String avatar;
+    private String deviceId;       // 设备标识(SC-C-18 登录设备校验输入,登录链路预检)
 }
 }

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DeviceBind.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 设备/账号绑定记录(child.auth.login · SC-C-18 设备/账号绑定校验)。
+ *
+ * <p>承载「登录设备」维度:登录时由前端上报 deviceId,服务端按 {@code (user_id, device_id)}
+ * 记录绑定,支撑新设备登记 / 正常设备放行 / 异常设备二次验证(TC-C-18 主流程与边界)。
+ * {@code status}: normal=正常设备、abnormal=异常设备(触发二次验证)、pending=新设备待确认。</p>
+ */
+@Data
+@TableName("device_bind")
+public class DeviceBind implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 登录用户ID(users.id,账号归属) */
+    private Long userId;
+
+    /** 家庭成员ID(family_members.id,可空;绑定到某成员视角) */
+    private Long memberId;
+
+    /** 设备标识(小程序端生成,登录时上报) */
+    private String deviceId;
+
+    /** 设备名称(可选) */
+    private String deviceName;
+
+    /** 设备型号(可选) */
+    private String deviceModel;
+
+    /** 平台: mp(小程序)/web/h5 */
+    private String platform;
+
+    /** 绑定状态: normal(正常)/abnormal(异常)/pending(待验证) */
+    private String status;
+
+    /** 首次登录时间 */
+    private Date firstSeenTime;
+
+    /** 最近登录时间 */
+    private Date lastLoginTime;
+
+    /** 登录次数 */
+    private Integer loginCount;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 94 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DeviceBindService.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.DeviceVerifyDTO;
+import com.etotem.cfc.dto.DeviceVerifyResultDTO;
+import com.etotem.cfc.entity.DeviceBind;
+import com.etotem.cfc.mapper.DeviceBindMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 设备/账号绑定校验服务(child.auth.login · SC-C-18 设备/账号绑定校验)。
+ *
+ * <p>登录准入预检:按 {@code (device_id[, user_id])} 查询 {@code device_bind}——
+ * 新设备 → 登记为 pending(正常放行,登录后链路可确认);已绑定 normal → 放行;
+ * 异常设备 abnormal → 返回 needVerify=true 触发二次验证(TC-C-18 边界)。</p>
+ */
+@Slf4j
+@Service
+public class DeviceBindService {
+
+    @Resource
+    private DeviceBindMapper deviceBindMapper;
+
+    /** 绑定状态常量 */
+    private static final String STATUS_NORMAL = "normal";
+    private static final String STATUS_ABNORMAL = "abnormal";
+    private static final String STATUS_PENDING = "pending";
+
+    /**
+     * 设备/账号绑定校验入口(登录链路预检;匿名可触达,无登录后权限维度)。
+     *
+     * @param dto 登录携带的设备信息(deviceId 必填)
+     * @return 校验结果:正常设备放行 / 新设备登记 / 异常设备二次验证(needVerify=true)
+     */
+    public DeviceVerifyResultDTO verifyDevice(DeviceVerifyDTO dto) {
+        String deviceId = dto.getDeviceId();
+        Long userId = dto.getUserId();
+
+        DeviceBind existing = findByDevice(deviceId, userId);
+        if (existing == null) {
+            // 新设备:登记为 pending(待确认),登录正常放行
+            registerNewDevice(dto);
+            return buildResult(false, "新设备已登记", STATUS_PENDING);
+        }
+
+        // 已绑定设备:异常设备 → 二次验证;正常/待确认 → 放行
+        if (STATUS_ABNORMAL.equals(existing.getStatus())) {
+            return buildResult(true, "设备存在异常登录记录,需二次验证", STATUS_ABNORMAL);
+        }
+        return buildResult(false, "正常设备", existing.getStatus());
+    }
+
+    private DeviceVerifyResultDTO buildResult(boolean needVerify, String message, String status) {
+        DeviceVerifyResultDTO r = new DeviceVerifyResultDTO();
+        r.setNeedVerify(needVerify);
+        r.setKnownDevice(STATUS_NORMAL.equals(status));
+        r.setMessage(message);
+        r.setStatus(status);
+        return r;
+    }
+
+    private DeviceBind findByDevice(String deviceId, Long userId) {
+        List<DeviceBind> rows = deviceBindMapper.selectList(
+                new LambdaQueryWrapper<DeviceBind>()
+                        .eq(DeviceBind::getDeviceId, deviceId)
+                        .eq(userId != null, DeviceBind::getUserId, userId)
+                        .last("LIMIT 1"));
+        return rows.isEmpty() ? null : rows.get(0);
+    }
+
+    private void registerNewDevice(DeviceVerifyDTO dto) {
+        Date now = new Date();
+        DeviceBind bind = new DeviceBind();
+        bind.setUserId(dto.getUserId());
+        bind.setMemberId(dto.getMemberId());
+        bind.setDeviceId(dto.getDeviceId());
+        bind.setDeviceName(dto.getDeviceName());
+        bind.setDeviceModel(dto.getDeviceModel());
+        bind.setPlatform(dto.getPlatform());
+        bind.setStatus(STATUS_PENDING);
+        bind.setFirstSeenTime(now);
+        bind.setLastLoginTime(now);
+        bind.setLoginCount(1);
+        bind.setCreatedAt(now);
+        bind.setUpdatedAt(now);
+        deviceBindMapper.insert(bind);
+        log.info("DeviceBind: 新设备登记 deviceId={}, userId={}", dto.getDeviceId(), dto.getUserId());
+    }
+}

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

@@ -0,0 +1,80 @@
+package com.etotem.cfc.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 服务端 token 失效载体(child.auth.login · SC-C-17 退出登录)。
+ *
+ * <p>JWT 无状态(SecurityConfig.logout().disable()),退出登录时把当前已签发 token 记入进程内
+ * 黑名单,并在其自然过期前拒绝放行(JwtInterceptor 在解析成功后先查黑名单,命中即 401,
+ * 满足 TC-C-17-E 异常流「退出后旧 token 调用接口应 401」)。</p>
+ *
+ * <p>限制:进程内存储,多实例部署时仅对本实例生效;如需跨实例强制下线,可升级为
+ * {@code token_blacklist} 表 / Redis 版本号载体(契约允许的等价实现之一),不在本最小闭环内。</p>
+ */
+@Slf4j
+@Service
+public class TokenBlacklistService {
+
+    /** token -> 失效截止时间戳(毫秒),到期后惰性清理 */
+    private final Map<String, Long> blacklist = new ConcurrentHashMap<>();
+
+    /** 默认失效时长:1 天(无剩余有效期可解析时的兜底 TTL) */
+    private static final long DEFAULT_TTL_MS = 24L * 60 * 60 * 1000;
+
+    /**
+     * 使 token 服务端失效(加入黑名单)。
+     *
+     * @param token 原始 JWT(不含 Bearer 前缀;为空时幂等忽略)
+     */
+    public void invalidate(String token) {
+        if (token == null || token.trim().isEmpty()) {
+            return;
+        }
+        blacklist.put(token, System.currentTimeMillis() + DEFAULT_TTL_MS);
+        log.info("Token 已加入黑名单(退出登录),{}ms 后自然过期", DEFAULT_TTL_MS);
+    }
+
+    /** 判断 token 是否已被服务端失效(黑名单命中且未到期) */
+    public boolean isInvalid(String token) {
+        if (token == null || token.trim().isEmpty()) {
+            return false;
+        }
+        Long expireAt = blacklist.get(token);
+        if (expireAt == null) {
+            return false;
+        }
+        if (expireAt < System.currentTimeMillis()) {
+            // 已到期的黑名单条目:惰性清理
+            blacklist.remove(token, expireAt);
+            return false;
+        }
+        return true;
+    }
+
+    /** {@link #isInvalid} 的等价别名(黑名单语义可读性) */
+    public boolean isBlacklisted(String token) {
+        return isInvalid(token);
+    }
+
+    /** 当前黑名单有效条目数(监控/大小校验用,含惰性清理) */
+    public int size() {
+        cleanupExpired();
+        return blacklist.size();
+    }
+
+    private void cleanupExpired() {
+        long now = System.currentTimeMillis();
+        Iterator<Map.Entry<String, Long>> it = blacklist.entrySet().iterator();
+        while (it.hasNext()) {
+            if (it.next().getValue() < now) {
+                it.remove();
+            }
+        }
+    }
+}

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

@@ -7253,3 +7253,24 @@ CREATE TABLE IF NOT EXISTS mind_model (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_model_type_version (model_type, version)
     UNIQUE KEY uk_model_type_version (model_type, version)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='舌诊/情绪模型版本管理(SC-A-21)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='舌诊/情绪模型版本管理(SC-A-21)';
+
+-- ============================================================
+-- 设备/账号绑定记录表(迁移471,S-cfc-325 child.auth.login · SC-C-18 设备/账号绑定校验)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS device_bind (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '登录用户ID(users.id,账号归属)',
+    member_id BIGINT NULL COMMENT '家庭成员ID(family_members.id,可空)',
+    device_id VARCHAR(128) NOT NULL COMMENT '设备标识(登录时上报)',
+    device_name VARCHAR(128) NULL COMMENT '设备名称(可选)',
+    device_model VARCHAR(64) NULL COMMENT '设备型号(可选)',
+    platform VARCHAR(32) NULL COMMENT '平台: mp(小程序)/web/h5',
+    status VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '绑定状态: normal(正常)/abnormal(异常)/pending(待验证)',
+    first_seen_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '首次登录时间',
+    last_login_time DATETIME NULL COMMENT '最近登录时间',
+    login_count INT NOT NULL DEFAULT 1 COMMENT '登录次数',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_user_device (user_id, device_id),
+    INDEX idx_device (device_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备/账号绑定记录(SC-C-18 登录设备校验)';

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

@@ -162,6 +162,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/auth/direct-register` | 直接注册(无邀请码) | — | — |
 | `POST /api/auth/direct-register` | 直接注册(无邀请码) | — | — |
 | `POST /api/auth/register-with-invite` | 邀请码注册 | inviteCode | — |
 | `POST /api/auth/register-with-invite` | 邀请码注册 | inviteCode | — |
 | `POST /api/auth/switch-role` | 切换角色 | role | — |
 | `POST /api/auth/switch-role` | 切换角色 | role | — |
+| `POST /api/auth/logout` | 退出登录(服务端 token 失效,SC-C-17) | —(Bearer token) | — |
+| `POST /api/auth/device/verify` | 设备/账号绑定校验(登录准入预检,SC-C-18) | deviceId(可选 userId/memberId) | — |
 | `POST /api/user/address/list` | 收货地址列表 | — | — |
 | `POST /api/user/address/list` | 收货地址列表 | — | — |
 | `POST /api/user/address/get` | 获取地址详情 | id | — |
 | `POST /api/user/address/get` | 获取地址详情 | id | — |
 | `POST /api/user/address/create` | 创建地址 | — | — |
 | `POST /api/user/address/create` | 创建地址 | — | — |

+ 49 - 0
docs/tdd/impl-2026-11-14-S-cfc-325-child-auth-login.md

@@ -0,0 +1,49 @@
+# S-cfc-325 child.auth.login — 实现验证报告 (2026-11-14)
+
+**能力键**: `child.auth.login`
+**任务号**: S-cfc-325
+**状态**: TDD-GREEN(红单测全绿,实现落地)
+**契约测试**: `cfc-backend/src/test/java/com/etotem/cfc/unit/ChildAuthLoginContractTest.java`(11 例)
+
+## 一、基线(RED 复现)
+
+- 基准:`origin/cfclub` @ `4eceb1859`(任务分支 `task/S-cfc-325` 基于此创建)
+- 复现命令:`mvn -Dtest=ChildAuthLoginContractTest test`
+- 结果:**11 例,6 失败**(SC-C-17 退出登录 ×2 + SC-C-18 设备/账号绑定 ×4,实现未落地):
+  - `authController_shouldHaveLogoutEndpoint`(无 POST /logout 端点)
+  - `logout_tokenShouldBeInvalidatableServerSide`(无 TokenBlacklistService)
+  - `loginDtos_shouldCarryDeviceId`(三个登录 DTO 无 deviceId)
+  - `authController_shouldHaveDeviceVerifyEndpoint`(无 POST /device/verify)
+  - `schemaSql_shouldHaveDeviceBindTable`(schema.sql 无 device_bind 表)
+  - `deviceBindService_shouldExistWithVerifyCapability`(无 DeviceBindService)
+
+## 二、实现(src/main,按 child-zone-contract G-03/G-04 落地)
+
+| 契约锚点 | 落地实现 |
+|---------|---------|
+| SC-C-17 服务端退出 + token 失效载体 | `AuthController` 新增 `POST /logout`(Bearer token 入黑名单);新增 `TokenBlacklistService`(进程内黑名单,`invalidate/isInvalid/isBlacklisted`);`JwtInterceptor` 解析成功后先查黑名单,命中即 401(TC-C-17-E 异常流);`/api/auth/verify` 同步校验黑名单 |
+| SC-C-18 设备/账号绑定校验 | `deviceId` 字段补入 `PhoneLoginDTO`/`WechatLoginDTO`/`WechatPhoneLoginDTO`;`AuthController` 新增 `POST /device/verify`(登录准入预检);新增 `DeviceBindService`(`verifyDevice`:新设备登记/正常放行/异常二次验证)+ `DeviceBind` 实体 + `DeviceBindMapper` + `DeviceVerifyDTO`/`DeviceVerifyResultDTO` |
+| schema.sql + 迁移 | `device_bind` 建表(schema.sql 快照 + `DatabaseInitializer` 迁移471,幂等 CREATE TABLE IF NOT EXISTS) |
+| 接口文档 | `docs/superpowers/api/API_REFERENCE.md` §4.1 登记 `/logout`、`/device/verify` |
+
+约束遵守:统一 `@PostMapping`、统一 `Result<T>`、`@Resource` 注入(字段名=默认 Bean Name)、迁移幂等且与 schema.sql 同步、未改任何测试断言/用例。
+
+## 三、验证结果
+
+- 目标测试:`mvn -Dtest=ChildAuthLoginContractTest test` → **11/11 全绿**(6 失败 → 0 失败)
+- 回归(受影响相邻套件):`JwtInterceptor*Test`(59/59)、`ScG09LogoutContractTest`(6/6)、`ScG03LoginMethodContractTest`(13/13)、`AuthWechatLoginContractTest`(10/10)、`UserServiceMaskChildSensitiveFieldsTest`(5/5)等全绿
+- 遗留 3 红(均有基线证明,非本任务回归):
+  1. `TeacherLoginSwitchContractTest.observation_noServerLogoutEndpoint_intentional` — SC-T-20 观察项断言「无 /logout 端点」;本任务按 SC-C-17 契约落地 `/logout` 后翻转。基线(87ab339a1)该用例全绿 16/16,证明为本任务引入的**测试侧陈旧观察与 SC-C-17 契约冲突**(不能改测试断言,交由测试侧裁决)。
+  2. `UserServiceWechatLoginTest` / `UserServiceWechatLoginContractTest` 各 1 红 — R197 已知存量红(UserServiceWechatLogin×2),在未改动的基线(87ab339a1)上同路径复现,与本次改动无关。
+
+## 四、改动文件
+
+- 新增:`cfc-backend/.../service/TokenBlacklistService.java`、`service/DeviceBindService.java`、`entity/DeviceBind.java`、`mapper/DeviceBindMapper.java`、`dto/DeviceVerifyDTO.java`、`dto/DeviceVerifyResultDTO.java`
+- 修改:`controller/auth/AuthController.java`、`config/JwtInterceptor.java`、`config/DatabaseInitializer.java`、`dto/{PhoneLoginDTO,WechatLoginDTO,WechatPhoneLoginDTO}.java`、`resources/schema.sql`、`docs/superpowers/api/API_REFERENCE.md`
+- 本报告:`docs/tdd/impl-2026-11-14-S-cfc-325-child-auth-login.md`
+
+## 五、暴露的缺陷
+
+1. **测试契约冲突(需测试/产品侧裁决)**:SC-T-20 观察项(TeacherLoginSwitchContractTest)与 SC-C-17(ChildAuthLoginContractTest)对「服务端 /logout 是否应存在」结论相反;SC-C-17 契约与 child-zone-contract G-03(加 token 黑名单强制下线)已明确采用服务端退出,SC-T-20 观察项应更新为「存在 /logout」或降级为纯说明。
+2. **TokenBlacklistService 为进程内实现**:单实例生效;多实例部署如需跨实例强制下线,需升级为 token_blacklist 表 / Redis 版本号载体(契约允许的等价实现,已注释说明)。
+3. **/device/verify 为登录准入预检最小实现**:异常设备判定目前依赖 status=abnormal 记录,风控规则(连续失败/异地登录等)未做,属后续增强(契约边界外)。