feat(m0): 后端骨架 + 账号体系(M0 验收 36/36 通过)

技术栈(已在 4.1.1 上实测跑通,非纸面选型)
  Java 21 + Spring Boot 4.1.1 + MyBatis-Plus 3.5.17(boot4) + PostgreSQL 16.15
  + Redis 7 + Flyway 12.4 + springdoc 3.1.1;前端定 Vue 3 + Element Plus + uni-app

交付内容
  M0-01 基础设施     deploy/docker-compose.yml(PG + Redis + MinIO,容器名与端口避让本机既有项目)
  M0-02 后端骨架     xt-common + xt-server;统一响应 / 全局异常 / 58 个错误码 /
                     多租户拦截 / 软删除 / 字段自动填充 / AES-GCM / HMAC 索引 / RSA / JWT
  M0-05 租户        开通租户(一次完成建租户 + 发 6 个内置角色 + 建首个管理员)、列表、详情
  M0-06 组织        CRUD + 组织树 + ancestor_path 自动维护 + 占用校验
  M0-07 PC 登录     RSA 解密 + 图形验证码 + 失败锁定 + 等时比较防账号枚举 + 审计日志
  M0-08 登出/当前用户  Token 黑名单(存 SHA-256,TTL 到自然过期)
  M0-01 数据库      Flyway 2 个迁移 → 26 张表(20 业务 + 5 标准 + 1 迁移记录)

安全姿态
  · 租户拦截器【默认拒绝】:匿名请求触及租户表直接抛 40101,不做「不加条件放行」
  · 权限码定义在代码里(PermissionDef / BuiltinRole),不建 sys_permission 表,杜绝前后端漂移
  · 匿名接口只靠 @PublicApi 放行,不做路径白名单,避免两套机制各说各话
  · 账号不存在时也跑一次 BCrypt(假哈希),拉平响应时间防枚举
  · 登录密码 RSA 传输,防止网关/APM 把明文密码记进日志

脚本
  scripts/sync-ddl.py          文档 DDL → Flyway 迁移,带 --check 防漂移
  scripts/e2e-m0.py            M0 验收 36 项,可进 CI
  scripts/verify-docs.py       文档一致性(接口数【逐节】核对、错误码、跨文档数字、表格列数)
  scripts/dev-login.py         RSA 加密登录助手
  scripts/dev-clean-e2e.py     清理 E2E 测试数据

文档修正(实测暴露,非人工审查)
  · DDL 缺陷:sys_tenant.contact_phone / sys_user.phone / collect_roster.phone
    原 varchar(20) 按明文长度建,装不下 AES-GCM 密文(约 52 字符),改 varchar(128)(PRD V2.4)
  · 错误码 40007b/40007c 带字母后缀与「code 为数字」的约定冲突,规范化为 40071/40072
  · 07-开发实施计划 新增 §9 版本兼容踩坑记录(10 条实录)与 §10 M0 完成情况
This commit is contained in:
2026-09-28 17:28:21 +08:00
parent 63aec55329
commit 9d7a8ca845
88 changed files with 7564 additions and 32 deletions
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.tianbaotong</groupId>
<artifactId>fill-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>xt-common</artifactId>
<name>xt-common · 通用层</name>
<description>统一响应、错误码、异常、加解密、上下文。不依赖任何业务模块。</description>
<dependencies>
<!-- Web 注解与 HttpStatus(GlobalExceptionHandler 需要) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- @Valid / 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 只要 BCrypt,不要完整 Spring Security 的过滤器链 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<!-- 图形验证码 / Base64 / 国密工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 通用层是普通 jar,不需要可执行 fat jar -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,78 @@
package cn.tianbaotong.fill.common.api;
import lombok.Getter;
import lombok.Setter;
/**
* 统一响应体(《03-接口设计》§1.3)。
*
* <pre>
* { "code": 0, "msg": "success", "data": { } }
* </pre>
*
* <p><b>空值约定</b>(§1.8):统一 {@code null},不用空字符串 {@code ""}。
* 因此这里<b>不加</b> {@code @JsonInclude(NON_NULL)}——null 要如实序列化出去。
*
* @param <T> data 的类型
*/
@Getter
@Setter
public class ApiResult<T> {
/** 业务码。0 成功;4xxxx 客户端;5xxxx 服务端。见 {@link ErrorCode}。 */
private int code;
/** 提示文案。成功固定为 {@code "success"}。 */
private String msg;
/** 业务数据。无数据时为 {@code null}。 */
private T data;
public ApiResult() {
}
public ApiResult(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
// ── 成功 ──────────────────────────────────────
public static <T> ApiResult<T> ok() {
return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), null);
}
public static <T> ApiResult<T> ok(T data) {
return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), data);
}
// ── 失败 ──────────────────────────────────────
public static <T> ApiResult<T> fail(ErrorCode ec) {
return new ApiResult<>(ec.getCode(), ec.getMsg(), null);
}
/** 覆盖默认文案(例如带上剩余锁定时间)。 */
public static <T> ApiResult<T> fail(ErrorCode ec, String msg) {
return new ApiResult<>(ec.getCode(), msg, null);
}
/**
* 带业务数据的失败。
*
* <p>用于 §1.3 的 {@code 40001} (blocking/warnings 清单)、{@code 40007}
* (剩余尝试次数)等需要回传结构的场景。
*/
public static <T> ApiResult<T> fail(ErrorCode ec, T data) {
return new ApiResult<>(ec.getCode(), ec.getMsg(), data);
}
public static <T> ApiResult<T> fail(ErrorCode ec, String msg, T data) {
return new ApiResult<>(ec.getCode(), msg, data);
}
// 注意:这里刻意不提供 isSuccess() 之类的方法。
// Jackson 会把 isXxx() 当成属性序列化出去,给响应体加一个契约里没有的 "success" 字段。
// 判断成功请用 code == ErrorCode.SUCCESS.getCode()。
}
@@ -0,0 +1,181 @@
package cn.tianbaotong.fill.common.api;
import lombok.Getter;
/**
* 错误码全集。
*
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>58 个</b>(含成功码 {@code 0})。
* <b>改这里之前先改文档,不要只改代码。</b>
*
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
* {@code {"code": 0, "msg": "success", "data": {}}},前端判断用 {@code code === 0}。
* 若混入 {@code "40007b"} 这类字符串码,JSON 类型会不一致、前端判断失效。
* 因此原 {@code 40007b}/{@code 40007c} 已规范化为 {@code 40071}/{@code 40072}。
*
* <p><b>HTTP 状态码规则</b>(§1.3):业务错误一律 HTTP 200,只有以下 4 个用真 HTTP 码:
* {@link #UNAUTHORIZED} 401、{@link #FORBIDDEN} 403、{@link #RATE_LIMITED} 429、5xxxx 用 200。
*/
@Getter
public enum ErrorCode {
// ── 成功 ──────────────────────────────────────
SUCCESS(0, "success"),
// ── 40001~40010 填报与文件 ────────────────────
/** 存在阻断性校验问题,无法提交 */
VALIDATION_BLOCKED(40001, "存在阻断性校验问题,无法提交"),
/** 文件格式不在白名单 */
FILE_FORMAT_NOT_ALLOWED(40002, "文件格式不在白名单"),
/** 文件体积超上限且压缩受限 */
FILE_TOO_LARGE(40003, "文件体积超上限且压缩受限"),
/** 身份证号校验位不通过 */
ID_CARD_CHECKSUM_FAILED(40004, "身份证号校验位不通过"),
/** 证件号与名册不一致 */
ID_CARD_MISMATCH_ROSTER(40005, "证件号与名册不一致"),
/** 疑似串档(转人工,不自动修正) */
SUSPECTED_WRONG_OWNER(40006, "疑似串档"),
/** 门禁未匹配——统一提示,不透露哪项错 */
GATE_NOT_MATCHED(40007, "没有找到你的信息,请核对后重试"),
/** 门禁尝试过于频繁(原 40007b) */
GATE_RATE_LIMITED(40071, "操作过于频繁,请稍后再试"),
/** 名单项已被他人绑定(原 40007c) */
GATE_ALREADY_BOUND(40072, "该信息已被登记"),
/** 任务已截止 / 已归档 / 已暂停 */
TASK_NOT_OPEN(40008, "任务已截止、已归档或已暂停"),
/** 重复提交(幂等键冲突) */
IDEMPOTENT_REPLAY(40009, "重复提交,已返回首次结果"),
/** 驳回次数超上限 */
REJECT_LIMIT_EXCEEDED(40010, "驳回次数已达上限"),
// ── 40011~40044 业务规则 ──────────────────────
/** 批量操作超过 200 条上限 */
BATCH_LIMIT_EXCEEDED(40011, "批量操作超过 200 条上限"),
/** 账号或密码错误(不透露账号是否存在) */
BAD_CREDENTIALS(40012, "账号或密码错误"),
/** 账号已锁定 */
ACCOUNT_LOCKED(40013, "账号已锁定"),
/** 用户有在办任务,不可停用 */
USER_HAS_ACTIVE_TASK(40014, "用户有在办任务,不可停用"),
/** 组织被占用,不可删除 */
ORG_OCCUPIED(40015, "组织被占用,不可删除"),
/** 名册为空,不可发布 */
ROSTER_EMPTY(40016, "名册为空,不可发布"),
/** 名册缺少「姓名」或「身份证号」列 */
ROSTER_MISSING_KEY_COLUMN(40017, "名册缺少「姓名」或「身份证号」列"),
/** 未配置必传材料项 */
NO_REQUIRED_MATERIAL(40018, "未配置必传材料项"),
/** 未设置覆盖班级或班主任 */
NO_TASK_ORG(40019, "未设置覆盖班级或班主任"),
/** 命名规则试跑未通过 */
NAMING_DRYRUN_FAILED(40020, "命名规则试跑未通过"),
/** 仅已发布任务可暂停 */
ONLY_PUBLISHED_CAN_PAUSE(40021, "仅已发布任务可暂停"),
/** 仅已暂停任务可恢复 */
ONLY_PAUSED_CAN_RESUME(40022, "仅已暂停任务可恢复"),
/** 存在待审单,不可归档 */
PENDING_AUDIT_EXISTS(40023, "存在待审单,不可归档"),
/** 报考范围未覆盖全部名册 */
PLAN_SCOPE_INCOMPLETE(40024, "报考范围未覆盖全部名册"),
/** 表单版本冲突 */
FORM_VERSION_CONFLICT(40025, "表单版本冲突"),
/** 内置字段不可删除 */
BUILTIN_FIELD_NOT_DELETABLE(40026, "内置字段不可删除"),
/** 材料项已有文件,不可删除 */
MATERIAL_HAS_FILES(40027, "材料项已有文件,不可删除"),
/** 无法自动定位表头行 */
HEADER_ROW_NOT_FOUND(40028, "无法自动定位表头行"),
/** 名册项已绑定/已提交,不可删除 */
ROSTER_ITEM_IN_USE(40029, "名册项已绑定或已提交,不可删除"),
/** 已进入审核,需先撤回才能删材料 */
AUDIT_IN_PROGRESS(40030, "已进入审核,需先撤回才能删材料"),
/** 已通过终审,学生不可撤回 */
FINAL_APPROVED_NO_WITHDRAW(40031, "已通过终审,不可撤回"),
/** 存在红色问题,不允许通过 */
RED_ISSUE_BLOCKS_APPROVE(40032, "存在红色问题,不允许通过"),
/** 已导出打包,不可撤回审核 */
EXPORTED_NO_WITHDRAW(40033, "已导出打包,不可撤回审核"),
/** 已归档,不可撤回 */
ARCHIVED_NO_WITHDRAW(40034, "已归档,不可撤回"),
/** 存在不合格文件,禁止导出 */
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
/** 加入申请次数超限 */
JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"),
/** 该证件号已有待审申请 */
JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"),
/** 该证件号已在名册中 */
ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"),
/** 申请状态已变更(非待审) */
JOIN_STATUS_CHANGED(40040, "申请状态已变更"),
/** 名册中该证件号已被占用 */
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
/** 申请已通过,不可再驳回 */
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
// ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */
UNAUTHORIZED(40101, "未登录或登录已失效"),
/** 无权限(含跨租户)(真 HTTP 403) */
FORBIDDEN(40301, "无权限访问"),
/** 签名 URL 已过期 */
SIGNED_URL_EXPIRED(40302, "签名 URL 已过期"),
/** 触发限流(真 HTTP 429) */
RATE_LIMITED(42901, "请求过于频繁,请稍后再试"),
// ── 50001~50007 服务端 ────────────────────────
// 降级原则:任何服务端能力失效都不得阻断学生提交
/** 文件处理失败(保留原图,允许重试) */
FILE_PROCESS_FAILED(50001, "文件处理失败,请重试"),
/** OCR 服务异常(降级为人工填写) */
OCR_FAILED(50002, "识别服务暂时不可用,请手动填写"),
/** 图片过大被熔断(转异步处理) */
IMAGE_TOO_LARGE_CIRCUIT_BREAK(50003, "图片过大,已转后台处理"),
/** VLM 服务异常(降级为不判) */
VLM_FAILED(50004, "辅助识别不可用"),
/** 导出打包失败(不重复计费) */
EXPORT_FAILED(50005, "导出打包失败,请重试"),
/** 通知发送失败(走降级链) */
NOTIFY_FAILED(50006, "通知发送失败"),
/** 存储写入失败(重试 3 次后提示用户重传) */
STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ──────────────────────────────────────
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
private final int code;
/** 默认提示文案(前端可覆盖)。 */
private final String msg;
ErrorCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
/**
* 该错误码应使用的 HTTP 状态码。
*
* <p>业务错误一律 200(§1.3:业务判定只看 {@code code}),
* 只有未登录 / 无权限 / 限流 / 5xxxx 才占用真 HTTP 状态码。
*/
public int httpStatus() {
return switch (this) {
case UNAUTHORIZED -> 401;
case FORBIDDEN -> 403;
case RATE_LIMITED -> 429;
default -> code >= 50000 ? 500 : 200;
};
}
/** 是否业务错误(前端需要展示提示)。 */
public boolean isBizError() {
return code >= 40000 && code < 50000;
}
}
@@ -0,0 +1,45 @@
package cn.tianbaotong.fill.common.api;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 分页响应(《03-接口设计》§1.4)。
*
* <p>⚠️ <b>分页结构直接放在 {@code data} 里</b>,不是 {@code data.page}:
*
* <pre>
* { "code": 0, "msg": "success", "data": { "list": [], "total": 128, "pageNum": 1, "pageSize": 20 } }
* </pre>
*
* @param <T> 列表元素类型
*/
@Getter
@Setter
public class PageResult<T> {
private List<T> list;
private long total;
private int pageNum;
private int pageSize;
public PageResult() {
}
public PageResult(List<T> list, long total, int pageNum, int pageSize) {
this.list = list;
this.total = total;
this.pageNum = pageNum;
this.pageSize = pageSize;
}
public static <T> PageResult<T> of(List<T> list, long total, int pageNum, int pageSize) {
return new PageResult<>(list, total, pageNum, pageSize);
}
public static <T> PageResult<T> empty(int pageNum, int pageSize) {
return new PageResult<>(List.of(), 0L, pageNum, pageSize);
}
}
@@ -0,0 +1,57 @@
package cn.tianbaotong.fill.common.api;
import java.util.List;
/**
* 校验问题清单(《03-接口设计》§1.3 的 {@code 40001} 标准结构)。
*
* <pre>
* {
* "canSubmit": false,
* "blocking": [ { "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表" } ],
* "warnings": [ { "materialCode": "PHOTO1", "ruleCode": "VAL-08", "title": "照片分辨率偏低" } ]
* }
* </pre>
*
* <p><b>blocking 与 warnings 的区别</b>(对应 §2.5 的红/黄分级):
* <ul>
* <li>{@code blocking} 红色——阻断提交,必须先处理</li>
* <li>{@code warnings} 黄色——可 {@code confirmWarning} 强制提交</li>
* </ul>
*/
public record ValidateProblem(
boolean canSubmit,
List<Item> blocking,
List<Item> warnings
) {
/**
* 单条问题。
*
* @param materialCode 材料编码,如 {@code SCHOOL_ROLL};非材料类问题为 null
* @param ruleCode 规则编号,如 {@code VAL-05};参数校验类问题为 null
* @param field 字段名,仅参数校验类问题使用(如 {@code phone})
* @param title 面向用户的描述
*/
public record Item(String materialCode, String ruleCode, String field, String title) {
/** 材料类问题。 */
public static Item ofMaterial(String materialCode, String ruleCode, String title) {
return new Item(materialCode, ruleCode, null, title);
}
/** 参数校验类问题。 */
public static Item ofField(String field, String title) {
return new Item(null, null, field, title);
}
}
/** 仅参数校验失败时使用(无材料上下文)。 */
public static ValidateProblem ofFieldErrors(List<Item> blocking) {
return new ValidateProblem(false, blocking, List.of());
}
public static ValidateProblem ofMaterialIssues(List<Item> blocking, List<Item> warnings) {
return new ValidateProblem(blocking.isEmpty(), blocking, warnings);
}
}
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.common.context;
import java.util.List;
import java.util.Set;
/**
* 当前登录用户(由 JWT 解出,见《03-接口设计》§1.2 的 JWT Payload)。
*
* <p><b>安全要点</b>:{@code tenantId} 只能来自这里(服务端解签),
* <b>绝不接受客户端通过参数传入</b>——否则越租户只需改一个数字。
*
* @param uid 用户 ID
* @param tenantId 租户 ID。<b>{@code 0} 表示平台运营</b>,是唯一可跨租户的身份
* @param orgIds 数据权限范围(本人 / 本班 / 本院系 / 全租户)
* @param roles 角色码数组
* @param userType {@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生
*/
public record LoginUser(
Long uid,
Long tenantId,
List<Long> orgIds,
Set<String> roles,
Integer userType
) {
/** 平台运营租户号,是唯一允许跨租户访问的 tenantId。 */
public static final long PLATFORM_TENANT_ID = 0L;
public boolean isPlatformAdmin() {
return tenantId != null && tenantId == PLATFORM_TENANT_ID;
}
public boolean hasRole(String roleCode) {
return roles != null && roles.contains(roleCode);
}
public boolean hasAnyRole(String... roleCodes) {
if (roles == null || roles.isEmpty()) {
return false;
}
for (String r : roleCodes) {
if (roles.contains(r)) {
return true;
}
}
return false;
}
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) {
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
return true;
}
return orgId != null && orgIds != null && orgIds.contains(orgId);
}
}
@@ -0,0 +1,64 @@
package cn.tianbaotong.fill.common.context;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
/**
* 当前请求的登录用户上下文。
*
* <p>由鉴权拦截器在请求进入时写入、请求结束时<b>必须清理</b>(见 {@link #clear()})。
* 线程池复用下若忘记清理,下一个请求会继承上一个用户的身份——这是最危险的越权漏洞之一。
*
* <p><b>为什么不直接用 Spring Security 的 SecurityContextHolder</b>:
* 本项目的权限模型是「多租户 + 组织树数据范围」,与 Spring Security 的
* Authority 模型不匹配;硬套要写大量自定义 Voter,不如自己维护一个明确的小上下文。
*/
public final class UserContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
private UserContext() {
}
public static void set(LoginUser user) {
HOLDER.set(user);
}
/** 取当前用户,未登录返回 {@code null}。 */
public static LoginUser get() {
return HOLDER.get();
}
/** 取当前用户,未登录直接抛 40101。 */
public static LoginUser require() {
LoginUser u = HOLDER.get();
if (u == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
return u;
}
public static Long uid() {
return require().uid();
}
/** 当前租户 ID。平台运营返回 {@code 0}。 */
public static Long tenantId() {
return require().tenantId();
}
public static boolean isPlatformAdmin() {
LoginUser u = HOLDER.get();
return u != null && u.isPlatformAdmin();
}
/**
* 请求结束时清理。
*
* <p>⚠️ 拦截器的 {@code afterCompletion} 里<b>务必</b>调用,且要放在 finally 语义的位置,
* 保证异常路径也会执行。
*/
public static void clear() {
HOLDER.remove();
}
}
@@ -0,0 +1,105 @@
package cn.tianbaotong.fill.common.crypto;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 敏感字段加密(AES-256-GCM)。
*
* <p>用于 {@code sys_user.id_card_no} / {@code phone} 等列的落库加密(《PRD》§10.3)。
*
* <p><b>为什么是 GCM 而不是 CBC</b>:GCM 自带完整性校验,密文被篡改会解密失败,
* 而不是解出一段看似合法但已被修改的明文。CBC 需要额外配 MAC,用错的人很多。
*
* <p><b>密文格式</b>:{@code Base64( IV(12B) || ciphertext || tag(16B) )}。
* IV 每次加密随机生成并前置——GCM 下 <b>IV 复用会直接泄露明文异或值</b>,绝不能固定 IV。
*
* <p><b>检索怎么办</b>:加密列无法做等值查询,检索走 {@link HmacIndex} 生成的 {@code *_hash} 列。
*/
public final class AesGcmCipher {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 12;
private static final int TAG_BITS = 128;
private static final int KEY_LENGTH_BYTES = 32;
private final SecretKey key;
private final SecureRandom random = new SecureRandom();
/**
* @param keyBytes 32 字节(256 位)密钥
*/
public AesGcmCipher(byte[] keyBytes) {
if (keyBytes == null || keyBytes.length != KEY_LENGTH_BYTES) {
throw new IllegalArgumentException(
"AES-256 需要 " + KEY_LENGTH_BYTES + " 字节密钥,实际 "
+ (keyBytes == null ? "null" : keyBytes.length + " 字节"));
}
this.key = new SecretKeySpec(keyBytes, "AES");
}
/**
* 从 Base64 密钥串构造。
*
* <p>生成密钥:{@code openssl rand -base64 32}
*/
public static AesGcmCipher fromBase64(String base64Key) {
return new AesGcmCipher(Base64.getDecoder().decode(base64Key));
}
/** 加密。{@code null} / 空串原样返回,避免把"没填"变成一段密文。 */
public String encrypt(String plain) {
if (plain == null || plain.isEmpty()) {
return plain;
}
try {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
Cipher c = Cipher.getInstance(TRANSFORMATION);
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] ct = c.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
throw new IllegalStateException("AES 加密失败", e);
}
}
/**
* 解密。
*
* <p>解密失败抛 {@link IllegalStateException}——这说明<b>数据损坏或密钥被换过</b>,
* 是真实的系统故障,不是业务错误,不该被当成"用户输入有问题"处理。
*/
public String decrypt(String encoded) {
if (encoded == null || encoded.isEmpty()) {
return encoded;
}
try {
byte[] all = Base64.getDecoder().decode(encoded);
if (all.length <= IV_LENGTH) {
throw new IllegalStateException("密文长度不足");
}
byte[] iv = new byte[IV_LENGTH];
System.arraycopy(all, 0, iv, 0, IV_LENGTH);
Cipher c = Cipher.getInstance(TRANSFORMATION);
c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] pt = c.doFinal(all, IV_LENGTH, all.length - IV_LENGTH);
return new String(pt, StandardCharsets.UTF_8);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("AES 解密失败(数据损坏或密钥不匹配)", e);
}
}
}
@@ -0,0 +1,68 @@
package cn.tianbaotong.fill.common.crypto;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
/**
* 敏感字段的确定性索引(HMAC-SHA256)。
*
* <p>加密列({@link AesGcmCipher})无法做等值查询——每次加密的 IV 都不同,同一个身份证号
* 两次加密得到的密文完全不一样,{@code WHERE id_card_no = ?} 永远匹配不到。
*
* <p>解法:<b>同时存一份 HMAC</b>(列名 {@code *_hash},{@code char(64)}),
* 检索与去重都走它。HMAC 是确定性的,同样的输入永远得到同样的输出。
*
* <p><b>为什么是 HMAC 而不是 SHA-256</b>:身份证号的取值空间很小
* (18 位、前 6 位是地区码、中间 8 位是生日、只剩最后 4 位不定),
* 裸 SHA-256 用彩虹表可以秒破。<b>加盐密钥(pepper)是必需的</b>,不是可选项。
*
* <p><b>门禁防枚举的配合</b>:{@code /mp/roster/match} 匹配失败时只落 hash、不落明文
* (见《03-接口设计》§5.4),即使数据库被拖走也不泄露身份证号。
*/
public final class HmacIndex {
private static final String ALGORITHM = "HmacSHA256";
private final SecretKeySpec key;
private final HexFormat hex = HexFormat.of();
/**
* @param secret HMAC 密钥。生成:{@code openssl rand -base64 48}
*/
public HmacIndex(byte[] secret) {
if (secret == null || secret.length < 16) {
throw new IllegalArgumentException("HMAC 密钥至少 16 字节,建议 32 字节以上");
}
this.key = new SecretKeySpec(secret, ALGORITHM);
}
public static HmacIndex fromBase64(String base64Key) {
return new HmacIndex(java.util.Base64.getDecoder().decode(base64Key));
}
/**
* 计算索引值,返回 <b>64 位小写十六进制</b>(与 DDL 的 {@code char(64)} 列对齐)。
*
* <p>入参先做规范化(去首尾空白 + 转大写)——身份证号末位可能是 {@code x} 或 {@code X},
* 不统一就会导致同一个人算出两个不同的 hash。
*/
public String hash(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim().toUpperCase();
if (normalized.isEmpty()) {
return null;
}
try {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(key);
byte[] out = mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8));
return hex.formatHex(out);
} catch (Exception e) {
throw new IllegalStateException("HMAC 计算失败", e);
}
}
}
@@ -0,0 +1,63 @@
package cn.tianbaotong.fill.common.crypto;
import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
/**
* 登录密码的传输解密(RSA)。
*
* <p>《03-接口设计》§5.1:密码由<b>前端 RSA 公钥加密后传输</b>,服务端用私钥解开再比对 BCrypt。
* 这不是替代 HTTPS,而是防止<b>日志与 APM 里出现明文密码</b>——网关、Nginx、链路追踪
* 都会记录请求体,明文密码一旦进日志就是永久泄露。
*
* <p><b>算法约定</b>:{@code RSA/ECB/PKCS1Padding},与前端 {@code jsencrypt} 的默认行为一致。
* 若前端改用 OAEP,这里必须同步改——两边不匹配的表现是"密码永远错",很难查。
*/
public final class RsaCipher {
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
private final PrivateKey privateKey;
/**
* @param pkcs8Base64 PKCS#8 格式私钥的 Base64(不含 PEM 头尾)
*/
public RsaCipher(String pkcs8Base64) {
try {
String cleaned = pkcs8Base64
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(cleaned);
this.privateKey = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (Exception e) {
throw new IllegalArgumentException("RSA 私钥格式不正确(需要 PKCS#8)", e);
}
}
/**
* 解密前端传来的密码。
*
* @param base64Cipher 前端加密后的 Base64 串
* @return 明文密码
* @throws IllegalArgumentException 密文非法(前端未加密、公钥不匹配、被截断)
*/
public String decrypt(String base64Cipher) {
if (base64Cipher == null || base64Cipher.isBlank()) {
throw new IllegalArgumentException("密码密文为空");
}
try {
byte[] cipherBytes = Base64.getDecoder().decode(base64Cipher.trim());
Cipher c = Cipher.getInstance(TRANSFORMATION);
c.init(Cipher.DECRYPT_MODE, privateKey);
return new String(c.doFinal(cipherBytes), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalArgumentException("密码解密失败", e);
}
}
}
@@ -0,0 +1,59 @@
package cn.tianbaotong.fill.common.exception;
import cn.tianbaotong.fill.common.api.ErrorCode;
/**
* 业务异常。所有可预期的业务失败都用它抛出,由 {@link GlobalExceptionHandler} 统一转成响应体。
*
* <p><b>不要用 {@code RuntimeException} 表达业务失败</b>——那样会走兜底的 50000,前端拿不到具体码。
*/
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
/** 需要随错误一起返回给前端的结构化数据(可为 null)。 */
private final transient Object payload;
public BizException(ErrorCode errorCode) {
super(errorCode.getMsg());
this.errorCode = errorCode;
this.payload = null;
}
public BizException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
this.payload = null;
}
public BizException(ErrorCode errorCode, String message, Object payload) {
super(message);
this.errorCode = errorCode;
this.payload = payload;
}
public BizException(ErrorCode errorCode, Object payload) {
super(errorCode.getMsg());
this.errorCode = errorCode;
this.payload = payload;
}
public ErrorCode getErrorCode() {
return errorCode;
}
public Object getPayload() {
return payload;
}
/**
* 业务异常不需要堆栈。
*
* <p>构造堆栈是 JVM 上最贵的操作之一,而业务失败(如"账号密码错误")
* 在限流/暴力破解场景下可能是高频路径。这里主动跳过填充。
*/
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
@@ -0,0 +1,152 @@
package cn.tianbaotong.fill.common.exception;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.ValidateProblem;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.ArrayList;
import java.util.List;
/**
* 全局异常处理。
*
* <p><b>两条铁律</b>:
* <ol>
* <li>业务错误一律 HTTP 200,只有未登录/无权限/限流/5xxxx 才占用真 HTTP 状态码(§1.3)</li>
* <li>任何异常都要变成标准响应体,<b>绝不把堆栈或内部细节泄露给前端</b></li>
* </ol>
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ── 业务异常 ──────────────────────────────────
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResult<Object>> handleBiz(BizException e, HttpServletRequest req) {
ErrorCode ec = e.getErrorCode();
log.info("业务异常 code={} uri={} msg={}", ec.getCode(), req.getRequestURI(), e.getMessage());
ApiResult<Object> body = e.getPayload() != null
? ApiResult.fail(ec, e.getMessage(), e.getPayload())
: ApiResult.fail(ec, e.getMessage());
return ResponseEntity.status(ec.httpStatus()).body(body);
}
// ── 参数校验 ──────────────────────────────────
/** {@code @RequestBody @Valid} 校验失败。 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleBodyInvalid(MethodArgumentNotValidException e) {
return badRequest(toFieldErrors(e.getBindingResult().getFieldErrors()));
}
/** 表单/查询参数绑定校验失败。 */
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleBind(BindException e) {
return badRequest(toFieldErrors(e.getBindingResult().getFieldErrors()));
}
/** {@code @Validated} 在方法参数上的校验失败。 */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleConstraint(ConstraintViolationException e) {
List<ValidateProblem.Item> items = e.getConstraintViolations().stream()
.map(v -> ValidateProblem.Item.ofField(lastNode(v.getPropertyPath().toString()), v.getMessage()))
.toList();
return badRequest(items);
}
/** 必填查询参数缺失。 */
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleMissingParam(MissingServletRequestParameterException e) {
return badRequest(List.of(ValidateProblem.Item.ofField(
e.getParameterName(), "缺少必填参数:" + e.getParameterName())));
}
/** 参数类型不匹配(如把 abc 传给 int64)。 */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return badRequest(List.of(ValidateProblem.Item.ofField(
e.getName(), "参数格式不正确:" + e.getName())));
}
/** 请求体不是合法 JSON。 */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResult<ValidateProblem>> handleUnreadable(HttpMessageNotReadableException e) {
log.warn("请求体解析失败: {}", e.getMessage());
return badRequest(List.of(ValidateProblem.Item.ofField(null, "请求体格式不正确")));
}
// ── 协议层 ────────────────────────────────────
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResult<Void>> handleMethod(HttpRequestMethodNotSupportedException e) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "请求方法不支持"));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResult<Void>> handleNoResource(NoResourceFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResult<Void>> handleUploadTooLarge(MaxUploadSizeExceededException e) {
return ResponseEntity.ok(ApiResult.fail(ErrorCode.FILE_TOO_LARGE));
}
// ── 兜底 ──────────────────────────────────────
/**
* 未预期异常。
*
* <p>⚠️ <b>对外只返回"系统繁忙"</b>,真实堆栈只进日志。
* 把异常详情返回给前端等于免费给攻击者做信息系统测绘。
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult<Void>> handleUnexpected(Exception e, HttpServletRequest req) {
log.error("未预期异常 uri={} method={}", req.getRequestURI(), req.getMethod(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResult.fail(ErrorCode.INTERNAL_ERROR));
}
// ── 工具 ──────────────────────────────────────
private static ResponseEntity<ApiResult<ValidateProblem>> badRequest(List<ValidateProblem.Item> items) {
return ResponseEntity.ok(ApiResult.fail(
ErrorCode.VALIDATION_BLOCKED,
ErrorCode.VALIDATION_BLOCKED.getMsg(),
ValidateProblem.ofFieldErrors(items)));
}
private static List<ValidateProblem.Item> toFieldErrors(List<FieldError> errors) {
List<ValidateProblem.Item> items = new ArrayList<>(errors.size());
for (FieldError fe : errors) {
items.add(ValidateProblem.Item.ofField(fe.getField(), fe.getDefaultMessage()));
}
return items;
}
/** 从 {@code login.username} 取 {@code username}。 */
private static String lastNode(String path) {
int i = path.lastIndexOf('.');
return i < 0 ? path : path.substring(i + 1);
}
}
@@ -0,0 +1,115 @@
package cn.tianbaotong.fill.common.util;
import java.security.SecureRandom;
/**
* 脱敏展示(《03-接口设计》§1.7)。
*
* <p><b>硬规则</b>:身份证号与手机号<b>一律不返回明文</b>,只返回脱敏值。
* 审核场景确实要看完整证件号时,走单独的 {@code GET /audit/submissions/{id}/idcard}
* 接口,并<b>单独记录审计日志</b>。
*
* <p>本类是纯函数,无状态。
*/
public final class MaskUtil {
private static final SecureRandom RANDOM = new SecureRandom();
/** 初始密码字符集:去掉了容易混淆的 0/O/1/l/I。 */
private static final String PWD_LOWER = "abcdefghijkmnpqrstuvwxyz";
private static final String PWD_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ";
private static final String PWD_DIGIT = "23456789";
private static final String PWD_SYMBOL = "!@#$%^&*";
private MaskUtil() {
}
/**
* 身份证号脱敏。
*
* <pre>
* 361029200912100010 → 3610292009******10
* </pre>
*
* <p>保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。
* 前 10 位保留是为了让审核人员能一眼核对地区与年份,又不暴露完整号码。
*/
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.isBlank()) {
return null;
}
String s = idCard.trim();
if (s.length() < 8) {
return "******";
}
int keepHead = Math.min(10, s.length() - 4);
return s.substring(0, keepHead) + "******" + s.substring(s.length() - 2);
}
/**
* 手机号脱敏。
*
* <pre>
* 13800138888 → 138****8888
* </pre>
*/
public static String maskPhone(String phone) {
if (phone == null || phone.isBlank()) {
return null;
}
String s = phone.trim();
if (s.length() < 8) {
return "****";
}
return s.substring(0, 3) + "****" + s.substring(s.length() - 4);
}
/**
* 姓名脱敏(用于公开列表等不需要全名的场景)。
*
* <pre>
* 曾伊豪 → 曾**
* 张三 → 张*
* </pre>
*/
public static String maskName(String name) {
if (name == null || name.isBlank()) {
return null;
}
String s = name.trim();
if (s.length() == 1) {
return s;
}
return s.charAt(0) + "*".repeat(s.length() - 1);
}
/**
* 生成初始密码(用于 {@code POST /users} 与批量导入)。
*
* <p>长度 8,<b>保证四类字符各至少一个</b>——很多系统的初始密码会因为随机
* 恰好没生成数字或符号而被弱密码策略拦下,返工很烦。
*/
public static String generateInitialPassword() {
StringBuilder sb = new StringBuilder(8);
sb.append(pick(PWD_LOWER));
sb.append(pick(PWD_UPPER));
sb.append(pick(PWD_DIGIT));
sb.append(pick(PWD_SYMBOL));
for (int i = 4; i < 8; i++) {
sb.append(pick(PWD_LOWER + PWD_UPPER + PWD_DIGIT));
}
// 打乱位置,避免"符号总在第 4 位"这种可预测模式
char[] arr = sb.toString().toCharArray();
for (int i = arr.length - 1; i > 0; i--) {
int j = RANDOM.nextInt(i + 1);
char t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
return new String(arr);
}
private static char pick(String s) {
return s.charAt(RANDOM.nextInt(s.length()));
}
}