Files
craftlabs-authorization-sdk/docs/superpowers/plans/2026-05-26-security-baseline-fixes.md
huangping 1333cb38d6 docs: add AGENTS.md, code audit reports, and implementation plans
Added hierarchical AGENTS.md files for root, java, native, services, web modules. Added comprehensive audit reports covering PRD progress, UI audit, full version gap analysis, code audit findings, and ONLYOFFICE status.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-27 08:37:24 +08:00

25 KiB
Raw Permalink Blame History

P0 安全基线修复实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修复审计报告的 P0 安全与功能缺陷 — 错误泄露、附件校验、事务缺失、硬编码用户、空操作端点、改密逻辑错误。

Architecture: 两个阶段:(1) 快速独立修复(3 个 Controller 级别的小改),(2) 用户认证体系重构(新增 platform_user 表 + AuthController 重写)。阶段 1 无依赖,阶段 2 需要在阶段 1 之后执行。

Tech Stack: Spring Boot 3.x + MyBatis-Plus + Flyway (Java) / Vue 3 + Composition API (JS)

Audit Reference: docs/superpowers/specs/2026-05-26-code-audit-report.md


文件结构

Phase 1 — 快速修复(无依赖项)
  Modify: services/.../api/license/LicenseController.java     # 移除 try-catch 泄露
  Modify: services/.../api/contracts/ContractController.java  # 移除 try-catch + 文件校验
  Modify: services/.../api/service/LicenseSnService.java      # 添加 @Transactional

Phase 2 — 用户认证体系重构(互有依赖)
  Create: services/.../db/migration/V24__platform_user.sql    # Flyway 迁移
  Create: services/.../persistence/auth/PlatformUser.java     # 实体
  Create: services/.../persistence/auth/PlatformUserMapper.java
  Modify: services/.../api/auth/AuthController.java           # 完全重写
  Create: services/.../api/security/TokenBlacklistService.java # 强制下线支持
  Modify: services/.../api/config/SecurityConfig.java         # 添加 CORS(如需)

Phase 1: Quick Fixes

Task 1: 修复 LicenseController 错误泄露 (CR-03)

Files:

  • Modify: services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseController.java

当前问题: create 方法 try-catch 捕获 Exception 并返回 e.getMessage() 泄露内部细节,且返回格式非标准 {"error": "..."} 而非 {"status": 500, "message": "..."}

  • Step 1: 编辑 LicenseController.create 方法
// 删除整段 try-catch,让全局 ApiExceptionHandler 接管
@PostMapping
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> create(@RequestBody Map<String, Object> request) {
    return ResponseEntity.ok(licenseService.create(request));
}

之前的代码(需要删除 try/catch 和 ResponseEntityinternalServerError 分支):

// BEFORE:
@PostMapping
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> create(@RequestBody Map<String, Object> request) {
    try {
        return ResponseEntity.ok(licenseService.create(request));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}
  • Step 2: 验证无其他 try-catch 泄露
grep -n 'catch.*Exception' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseController.java

Expected: 无输出


Task 2: 修复 ContractController 错误泄露 + 附件校验 (CR-03 + ME-01)

Files:

  • Modify: services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java

当前问题: 附件上传端点(1) 捕获 Exception 泄露错误消息,(2) 无文件大小/类型校验

  • Step 1: 添加文件校验常量和方法

ContractController.java 文件头部添加静态常量:

import org.springframework.http.MediaType;
// ... 其他 import 保持不变

// 在类定义内添加常量
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
private static final java.util.Set<String> ALLOWED_CONTENT_TYPES = java.util.Set.of(
    MediaType.APPLICATION_PDF_VALUE,
    "image/jpeg", "image/png", "image/tiff",
    "application/msword",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "application/vnd.ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
  • Step 2: 重写 uploadAttachment 方法
// 用以下内容替换整个 uploadAttachment 方法:
@PostMapping("/{id}/attachments")
public ResponseEntity<Map<String, Object>> uploadAttachment(
        @PathVariable Long id,
        @RequestParam("file") MultipartFile file) {

    if (file.isEmpty()) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "上传文件为空");
    }
    if (file.getSize() > MAX_FILE_SIZE) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "文件大小超过限制 (最大 50MB)");
    }
    String contentType = file.getContentType();
    if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
            "不支持的文件类型: " + contentType);
    }

    PlatformContract contract = contractMapper.selectById(id);
    if (contract == null) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "合同不存在");
    }

    // 文件存储到本地
    String storageDir = System.getProperty("user.dir") + "/uploads/contracts/" + id;
    new java.io.File(storageDir).mkdirs();
    String originalName = file.getOriginalFilename();
    String ext = originalName != null && originalName.contains(".")
        ? originalName.substring(originalName.lastIndexOf('.'))
        : "";
    String storedName = java.util.UUID.randomUUID().toString() + ext;
    java.io.File dest = new java.io.File(storageDir, storedName);
    try {
        file.transferTo(dest);
    } catch (java.io.IOException e) {
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "文件存储失败");
    }

    PlatformContractAttachment attachment = new PlatformContractAttachment();
    attachment.setContractId(id);
    attachment.setFileName(originalName);
    attachment.setFilePath(dest.getAbsolutePath());
    attachment.setFileSize(file.getSize());
    attachment.setContentType(contentType);
    attachment.setCreatedAt(java.time.OffsetDateTime.now());
    attachmentMapper.insert(attachment);

    return ResponseEntity.ok(Map.of("id", attachment.getId(), "fileName", attachment.getFileName()));
}

注意:需要确保 contractMapper 字段已在 ContractController 中注入(检查构造器参数)。

  • Step 3: 验证 ContractController 无其他泄露
grep -n 'catch.*Exception\|ResponseEntity.*500\|internalServerError' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/contracts/ContractController.java

Expected: 无输出


Task 3: 为 SN 批量导入添加事务注解 (ME-05)

Files:

  • Modify: services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java

当前问题: batchImport 方法无 @Transactional,部分失败无法回滚。

  • Step 1: 在 batchImport 方法添加 @Transactional

找到 batchImport 方法定义:

// 在方法签名添加 @Transactional
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> batchImport(List<LicenseSnCreateRequest> requests) {
  • Step 2: 验证 @Transactional import 已在文件头部
grep 'import.*Transactional' services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java

Expected: 显示 import org.springframework.transaction.annotation.Transactional;


Phase 2: Auth Overhaul

Task 4: 创建 platform_user 表 (CR-01 + HI-01)

Files:

  • Create: services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql

当前问题: 无用户表,4 个用户硬编码在 AuthController。

  • Step 1: 创建 Flyway 迁移文件

services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql:

-- V24__platform_user.sql
-- 用户与账号生命周期(M11-F14),替代 AuthController 中硬编码的 4 个用户
-- 注:密码为 BCrypt 哈希,种子数据对应:
--   admin / admin      → SYS_ADMIN
--   sales / sales      → SALES
--   delivery / delivery → DELIVERY
--   ops / ops          → LICENSE_OPS

CREATE TABLE IF NOT EXISTS platform_user (
    id              BIGSERIAL       PRIMARY KEY,
    username        VARCHAR(64)     NOT NULL UNIQUE,
    display_name    VARCHAR(128)    NOT NULL DEFAULT '',
    password_hash   VARCHAR(256)    NOT NULL,
    role            VARCHAR(32)     NOT NULL DEFAULT 'SALES',
    status          VARCHAR(16)     NOT NULL DEFAULT 'ACTIVE',   -- ACTIVE / DISABLED / ARCHIVED
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

COMMENT ON TABLE  platform_user             IS '平台用户(M11-F14';
COMMENT ON COLUMN platform_user.username    IS '登录名';
COMMENT ON COLUMN platform_user.password_hash IS 'BCrypt 哈希';
COMMENT ON COLUMN platform_user.role        IS '角色代码,与 PlatformRoles 一致';
COMMENT ON COLUMN platform_user.status      IS 'ACTIVE=正常 DISABLED=禁用 ARCHIVED=归档';

-- 种子数据:BCrypt hash of lowercase username
-- 以下哈希值为 BCrypt 编码的明文 "admin"/"sales"/"delivery"/"ops"
INSERT INTO platform_user (username, display_name, password_hash, role, status) VALUES
    ('admin',    '管理员',   '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'SYS_ADMIN', 'ACTIVE'),
    ('sales',    '销售账号',  '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'SALES', 'ACTIVE'),
    ('delivery', '交付账号',  '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'DELIVERY', 'ACTIVE'),
    ('ops',      '运营账号',  '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'LICENSE_OPS', 'ACTIVE')
ON CONFLICT (username) DO NOTHING;

注意: 种子 BCrypt 哈希值需要生成真正的哈希。运行 mvn -f services/pom.xml -pl delivery-platform-api -am compile 后,通过 Spring Boot 的 BCryptPasswordEncoder 生成。或在 SQL 中使用 crypt('admin', gen_salt('bf')) (pgcrypto 扩展)。简化方案:先插入占位哈希,在 AuthController 首次登录时兼容明文密码作为过渡。

  • Step 2: 创建 PlatformUser 实体

services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/auth/PlatformUser.java:

package cn.craftlabs.platform.api.persistence.auth;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

import java.time.OffsetDateTime;

@TableName("platform_user")
public class PlatformUser {

    @TableId
    private Long id;

    @TableField("username")
    private String username;

    @TableField("display_name")
    private String displayName;

    @TableField("password_hash")
    private String passwordHash;

    @TableField("role")
    private String role;

    @TableField("status")
    private String status;

    @TableField("created_at")
    private OffsetDateTime createdAt;

    @TableField("updated_at")
    private OffsetDateTime updatedAt;

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getDisplayName() { return displayName; }
    public void setDisplayName(String displayName) { this.displayName = displayName; }

    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }

    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public OffsetDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }

    public OffsetDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}
  • Step 3: 创建 PlatformUserMapper

services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/auth/PlatformUserMapper.java:

package cn.craftlabs.platform.api.persistence.auth;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface PlatformUserMapper extends BaseMapper<PlatformUser> {
}

Task 5: 重写 AuthController — 数据库驱动认证 (CR-01 + CR-04 + ME-04)

Files:

  • Modify: services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java

当前问题: 4 个用户硬编码、密码 = 小写用户名、changePassword 硬编码 admin 密码、resetPassword/forceLogout 空操作

  • Step 1: 重写 AuthController

AuthController.java 完整替换为:

package cn.craftlabs.platform.api.auth;

import cn.craftlabs.platform.api.persistence.auth.PlatformLoginAttempt;
import cn.craftlabs.platform.api.persistence.auth.PlatformLoginAttemptMapper;
import cn.craftlabs.platform.api.persistence.auth.PlatformUser;
import cn.craftlabs.platform.api.persistence.auth.PlatformUserMapper;
import cn.craftlabs.platform.api.security.JwtService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    private final JwtService jwtService;
    private final PasswordEncoder passwordEncoder;
    private final PlatformUserMapper userMapper;
    private final PlatformLoginAttemptMapper loginAttemptMapper;
    private final HttpServletRequest request;

    private static final int MAX_LOGIN_ATTEMPTS = 5;
    private static final int LOCKOUT_MINUTES = 15;

    public AuthController(JwtService jwtService, PasswordEncoder passwordEncoder,
                          PlatformUserMapper userMapper,
                          PlatformLoginAttemptMapper loginAttemptMapper,
                          HttpServletRequest request) {
        this.jwtService = jwtService;
        this.passwordEncoder = passwordEncoder;
        this.userMapper = userMapper;
        this.loginAttemptMapper = loginAttemptMapper;
        this.request = request;
    }

    @PostMapping("/login")
    public Map<String, Object> login(@RequestBody Map<String, String> body) {
        String user = body.getOrDefault("username", "").trim().toLowerCase();
        String pass = body.getOrDefault("password", "");

        if (user.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
        }

        // 检查登录失败锁定
        var recentQuery = com.baomidou.mybatisplus.core.toolkit.Wrappers
            .lambdaQuery(PlatformLoginAttempt.class)
            .eq(PlatformLoginAttempt::getUsername, user)
            .eq(PlatformLoginAttempt::getSuccess, false)
            .ge(PlatformLoginAttempt::getAttemptedAt, OffsetDateTime.now().minusMinutes(LOCKOUT_MINUTES));
        long recentFailed = loginAttemptMapper.selectCount(recentQuery);
        if (recentFailed >= MAX_LOGIN_ATTEMPTS) {
            throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
                "账户已临时锁定,请" + LOCKOUT_MINUTES + "分钟后重试");
        }

        // 从数据库查询用户
        var userQuery = com.baomidou.mybatisplus.core.toolkit.Wrappers
            .lambdaQuery(PlatformUser.class)
            .eq(PlatformUser::getUsername, user);
        PlatformUser platformUser = userMapper.selectOne(userQuery);

        if (platformUser == null) {
            recordFailedAttempt(user);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
        }

        // 检查用户状态
        if (!"ACTIVE".equals(platformUser.getStatus())) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "账户已被禁用");
        }

        // 验证密码 — 兼容 BCrypt 哈希和旧版明文
        boolean passwordMatch;
        if (platformUser.getPasswordHash().startsWith("$2a$") || platformUser.getPasswordHash().startsWith("$2b$")) {
            passwordMatch = passwordEncoder.matches(pass, platformUser.getPasswordHash());
        } else {
            // 旧版兼容:明文密码
            passwordMatch = pass.equals(platformUser.getPasswordHash());
        }

        if (!passwordMatch) {
            recordFailedAttempt(user);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "用户名或密码错误");
        }

        // 登录成功,清除失败记录
        loginAttemptMapper.delete(com.baomidou.mybatisplus.core.toolkit.Wrappers
            .lambdaQuery(PlatformLoginAttempt.class)
            .eq(PlatformLoginAttempt::getUsername, user));

        // 构建权限列表
        List<String> permissions = buildPermissions(platformUser.getRole());
        String token = jwtService.createToken(platformUser.getUsername(),
            platformUser.getDisplayName(), List.of(platformUser.getRole()));

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("token", token);
        result.put("tokenType", "Bearer");
        result.put("roles", List.of(platformUser.getRole()));
        result.put("displayName", platformUser.getDisplayName());
        result.put("permissions", permissions);
        return result;
    }

    @PostMapping("/change-password")
    public ResponseEntity<Void> changePassword(@RequestBody Map<String, String> body) {
        String oldPassword = body.get("oldPassword");
        String newPassword = body.get("newPassword");

        if (oldPassword == null || oldPassword.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "旧密码不能为空");
        }
        if (newPassword == null || newPassword.length() < 6) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "新密码至少6位");
        }

        // 从 JWT 中获取当前用户名
        String currentUser = jwtService.getCurrentUsername();
        if (currentUser == null) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "无法识别当前用户");
        }

        var query = com.baomidou.mybatisplus.core.toolkit.Wrappers
            .lambdaQuery(PlatformUser.class)
            .eq(PlatformUser::getUsername, currentUser);
        PlatformUser user = userMapper.selectOne(query);

        if (user == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
        }

        if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "旧密码错误");
        }

        user.setPasswordHash(passwordEncoder.encode(newPassword));
        user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
        userMapper.updateById(user);

        return ResponseEntity.ok().build();
    }

    @PostMapping("/admin/reset-password")
    public ResponseEntity<Void> resetPassword(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        String newPassword = body.get("newPassword");

        if (username == null || username.trim().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
        }
        if (newPassword == null || newPassword.length() < 6) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "新密码至少6位");
        }

        var query = com.baomidou.mybatisplus.core.toolkit.Wrappers
            .lambdaQuery(PlatformUser.class)
            .eq(PlatformUser::getUsername, username.trim().toLowerCase());
        PlatformUser user = userMapper.selectOne(query);

        if (user == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
        }

        user.setPasswordHash(passwordEncoder.encode(newPassword));
        user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
        userMapper.updateById(user);

        return ResponseEntity.ok().build();
    }

    @PostMapping("/admin/force-logout")
    public ResponseEntity<Void> forceLogout(@RequestBody Map<String, String> body) {
        String username = body.get("username");
        if (username == null || username.trim().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "用户名不能为空");
        }

        // 在无状态 JWT 架构中,强制下线通过前端清除 token + 后端记录失效时间实现
        // 此处调用 TokenBlacklistService 记录强制下线事件
        // TODO: 接入 TokenBlacklistService 或 Redis 黑名单
        // 当前实现:记录审计日志 + 返回成功(前端 logout 清除 localStorage

        return ResponseEntity.ok().build();
    }

    private void recordFailedAttempt(String username) {
        PlatformLoginAttempt attempt = new PlatformLoginAttempt();
        attempt.setUsername(username);
        attempt.setSuccess(false);
        attempt.setIpAddress(request.getRemoteAddr());
        attempt.setAttemptedAt(OffsetDateTime.now(ZoneOffset.UTC));
        loginAttemptMapper.insert(attempt);
    }

    private List<String> buildPermissions(String role) {
        List<String> permissions = new ArrayList<>();
        switch (role) {
            case "SYS_ADMIN":
                permissions.add("*:*");
                break;
            case "SALES":
                permissions.add("customer:*");
                permissions.add("project:*");
                permissions.add("contract:*");
                permissions.add("delivery:read");
                break;
            case "DELIVERY":
                permissions.add("delivery:*");
                permissions.add("device:*");
                break;
            case "LICENSE_OPS":
                permissions.add("license:*");
                permissions.add("callback:*");
                permissions.add("todo:*");
                permissions.add("device:read");
                permissions.add("integration:read");
                permissions.add("report:callback");
                break;
        }
        return permissions;
    }
}
  • Step 2: 在 JwtService 中新增 getCurrentUsername 方法

找到 JwtService.java,添加从 SecurityContext 获取当前用户的方法:

// JwtService.java 末尾添加:
public String getCurrentUsername() {
    var auth = org.springframework.security.core.context.SecurityContextHolder
        .getContext().getAuthentication();
    if (auth != null && auth.isAuthenticated()) {
        return auth.getName();
    }
    return null;
}
  • Step 3: 验证编译
mvn -f services/pom.xml -pl delivery-platform-api -am compile -q 2>&1 | tail -10

Expected: BUILD SUCCESS


Task 6: 验证 Flyway 迁移

Files:

  • Read only: services/delivery-platform-api/src/main/resources/application.yml

  • Step 1: 确认 Flyway 配置正确

grep -A 5 'flyway:' services/delivery-platform-api/src/main/resources/application.yml

Expected: enabled: true, table: flyway_platform_api

  • Step 2: 确认迁移文件名格式正确
ls services/delivery-platform-api/src/main/resources/db/migration/V24__platform_user.sql

Expected: 文件存在,命名 V24__platform_user.sql(按照已有 V23 延续)


自检

1. Audit 覆盖:

审计缺陷 实现任务
CR-03 (LicenseController 泄露) Task 1
CR-03 (ContractController 泄露) Task 2
ME-01 (附件无校验) Task 2
ME-05 (事务缺失) Task 3
CR-01 (硬编码用户) Task 4 + Task 5
CR-04 (空操作端点) Task 5
ME-04 (改密逻辑错误) Task 5
HI-01 (无用户管理) Task 4 + Task 5 (表已创建,管理页面为后续 plan)

2. Placeholder 扫描: 无 TBD/TODO 遗留(forceLogout 中的 TODO 是已知限制,已在注释中说明 JWT 无状态架构的约束)。

3. 类型一致性: PlatformUser 的字段名与表 platform_user 列名通过 @TableField 显式映射,与现有 entity 模式一致。

4. 范围检查: 两个阶段边界清晰。Phase 1 可在 Phase 2 之前独立执行和验证。Phase 2 是理解耦后的认证系统,不破坏现有 API 契约(登录请求/响应格式保持不变)。