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

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 6 часов назад
Родитель
С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());
         }
 
-        // 迁移470: 创建模型管理表 mind_model
+// 迁移470: 创建模型管理表 mind_model
         //(mind.model.manage · SC-A-21 舌诊/情绪模型版本/开关:版控/上下线/下线前回退方案)
         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,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      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.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.TokenBlacklistService;
 import io.jsonwebtoken.Claims;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
@@ -34,6 +35,9 @@ public class JwtInterceptor implements HandlerInterceptor {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private TokenBlacklistService tokenBlacklistService;
+
 private static final String[] PUBLIC_PATHS = {
          "/api/auth/send-code",
          "/api/auth/phone-login",
@@ -216,6 +220,13 @@ private static final String[] PUBLIC_PATHS = {
         try {
             // 解析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());
             String role = (String) claims.get("role");
             @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.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.DeviceBindService;
 import com.etotem.cfc.service.SsoService;
+import com.etotem.cfc.service.TokenBlacklistService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
@@ -43,6 +45,12 @@ public class AuthController {
     @Resource
     private SsoService ssoService;
 
+    @Resource
+    private TokenBlacklistService tokenBlacklistService;
+
+    @Resource
+    private DeviceBindService deviceBindService;
+
     /**
      * 发送验证码
      */
@@ -230,6 +238,12 @@ public class AuthController {
         try {
             String token = authHeader.substring(7);
             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());
             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 = "验证密码")
     @PostMapping("/verify-password")
     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 Date birthday;
     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 nickname;
     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 nickname;
     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 '更新时间',
     UNIQUE KEY uk_model_type_version (model_type, version)
 ) 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/register-with-invite` | 邀请码注册 | inviteCode | — |
 | `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/get` | 获取地址详情 | id | — |
 | `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 记录,风控规则(连续失败/异地登录等)未做,属后续增强(契约边界外)。