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
+133
View File
@@ -0,0 +1,133 @@
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>cn.tianbaotong</groupId>
<artifactId>fill-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>数据信息填报系统</name>
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
<modules>
<module>xt-common</module>
<module>xt-server</module>
</modules>
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- ⚠️ 版本已在 Maven Central 核对,勿凭记忆改 -->
<mybatis-plus.version>3.5.17</mybatis-plus.version>
<jjwt.version>0.13.0</jjwt.version>
<hutool.version>5.8.47</hutool.version>
<easyexcel.version>4.0.3</easyexcel.version>
<!-- springdoc 3.x 对应 Boot 4;2.x 是给 Boot 3 的,别混 -->
<springdoc.version>3.1.1</springdoc.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- 本项目模块 -->
<dependency>
<groupId>cn.tianbaotong</groupId>
<artifactId>xt-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- MyBatis-Plus:Boot 4 专用 starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!--
⚠️ MP 3.5.9+ 把分页/多租户/防全表更新这些插件抽到了独立模块,
它们依赖 jsqlparser。不加这个,starter 能启动但用不了插件,
编译期报「找不到 TenantLineInnerInterceptor」。
两个变体:mybatis-plus-jsqlparser(jsqlparser 5.x,需 Java 11+)
mybatis-plus-jsqlparser-4.9(jsqlparser 4.9,兼容老项目)
本项目 Java 21,用前者。
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 图形验证码、加解密工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- Excel 导入导出 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!-- OpenAPI 文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>${maven.compiler.release}</release>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
+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()));
}
}
+133
View File
@@ -0,0 +1,133 @@
<?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-server</artifactId>
<name>xt-server · 主应用</name>
<description>Web 层与业务模块。图片处理服务在 M2 拆出,不在这里。</description>
<dependencies>
<dependency>
<groupId>cn.tianbaotong</groupId>
<artifactId>xt-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- ⚠️ Boot 4 改名:原来的 spring-boot-starter-aop 现在是 spring-boot-starter-aspectj -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Redis:验证码 / 登录锁定 / Token 黑名单 / 限流 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce 连接池需要 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- ORM(Boot 4 专用 starter) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<!-- 分页 / 多租户 / 防全表更新插件(MP 3.5.9+ 独立模块,见父 POM 注释) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!-- 数据库驱动 + 迁移 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!--
⚠️ Boot 4 + Flyway 的双重坑,两个都踩了才跑得起来:
1) 自动配置被拆成独立模块(spring-boot-flyway)。
只加 org.flywaydb:flyway-core 会「依赖在、但迁移不跑」,且不报任何错。
2) Flyway 10+ 把「数据库支持」也拆成了独立模块。
只加 starter(内含 flyway-core)会在启动时报 Unsupported Database: PostgreSQL。
所以 starter + flyway-database-postgresql 两个都要。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Excel 导入导出(用户/组织/名单) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
</dependency>
<!-- OpenAPI 文档(dev/test 环境启用) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,22 @@
package cn.tianbaotong.fill;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* 数据信息填报系统 · 主应用。
*
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@MapperScan("cn.tianbaotong.fill.module.**.mapper")
public class FillApplication {
public static void main(String[] args) {
SpringApplication.run(FillApplication.class, args);
}
}
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.common.crypto.AesGcmCipher;
import cn.tianbaotong.fill.common.crypto.HmacIndex;
import cn.tianbaotong.fill.common.crypto.RsaCipher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 加密组件装配。
*
* <p>密钥全部来自 {@link FillProperties},不硬编码,可用 KMS 注入(PRD §10.3「密钥托管于 KMS,禁止硬编码」)。
*/
@Configuration
public class CryptoConfig {
/** 证件号、手机号等敏感列的落库加密。 */
@Bean
public AesGcmCipher aesGcmCipher(FillProperties props) {
return AesGcmCipher.fromBase64(require(props.crypto().aesKey(), "fill.crypto.aes-key"));
}
/** 敏感列的等值检索与去重({@code *_hash} 列)。 */
@Bean
public HmacIndex hmacIndex(FillProperties props) {
return HmacIndex.fromBase64(require(props.crypto().hmacKey(), "fill.crypto.hmacKey"));
}
/** 登录密码的传输解密。 */
@Bean
public RsaCipher rsaCipher(FillProperties props) {
return new RsaCipher(require(props.rsa().privateKey(), "fill.rsa.private-key"));
}
/**
* 密码哈希。
*
* <p>BCrypt 强度用默认的 10。不调到 12+ 的原因:本系统登录接口有 5 次/分钟的限流,
* 暴力破解的瓶颈在限流而不在哈希耗时,调高只会让正常登录变慢、也更容易被
* 并发登录打满 CPU(BCrypt 是刻意设计成慢的,慢就是它的 DoS 面)。
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
private static String require(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"缺少配置 " + key + "。生产环境请通过环境变量注入,不要写进配置文件。");
}
return value;
}
}
@@ -0,0 +1,92 @@
package cn.tianbaotong.fill.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 本项目业务配置(前缀 {@code fill})。
*
* <p>密钥类字段({@code jwt.secret} / {@code crypto.*} / {@code rsa.private-key})
* 在 dev 走 {@code application-dev.yml}(开发专用,可入库),
* 在 test/prod <b>必须</b>走环境变量,见 {@code application-prod.yml}。
*
* @param jwt JWT 相关
* @param crypto 对称加密与 HMAC 索引
* @param rsa 登录密码传输解密
* @param security 登录安全策略
* @param upload 上传限制
*/
@ConfigurationProperties(prefix = "fill")
public record FillProperties(
Jwt jwt,
Crypto crypto,
Rsa rsa,
Security security,
Upload upload
) {
/**
* @param issuer 签发者,进 JWT 的 {@code iss}
* @param secret HMAC 密钥(Base64),用于 HS256 签名
* @param pcExpireSeconds PC 端有效期,默认 8 小时(§1.2)
* @param mpExpireSeconds 小程序端有效期,默认 7 天(§1.2)
*/
public record Jwt(
String issuer,
String secret,
Long pcExpireSeconds,
Long mpExpireSeconds
) {
public long pcExpireSecondsOrDefault() {
return pcExpireSeconds == null ? 28800L : pcExpireSeconds;
}
public long mpExpireSecondsOrDefault() {
return mpExpireSeconds == null ? 604800L : mpExpireSeconds;
}
}
/**
* @param aesKey AES-256 密钥(Base64,32 字节)。生成:{@code openssl rand -base64 32}
* @param hmacKey HMAC 密钥(Base64,≥32 字节)。生成:{@code openssl rand -base64 48}
*/
public record Crypto(String aesKey, String hmacKey) {
}
/**
* @param privateKey PKCS#8 私钥(单行 Base64,无 PEM 头尾)
*/
public record Rsa(String privateKey) {
}
/**
* @param loginMaxFail 连续失败几次锁定账号,默认 5(§1.6)
* @param loginLockMinutes 锁定时长(分钟),默认 15
* @param captchaAfterFails 连续失败几次后强制图形验证码,默认 2(§5.1)
*/
public record Security(
Integer loginMaxFail,
Integer loginLockMinutes,
Integer captchaAfterFails
) {
public int loginMaxFailOrDefault() {
return loginMaxFail == null ? 5 : loginMaxFail;
}
public int loginLockMinutesOrDefault() {
return loginLockMinutes == null ? 15 : loginLockMinutes;
}
public int captchaAfterFailsOrDefault() {
return captchaAfterFails == null ? 2 : captchaAfterFails;
}
}
/**
* @param maxFileSizeMb 单文件上传上限(MB),默认 50(PRD §10.1)
*/
public record Upload(Integer maxFileSizeMb) {
public int maxFileSizeMbOrDefault() {
return maxFileSizeMb == null ? 50 : maxFileSizeMb;
}
}
}
@@ -0,0 +1,97 @@
package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Set;
/**
* MyBatis-Plus 插件装配。
*
* <p><b>插件顺序不能乱</b>:多租户 → 分页 → 防全表更新/删除。
* 多租户必须在分页之前——否则分页的 count 语句会漏掉租户条件,
* 表现为"列表条数比实际多",很难发现。
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1) 多租户:自动给租户表追加 tenant_id 条件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new FillTenantLineHandler()));
// 2) 分页
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
// 3) 防全表更新/删除:没有 WHERE 的 UPDATE/DELETE 一律拦下
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
/**
* 租户条件注入器。
*
* <p><b>安全姿态:默认拒绝</b>。若当前请求没有登录用户,且 SQL 触及租户表,
* {@link #getTenantId()} 会直接抛 {@code 40101},而不是"不加条件放行"。
*
* <p>这样设计的原因:漏加租户条件 = 越租户泄露全校身份证号,是不可接受的失败模式。
* 宁可让开发者在写匿名接口时立刻撞墙,也不能让漏洞悄悄上线。
* 确实需要跨租户查询的匿名接口(如登录、门禁),必须显式加
* {@code @InterceptorIgnore(tenantLine = "true")} —— 显式声明本身就是一次代码审查点。
*/
static class FillTenantLineHandler implements TenantLineHandler {
/**
* 没有 {@code tenant_id} 列的表,必须排除,否则 SQL 会直接报错。
*
* <p>新增表时<b>务必回来看这里</b>:只有确实不带租户维度的全局表才能加进来,
* 业务表永远不加——业务表不加租户条件就是越权漏洞。
*/
private static final Set<String> NO_TENANT_TABLES = Set.of(
"sys_tenant", // 租户表自身:主键 id 就是租户标识,没有 tenant_id 列
"sys_occupation_ref", // 职业工种参考字典:全局共享,PRD 定位为「仅拼写校验,非约束」
"flyway_schema_history"
);
@Override
public Expression getTenantId() {
LoginUser user = UserContext.get();
if (user == null || user.tenantId() == null) {
// 匿名请求触及租户表 —— 默认拒绝
throw new BizException(ErrorCode.UNAUTHORIZED);
}
return new LongValue(user.tenantId());
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
String t = tableName == null ? "" : tableName.toLowerCase();
if (NO_TENANT_TABLES.contains(t)) {
return true;
}
// 平台运营(tenant_id = 0)是唯一允许跨租户的身份
LoginUser user = UserContext.get();
return user != null && user.isPlatformAdmin();
}
}
}
@@ -0,0 +1,50 @@
package cn.tianbaotong.fill.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* OpenAPI 文档({@code /doc.html})。
*
* <p><b>⚠️ 生产环境必须关闭</b>——文档页面会把全部接口、字段、枚举、
* 错误码结构暴露出来,等于给攻击者一份完整的信息系统测绘报告。
* 在 {@code application-prod.yml} 里设 {@code springdoc.api-docs.enabled=false}
* 与 {@code springdoc.swagger-ui.enabled=false}。
*/
@Configuration
public class OpenApiConfig {
private static final String SECURITY_SCHEME = "bearerAuth";
@Bean
public OpenAPI fillOpenApi() {
return new OpenAPI()
.info(new Info()
.title("数据信息填报系统 API")
.version("1.0.0")
.description("""
职业技能等级认定 · 报名材料收集 SaaS
**一期范围**:学校收集 + 自主认定。
社会收集(`collectMode=2`)与社会评价 / 专项职业能力(`certType=2/3`)
本期不启用,传入返回 `40044`。
**响应约定**:业务错误一律 HTTP 200,判定看 `code`;
只有 `40101` / `40301` / `42901` 用真 HTTP 状态码。
""")
.contact(new Contact().name("天宝通")))
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME))
.components(new Components().addSecuritySchemes(SECURITY_SCHEME,
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("登录接口返回的 token,填在这里即可调试后续接口")));
}
}
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* 全链路追踪 ID。
*
* <p>PRD §10.5 要求「traceId 贯穿上传 → 处理 → 校验 → 审核 → 导出」。
* 这里只做最基础的一环:每个请求分配 ID 并塞进日志 MDC,
* 之后文件处理与打包是把同一个 ID 透传下去。
*
* <p>ID 也回写到响应头 {@code X-Trace-Id},这样前端遇到问题时能把 ID 报给客服,
* 客服直接按 ID 捞日志——比让学生描述"我点了个蓝色的按钮然后卡住了"高效得多。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID = "traceId";
private static final String HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String traceId = request.getHeader(HEADER);
if (traceId == null || traceId.isBlank() || traceId.length() > 64) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 24);
}
MDC.put(TRACE_ID, traceId);
response.setHeader(HEADER, traceId);
try {
chain.doFilter(request, response);
} finally {
// 线程复用下不清会串号
MDC.remove(TRACE_ID);
}
}
}
@@ -0,0 +1,53 @@
package cn.tianbaotong.fill.config;
import cn.tianbaotong.fill.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web 层装配。
*
* <p>拦截器只挂在 {@code /api/**} 上:{@code /actuator/**} 与文档页面
* {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权,
* 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 只靠 @PublicApi 一个机制放行匿名接口,不做路径白名单——
// 两套机制会让「哪些接口是公开的」出现两个来源,改一处漏一处。
// 忘了标注的公开接口会立刻 401,是安全的失败方向。
registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
}
/**
* 跨域配置。
*
* <p><b>⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}</b>——写 {@code *} 配合
* {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行
* 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的
* {@code fill.cors.allowed-origins}。
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns(
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.*.*:*"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Trace-Id")
.allowCredentials(true)
.maxAge(3600);
}
}
@@ -0,0 +1,60 @@
package cn.tianbaotong.fill.module.auth.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
import cn.tianbaotong.fill.module.auth.service.AuthService;
import cn.tianbaotong.fill.module.auth.service.CaptchaService;
import cn.tianbaotong.fill.security.AuthInterceptor;
import cn.tianbaotong.fill.security.PublicApi;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证接口(《03-接口设计》§5.1 / §5.2 / §5.9)。
*/
@Tag(name = "认证", description = "登录、登出、当前用户")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final CaptchaService captchaService;
@PublicApi
@PostMapping("/login")
@Operation(summary = "PC 登录", description = "密码需 RSA 公钥加密后传输;连续失败 2 次后需图形验证码")
public ApiResult<LoginVO> login(@Valid @RequestBody LoginReq req, HttpServletRequest httpReq) {
return ApiResult.ok(authService.login(req, httpReq));
}
@PublicApi
@GetMapping("/captcha")
@Operation(summary = "获取图形验证码", description = "返回 key 与 base64 图片;有效期 5 分钟,校验一次即失效")
public ApiResult<CaptchaService.CaptchaVO> captcha() {
return ApiResult.ok(captchaService.generate());
}
@GetMapping("/me")
@Operation(summary = "当前用户", description = "返回用户、租户、角色、菜单与细粒度权限码")
public ApiResult<LoginVO> me() {
return ApiResult.ok(authService.me());
}
@PostMapping("/logout")
@Operation(summary = "登出", description = "把 token 加入黑名单,TTL 到其自然过期为止")
public ApiResult<Void> logout(HttpServletRequest request) {
Object token = request.getAttribute(AuthInterceptor.ATTR_TOKEN);
authService.logout(token == null ? null : String.valueOf(token));
return ApiResult.ok();
}
}
@@ -0,0 +1,51 @@
package cn.tianbaotong.fill.module.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* PC 登录请求(《03-接口设计》§5.1)。
*/
@Getter
@Setter
@Schema(description = "PC 登录请求")
public class LoginReq {
@NotBlank(message = "请输入账号")
@Size(max = 64, message = "账号过长")
@Schema(description = "登录账号", example = "admin", requiredMode = Schema.RequiredMode.REQUIRED)
private String username;
/**
* 密码。
*
* <p>⚠️ 传的是<b>RSA 公钥加密后的 Base64</b>,不是明文。
* 目的不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志。
*/
@NotBlank(message = "请输入密码")
@Schema(description = "密码(RSA 公钥加密后的 Base64,非明文)", requiredMode = Schema.RequiredMode.REQUIRED)
private String password;
/**
* 租户码。
*
* <p><b>本项目对文档的补充字段</b>:{@code sys_user} 的账号唯一性是
* {@code (tenant_id, username)} 而非全局,所以同一个 {@code admin}
* 可能存在于多所学校。此时必须由客户端指明租户,服务端拒绝"随便挑一条"。
* 当该账号在系统中唯一时本字段可省略。
*/
@Size(max = 32, message = "租户码过长")
@Schema(description = "租户码(账号在多个租户下重名时必填)", example = "demo")
private String tenantCode;
@Size(max = 64)
@Schema(description = "图形验证码 key(连续失败 2 次后必填)")
private String captchaKey;
@Size(max = 8)
@Schema(description = "图形验证码")
private String captchaCode;
}
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.module.auth.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
import java.util.Set;
/**
* 登录响应(《03-接口设计》§5.1 / §5.2 共用)。
*
* <p>{@code GET /auth/me} 返回的是同一个对象,只是不带 {@code token}。
* 复用同一个 VO 的好处是前端只需一份解析逻辑。
*/
@Getter
@Setter
public class LoginVO {
/** 仅登录时返回;{@code /auth/me} 为 null。 */
private String token;
/** 有效期秒数。PC 默认 28800(8 小时)。 */
private Long expireSeconds;
private UserInfo user;
private TenantInfo tenant;
private List<String> roleCodes;
/** 菜单键集合,前端据此渲染导航。 */
private Set<String> menus;
/** 细粒度权限码,前端据此控制按钮显隐。 */
private Set<String> permissions;
@Getter
@Setter
public static class UserInfo {
private Long id;
private String realName;
private Integer userType;
private String avatarUrl;
private Long orgId;
private String username;
}
@Getter
@Setter
public static class TenantInfo {
private Long id;
private String tenantCode;
private String tenantName;
private String logoUrl;
}
}
@@ -0,0 +1,303 @@
package cn.tianbaotong.fill.module.auth.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.crypto.RsaCipher;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.auth.dto.LoginReq;
import cn.tianbaotong.fill.module.auth.dto.LoginVO;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import cn.tianbaotong.fill.module.log.mapper.LoginLogMapper;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.security.PermissionChecker;
import cn.tianbaotong.fill.security.TokenBlacklist;
import cn.tianbaotong.fill.support.WebUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
/**
* 认证服务:登录、登出、当前用户。
*/
@Slf4j
@Service
public class AuthService {
private static final int LOGIN_TYPE_PC = 1;
private final UserMapper userMapper;
private final UserRoleMapper userRoleMapper;
private final TenantMapper tenantMapper;
private final LoginLogMapper loginLogMapper;
private final LoginGuard loginGuard;
private final CaptchaService captchaService;
private final RsaCipher rsaCipher;
private final PasswordEncoder passwordEncoder;
private final cn.tianbaotong.fill.security.JwtService jwtService;
private final TokenBlacklist tokenBlacklist;
private final PermissionChecker permissionChecker;
/**
* 等时比较用的假哈希。
*
* <p><b>为什么需要</b>:BCrypt 比对约 50~100ms。若"账号不存在"时直接 return,
* 而"账号存在但密码错"时要跑完 BCrypt,攻击者用响应时间就能判断账号是否存在
* ——这就是账号枚举漏洞,会直接把后续的密码喷洒成功率提高几个数量级。
*
* <p>做法:账号不存在时也拿这个假哈希跑一次比对,把两条路径的耗时拉平。
*/
private final String dummyHash;
public AuthService(UserMapper userMapper,
UserRoleMapper userRoleMapper,
TenantMapper tenantMapper,
LoginLogMapper loginLogMapper,
LoginGuard loginGuard,
CaptchaService captchaService,
RsaCipher rsaCipher,
PasswordEncoder passwordEncoder,
cn.tianbaotong.fill.security.JwtService jwtService,
TokenBlacklist tokenBlacklist,
PermissionChecker permissionChecker) {
this.userMapper = userMapper;
this.userRoleMapper = userRoleMapper;
this.tenantMapper = tenantMapper;
this.loginLogMapper = loginLogMapper;
this.loginGuard = loginGuard;
this.captchaService = captchaService;
this.rsaCipher = rsaCipher;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.tokenBlacklist = tokenBlacklist;
this.permissionChecker = permissionChecker;
this.dummyHash = passwordEncoder.encode("this-is-not-a-real-password");
}
// ── 登录 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public LoginVO login(LoginReq req, HttpServletRequest httpReq) {
String account = req.getUsername().trim();
// 1) 锁定检查 —— 放在最前,锁定期间不做任何密码校验
if (loginGuard.isLocked(account)) {
long remain = loginGuard.lockRemainingSeconds(account);
writeLog(null, null, account, 0, "LOCKED", httpReq);
throw new BizException(ErrorCode.ACCOUNT_LOCKED,
"账号已锁定,请 " + Math.max(1, remain / 60 + 1) + " 分钟后再试");
}
// 2) 图形验证码 —— 连续失败后才要求(§5.1)
if (loginGuard.captchaRequired(account)
&& !captchaService.verify(req.getCaptchaKey(), req.getCaptchaCode())) {
writeLog(null, null, account, 0, "CAPTCHA_FAIL", httpReq);
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "验证码不正确或已过期");
}
// 3) 定位用户(账号唯一性是 (tenant_id, username),可能跨租户重名)
SysUser user = locateUser(req, account);
// 4) 密码校验
String plainPassword;
try {
plainPassword = rsaCipher.decrypt(req.getPassword());
} catch (IllegalArgumentException e) {
// 前端未加密 / 公钥不匹配 / 密文被截断,都走同一条失败路径
plainPassword = null;
}
boolean passwordOk = passwordMatches(plainPassword, user);
if (user == null || !passwordOk) {
loginGuard.recordFailure(account);
int remainFail = Math.max(0,
loginGuardMax() - loginGuard.failCount(account));
writeLog(user == null ? null : user.getTenantId(),
user == null ? null : user.getId(), account, 0,
user == null ? "NO_SUCH_USER" : "BAD_PASSWORD", httpReq);
// 统一提示,不透露是账号不存在还是密码错(§5.1)
throw new BizException(ErrorCode.BAD_CREDENTIALS);
}
if (user.getStatus() != null && user.getStatus() == 0) {
writeLog(user.getTenantId(), user.getId(), account, 0, "DISABLED", httpReq);
throw new BizException(ErrorCode.BAD_CREDENTIALS, "账号已停用,请联系管理员");
}
// 5) 密码已通过,先把租户上下文设上。
// 后面查角色、查租户、写日志都会走正常的租户过滤,不需要再逐个加 @InterceptorIgnore。
// 注意这里先放一个「只有租户、没有角色」的临时上下文,拿到角色后再升级为完整的。
UserContext.set(new LoginUser(user.getId(), user.getTenantId(),
List.of(), Set.of(), user.getUserType()));
loginGuard.clear(account);
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
LoginUser principal = new LoginUser(
user.getId(), user.getTenantId(),
user.getOrgId() == null ? List.of() : List.of(user.getOrgId()),
Set.copyOf(roleCodes),
user.getUserType());
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
SysUser touch = new SysUser();
touch.setId(user.getId());
touch.setLastLoginTime(java.time.LocalDateTime.now());
userMapper.updateById(touch);
writeLog(user.getTenantId(), user.getId(), account, 1, null, httpReq);
LoginVO vo = buildProfile(user, roleCodes);
vo.setToken(token);
vo.setExpireSeconds(jwtService.expireSecondsOf(
cn.tianbaotong.fill.security.JwtService.Client.PC));
return vo;
}
// ── 当前用户 ──────────────────────────────────
public LoginVO me() {
LoginUser principal = UserContext.require();
SysUser user = userMapper.selectById(principal.uid());
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
LoginVO vo = buildProfile(user, roleCodes);
vo.setPermissions(permissionChecker.currentPermissions());
return vo;
}
// ── 登出 ──────────────────────────────────────
public void logout(String token) {
if (token == null || token.isBlank()) {
return;
}
long remain = jwtService.remainingSeconds(token);
// 只把 token 拉黑到它自然过期为止,之后 JWT 自己就失效了
tokenBlacklist.add(token, remain);
}
// ── 内部 ──────────────────────────────────────
/**
* 定位用户。
*
* <p>三种情况:
* <ol>
* <li>客户端给了租户码 → 精确定位</li>
* <li>该账号全局唯一 → 直接使用</li>
* <li>该账号在多个租户下重名 → <b>拒绝登录</b>并提示补租户码</li>
* </ol>
* 第 3 条绝不"随便挑一条"——那会让用户登进别人的学校。
*/
private SysUser locateUser(LoginReq req, String account) {
List<SysUser> candidates;
if (req.getTenantCode() != null && !req.getTenantCode().isBlank()) {
candidates = userMapper.selectByTenantCodeAndUsername(req.getTenantCode().trim(), account);
} else {
candidates = userMapper.selectByUsernameAcrossTenants(account);
}
if (candidates.isEmpty()) {
return null;
}
if (candidates.size() > 1) {
throw new BizException(ErrorCode.BAD_CREDENTIALS,
"该账号存在于多个单位,请填写租户码后再登录");
}
return candidates.get(0);
}
/** 密码比对。用户不存在时也要跑一次 BCrypt,拉平耗时(防账号枚举)。 */
private boolean passwordMatches(String plainPassword, SysUser user) {
if (user == null || user.getPassword() == null) {
passwordEncoder.matches("dummy-input-for-timing", dummyHash);
return false;
}
if (plainPassword == null) {
passwordEncoder.matches("dummy-input-for-timing", dummyHash);
return false;
}
return passwordEncoder.matches(plainPassword, user.getPassword());
}
private int loginGuardMax() {
return 5;
}
private LoginVO buildProfile(SysUser user, List<String> roleCodes) {
LoginVO vo = new LoginVO();
LoginVO.UserInfo ui = new LoginVO.UserInfo();
ui.setId(user.getId());
ui.setUsername(user.getUsername());
ui.setRealName(user.getRealName());
ui.setUserType(user.getUserType());
ui.setAvatarUrl(user.getAvatarUrl());
ui.setOrgId(user.getOrgId());
vo.setUser(ui);
SysTenant tenant = tenantMapper.selectById(user.getTenantId());
if (tenant != null) {
LoginVO.TenantInfo ti = new LoginVO.TenantInfo();
ti.setId(tenant.getId());
ti.setTenantCode(tenant.getTenantCode());
ti.setTenantName(tenant.getTenantName());
ti.setLogoUrl(tenant.getLogoUrl());
vo.setTenant(ti);
}
vo.setRoleCodes(roleCodes);
vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
return vo;
}
/**
* 写登录日志。
*
* <p>⚠️ <b>失败原因只记类别</b>({@code NO_SUCH_USER} / {@code BAD_PASSWORD} / {@code LOCKED}),
* <b>绝不记密码</b>,也<b>绝不把失败原因返回给客户端</b>。
* 库里记细是为了给安全分析用;接口返回粗是为了不给攻击者反馈。
*/
private void writeLog(Long tenantId, Long userId, String username,
int success, String failReason, HttpServletRequest req) {
try {
SysLoginLog row = new SysLoginLog();
row.setTenantId(tenantId == null ? 0L : tenantId);
row.setUserId(userId);
row.setUsername(username == null ? null
: (username.length() <= 64 ? username : username.substring(0, 64)));
row.setLoginType(LOGIN_TYPE_PC);
row.setSuccess(success);
row.setFailReason(failReason);
row.setIp(WebUtil.clientIp(req));
row.setUserAgent(WebUtil.userAgent(req));
row.setTraceId(MDC.get("traceId"));
loginLogMapper.insertIgnoreTenant(row);
} catch (Exception e) {
// 日志写失败绝不能影响登录本身——否则日志表满了就没人能登录了
log.warn("写登录日志失败: {}", e.getMessage());
}
}
/** 供 {@code /auth/me} 复用的租户查询条件(避免未使用告警)。 */
@SuppressWarnings("unused")
private static com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysTenant> tenantByCode(String code) {
return Wrappers.<SysTenant>lambdaQuery().eq(SysTenant::getTenantCode, code);
}
}
@@ -0,0 +1,74 @@
package cn.tianbaotong.fill.module.auth.service;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.UUID;
/**
* 图形验证码。
*
* <p><b>为什么需要它</b>:登录接口的 {{@code 40012} 提示"账号或密码错误"本身是安全的
* (不区分账号是否存在),但攻击者可以拿一个密码字典跑几万个账号——
* 没有验证码的话,密码喷洒(password spraying)几乎零成本。
*
* <p><b>为什么只在连续失败后才要求</b>(§5.1「连续失败 2 次后必填」):
* 一上来就要求验证码,会让正常用户每次登录都多一步;
* 而攻击者一旦开始喷洒,第 3 次起就会撞上验证码。
*/
@Service
@RequiredArgsConstructor
public class CaptchaService {
private static final String KEY_PREFIX = "xt:captcha:";
private static final Duration TTL = Duration.ofMinutes(5);
private final StringRedisTemplate redis;
/** 生成验证码,返回 key 与图片。 */
public CaptchaVO generate() {
// 4 位字符、20 条干扰线:够挡住脚本,又不至于让老师看不清
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(140, 44, 4, 20);
String key = UUID.randomUUID().toString().replace("-", "");
redis.opsForValue().set(KEY_PREFIX + key, captcha.getCode().toLowerCase(), TTL);
return new CaptchaVO(key, captcha.getImageBase64Data(), TTL.toSeconds());
}
/**
* 校验验证码。
*
* <p><b>无论成功失败都立即删除</b>——一是防重放(同一张验证码不可能用两次),
* 二是防爆破(攻击者不能拿同一张图反复试)。
*/
public boolean verify(String key, String code) {
if (key == null || key.isBlank() || code == null || code.isBlank()) {
return false;
}
String redisKey = KEY_PREFIX + key;
String expected = redis.opsForValue().get(redisKey);
redis.delete(redisKey);
return expected != null && expected.equalsIgnoreCase(code.trim());
}
@Getter
@Setter
public static class CaptchaVO {
/** 提交登录时回传该 key。 */
private final String captchaKey;
/** 形如 {@code data:image/png;base64,...},前端可直接放进 img 的 src。 */
private final String image;
private final long expireSeconds;
public CaptchaVO(String captchaKey, String image, long expireSeconds) {
this.captchaKey = captchaKey;
this.image = image;
this.expireSeconds = expireSeconds;
}
}
}
@@ -0,0 +1,93 @@
package cn.tianbaotong.fill.module.auth.service;
import cn.tianbaotong.fill.config.FillProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 登录失败计数与账号锁定(《03-接口设计》§1.6:账号锁定 5 次)。
*
* <p><b>为什么锁定要按账号而不是按 IP</b>:IP 可以换(代理池),
* 但攻击者想打的那个账号是固定的。按账号锁才能真正保护目标账号。
* 反过来,纯按 IP 锁会误伤整个学校的出口 NAT——一所学校几百人共用一个公网 IP。
*
* <p><b>为什么用 Redis 而不是内存</b>:多实例部署时内存计数各算各的,
* 5 次变 5×N 次;且重启就清零。
*
* <p><b>计数器与锁分开存</b>:计数器的 TTL 是滑动的(一直失败就一直存在),
* 锁的 TTL 是固定的(锁 15 分钟到点自动解)。合成一个 key 会导致
* "锁到一半时又被新的失败续期",变成永久锁定。
*/
@Service
@RequiredArgsConstructor
public class LoginGuard {
private static final String FAIL_PREFIX = "xt:login:fail:";
private static final String LOCK_PREFIX = "xt:login:lock:";
private final StringRedisTemplate redis;
private final FillProperties props;
/** 账号是否处于锁定状态。 */
public boolean isLocked(String account) {
return Boolean.TRUE.equals(redis.hasKey(LOCK_PREFIX + normalize(account)));
}
/** 剩余锁定秒数,未锁定返回 0。 */
public long lockRemainingSeconds(String account) {
Long ttl = redis.getExpire(LOCK_PREFIX + normalize(account));
return ttl == null || ttl < 0 ? 0 : ttl;
}
/** 当前连续失败次数。 */
public int failCount(String account) {
String v = redis.opsForValue().get(FAIL_PREFIX + normalize(account));
if (v == null) {
return 0;
}
try {
return Integer.parseInt(v);
} catch (NumberFormatException e) {
return 0;
}
}
/** 是否已需要图形验证码。 */
public boolean captchaRequired(String account) {
return failCount(account) >= props.security().captchaAfterFailsOrDefault();
}
/**
* 记一次失败。达到阈值则加锁。
*
* <p>计数器 TTL 取「锁定窗口 × 2」,即从最后一次失败起算,
* 若在该窗口内不再尝试,失败次数自然清零。
*/
public void recordFailure(String account) {
String key = FAIL_PREFIX + normalize(account);
Long n = redis.opsForValue().increment(key);
int lockMinutes = props.security().loginLockMinutesOrDefault();
redis.expire(key, Duration.ofMinutes((long) lockMinutes * 2));
if (n != null && n >= props.security().loginMaxFailOrDefault()) {
redis.opsForValue().set(LOCK_PREFIX + normalize(account), "1",
Duration.ofMinutes(lockMinutes));
// 加锁后清掉计数器:否则解锁瞬间用户只要再错 1 次就又被锁,
// 表现为"解锁后只能试一次",体验极差。
redis.delete(key);
}
}
/** 登录成功后清零。 */
public void clear(String account) {
redis.delete(FAIL_PREFIX + normalize(account));
redis.delete(LOCK_PREFIX + normalize(account));
}
private static String normalize(String account) {
return account == null ? "" : account.trim().toLowerCase();
}
}
@@ -0,0 +1,59 @@
package cn.tianbaotong.fill.module.log.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 登录日志(对应 {@code sys_login_log})。
*
* <p>⚠️ <b>不继承 {@code BaseEntity}</b>:日志表是<b>只增不改</b>的,
* 既没有 {@code update_by/update_time},也没有 {@code deleted}——
* 日志加软删除没有业务含义("删除一条登录记录"本身就是审计事故),
* 且多两个列会让每次插入都写无意义的数据。
*
* <p>{@code tenant_id} 也不自动填充:登录失败时可能根本不知道是哪个租户,
* 或用户根本不存在。这里保留可空 + 默认 0,让库自己兜底。
*/
@Getter
@Setter
@TableName("sys_login_log")
public class SysLoginLog {
@TableId(type = IdType.AUTO)
private Long id;
/** {@code 0} 表示平台或未识别到租户。 */
private Long tenantId;
private Long userId;
/** 冗余存登录名:用户可能不存在(输错账号),也要能审计到"谁在什么时候试了什么账号"。 */
private String username;
/** {@code 1} PC / {@code 2} 小程序 / {@code 3} 开放接口。 */
private Integer loginType;
/** {@code 0} 失败 / {@code 1} 成功。 */
private Integer success;
/**
* 失败原因。
*
* <p>⚠️ <b>绝不记录密码,也绝不记录"密码错了"与"账号不存在"的区别</b>——
* 这份日志能覆盖到账号枚举攻击的完整清单,本身就是高敏感数据。
*/
private String failReason;
private String ip;
private String userAgent;
private String traceId;
private LocalDateTime createTime;
}
@@ -0,0 +1,64 @@
package cn.tianbaotong.fill.module.log.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 操作日志(对应 {@code sys_operation_log})。只增不改,理由同 {@link SysLoginLog}。
*
* <p>PRD §10.3 要求记录:登录、<b>查看明文</b>、下载、导出、代传、删除。
* 「查看明文」单独记一条是刻意的——身份证号明文是被内部人泄露的首要风险点,
* 有这个日志才能回答"谁在什么时候看了谁的材料"。
*/
@Getter
@Setter
@TableName("sys_operation_log")
public class SysOperationLog {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private String username;
/** 业务模块,如 {@code tenant} / {@code org} / {@code user} / {@code task}。 */
private String module;
/** 动作,如 {@code create} / {@code update} / {@code delete} / {@code export} / {@code approve}。 */
private String action;
private String requestMethod;
private String requestUri;
/**
* 请求参数。
*
* <p>⚠️ <b>入库前必须脱敏</b>:去掉 {@code password}、证件号明文等。
* 操作日志的目的是追责,不是复制一份业务数据——把敏感信息抄进来
* 等于给系统加了一个无鉴权的高危数据副本。
*/
private String requestParam;
/** 业务返回码,用于区分成功与业务失败。 */
private Integer bizCode;
/** 耗时毫秒。 */
private Integer durationMs;
private String ip;
private String userAgent;
private String traceId;
private LocalDateTime createTime;
}
@@ -0,0 +1,31 @@
package cn.tianbaotong.fill.module.log.mapper;
import cn.tianbaotong.fill.module.log.entity.SysLoginLog;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
/**
* 写一条登录日志(<b>跳过租户拦截</b>)。
*
* <p>为什么必须绕过:登录失败时根本没有登录上下文,
* 多租户拦截器会为 INSERT 补 {@code tenant_id} 列,而它拿不到值 → 抛 40101。
* 结果就是「越需要审计的场景(登录失败/账号不存在)越写不进日志」,
* 把审计能力丢在最关键的地方。
*
* <p>风险可控:这里由 AuthService 显式传入 {@code tenantId}(可能为 0 或 null),
* 不依赖任何隐式上下文,所以绕过拦截器不会引入越权。
*/
@InterceptorIgnore(tenantLine = "true")
@Insert("""
INSERT INTO sys_login_log
(tenant_id, user_id, username, login_type, success, fail_reason, ip, user_agent, trace_id, create_time)
VALUES
(#{tenantId}, #{userId}, #{username}, #{loginType}, #{success}, #{failReason}, #{ip}, #{userAgent}, #{traceId}, CURRENT_TIMESTAMP)
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnoreTenant(SysLoginLog log);
}
@@ -0,0 +1,7 @@
package cn.tianbaotong.fill.module.log.mapper;
import cn.tianbaotong.fill.module.log.entity.SysOperationLog;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
}
@@ -0,0 +1,67 @@
package cn.tianbaotong.fill.module.org.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.org.dto.OrgTreeVO;
import cn.tianbaotong.fill.module.org.dto.OrgUpsertReq;
import cn.tianbaotong.fill.module.org.service.OrgService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 组织架构(《03-接口设计》§6.1~§6.3)。
*/
@Tag(name = "组织架构")
@RestController
@RequestMapping("/api/v1/orgs")
@RequiredArgsConstructor
public class OrgController {
private final OrgService orgService;
@GetMapping("/tree")
@RequirePerm(PermissionDef.ORG_VIEW)
@Operation(summary = "组织树", description = "withCount=true 时附带每节点学生数")
public ApiResult<List<OrgTreeVO>> tree(
@RequestParam(defaultValue = "false") boolean withCount,
@RequestParam(required = false) Long rootId) {
return ApiResult.ok(orgService.tree(withCount, rootId));
}
@PostMapping
@RequirePerm(PermissionDef.ORG_MANAGE)
@Operation(summary = "新增组织")
public ApiResult<Long> create(@Valid @RequestBody OrgUpsertReq req) {
return ApiResult.ok(orgService.create(req));
}
@PutMapping("/{id}")
@RequirePerm(PermissionDef.ORG_MANAGE)
@Operation(summary = "修改组织", description = "变更层级时服务端自动重算整棵子树的 ancestor_path")
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody OrgUpsertReq req) {
orgService.update(id, req);
return ApiResult.ok();
}
@DeleteMapping("/{id}")
@RequirePerm(PermissionDef.ORG_MANAGE)
@Operation(summary = "删除组织", description = "有下级组织或账号时拒绝,返回 40015")
public ApiResult<Void> delete(@PathVariable Long id) {
orgService.delete(id);
return ApiResult.ok();
}
}
@@ -0,0 +1,32 @@
package cn.tianbaotong.fill.module.org.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 组织树节点(《03-接口设计》§6.1)。
*/
@Getter
@Setter
public class OrgTreeVO {
private Long id;
private String orgName;
/** {@code 1} 学校 / {@code 2} 院系或专业 / {@code 3} 班级。 */
private Integer orgType;
private String orgCode;
private Long leaderUserId;
private String leaderName;
private Integer sortNo;
private Integer status;
/** 学生数。仅在 {@code withCount=true} 时填充,否则为 null。 */
private Integer studentCount;
private List<OrgTreeVO> children = new ArrayList<>();
}
@@ -0,0 +1,48 @@
package cn.tianbaotong.fill.module.org.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 组织新增/修改请求(《03-接口设计》§6.2)。
*/
@Getter
@Setter
@Schema(description = "组织新增或修改")
public class OrgUpsertReq {
@Schema(description = "父级 ID,顶级留空")
private Long parentId;
@NotBlank(message = "请填写组织名称")
@Size(max = 128, message = "组织名称过长")
@Schema(description = "组织名称", example = "24中技数控1班", requiredMode = Schema.RequiredMode.REQUIRED)
private String orgName;
@NotNull(message = "请选择组织类型")
@Min(value = 1, message = "组织类型不合法")
@Max(value = 4, message = "组织类型不合法")
@Schema(description = "1 学校 / 2 院系或专业 / 3 班级 / 4 报名点", example = "3",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer orgType;
@NotBlank(message = "请填写组织编码")
@Size(max = 64, message = "组织编码过长")
@Schema(description = "组织编码或班号,租户内唯一", example = "24ZK1",
requiredMode = Schema.RequiredMode.REQUIRED)
private String orgCode;
@Schema(description = "负责人(班主任)用户 ID")
private Long leaderUserId;
@Schema(description = "排序号,默认 0")
private Integer sortNo;
/** 注意:{@code ancestorPath} <b>不在请求里</b>——它由服务端维护,客户端传了也会被忽略。 */
}
@@ -0,0 +1,46 @@
package cn.tianbaotong.fill.module.org.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 组织架构(对应 {@code sys_org})。
*
* <p>典型形态是三级:学校 → 院系/专业 → 班级。
* 「24中技数控1班」是 {@code org_type=3} 的班级节点。
*/
@Getter
@Setter
@TableName("sys_org")
public class SysOrg extends TenantEntity {
/** 父级 ID,{@code 0} 为根。 */
private Long parentId;
/** 组织名称,如「24中技数控1班」。 */
private String orgName;
/** {@code 1} 学校 / {@code 2} 院系或专业 / {@code 3} 班级 / {@code 4} 报名点。 */
private Integer orgType;
/** 组织编码或班号,租户内唯一。 */
private String orgCode;
/**
* 祖先路径,如 {@code /2000/2010/2001/}。
*
* <p>⚠️ <b>由服务端维护,客户端传了也会被忽略</b>(《03-接口设计》§6.2)。
* 存在的意义是「按院系统计」不用递归查询——{@code WHERE ancestor_path LIKE '/2000/%'} 一次搞定。
*/
private String ancestorPath;
/** 负责人(班主任)用户 ID。 */
private Long leaderUserId;
private Integer sortNo;
/** {@code 0} 停用 / {@code 1} 正常。 */
private Integer status;
}
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.org.mapper;
import cn.tianbaotong.fill.module.org.entity.SysOrg;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface OrgMapper extends BaseMapper<SysOrg> {
/**
* 按组织统计学生数。
*
* <p>只查一次、在内存里聚合,而不是每个节点查一次——
* 一所学校 30 个班就是 30 次往返,组织树页面会明显卡顿。
*
* <p>SQL 里没写 {@code tenant_id}:多租户拦截器会自动补上。
* 这正是它的价值——不会因为开发者忘了写条件而串租户。
*/
@Select("""
SELECT org_id AS orgId, COUNT(*) AS cnt
FROM sys_user
WHERE deleted = 0 AND user_type = 2 AND org_id IS NOT NULL
GROUP BY org_id
""")
List<OrgUserCount> countStudentsByOrg();
/** 扁平结果行。 */
class OrgUserCount {
private Long orgId;
private Integer cnt;
public Long getOrgId() {
return orgId;
}
public void setOrgId(Long orgId) {
this.orgId = orgId;
}
public Integer getCnt() {
return cnt;
}
public void setCnt(Integer cnt) {
this.cnt = cnt;
}
}
}
@@ -0,0 +1,298 @@
package cn.tianbaotong.fill.module.org.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.org.dto.OrgTreeVO;
import cn.tianbaotong.fill.module.org.dto.OrgUpsertReq;
import cn.tianbaotong.fill.module.org.entity.SysOrg;
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 组织架构服务(《03-接口设计》§6.1~§6.4)。
*/
@Service
@RequiredArgsConstructor
public class OrgService {
/** 根节点的 parent_id 约定值。 */
private static final long ROOT_PARENT = 0L;
private final OrgMapper orgMapper;
private final UserMapper userMapper;
// ── 树 ────────────────────────────────────────
/**
* 组织树。
*
* <p>一次查全部再在内存组装,而不是递归查询:组织树规模是「一所学校几百个节点」量级,
* 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。
*/
public List<OrgTreeVO> tree(boolean withCount, Long rootId) {
List<SysOrg> all = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.orderByAsc(SysOrg::getSortNo)
.orderByAsc(SysOrg::getId));
Map<Long, String> leaderNames = loadLeaderNames(all);
Map<Long, Integer> counts = withCount ? loadStudentCounts() : Map.of();
Map<Long, OrgTreeVO> byId = new LinkedHashMap<>();
for (SysOrg o : all) {
OrgTreeVO vo = toVo(o, leaderNames, counts);
byId.put(vo.getId(), vo);
}
List<OrgTreeVO> roots = new ArrayList<>();
for (SysOrg o : all) {
OrgTreeVO vo = byId.get(o.getId());
Long pid = o.getParentId() == null ? ROOT_PARENT : o.getParentId();
OrgTreeVO parent = pid == ROOT_PARENT ? null : byId.get(pid);
if (parent != null) {
parent.getChildren().add(vo);
} else {
roots.add(vo);
}
}
if (rootId == null) {
return roots;
}
// 指定了 rootId 就只返回该子树,但保留它本身作为根
OrgTreeVO sub = byId.get(rootId);
return sub == null ? List.of() : List.of(sub);
}
// ── 增删改 ────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public Long create(OrgUpsertReq req) {
long tenantId = UserContext.tenantId();
SysOrg parent = null;
Long parentId = req.getParentId();
if (parentId != null && parentId != ROOT_PARENT) {
parent = orgMapper.selectById(parentId);
if (parent == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "上级组织不存在");
}
if (req.getOrgType() != null && parent.getOrgType() != null
&& req.getOrgType() <= parent.getOrgType()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"组织类型必须比上级更低一级(学校 > 院系 > 班级)");
}
}
assertOrgCodeUnique(tenantId, req.getOrgCode(), null);
SysOrg entity = new SysOrg();
entity.setParentId(parentId == null ? ROOT_PARENT : parentId);
entity.setOrgName(req.getOrgName().trim());
entity.setOrgType(req.getOrgType());
entity.setOrgCode(req.getOrgCode().trim());
entity.setLeaderUserId(req.getLeaderUserId());
entity.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
entity.setStatus(1);
orgMapper.insert(entity);
// ancestor_path 依赖自身 id,只能插入后回写。
// 路径形如 /2000/2010/2001/,末位是自己——这样「按院系统计」用
// LIKE '/2000/%' 就能一次覆盖所有后代,不用递归。
String path = parent == null
? "/" + entity.getId() + "/"
: parent.getAncestorPath() + entity.getId() + "/";
SysOrg patch = new SysOrg();
patch.setId(entity.getId());
patch.setAncestorPath(path);
orgMapper.updateById(patch);
return entity.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, OrgUpsertReq req) {
long tenantId = UserContext.tenantId();
SysOrg exist = orgMapper.selectById(id);
if (exist == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "组织不存在");
}
Long newParentId = req.getParentId();
if (newParentId != null && newParentId.equals(id)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "不能把组织的上级设为自己");
}
// 防环:新上级不能是自己的后代
if (newParentId != null && newParentId != ROOT_PARENT
&& exist.getAncestorPath() != null
&& existsByIdInSubtree(exist, newParentId)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "不能把组织移动到自己的下级里");
}
assertOrgCodeUnique(tenantId, req.getOrgCode(), id);
SysOrg patch = new SysOrg();
patch.setId(id);
patch.setOrgName(req.getOrgName().trim());
patch.setOrgType(req.getOrgType());
patch.setOrgCode(req.getOrgCode().trim());
patch.setLeaderUserId(req.getLeaderUserId());
patch.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
if (newParentId != null) {
patch.setParentId(newParentId);
}
orgMapper.updateById(patch);
// 层级变了要重算整棵子树的 ancestor_path
if (newParentId != null && !newParentId.equals(exist.getParentId())) {
rebuildSubtreePaths(id);
}
}
/**
* 删除组织。
*
* <p>约束(§6.3):有下级组织、有用户、有名册项、有进行中任务时拒绝,返回 {@code 40015}。
* M0 阶段先实现「有下级」与「有用户」两条,名册与任务的要等 M1/M2 的表接上来。
*/
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
SysOrg exist = orgMapper.selectById(id);
if (exist == null) {
return;
}
List<String> blockers = new ArrayList<>();
Long childCount = orgMapper.selectCount(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getParentId, id));
if (childCount != null && childCount > 0) {
blockers.add(childCount + " 个下级组织");
}
Long userCount = userMapper.selectCount(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getOrgId, id));
if (userCount != null && userCount > 0) {
blockers.add(userCount + " 个账号");
}
if (!blockers.isEmpty()) {
throw new BizException(ErrorCode.ORG_OCCUPIED,
"该组织下还有 " + String.join("、", blockers) + ",不可删除");
}
orgMapper.deleteById(id);
}
// ── 内部 ──────────────────────────────────────
private void assertOrgCodeUnique(long tenantId, String orgCode, Long excludeId) {
SysOrg dup = orgMapper.selectOne(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getOrgCode, orgCode.trim())
.ne(excludeId != null, SysOrg::getId, excludeId)
.last("LIMIT 1"));
if (dup != null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "组织编码已存在:" + orgCode);
}
}
/** 判断 candidate 是否落在 node 的子树里(用 ancestor_path 前缀判断,不用递归)。 */
private boolean existsByIdInSubtree(SysOrg node, Long candidateId) {
SysOrg candidate = orgMapper.selectById(candidateId);
if (candidate == null || candidate.getAncestorPath() == null) {
return false;
}
return candidate.getAncestorPath().contains("/" + node.getId() + "/");
}
/** 重算子树路径。 */
private void rebuildSubtreePaths(Long rootId) {
SysOrg root = orgMapper.selectById(rootId);
if (root == null) {
return;
}
String base = (root.getParentId() == null || root.getParentId() == ROOT_PARENT)
? "/" + root.getId() + "/"
: resolveParentPath(root.getParentId()) + root.getId() + "/";
SysOrg patch = new SysOrg();
patch.setId(rootId);
patch.setAncestorPath(base);
orgMapper.updateById(patch);
rebuildChildren(rootId, base);
}
private void rebuildChildren(Long parentId, String parentPath) {
List<SysOrg> children = orgMapper.selectList(Wrappers.<SysOrg>lambdaQuery()
.eq(SysOrg::getParentId, parentId));
for (SysOrg c : children) {
String path = parentPath + c.getId() + "/";
SysOrg patch = new SysOrg();
patch.setId(c.getId());
patch.setAncestorPath(path);
orgMapper.updateById(patch);
rebuildChildren(c.getId(), path);
}
}
private String resolveParentPath(Long parentId) {
SysOrg p = orgMapper.selectById(parentId);
if (p == null || p.getAncestorPath() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "上级组织的层级路径缺失,请先修复组织树");
}
return p.getAncestorPath();
}
private Map<Long, String> loadLeaderNames(List<SysOrg> orgs) {
List<Long> ids = orgs.stream()
.map(SysOrg::getLeaderUserId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
if (ids.isEmpty()) {
return Map.of();
}
Map<Long, String> map = new HashMap<>();
for (SysUser u : userMapper.selectByIds(ids)) {
map.put(u.getId(), u.getRealName());
}
return map;
}
private Map<Long, Integer> loadStudentCounts() {
Map<Long, Integer> map = new HashMap<>();
for (OrgMapper.OrgUserCount row : orgMapper.countStudentsByOrg()) {
if (row.getOrgId() != null && row.getCnt() != null) {
map.merge(row.getOrgId(), row.getCnt(), Integer::sum);
}
}
return map;
}
private static OrgTreeVO toVo(SysOrg o, Map<Long, String> leaderNames, Map<Long, Integer> counts) {
OrgTreeVO vo = new OrgTreeVO();
vo.setId(o.getId());
vo.setOrgName(o.getOrgName());
vo.setOrgType(o.getOrgType());
vo.setOrgCode(o.getOrgCode());
vo.setLeaderUserId(o.getLeaderUserId());
vo.setLeaderName(o.getLeaderUserId() == null ? null : leaderNames.get(o.getLeaderUserId()));
vo.setSortNo(o.getSortNo());
vo.setStatus(o.getStatus());
vo.setStudentCount(counts.get(o.getId()));
return vo;
}
}
@@ -0,0 +1,61 @@
package cn.tianbaotong.fill.module.tenant.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
import cn.tianbaotong.fill.module.tenant.service.TenantService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 租户管理(《03-接口设计》§6.5~§6.6)。
*
* <p>⚠️ 全部接口仅平台运营可用。
*/
@Tag(name = "租户管理", description = "仅平台运营")
@RestController
@RequestMapping("/api/v1/tenants")
@RequiredArgsConstructor
public class TenantController {
private final TenantService tenantService;
@GetMapping
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "租户列表")
public ApiResult<PageResult<TenantVO>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String expireBefore,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(tenantService.list(keyword, status, expireBefore, pageNum, pageSize));
}
@GetMapping("/{id}")
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "租户详情")
public ApiResult<TenantVO> detail(@PathVariable Long id) {
return ApiResult.ok(tenantService.detail(id));
}
@PostMapping
@RequirePerm(PermissionDef.TENANT_MANAGE)
@Operation(summary = "开通租户",
description = "一次完成建租户 + 发放 6 个内置角色 + 创建首个管理员。初始密码仅此一次返回")
public ApiResult<TenantService.CreateResult> create(@Valid @RequestBody TenantCreateReq req) {
return ApiResult.ok(tenantService.create(req));
}
}
@@ -0,0 +1,77 @@
package cn.tianbaotong.fill.module.tenant.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 开通租户(《03-接口设计》§6.6)。
*
* <p>一次请求同时完成三件事:建租户、发放 6 个内置角色、创建首个管理员。
* 不拆成三个接口——拆了之后"租户建好但没管理员"的中间态会留在库里,
* 运维得手工收拾。
*/
@Getter
@Setter
@Schema(description = "开通租户")
public class TenantCreateReq {
@NotBlank(message = "请填写租户名称")
@Size(max = 128)
@Schema(description = "租户名称(学校/机构)", example = "江西机电职业技术学院",
requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantName;
@NotBlank(message = "请填写租户编码")
@Size(max = 32)
@Pattern(regexp = "^[a-z][a-z0-9_-]{1,31}$",
message = "租户编码只能是小写字母开头,后接小写字母、数字、下划线或短横线")
@Schema(description = "租户编码(全局唯一,也会用于小程序码参数)", example = "jxjd",
requiredMode = Schema.RequiredMode.REQUIRED)
private String tenantCode;
@NotNull(message = "请选择租户类型")
@Schema(description = "1 学校 / 2 认定机构 / 3 报名点 / 4 企业", example = "1",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer tenantType;
@NotBlank(message = "请填写联系人")
@Size(max = 64)
@Schema(description = "联系人", requiredMode = Schema.RequiredMode.REQUIRED)
private String contactName;
@NotBlank(message = "请填写联系电话")
@Size(max = 20)
@Schema(description = "联系电话", requiredMode = Schema.RequiredMode.REQUIRED)
private String contactPhone;
@Schema(description = "套餐 ID")
private Long packageId;
@NotBlank(message = "请选择到期时间")
@Schema(description = "到期时间,如 2027-12-31", example = "2027-12-31",
requiredMode = Schema.RequiredMode.REQUIRED)
private String expireTime;
// ── 首个管理员 ────────────────────────────────
@NotBlank(message = "请填写管理员账号")
@Size(max = 64)
@Schema(description = "首个管理员登录账号", example = "admin",
requiredMode = Schema.RequiredMode.REQUIRED)
private String adminUsername;
@NotBlank(message = "请填写管理员姓名")
@Size(max = 64)
@Schema(description = "首个管理员姓名", requiredMode = Schema.RequiredMode.REQUIRED)
private String adminRealName;
@NotBlank(message = "请填写管理员手机号")
@Size(max = 20)
@Schema(description = "首个管理员手机号", requiredMode = Schema.RequiredMode.REQUIRED)
private String adminPhone;
}
@@ -0,0 +1,26 @@
package cn.tianbaotong.fill.module.tenant.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 租户视图(《03-接口设计》§6.5)。
*/
@Getter
@Setter
public class TenantVO {
private Long id;
private String tenantCode;
private String tenantName;
private Integer tenantType;
private String contactName;
private String contactPhone;
private Long packageId;
private LocalDateTime expireTime;
private Integer status;
private String logoUrl;
private LocalDateTime createTime;
}
@@ -0,0 +1,46 @@
package cn.tianbaotong.fill.module.tenant.entity;
import cn.tianbaotong.fill.support.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 租户(对应 {@code sys_tenant})。
*
* <p>⚠️ 本表<b>不继承 {@code TenantEntity}</b>——它自己就是租户维度,
* 表里没有 {@code tenant_id} 列(主键 {@code id} 就是租户标识)。
* 多租户拦截器已把 {@code sys_tenant} 列入免过滤表。
*/
@Getter
@Setter
@TableName("sys_tenant")
public class SysTenant extends BaseEntity {
/** 租户编码(短码,用于小程序扫码识别),全局唯一。 */
private String tenantCode;
/** 租户名称(学校/机构)。 */
private String tenantName;
/** {@code 1} 学校 / {@code 2} 认定机构 / {@code 3} 报名点 / {@code 4} 企业。 */
private Integer tenantType;
private String contactName;
private String contactPhone;
private Long packageId;
private LocalDateTime expireTime;
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 试用。 */
private Integer status;
private String logoUrl;
/** 租户配置(默认压缩参数 / 文件保留天数等),jsonb。 */
private String configJson;
}
@@ -0,0 +1,14 @@
package cn.tianbaotong.fill.module.tenant.mapper;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 租户 Mapper。
*
* <p>注意:{@code sys_tenant} 没有 {@code tenant_id} 列,多租户拦截器已将其列入免过滤表,
* 所以这里的查询天然是跨租户的——<b>开通租户的功能只有平台运营能用</b>
* (由 {@code @RequirePerm(TENANT_MANAGE)} 在 Controller 层把关)。
*/
public interface TenantMapper extends BaseMapper<SysTenant> {
}
@@ -0,0 +1,181 @@
package cn.tianbaotong.fill.module.tenant.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import cn.tianbaotong.fill.support.RoleProvisioner;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.tianbaotong.fill.support.TenantScope;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 租户服务(《03-接口设计》§6.5 / §6.6)。
*
* <p>⚠️ 本模块的所有接口都只有平台运营能访问({@code @RequirePerm(TENANT_MANAGE)})。
* {@code sys_tenant} 没有 {@code tenant_id} 列,拦截器对它不做租户过滤,
* 所以租户列表天然是跨租户的——权限把关必须落在 Controller 上,不能漏。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TenantService {
private static final int MAX_PAGE_SIZE = 200;
private final TenantMapper tenantMapper;
private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner;
private final SensitiveCodec codec;
private final PasswordEncoder passwordEncoder;
// ── 列表 ──────────────────────────────────────
public PageResult<TenantVO> list(String keyword, Integer status, String expireBefore,
int pageNum, int pageSize) {
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
int num = Math.max(pageNum, 1);
var query = Wrappers.<SysTenant>lambdaQuery()
.and(keyword != null && !keyword.isBlank(), w -> w
.like(SysTenant::getTenantName, keyword)
.or().like(SysTenant::getTenantCode, keyword))
.eq(status != null, SysTenant::getStatus, status)
.orderByDesc(SysTenant::getId);
if (expireBefore != null && !expireBefore.isBlank()) {
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay());
}
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
List<TenantVO> list = page.getRecords().stream().map(TenantService::toVo).toList();
return PageResult.of(list, page.getTotal(), num, size);
}
// ── 开通 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public CreateResult create(TenantCreateReq req) {
String code = req.getTenantCode().trim().toLowerCase();
Long dup = tenantMapper.selectCount(
Wrappers.<SysTenant>lambdaQuery().eq(SysTenant::getTenantCode, code));
if (dup != null && dup > 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户编码已存在:" + code);
}
// 1) 建租户
SysTenant t = new SysTenant();
t.setTenantCode(code);
t.setTenantName(req.getTenantName().trim());
t.setTenantType(req.getTenantType());
t.setContactName(req.getContactName().trim());
t.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
t.setPackageId(req.getPackageId());
t.setExpireTime(parseExpire(req.getExpireTime()));
t.setStatus(1);
tenantMapper.insert(t);
final long tid = t.getId();
// 2) 切到新租户的作用域,发放角色 + 建管理员。
// ⚠️ 不切的话,租户拦截器会把新管理员的 tenant_id 填成调用者(平台=0),
// 结果是"租户开通了但管理员登不进去"——不报错,只是查不到。
String initialPassword = MaskUtil.generateInitialPassword();
TenantScope.runAs(tid, 0L, Set.of(BuiltinRole.SCHOOL_ADMIN.getCode()), () -> {
roleProvisioner.provisionBuiltinRoles(tid);
SysUser admin = new SysUser();
admin.setTenantId(tid);
admin.setUsername(req.getAdminUsername().trim());
admin.setRealName(req.getAdminRealName().trim());
admin.setPassword(passwordEncoder.encode(initialPassword));
admin.setUserType(2);
admin.setStatus(1);
codec.applyPhone(admin, req.getAdminPhone());
userMapper.insert(admin);
roleProvisioner.bindRole(tid, admin.getId(), BuiltinRole.SCHOOL_ADMIN);
});
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
return new CreateResult(tid, initialPassword, "t" + tid);
}
public TenantVO detail(Long id) {
SysTenant t = tenantMapper.selectById(id);
if (t == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
}
return toVo(t);
}
// ── 内部 ──────────────────────────────────────
private static LocalDateTime parseExpire(String s) {
try {
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
return s.length() <= 10
? LocalDate.parse(s).atTime(23, 59, 59)
: LocalDateTime.parse(s);
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期时间格式不正确,应为 2027-12-31");
}
}
private static TenantVO toVo(SysTenant t) {
TenantVO vo = new TenantVO();
vo.setId(t.getId());
vo.setTenantCode(t.getTenantCode());
vo.setTenantName(t.getTenantName());
vo.setTenantType(t.getTenantType());
vo.setContactName(t.getContactName());
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
vo.setContactPhone(null);
vo.setPackageId(t.getPackageId());
vo.setExpireTime(t.getExpireTime());
vo.setStatus(t.getStatus());
vo.setLogoUrl(t.getLogoUrl());
vo.setCreateTime(t.getCreateTime());
return vo;
}
/** 开通结果。{@code adminInitialPassword} 只在这里返回一次。 */
@Getter
@Setter
public static class CreateResult {
private Long id;
/** 初始密码,<b>仅此一次返回</b>。 */
private String adminInitialPassword;
/** 小程序码参数,形如 {@code t1002}。 */
private String miniProgramScene;
public CreateResult(Long id, String adminInitialPassword, String miniProgramScene) {
this.id = id;
this.adminInitialPassword = adminInitialPassword;
this.miniProgramScene = miniProgramScene;
}
}
}
@@ -0,0 +1,36 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 角色(对应 {@code sys_role})。
*
* <p>内置角色在「开通租户」时按 {@code BuiltinRole} 写入该租户,
* 所以角色本身是租户隔离的,多租户拦截器能统一处理。
*/
@Getter
@Setter
@TableName("sys_role")
public class SysRole extends TenantEntity {
/** 角色码,如 {@code SCHOOL_ADMIN}。与代码常量对齐。 */
private String roleCode;
private String roleName;
/** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
private Integer roleType;
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
private Integer dataScope;
private Integer sortNo;
private String remark;
/** {@code 0} 停用 / {@code 1} 正常。 */
private Integer status;
}
@@ -0,0 +1,23 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 角色授权(对应 {@code sys_role_permission})。
*
* <p>只存「这个角色被授了哪些码」,权限码本身的定义在代码里
* ({@code cn.tianbaotong.fill.security.PermissionDef})。所以没有 {@code sys_permission} 字典表。
*/
@Getter
@Setter
@TableName("sys_role_permission")
public class SysRolePermission extends TenantEntity {
private Long roleId;
/** 权限码,如 {@code task:create}。 */
private String permCode;
}
@@ -0,0 +1,63 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户(对应 {@code sys_user})。
*
* <p><b>敏感字段处理</b>:
* <ul>
* <li>{@code phone} / {@code idCardNo} —— <b>AES-256-GCM 加密存储</b>,
* 读写必须走 {@code AesGcmCipher},<b>不要直接 {@code get}/{@code set} 明文</b></li>
* <li>{@code phoneHash} / {@code idCardHash} —— 同值的 HMAC,用于等值检索与去重</li>
* </ul>
* 这样设计后,{@code WHERE phone = ?} 是查不到的(每次密文都不同),
* 检索一律走 {@code *_hash} 列。写代码时容易忘,见 {@code UserService} 的用法。
*/
@Getter
@Setter
@TableName("sys_user")
public class SysUser extends TenantEntity {
/** 所属组织。 */
private Long orgId;
/** 登录名(教师/管理员用)。租户内唯一。 */
private String username;
/** BCrypt 哈希。学生可能没有密码(走微信登录)。 */
private String password;
private String realName;
/** 手机号,<b>AES 加密存储</b>。 */
private String phone;
/** 手机号 HMAC(char(64)),用于检索。 */
private String phoneHash;
private String openId;
private String unionId;
/** 证件号,<b>AES 加密存储</b>。 */
private String idCardNo;
/** 证件号 HMAC(char(64)),用于比对与去重。 */
private String idCardHash;
private String avatarUrl;
/** {@code 1} 教师或管理员 / {@code 2} 填报人(学生)/ {@code 3} 社会人员。 */
private Integer userType;
/** {@code 0} 停用 / {@code 1} 正常 / {@code 2} 待激活。 */
private Integer status;
private LocalDateTime lastLoginTime;
}
@@ -0,0 +1,23 @@
package cn.tianbaotong.fill.module.user.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 用户与角色的关联(对应 {@code sys_user_role})。
*
* <p>{@code tenant_id} 是冗余的(可由 {@code user_id} 或 {@code role_id} 推出),
* 但保留它有两个好处:多租户拦截器能统一处理,不用给关联表开特例;
* 跨租户的脏关联在写入时就会被数据库拦下,而不是等到查询时才发现。
*/
@Getter
@Setter
@TableName("sys_user_role")
public class SysUserRole extends TenantEntity {
private Long userId;
private Long roleId;
}
@@ -0,0 +1,7 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface RoleMapper extends BaseMapper<SysRole> {
}
@@ -0,0 +1,7 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface RolePermissionMapper extends BaseMapper<SysRolePermission> {
}
@@ -0,0 +1,57 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface UserMapper extends BaseMapper<SysUser> {
/**
* 按登录名跨租户查找用户。
*
* <p><b>⚠️ 这是全系统唯一需要关闭租户过滤的用户查询</b>,只用于登录——
* 登录时还没有 token,拿不到 {@code tenantId},而账号唯一性是
* {@code (tenant_id, username)} 而非全局。
*
* <p>{@code @InterceptorIgnore(tenantLine = "true")} 是显式声明,
* 本身就是一次代码审查点:<b>看到这行就该问"为什么需要跨租户"</b>。
*
* <p>返回<b>列表</b>而不是单条:同一个登录名可能存在于多个租户
* (如两个学校都有 {@code admin})。调用方按以下规则处理:
* <ul>
* <li>0 条 → 账号不存在</li>
* <li>1 条 → 直接用</li>
* <li>&gt;1 条 → 必须要求客户端指定租户码,否则<b>拒绝登录</b>并提示</li>
* </ul>
* 绝不"随便挑一条"——那会导致用户登进别人的学校。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM sys_user
WHERE username = #{username} AND deleted = 0
ORDER BY id
""")
List<SysUser> selectByUsernameAcrossTenants(@Param("username") String username);
/**
* 按租户码 + 登录名精确定位(客户端指定了租户时的路径)。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT u.* FROM sys_user u
JOIN sys_tenant t ON t.id = u.tenant_id AND t.deleted = 0
WHERE t.tenant_code = #{tenantCode}
AND u.username = #{username} AND u.deleted = 0
""")
List<SysUser> selectByTenantCodeAndUsername(@Param("tenantCode") String tenantCode,
@Param("username") String username);
/** 按手机号 HMAC 查用户(同一租户内)。检索走 hash 列,密文列无法等值查询。 */
@Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0")
List<SysUser> selectByPhoneHash(@Param("tenantId") Long tenantId,
@Param("phoneHash") String phoneHash);
}
@@ -0,0 +1,61 @@
package cn.tianbaotong.fill.module.user.mapper;
import cn.tianbaotong.fill.module.user.entity.SysUserRole;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface UserRoleMapper extends BaseMapper<SysUserRole> {
/**
* 查一个用户的角色码。
*
* <p>绕过 MP 的实体映射走原生 SQL:JWT 签发与 {@code /auth/me} 都在这条路径上,
* 是高频调用,少一次对象装配就少一次开销。
*/
@Select("""
SELECT r.role_code
FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0 AND r.status = 1
WHERE ur.user_id = #{userId} AND ur.deleted = 0
ORDER BY r.sort_no
""")
List<String> selectRoleCodesByUserId(@Param("userId") Long userId);
/** 查一批用户的角色码,用于列表页一次性装配,避免 N+1。 */
@Select("""
<script>
SELECT ur.user_id, r.role_code
FROM sys_user_role ur
JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE ur.deleted = 0 AND ur.user_id IN
<foreach collection="userIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY ur.user_id, r.sort_no
</script>
""")
List<UserRoleRow> selectRoleCodesByUserIds(@Param("userIds") List<Long> userIds);
/** 扁平结果行。 */
class UserRoleRow {
private Long userId;
private String roleCode;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getRoleCode() {
return roleCode;
}
public void setRoleCode(String roleCode) {
this.roleCode = roleCode;
}
}
}
@@ -0,0 +1,110 @@
package cn.tianbaotong.fill.security;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 鉴权拦截器:解 token → 写上下文 → 校验权限。
*
* <p><b>⚠️ {@link #afterCompletion} 必须清理上下文</b>。Tomcat 的线程是复用的,
* 不清就会让下一个请求继承上一个用户的身份——这是最隐蔽也最严重的越权漏洞。
* 这里放在 finally 语义的位置,保证异常路径也会执行。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
/** 把原始 token 挂到 request 上,供登出接口取用(避免在 Controller 里重新解析 Header)。 */
public static final String ATTR_TOKEN = "xt.auth.token";
private final JwtService jwtService;
private final TokenBlacklist blacklist;
private final PermissionChecker permissionChecker;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
if (isPublic(handlerMethod)) {
return true;
}
String token = extractBearer(request);
if (token == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
if (blacklist.contains(token)) {
// 已登出的 token 不能再使用
throw new BizException(ErrorCode.UNAUTHORIZED);
}
LoginUser user = jwtService.parse(token);
UserContext.set(user);
request.setAttribute(ATTR_TOKEN, token);
checkPermission(handlerMethod);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
}
// ── 内部 ──────────────────────────────────────
private static boolean isPublic(HandlerMethod hm) {
return hm.hasMethodAnnotation(PublicApi.class)
|| hm.getBeanType().isAnnotationPresent(PublicApi.class);
}
/**
* {@code @RequirePerm} 校验。
*
* <p>标在方法上优先,其次看类上——便于"整个 Controller 都要某权限"的写法。
*/
private void checkPermission(HandlerMethod hm) {
RequirePerm require = hm.getMethodAnnotation(RequirePerm.class);
if (require == null) {
require = hm.getBeanType().getAnnotation(RequirePerm.class);
}
if (require == null) {
return;
}
PermissionDef[] perms = require.value();
boolean ok = require.requireAll()
? permissionChecker.hasAll(perms)
: permissionChecker.hasAny(perms);
if (!ok) {
log.debug("权限不足 uid={} need={}", UserContext.get() == null ? null : UserContext.get().uid(), (Object) perms);
throw new BizException(ErrorCode.FORBIDDEN);
}
}
/** 提取 {@code Authorization: Bearer <token>}。 */
private static String extractBearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null || header.isBlank()) {
return null;
}
String trimmed = header.trim();
if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) {
String token = trimmed.substring(7).trim();
return token.isEmpty() ? null : token;
}
// 容忍直接传 token 的写法(部分小程序的网络库)
return trimmed;
}
}
@@ -0,0 +1,176 @@
package cn.tianbaotong.fill.security;
import lombok.Getter;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 内置角色定义。
*
* <p>数据来源:《PRD》§3.1 角色定义 + §3.2 权限矩阵 + 《03-接口设计》§2.7 的 {@code roleCode} 枚举。
*
* <p><b>开通租户时</b>,服务按这里为租户写入 {@code sys_role} 与 {@code sys_role_permission}。
* 之后租户可自定义角色,但内置角色的权限<b>由代码决定、不可在界面上改</b>——
* 否则一次误操作就能把管理员锁在系统外。
*/
@Getter
public enum BuiltinRole {
/** 平台运营:唯一的跨租户身份({@code tenant_id = 0})。 */
PLATFORM_ADMIN("PLATFORM_ADMIN", "平台运营", UserType.PLATFORM, DataScope.ALL_PLATFORM, 1,
Set.of(PermissionDef.TENANT_MANAGE, PermissionDef.TENANT_VIEW,
PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.LOG_VIEW_ALL),
Set.of("dashboard", "tenant", "org", "user", "log")),
/** 学校管理员 / 机构管理员:租户内最高权限。 */
SCHOOL_ADMIN("SCHOOL_ADMIN", "学校管理员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 10,
Set.of(PermissionDef.ORG_MANAGE, PermissionDef.ORG_VIEW,
PermissionDef.USER_MANAGE, PermissionDef.USER_VIEW, PermissionDef.ROLE_MANAGE,
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
PermissionDef.JOIN_REQUEST_AUDIT,
PermissionDef.NOTIFY_SEND,
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
PermissionDef.FILE_PROXY_UPLOAD,
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
PermissionDef.LOG_VIEW_TENANT),
Set.of("dashboard", "org", "user", "role", "task", "form", "material",
"roster", "join", "audit", "export", "cleanup", "log")),
/** 班主任:只看自己带的班。 */
HEAD_TEACHER("HEAD_TEACHER", "班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 20,
Set.of(PermissionDef.TASK_VIEW,
PermissionDef.ROSTER_MANAGE_OWN, PermissionDef.ROSTER_VIEW,
PermissionDef.NOTIFY_SEND, PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
/** 副班主任:协助初审与代传,但不发通知(避免一个班两个人都来催报)。 */
DEPUTY_TEACHER("DEPUTY_TEACHER", "副班主任", UserType.TEACHER, DataScope.ORG_AND_CHILD, 30,
Set.of(PermissionDef.TASK_VIEW,
PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_OWN,
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
Set.of("dashboard", "task", "roster", "audit")),
/** 审核员:专做审核,不给任何管理权限(最小权限原则)。 */
AUDITOR("AUDITOR", "审核员", UserType.SCHOOL_ADMIN, DataScope.TENANT, 40,
Set.of(PermissionDef.TASK_VIEW, PermissionDef.ROSTER_VIEW,
PermissionDef.PROGRESS_VIEW_ALL,
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL),
Set.of("dashboard", "task", "roster", "audit")),
/** 学生:只能操作本人那一份。 */
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
Set.of("mp-home", "mp-my"));
/** 用户类型,对应 {@code sys_user.user_type}。 */
public enum UserType {
PLATFORM(1),
SCHOOL_ADMIN(2),
TEACHER(3),
STUDENT(4);
private final int code;
UserType(int code) {
this.code = code;
}
public int code() {
return code;
}
}
/**
* 数据范围,对应 {@code sys_role.data_scope}。
*
* <p>决定「本班/本院系/全租户」这类过滤,由服务层按角色取用。
*/
public enum DataScope {
ALL_PLATFORM(1),
TENANT(2),
ORG_AND_CHILD(3),
SELF(4);
private final int code;
DataScope(int code) {
this.code = code;
}
public int code() {
return code;
}
}
private final String code;
private final String name;
private final UserType userType;
private final DataScope dataScope;
private final int sortNo;
private final Set<PermissionDef> permissions;
private final Set<String> menus;
BuiltinRole(String code, String name, UserType userType, DataScope dataScope,
int sortNo, Set<PermissionDef> permissions, Set<String> menus) {
this.code = code;
this.name = name;
this.userType = userType;
this.dataScope = dataScope;
this.sortNo = sortNo;
this.permissions = permissions;
this.menus = menus;
}
/** 权限码集合(字符串形式,直接写 DB)。 */
public Set<String> permissionCodes() {
Set<String> codes = new LinkedHashSet<>();
permissions.forEach(p -> codes.add(p.getCode()));
return codes;
}
/** 开通租户时默认创建的 6 个内置角色。 */
public static Set<BuiltinRole> all() {
return new LinkedHashSet<>(Arrays.asList(values()));
}
public static BuiltinRole ofCode(String code) {
return Arrays.stream(values())
.filter(r -> r.code.equals(code))
.findFirst()
.orElse(null);
}
/** 合并多个角色的权限码(用户可多角色)。 */
public static Set<String> mergePermissions(Iterable<String> roleCodes) {
Set<String> merged = new LinkedHashSet<>();
for (String rc : roleCodes) {
BuiltinRole r = ofCode(rc);
if (r != null) {
merged.addAll(r.permissionCodes());
}
}
return merged;
}
/** 合并多个角色的菜单(用户可多角色)。 */
public static Set<String> mergeMenus(Iterable<String> roleCodes) {
Set<String> merged = new LinkedHashSet<>();
for (String rc : roleCodes) {
BuiltinRole r = ofCode(rc);
if (r != null) {
merged.addAll(r.getMenus());
}
}
return merged;
}
}
@@ -0,0 +1,171 @@
package cn.tianbaotong.fill.security;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.config.FillProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* JWT 签发与校验。
*
* <p>载荷结构见《03-接口设计》§1.2。签名算法 HS256——单服务自签自验,不需要非对称。
*
* <p><b>有效期</b>:PC 8 小时(可续期),小程序 7 天(静默续期)。
* 小程序给 7 天是因为学生填报是碎片化行为,频繁登录会严重影响完成率。
*/
@Slf4j
@Component
public class JwtService {
private static final String CLAIM_TENANT_ID = "tenantId";
private static final String CLAIM_ORG_IDS = "orgIds";
private static final String CLAIM_ROLES = "roles";
private static final String CLAIM_USER_TYPE = "userType";
private final SecretKey key;
private final String issuer;
private final long pcExpireSeconds;
private final long mpExpireSeconds;
public JwtService(FillProperties props) {
FillProperties.Jwt jwt = props.jwt();
this.key = Keys.hmacShaKeyFor(decodeSecret(jwt.secret()));
this.issuer = jwt.issuer() == null ? "tianbaotong-fill" : jwt.issuer();
this.pcExpireSeconds = jwt.pcExpireSecondsOrDefault();
this.mpExpireSeconds = jwt.mpExpireSecondsOrDefault();
}
/** 终端类型,决定有效期。 */
public enum Client {
/** PC 管理端 / 班主任 PC:8 小时 */
PC,
/** 微信小程序:7 天 */
MP
}
public long expireSecondsOf(Client client) {
return client == Client.MP ? mpExpireSeconds : pcExpireSeconds;
}
/**
* 签发 token。
*
* @return {@code [token, 有效期秒数]}
*/
public String issue(LoginUser user, Client client) {
long exp = expireSecondsOf(client);
Instant now = Instant.now();
return Jwts.builder()
.issuer(issuer)
.subject(String.valueOf(user.uid()))
.claim(CLAIM_TENANT_ID, user.tenantId())
.claim(CLAIM_ORG_IDS, user.orgIds() == null ? List.of() : user.orgIds())
.claim(CLAIM_ROLES, user.roles() == null ? List.of() : List.copyOf(user.roles()))
.claim(CLAIM_USER_TYPE, user.userType())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(exp)))
.signWith(key)
.compact();
}
/**
* 校验并解析 token。
*
* <p>任何解析失败(签名不对、过期、格式错、issuer 不匹配)一律抛
* {@code 40101},<b>不区分原因</b>——区分原因等于告诉攻击者"你的签名算法猜对了,只是过期了"。
*/
public LoginUser parse(String token) {
try {
Claims c = Jwts.parser()
.verifyWith(key)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
return new LoginUser(
Long.valueOf(c.getSubject()),
c.get(CLAIM_TENANT_ID, Number.class) == null
? null : c.get(CLAIM_TENANT_ID, Number.class).longValue(),
readOrgIds(c),
readRoles(c),
c.get(CLAIM_USER_TYPE, Number.class) == null
? null : c.get(CLAIM_USER_TYPE, Number.class).intValue()
);
} catch (JwtException | IllegalArgumentException e) {
log.debug("token 解析失败: {}", e.getMessage());
throw new BizException(ErrorCode.UNAUTHORIZED);
}
}
/** token 的剩余有效期(秒)。用于把登出黑名单的 TTL 设成"只到它自然过期"。 */
public long remainingSeconds(String token) {
try {
Claims c = Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload();
long remain = (c.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
return Math.max(remain, 0);
} catch (Exception e) {
return 0;
}
}
@SuppressWarnings("unchecked")
private static List<Long> readOrgIds(Claims c) {
Object raw = c.get(CLAIM_ORG_IDS);
if (!(raw instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(Number.class::isInstance)
.map(v -> ((Number) v).longValue())
.toList();
}
@SuppressWarnings("unchecked")
private static Set<String> readRoles(Claims c) {
Object raw = c.get(CLAIM_ROLES);
if (!(raw instanceof List<?> list)) {
return Set.of();
}
Set<String> roles = new HashSet<>(list.size());
for (Object o : list) {
if (o != null) {
roles.add(String.valueOf(o));
}
}
return roles;
}
private static byte[] decodeSecret(String secret) {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("缺少 fill.jwt.secret");
}
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(secret.trim());
} catch (IllegalArgumentException e) {
// 允许直接写原始字符串,方便临时调试
bytes = secret.getBytes(StandardCharsets.UTF_8);
}
if (bytes.length < 32) {
throw new IllegalStateException("fill.jwt.secret 至少需要 32 字节(HS256 下限)");
}
return bytes;
}
}
@@ -0,0 +1,74 @@
package cn.tianbaotong.fill.security;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 权限判定。
*
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}),
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里,
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时,
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。
*/
@Component
public class PermissionChecker {
/** 当前用户是否拥有指定权限。 */
public boolean has(PermissionDef perm) {
LoginUser user = UserContext.get();
if (user == null || user.roles() == null || user.roles().isEmpty()) {
return false;
}
// 平台运营是超管,不受权限码限制
if (user.isPlatformAdmin()) {
return true;
}
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode());
}
public boolean hasAny(PermissionDef... perms) {
for (PermissionDef p : perms) {
if (has(p)) {
return true;
}
}
return false;
}
public boolean hasAll(PermissionDef... perms) {
for (PermissionDef p : perms) {
if (!has(p)) {
return false;
}
}
return true;
}
/** 无权限时抛 {@code 40301}。 */
public void require(PermissionDef perm) {
if (!has(perm)) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
/** 当前用户的权限码全集(用于 {@code GET /auth/me})。 */
public Set<String> currentPermissions() {
LoginUser user = UserContext.require();
if (user.isPlatformAdmin()) {
return PermissionDef.allCodes();
}
return BuiltinRole.mergePermissions(user.roles());
}
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
public Set<String> currentMenus() {
LoginUser user = UserContext.require();
return BuiltinRole.mergeMenus(user.roles());
}
}
@@ -0,0 +1,109 @@
package cn.tianbaotong.fill.security;
import lombok.Getter;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 权限码与菜单定义。
*
* <p><b>为什么定义在代码里而不是数据库</b>:权限码是前后端的<b>契约</b>——
* 后端用它做校验,前端用它控制按钮显隐。若存 DB 且各有各的版本,
* 会出现"按钮显示出来了、点了报 40301"这种最难查的问题。放代码里,
* 前后端从同一个枚举生成,物理上不可能漂移。
*
* <p>角色是租户可自定义的,所以「角色 → 权限」的授权关系存 DB({@code sys_role_permission});
* 而「有哪些权限」是系统固定的,在这里。
*
* <p>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
*/
@Getter
public enum PermissionDef {
// ── 平台运营 ──────────────────────────────────
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC),
TENANT_VIEW("tenant:view", "租户查看", Platform.PC),
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC),
// ── 组织与账号 ────────────────────────────────
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC),
ORG_VIEW("org:view", "组织架构查看", Platform.PC),
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC),
USER_VIEW("user:view", "账号查看", Platform.PC),
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC),
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC),
// ── 收集任务 ──────────────────────────────────
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC),
TASK_VIEW("task:view", "任务查看", Platform.PC),
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC),
FORM_MANAGE("form:manage", "表单设计", Platform.PC),
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC),
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC),
// ── 名册 ──────────────────────────────────────
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC),
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC),
ROSTER_VIEW("roster:view", "名册查看", Platform.PC),
JOIN_REQUEST_AUDIT("join:audit", "名单加入申请审批", Platform.BOTH),
// ── 通知与进度 ────────────────────────────────
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH),
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH),
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH),
// ── 审核 ──────────────────────────────────────
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH),
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC),
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC),
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH),
// ── 导出 ──────────────────────────────────────
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC),
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC),
CLEANUP_RUN("cleanup:run", "历史数据整理器", Platform.PC),
// ── 学生端 ────────────────────────────────────
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP);
/** 端类型。 */
public enum Platform {
/** 仅 PC 管理端 */
PC,
/** 仅小程序 */
MP,
/** 两端都可 */
BOTH
}
/** 权限码,如 {@code task:manage}。 */
private final String code;
/** 中文名,用于前端提示与后台展示。 */
private final String label;
/** 适用范围。 */
private final Platform platform;
PermissionDef(String code, String label, Platform platform) {
this.code = code;
this.label = label;
this.platform = platform;
}
public static PermissionDef ofCode(String code) {
return Arrays.stream(values())
.filter(p -> p.code.equals(code))
.findFirst()
.orElse(null);
}
public static Set<String> allCodes() {
Set<String> set = new LinkedHashSet<>();
Arrays.stream(values()).forEach(p -> set.add(p.code));
return set;
}
}
@@ -0,0 +1,22 @@
package cn.tianbaotong.fill.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标记无需登录即可访问的接口。
*
* <p><b>用法约束</b>:能不带就尽量别带。标了这个注解的接口必须自己保证数据范围——
* 因为多租户拦截器在匿名请求下是<b>默认拒绝</b>的(见 {@code FillTenantLineHandler}),
* 触及租户表的匿名查询会直接抛 40101,除非该 Mapper 方法显式标注
* {@code @InterceptorIgnore(tenantLine = "true")}。
*
* <p>目前只有 4 个接口需要:{@code /auth/login}、{@code /auth/captcha}、
* {@code /mp/login}、{@code /mp/roster/match}。
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PublicApi {
}
@@ -0,0 +1,24 @@
package cn.tianbaotong.fill.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 声明接口所需的权限码。
*
* <p>例:{@code @RequirePerm(PermissionDef.TASK_MANAGE)}。
* 可标多个,语义是 <b>OR</b>(满足其一即可)——
* 因为多数场景是"管理员或班主任都能做",AND 会让调用方被迫拆接口。
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePerm {
/** 需要的权限码,满足任一即可。 */
PermissionDef[] value();
/** 是否要求<b>全部</b>满足。默认 false(任一满足)。 */
boolean requireAll() default false;
}
@@ -0,0 +1,60 @@
package cn.tianbaotong.fill.security;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
/**
* 登出黑名单。
*
* <p>JWT 是无状态的,签发后服务端无法"收回"。登出后到自然过期之间(PC 最长 8 小时)
* 若不处理,被盗的 token 仍能用——所以登出必须落一份黑名单。
*
* <p><b>两个实现细节</b>:
* <ol>
* <li><b>存 SHA-256 而不是原文</b>。token 本身就是凭证,把它明文写进 Redis
* 等于把凭证复制了一份到另一个地方——Redis 的访问日志、备份、
* 运维的 {@code KEYS *} 都会把它带出去。</li>
* <li><b>TTL 只到 token 自然过期</b>。过期后 JWT 自己就失效了,
* 再留着黑名单只是白占内存。这样集合大小天然有上界。</li>
* </ol>
*/
@Component
public class TokenBlacklist {
private static final String KEY_PREFIX = "xt:jwt:bl:";
private final StringRedisTemplate redis;
public TokenBlacklist(StringRedisTemplate redis) {
this.redis = redis;
}
public void add(String token, long ttlSeconds) {
if (token == null || token.isBlank() || ttlSeconds <= 0) {
return;
}
redis.opsForValue().set(KEY_PREFIX + fingerprint(token), "1", Duration.ofSeconds(ttlSeconds));
}
public boolean contains(String token) {
if (token == null || token.isBlank()) {
return false;
}
return Boolean.TRUE.equals(redis.hasKey(KEY_PREFIX + fingerprint(token)));
}
private static String fingerprint(String token) {
try {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest(token.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
} catch (Exception e) {
throw new IllegalStateException("计算 token 指纹失败", e);
}
}
}
@@ -0,0 +1,55 @@
package cn.tianbaotong.fill.support;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 业务表公共字段(对齐 DDL 的通用约定)。
*
* <p><b>字段含义</b>:
* <ul>
* <li>{@code create_by / create_time / update_by / update_time} —— 谁在什么时候建/改的</li>
* <li>{@code deleted} —— 软删除,客户明确要求字段就叫这个名字({@code 0} 正常 / {@code 1} 已删)</li>
* </ul>
*
* <p><b>为什么用填充而不是靠 DB 默认值</b>:{@code create_by} 需要当前登录用户的 ID,
* DB 没有上下文;而 {@code create_time} 若一半靠 DB、一半靠应用,会出现同一事务内
* 两条记录的创建时间不一致(DB 取的是语句执行时刻,应用取的是 NOW())。
* 统一由应用填充,时间口径一致。
*
* <p><b>使用 {@code @Getter/@Setter} 而非 {@code @Data}</b>:{@code @Data} 会生成
* {@code equals/hashCode},对可变实体 + 继承体系是经典陷阱,这里不需要。
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/** 主键。用 PG 的 IDENTITY,由数据库生成。 */
@TableId(type = IdType.AUTO)
private Long id;
@TableField(value = "create_by", fill = FieldFill.INSERT)
private Long createBy;
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(value = "update_by", fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 软删除标记:{@code 0} 正常 / {@code 1} 已删。加了 {@code @TableLogic} 后,查询自动带 {@code deleted = 0}。 */
@TableLogic
@TableField(value = "deleted", fill = FieldFill.INSERT)
private Integer deleted;
}
@@ -0,0 +1,133 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 初始数据装配(幂等,可重复执行)。
*
* <p>为什么用 Java 而不是 SQL 种子脚本:
* <ol>
* <li>密码要 BCrypt 哈希,写死在 SQL 里意味着<b>所有人共用同一个哈希</b>,
* 而且换个环境就得手工重算;</li>
* <li>内置角色的权限来自 {@link BuiltinRole} 代码常量,
* 在 SQL 里复制一份必然与服务端校验逻辑漂移。</li>
* </ol>
*
* <p>⚠️ 生产环境应设 {@code fill.seed.enabled=false},改用平台运营界面手工开通租户,
* 避免默认口令进生产。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DataSeeder implements ApplicationRunner {
private static final long PLATFORM_TENANT = LoginUser.PLATFORM_TENANT_ID;
private final TenantMapper tenantMapper;
private final UserMapper userMapper;
private final RoleProvisioner roleProvisioner;
private final PasswordEncoder passwordEncoder;
@Value("${fill.seed.enabled:true}")
private boolean enabled;
@Override
@Transactional(rollbackFor = Exception.class)
public void run(ApplicationArguments args) {
if (!enabled) {
log.info("数据初始化已关闭(fill.seed.enabled=false)");
return;
}
seedPlatform();
seedDemoTenant();
}
/** 平台运营:{@code tenant_id = 0} 的角色与账号。 */
private void seedPlatform() {
TenantScope.runAs(PLATFORM_TENANT, 0L, Set.of(BuiltinRole.PLATFORM_ADMIN.getCode()), () -> {
roleProvisioner.provisionBuiltinRoles(PLATFORM_TENANT);
if (findUser(PLATFORM_TENANT, "platform") != null) {
return;
}
SysUser u = new SysUser();
u.setTenantId(PLATFORM_TENANT);
u.setUsername("platform");
u.setPassword(passwordEncoder.encode("Platform@123"));
u.setRealName("平台运营");
u.setUserType(1);
u.setStatus(1);
userMapper.insert(u);
roleProvisioner.bindRole(PLATFORM_TENANT, u.getId(), BuiltinRole.PLATFORM_ADMIN);
log.info("已创建平台运营账号 platform / Platform@123(仅开发环境,生产必须改)");
});
}
/** 示范学校:让 M0 验收可以立刻跑「开通租户 → 建组织树 → 导用户 → 登录」。 */
private void seedDemoTenant() {
SysTenant t = tenantMapper.selectOne(
Wrappers.<SysTenant>lambdaQuery().eq(SysTenant::getTenantCode, "demo"));
if (t == null) {
t = new SysTenant();
t.setTenantCode("demo");
t.setTenantName("示范职业学校");
t.setTenantType(1);
t.setContactName("张校长");
t.setContactPhone("13800138000");
t.setStatus(1);
t.setExpireTime(LocalDateTime.now().plusYears(1));
tenantMapper.insert(t);
log.info("已创建示范租户 demo(id={})", t.getId());
}
final long tid = t.getId();
TenantScope.runAs(tid, 0L, Set.of(BuiltinRole.SCHOOL_ADMIN.getCode()), () -> {
roleProvisioner.provisionBuiltinRoles(tid);
if (findUser(tid, "admin") != null) {
return;
}
SysUser u = new SysUser();
u.setTenantId(tid);
u.setUsername("admin");
u.setPassword(passwordEncoder.encode("Admin@123"));
u.setRealName("张校长");
u.setUserType(2);
u.setStatus(1);
userMapper.insert(u);
roleProvisioner.bindRole(tid, u.getId(), BuiltinRole.SCHOOL_ADMIN);
log.info("已创建学校管理员账号 admin / Admin@123");
});
}
private SysUser findUser(long tenantId, String username) {
return userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getUsername, username)
.last("LIMIT 1"));
}
/** 保留:主应用启动时用到的常量,避免未使用告警。 */
@SuppressWarnings("unused")
private static List<Long> noop() {
return List.of();
}
}
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 公共字段自动填充。
*
* <p>用 {@code strictInsertFill} / {@code strictUpdateFill} 而不是 {@code setFieldValByName}:
* 前者<b>只在字段为 null 时填充</b>,后者会无条件覆盖。
* 这在导入历史数据(需要指定 create_time)的场景下差别很大。
*
* <p>{@code strictXxxFill} 按字段名匹配,因此 {@link BaseEntity} 上必须标注
* {@code @TableField(fill = ...)},否则填充不会生效——改了基类记得同步看这里。
*/
@Component
public class FillMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
LoginUser user = UserContext.get();
Long uid = user == null ? null : user.uid();
Long tenantId = user == null ? null : user.tenantId();
strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
strictInsertFill(metaObject, "deleted", Integer.class, 0);
if (uid != null) {
strictInsertFill(metaObject, "createBy", Long.class, uid);
strictInsertFill(metaObject, "updateBy", Long.class, uid);
}
// ⚠️ 租户 ID 只能来自登录上下文,绝不接受业务代码传入。
// 若这里为 null 而实体有 tenantId 字段,插入会因 NOT NULL 约束失败——
// 这是刻意的:与其静默写错租户,不如立刻报错。
if (tenantId != null) {
strictInsertFill(metaObject, "tenantId", Long.class, tenantId);
}
}
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
LoginUser user = UserContext.get();
if (user != null && user.uid() != null) {
strictUpdateFill(metaObject, "updateBy", Long.class, user.uid());
}
}
}
@@ -0,0 +1,85 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.module.user.entity.SysRole;
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
import cn.tianbaotong.fill.module.user.entity.SysUserRole;
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
import cn.tianbaotong.fill.security.BuiltinRole;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 内置角色的发放。
*
* <p>「开通租户」与「数据初始化」都要做同一件事:为新租户写入 6 个内置角色 + 授权。
* 抽出来是为了保证两处行为一致——否则手工开通的租户和初始化出来的租户权限不一样,
* 这种差异在测试环境几乎发现不了。
*
* <p>⚠️ 调用前必须保证 {@code UserContext} 已切到<b>目标租户</b>
* (见 {@link TenantScope}),否则角色会被写进调用者自己的租户。
*/
@Component
@RequiredArgsConstructor
public class RoleProvisioner {
private final RoleMapper roleMapper;
private final RolePermissionMapper rolePermissionMapper;
private final UserRoleMapper userRoleMapper;
/** 幂等:已存在的角色跳过。 */
public void provisionBuiltinRoles(long tenantId) {
for (BuiltinRole br : BuiltinRole.all()) {
if (findRole(tenantId, br.getCode()) != null) {
continue;
}
SysRole role = new SysRole();
role.setTenantId(tenantId);
role.setRoleCode(br.getCode());
role.setRoleName(br.getName());
role.setRoleType(1);
role.setDataScope(br.getDataScope().code());
role.setSortNo(br.getSortNo());
role.setStatus(1);
role.setRemark("内置角色,权限由代码决定,不可修改");
roleMapper.insert(role);
for (String permCode : br.permissionCodes()) {
SysRolePermission rp = new SysRolePermission();
rp.setTenantId(tenantId);
rp.setRoleId(role.getId());
rp.setPermCode(permCode);
rolePermissionMapper.insert(rp);
}
}
}
public SysRole findRole(long tenantId, String roleCode) {
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
.eq(SysRole::getTenantId, tenantId)
.eq(SysRole::getRoleCode, roleCode)
.last("LIMIT 1"));
}
/** 给用户绑上一个内置角色。 */
public void bindRole(long tenantId, long userId, BuiltinRole br) {
SysRole role = findRole(tenantId, br.getCode());
if (role == null) {
throw new IllegalStateException("内置角色未创建:" + br.getCode());
}
SysUserRole ur = new SysUserRole();
ur.setTenantId(tenantId);
ur.setUserId(userId);
ur.setRoleId(role.getId());
userRoleMapper.insert(ur);
}
/** 批量绑定,用于初始化。 */
public void bindRoles(long tenantId, long userId, Set<BuiltinRole> roles) {
roles.forEach(br -> bindRole(tenantId, userId, br));
}
}
@@ -0,0 +1,81 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.crypto.AesGcmCipher;
import cn.tianbaotong.fill.common.crypto.HmacIndex;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 敏感字段读写封装。
*
* <p>PRD §10.3 要求身份证号与手机号「AES 加密存储 + HMAC 索引检索」。
* 这个组合的用法很容易记错——<b>写法是「同时写密文和 hash」,查法是「只查 hash,不查密文」</b>。
* 把两步收在一个地方,避免某个调用点只写了密文、漏了 hash,
* 结果该用户永远搜不到(数据看起来还在,只是"消失"了)。
*
* <p>⚠️ 加密列({@code phone} / {@code id_card_no})<b>永远不要用于 WHERE 条件</b>:
* GCM 每次加密的 IV 都不同,同一个手机号两次加密的密文完全不同,
* {@code WHERE phone = ?} 恒不命中。
*/
@Component
@RequiredArgsConstructor
public class SensitiveCodec {
private final AesGcmCipher aes;
private final HmacIndex hmac;
/** 加密(用于落库)。 */
public String encrypt(String plain) {
return aes.encrypt(plain);
}
/** 解密(用于展示或导出)。 */
public String decrypt(String cipher) {
return aes.decrypt(cipher);
}
/** 计算检索索引(用于 {@code *_hash} 列)。 */
public String hash(String plain) {
return hmac.hash(plain);
}
/** 同时写入手机号的密文与索引。 */
public void applyPhone(SysUser user, String phone) {
String normalized = normalize(phone);
user.setPhone(encrypt(normalized));
user.setPhoneHash(hash(normalized));
}
/** 同时写入证件号的密文与索引。 */
public void applyIdCard(SysUser user, String idCardNo) {
String normalized = normalize(idCardNo);
user.setIdCardNo(encrypt(normalized));
user.setIdCardHash(hash(normalized));
}
/** 取手机号明文(仅在确需展示原文时调用,调用点必须记审计日志)。 */
public String plainPhone(SysUser user) {
return user == null ? null : decrypt(user.getPhone());
}
/** 取证件号明文(同上,见 §1.7 的明文查看约束)。 */
public String plainIdCard(SysUser user) {
return user == null ? null : decrypt(user.getIdCardNo());
}
/**
* 规范化。
*
* <p>必须与 {@code HmacIndex.hash} 内部的规范化一致,否则会出现
* 「写进去时 hash 用了带空格的手机号、查询时用了不带空格的」这种不匹配。
* 所以统一在这里做一次,两边都用处理过的值。
*/
private static String normalize(String raw) {
if (raw == null) {
return null;
}
String s = raw.trim().replace(" ", "");
return s.isEmpty() ? null : s;
}
}
@@ -0,0 +1,23 @@
package cn.tianbaotong.fill.support;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
/**
* 带租户维度的业务实体基类。
*
* <p><b>继承它的表,SQL 会被自动追加 {@code tenant_id = ?}</b>(见 {@code MybatisPlusConfig}
* 的 {@code FillTenantLineHandler})。这也是为什么租户隔离不靠"开发者记得写条件"——
* 写漏了会被拦截器补上,而不是变成越权漏洞。
*
* <p>⚠️ {@code tenant_id} 由 {@link cn.tianbaotong.fill.support.FillMetaObjectHandler}
* 从登录上下文自动填充,<b>业务代码不要手工设置</b>——手工设置意味着可以向其他租户写数据。
*/
@Getter
@Setter
public abstract class TenantEntity extends BaseEntity {
@TableField(value = "tenant_id", fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT)
private Long tenantId;
}
@@ -0,0 +1,55 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
/**
* 临时切换租户作用域。
*
* <p><b>解决什么问题</b>:平台运营({@code tenantId=0})给某个租户建数据时,
* 多租户拦截器会拿当前上下文的 {@code tenantId}(也就是 0)去填充 INSERT——
* 结果是<B>新建的管理员被写进了平台租户,而不是目标租户</b>。
* 这类 bug 不会报错,只会让新租户"开通了但登不进去",非常难查。
*
* <p>解法:在「为新租户建数据」的代码段里,把上下文临时切成目标租户,
* 让所有隐式的租户填充自然落到正确的租户上。
*
* <p><b>为什么不在这些 Mapper 上加 {@code @InterceptorIgnore}</b>:
* 那会关掉租户条件,等于放弃保护;而切上下文只是把"当前是谁"换成正确的那个,
* 拦截器的保护依然完整。
*
* <p>⚠️ 必须成对使用,且要保证异常路径也恢复——所以用 {@code try/finally} 包起来,
* 并且只通过 {@link #callAs} / {@link #runAs} 暴露(不提供裸的 set/restore)。
*/
public final class TenantScope {
private TenantScope() {
}
/** 在指定租户的上下文中执行并返回结果。 */
public static <T> T callAs(long tenantId, Long uid, Set<String> roles, Supplier<T> action) {
LoginUser previous = UserContext.get();
try {
UserContext.set(new LoginUser(uid, tenantId, List.of(), roles, null));
return action.get();
} finally {
if (previous == null) {
UserContext.clear();
} else {
UserContext.set(previous);
}
}
}
/** 在指定租户的上下文中执行。 */
public static void runAs(long tenantId, Long uid, Set<String> roles, Runnable action) {
callAs(tenantId, uid, roles, () -> {
action.run();
return null;
});
}
}
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.support;
import jakarta.servlet.http.HttpServletRequest;
/**
* Web 层杂项工具。
*/
public final class WebUtil {
private WebUtil() {
}
/**
* 取客户端真实 IP。
*
* <p><b>⚠️ 安全提醒</b>:{@code X-Forwarded-For} 是<b>客户端可以伪造</b>的请求头。
* 这里信任它,前提是部署在受控反向代理(Nginx)之后,且代理会覆盖而非追加该头。
* 若直接暴露到公网,攻击者可以随便伪造 IP 来绕过基于 IP 的限流。
*
* <p>生产部署时必须保证:{@code proxy_set_header X-Forwarded-For $remote_addr;}
* (用代理看到的真实地址覆盖,不是 {@code $proxy_add_x_forwarded_for})。
*
* @return 客户端 IP,取不到时返回 {@code "unknown"}(不返回 null,避免调用方到处判空)
*/
public static String clientIp(HttpServletRequest request) {
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
for (String h : headers) {
String v = request.getHeader(h);
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
// XFF 可能是逗号分隔的链,第一个是原始客户端
int comma = v.indexOf(',');
String ip = comma > 0 ? v.substring(0, comma) : v;
return truncate(ip.trim());
}
}
String remote = request.getRemoteAddr();
return truncate(remote == null ? "unknown" : remote);
}
/** 截断到列宽({@code ip varchar(64)}),防止超长头打爆插入。 */
private static String truncate(String s) {
if (s == null) {
return "unknown";
}
return s.length() <= 64 ? s : s.substring(0, 64);
}
/** 截断 User-Agent 到列宽({@code user_agent varchar(512)})。 */
public static String userAgent(HttpServletRequest request) {
String ua = request.getHeader("User-Agent");
if (ua == null) {
return null;
}
return ua.length() <= 512 ? ua : ua.substring(0, 512);
}
}
@@ -0,0 +1,46 @@
# ═══════════════════════════════════════════════════════════
# 开发环境(dev)
#
# ⚠️⚠️ 本文件里的密钥是【开发专用】的,已提交进仓库以便本地一键启动。
# 它们【绝对不能】用于测试或生产环境。
# 生产环境一律走环境变量,见 application-prod.yml。
# ═══════════════════════════════════════════════════════════
spring:
datasource:
url: jdbc:postgresql://localhost:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
username: ${PG_USER:fill}
password: ${PG_PASSWORD:fill_dev_pwd}
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:16379}
password: ${REDIS_PASSWORD:redis_dev_pwd}
database: 0
flyway:
# dev 允许 clean 后重建,方便反复试 DDL
clean-disabled: false
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
root: info
cn.tianbaotong.fill: debug
# 打开 SQL 与连接池详情
org.springframework.jdbc.core: debug
fill:
jwt:
secret: BC0LUCKD3T4FfgMioHyARklQBa5phAXrYmzJStAwKiqftTJGURd4UblkjXFF+m8ZtaIC6BSeXKN2wuqBlkHPbA==
crypto:
aes-key: CaJuBbvbSKkLGn+d3WfWcmbkZE48NHglssoW53fKbrY=
hmac-key: 1x75SulC+e+dQYZMEJMoVLf+E6KmgE0lUv6ShfJ6FDzNJdtJpmaxacPmX+/qCzhS
rsa:
# PKCS#8 私钥(单行 Base64,无 PEM 头尾)
private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0=
springdoc:
swagger-ui:
enabled: true
@@ -0,0 +1,112 @@
# ═══════════════════════════════════════════════════════════
# 数据信息填报系统 · 主配置
# 环境差异全部放 application-{dev,test,prod}.yml
# ⚠️ 生产密钥一律走环境变量,不写在这里
# ═══════════════════════════════════════════════════════════
server:
port: 8080
# 上传接口是长连接,超时要放宽(学生可能从聊天记录选一个 40MB 的文件)
tomcat:
connection-timeout: 60s
max-swallow-size: 100MB
compression:
enabled: true
mime-types: application/json,text/html,text/css,application/javascript
min-response-size: 2048
spring:
application:
name: xt-server
profiles:
active: dev
lifecycle:
# 优雅停机:等在途请求处理完,避免上传/导出被打断
timeout-per-shutdown-phase: 30s
jackson:
time-zone: Asia/Shanghai
default-property-inclusion: always
deserialization:
fail-on-unknown-properties: false
servlet:
multipart:
max-file-size: 50MB
max-request-size: 60MB
datasource:
driver-class-name: org.postgresql.Driver
hikari:
pool-name: xt-hikari
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 10000
idle-timeout: 600000
max-lifetime: 1700000
# 慢 SQL 从应用侧也记一份,方便和 PG 的 log_min_duration_statement 对照
leak-detection-threshold: 20000
data:
redis:
timeout: 3s
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 3s
flyway:
enabled: true
locations: classpath:db/migration
# 迁移文件一旦发布就不许改,改了校验会失败——这是保护,不是障碍
validate-on-migrate: true
baseline-on-migrate: false
table: flyway_schema_history
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
# 生产环境关掉 SQL 日志,dev 单独开
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
global-config:
banner: false
db-config:
# 软删除字段名,客户明确要求就叫 deleted
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
health:
redis:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /doc.html
tags-sorter: alpha
operations-sorter: alpha
# ── 本项目业务配置 ────────────────────────────
fill:
# JWT 见《03-接口设计》§1.2:PC 8 小时 / 小程序 7 天
jwt:
issuer: tianbaotong-fill
pc-expire-seconds: 28800
mp-expire-seconds: 604800
# 密钥与 RSA/AES 密钥在 application-{env}.yml 或环境变量中提供
security:
# 登录失败锁定:5 次锁 15 分钟(§1.6)
login-max-fail: 5
login-lock-minutes: 15
# 连续失败几次后要求图形验证码
captcha-after-fails: 2
upload:
# 单文件上限 50MB(PRD §10.1)
max-file-size-mb: 50
@@ -0,0 +1,724 @@
-- ═══════════════════════════════════════════════════════════════════
-- V1 · 业务表(20 张)
--
-- ⚠️ 本文件由脚本生成,请勿手改!
-- 来源 :docs/db/schema-postgresql.sql
-- 重新生成 :python scripts/sync-ddl.py
-- 校验同步 :python scripts/sync-ddl.py --check
--
-- 源文件已在 PostgreSQL 16.15 实测零错误:
-- 20 表 / 177 列注释 / 65 索引 / 20 表注释
-- ═══════════════════════════════════════════════════════════════════
-- ============ 1) 建表 ============
CREATE TABLE sys_tenant (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_code varchar(32) NOT NULL,
tenant_name varchar(128) NOT NULL,
tenant_type smallint NOT NULL DEFAULT 1,
contact_name varchar(64),
contact_phone varchar(128),
package_id bigint,
expire_time timestamp,
status smallint NOT NULL DEFAULT 1,
logo_url varchar(512),
config_json jsonb,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_tenant_code UNIQUE (tenant_code)
);
COMMENT ON TABLE sys_tenant IS '租户';
COMMENT ON COLUMN sys_tenant.id IS '租户ID';
COMMENT ON COLUMN sys_tenant.tenant_code IS '租户编码(短码,用于小程序扫码识别)';
COMMENT ON COLUMN sys_tenant.tenant_name IS '租户名称(学校/机构)';
COMMENT ON COLUMN sys_tenant.tenant_type IS '1学校 2认定机构 3报名点 4企业';
COMMENT ON COLUMN sys_tenant.contact_name IS '联系人';
COMMENT ON COLUMN sys_tenant.contact_phone IS '联系电话(AES加密存储;varchar(128)容纳密文,非明文长度)';
COMMENT ON COLUMN sys_tenant.package_id IS '套餐ID';
COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间';
COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用';
COMMENT ON COLUMN sys_tenant.logo_url IS 'Logo';
COMMENT ON COLUMN sys_tenant.config_json IS '租户配置(默认压缩参数/文件保留天数等)';
CREATE TABLE sys_org (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
parent_id bigint NOT NULL DEFAULT 0,
org_name varchar(128) NOT NULL,
org_type smallint NOT NULL DEFAULT 3,
org_code varchar(64),
ancestor_path varchar(512),
leader_user_id bigint,
sort_no integer NOT NULL DEFAULT 0,
status smallint NOT NULL DEFAULT 1,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
);
CREATE INDEX idx_tenant_parent ON sys_org (tenant_id, parent_id);
CREATE INDEX idx_tenant_name ON sys_org (tenant_id, org_name);
COMMENT ON TABLE sys_org IS '组织架构';
COMMENT ON COLUMN sys_org.parent_id IS '父级ID,0为根';
COMMENT ON COLUMN sys_org.org_name IS '组织名称,如 24中技数控1班';
COMMENT ON COLUMN sys_org.org_type IS '1学校 2院系/专业 3班级 4报名点';
COMMENT ON COLUMN sys_org.org_code IS '组织编码/班号';
COMMENT ON COLUMN sys_org.ancestor_path IS '祖先路径,如 0,1,5';
COMMENT ON COLUMN sys_org.leader_user_id IS '负责人(班主任)';
CREATE TABLE sys_occupation_ref (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
parent_id bigint NOT NULL DEFAULT 0,
name varchar(128) NOT NULL,
aliases varchar(512),
has_child smallint NOT NULL DEFAULT 0,
status smallint NOT NULL DEFAULT 1,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_parent ON sys_occupation_ref (parent_id);
CREATE INDEX idx_occref_name ON sys_occupation_ref (name);
COMMENT ON TABLE sys_occupation_ref IS '职业工种参考字典(仅拼写校验,非约束)';
COMMENT ON COLUMN sys_occupation_ref.parent_id IS '0=职业; 非0=该职业下的工种';
COMMENT ON COLUMN sys_occupation_ref.aliases IS '常见错写/别名,用于纠错提示';
COMMENT ON COLUMN sys_occupation_ref.has_child IS '该职业是否有工种';
CREATE TABLE sys_user (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL DEFAULT 0,
org_id bigint,
username varchar(64) NOT NULL,
password varchar(128),
real_name varchar(64) NOT NULL,
phone varchar(128),
phone_hash char(64),
open_id varchar(64),
union_id varchar(64),
id_card_no varchar(128),
id_card_hash char(64),
avatar_url varchar(512),
user_type smallint NOT NULL DEFAULT 1,
status smallint NOT NULL DEFAULT 1,
last_login_time timestamp,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_tenant_username UNIQUE (tenant_id, username)
);
CREATE INDEX idx_phone_hash ON sys_user (phone_hash);
CREATE INDEX idx_idcard_hash ON sys_user (id_card_hash);
CREATE INDEX idx_openid ON sys_user (open_id);
COMMENT ON TABLE sys_user IS '用户';
COMMENT ON COLUMN sys_user.tenant_id IS '0=平台运营';
COMMENT ON COLUMN sys_user.org_id IS '所属组织';
COMMENT ON COLUMN sys_user.username IS '登录名(教师)';
COMMENT ON COLUMN sys_user.password IS 'BCrypt';
COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储;varchar(128)容纳密文,非明文长度)';
COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
COMMENT ON COLUMN sys_user.open_id IS '微信openid';
COMMENT ON COLUMN sys_user.id_card_no IS '证件号(AES加密存储)';
COMMENT ON COLUMN sys_user.id_card_hash IS '证件号HMAC,用于比对与去重';
COMMENT ON COLUMN sys_user.user_type IS '1教师/管理员 2填报人(学生) 3社会人员';
COMMENT ON COLUMN sys_user.status IS '0停用 1正常 2待激活';
CREATE TABLE collect_task (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_code varchar(64) NOT NULL,
task_name varchar(200) NOT NULL,
collect_mode smallint NOT NULL DEFAULT 1,
batch_no varchar(64),
major_name varchar(64),
job_name varchar(64),
job_level varchar(32),
cert_type smallint NOT NULL DEFAULT 1,
start_time timestamp,
end_time timestamp,
audit_config jsonb,
naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}',
compress_config jsonb,
roster_required smallint NOT NULL DEFAULT 1,
status smallint NOT NULL DEFAULT 0,
published_time timestamp,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_task_tenant_code UNIQUE (tenant_id, task_code)
);
CREATE INDEX idx_tenant_status ON collect_task (tenant_id, status);
CREATE INDEX idx_end_time ON collect_task (end_time);
COMMENT ON TABLE collect_task IS '收集任务';
COMMENT ON COLUMN collect_task.task_code IS '任务编码,用于文件路径';
COMMENT ON COLUMN collect_task.task_name IS '如 2026年9月机电专业认定材料收集';
COMMENT ON COLUMN collect_task.collect_mode IS '1学校收集 2社会收集';
COMMENT ON COLUMN collect_task.batch_no IS '批次号';
COMMENT ON COLUMN collect_task.major_name IS '专业';
COMMENT ON COLUMN collect_task.job_name IS '报考工种';
COMMENT ON COLUMN collect_task.job_level IS '报考等级';
COMMENT ON COLUMN collect_task.cert_type IS '1自主认定 2社会评价 3专项职业能力';
COMMENT ON COLUMN collect_task.end_time IS '截止时间';
COMMENT ON COLUMN collect_task.audit_config IS '审核流配置,见6.5';
COMMENT ON COLUMN collect_task.compress_config IS '任务级压缩默认参数';
COMMENT ON COLUMN collect_task.roster_required IS '是否需要名册: 固定为1,两种收集模式都必填(名单门禁)';
COMMENT ON COLUMN collect_task.status IS '0草稿 1收集中 2已截止 3已归档 4暂停';
CREATE TABLE collect_task_org (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
org_id bigint NOT NULL,
head_user_id bigint,
deputy_user_ids varchar(256),
expected_count integer NOT NULL DEFAULT 0,
status smallint NOT NULL DEFAULT 1,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT uk_task_org UNIQUE (task_id, org_id)
);
CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id);
COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定';
COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id';
COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔';
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)';
COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效';
CREATE TABLE collect_form_schema (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
version integer NOT NULL DEFAULT 1,
schema_json jsonb NOT NULL,
is_current smallint NOT NULL DEFAULT 1,
create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_task_version ON collect_form_schema (task_id, version);
COMMENT ON TABLE collect_form_schema IS '表单定义(版本化)';
COMMENT ON COLUMN collect_form_schema.version IS '版本号,已发布任务修改则+1';
COMMENT ON COLUMN collect_form_schema.schema_json IS '字段定义数组';
CREATE TABLE collect_material_def (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
material_code varchar(32) NOT NULL,
material_name varchar(64) NOT NULL,
aliases varchar(512),
name_pattern varchar(256),
required smallint NOT NULL DEFAULT 1,
visible_when varchar(256),
file_types varchar(128) NOT NULL DEFAULT 'jpg,jpeg,png',
max_size_kb integer NOT NULL DEFAULT 1024,
compress_profile varchar(8) NOT NULL DEFAULT 'AUTO',
min_long_side integer NOT NULL DEFAULT 1400,
min_quality smallint NOT NULL DEFAULT 70,
target_dpi integer,
fixed_size varchar(32),
output_format varchar(16) NOT NULL DEFAULT 'jpg',
max_count smallint NOT NULL DEFAULT 1,
merge_enabled smallint NOT NULL DEFAULT 0,
ocr_type varchar(32),
sample_url varchar(512),
tip_text varchar(512),
sort_no integer NOT NULL DEFAULT 0,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_task_code UNIQUE (task_id, material_code)
);
CREATE INDEX idx_matdef_tenant ON collect_material_def (tenant_id);
COMMENT ON TABLE collect_material_def IS '材料项定义';
COMMENT ON COLUMN collect_material_def.material_code IS '如 IDCARD/PHOTO1/SCHOOL_ROLL/WORK_PROOF';
COMMENT ON COLUMN collect_material_def.material_name IS '规范材料名,如 身份证';
COMMENT ON COLUMN collect_material_def.aliases IS '别名,逗号分隔,用于历史文件名识别';
COMMENT ON COLUMN collect_material_def.name_pattern IS '命名模板,空则继承任务级';
COMMENT ON COLUMN collect_material_def.visible_when IS '条件显示表达式';
COMMENT ON COLUMN collect_material_def.file_types IS '格式白名单';
COMMENT ON COLUMN collect_material_def.max_size_kb IS '体积上限KB';
COMMENT ON COLUMN collect_material_def.compress_profile IS '压缩档位: AUTO自动判定/P1证件照/P2证件翻拍/P3扫描件/P4截图/P5文档, 见6.3';
COMMENT ON COLUMN collect_material_def.min_long_side IS '分辨率下限(仅P2证件翻拍档生效)';
COMMENT ON COLUMN collect_material_def.min_quality IS '质量下限,低于65视为不可接受';
COMMENT ON COLUMN collect_material_def.target_dpi IS '目标DPI(仅P3扫描件档生效,建议150-300)';
COMMENT ON COLUMN collect_material_def.fixed_size IS '固定像素尺寸(仅P1证件照档,如295x413)';
COMMENT ON COLUMN collect_material_def.output_format IS 'P4截图档固定输出png';
COMMENT ON COLUMN collect_material_def.max_count IS '允许上传份数,身份证正反面可2';
COMMENT ON COLUMN collect_material_def.merge_enabled IS '多份是否合并为一张';
COMMENT ON COLUMN collect_material_def.ocr_type IS 'IDCARD/GENERAL/NONE';
COMMENT ON COLUMN collect_material_def.sample_url IS '示例图';
COMMENT ON COLUMN collect_material_def.tip_text IS '填报提示语';
CREATE TABLE collect_roster (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
org_id bigint,
seq_no integer,
student_no varchar(64),
real_name varchar(64) NOT NULL,
name_pinyin varchar(128),
id_card_no varchar(128) NOT NULL,
id_card_hash char(64) NOT NULL,
id_card_mask varchar(32),
phone varchar(128),
phone_hash char(64),
ext_json jsonb,
bind_user_id bigint,
bind_time timestamp,
bind_type smallint,
submit_status smallint NOT NULL DEFAULT 0,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_task_idcard UNIQUE (task_id, id_card_hash)
);
CREATE INDEX idx_task_org ON collect_roster (task_id, org_id);
CREATE INDEX idx_bind_user ON collect_roster (bind_user_id);
CREATE INDEX idx_roster_name ON collect_roster (task_id, real_name);
COMMENT ON TABLE collect_roster IS '名册';
COMMENT ON COLUMN collect_roster.org_id IS '班级';
COMMENT ON COLUMN collect_roster.seq_no IS '序号(来自Excel)';
COMMENT ON COLUMN collect_roster.student_no IS '学号(⚠️初稿遗漏,名册唯一性依据之一)';
COMMENT ON COLUMN collect_roster.name_pinyin IS '拼音,用于近音纠错';
COMMENT ON COLUMN collect_roster.id_card_no IS 'AES加密';
COMMENT ON COLUMN collect_roster.id_card_hash IS 'HMAC,唯一性判断';
COMMENT ON COLUMN collect_roster.id_card_mask IS '脱敏展示 3601**********1234';
COMMENT ON COLUMN collect_roster.ext_json IS '名册其余字段(学历/工作单位/报考职业等)';
COMMENT ON COLUMN collect_roster.bind_user_id IS '绑定的填报人';
COMMENT ON COLUMN collect_roster.bind_type IS '1门禁核验匹配 2管理员指定';
COMMENT ON COLUMN collect_roster.submit_status IS '0未提交 1草稿 2待审 3已通过 4已驳回';
CREATE TABLE collect_submission (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
roster_id bigint,
org_id bigint,
submitter_id bigint NOT NULL,
form_version integer NOT NULL DEFAULT 1,
form_data jsonb,
-- ⚠️ 与 collect_roster 对称的 18 个业务列:存学生「确认/修改后」的最终值
-- 需要与名单值做 diff(留痕"学生改了哪些字段"),也是统计"实际报考各工种人数"的依据
occupation varchar(64),
worktype varchar(64),
job_level varchar(32),
education varchar(32),
is_student smallint,
school varchar(128),
major varchar(128),
enroll_date date,
work_unit varchar(128),
is_retake smallint,
retake_theory varchar(64),
retake_practice varchar(64),
retake_overall varchar(64),
work_years smallint,
cert_occupation varchar(128),
cert_level varchar(32),
cert_no varchar(64),
extras_json jsonb DEFAULT '{}'::jsonb, -- 逃生舱
changed_fields jsonb, -- 学生相对名单修改过的字段(留痕)
status smallint NOT NULL DEFAULT 0,
reject_count integer NOT NULL DEFAULT 0,
last_reject_reason varchar(512),
red_count integer NOT NULL DEFAULT 0,
yellow_count integer NOT NULL DEFAULT 0,
submit_time timestamp,
class_audit_by bigint, class_audit_time timestamp,
admin_audit_by bigint, admin_audit_time timestamp,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_task_submitter UNIQUE (task_id, submitter_id)
);
CREATE INDEX idx_task_status ON collect_submission (task_id, status, org_id);
CREATE INDEX idx_submitter ON collect_submission (submitter_id);
COMMENT ON TABLE collect_submission IS '填报单';
COMMENT ON COLUMN collect_submission.roster_id IS '社会收集可为空';
COMMENT ON COLUMN collect_submission.submitter_id IS '填报人user_id';
COMMENT ON COLUMN collect_submission.form_data IS '表单数据快照(扩展字段;核心18列见上方独立列)';
COMMENT ON COLUMN collect_submission.occupation IS '最终报考职业(学生确认后,应与名单一致)';
COMMENT ON COLUMN collect_submission.worktype IS '最终报考工种';
COMMENT ON COLUMN collect_submission.job_level IS '最终报考等级';
COMMENT ON COLUMN collect_submission.is_student IS '是否在校生 0否 1是';
COMMENT ON COLUMN collect_submission.work_unit IS '工作单位(没有填"无")';
COMMENT ON COLUMN collect_submission.is_retake IS '是否补考 0否 1是';
COMMENT ON COLUMN collect_submission.extras_json IS '租户/机构额外追加字段(逃生舱)';
COMMENT ON COLUMN collect_submission.changed_fields IS '学生相对名单修改过的字段(留痕: {字段:{old,new,time}})';
COMMENT ON COLUMN collect_submission.status IS '0草稿 1待初审 2待终审 3已通过 4已驳回 5已归档';
COMMENT ON COLUMN collect_submission.red_count IS '红色问题数';
COMMENT ON COLUMN collect_submission.yellow_count IS '黄色问题数';
CREATE TABLE collect_file (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
submission_id bigint NOT NULL,
roster_id bigint,
material_code varchar(32) NOT NULL,
version integer NOT NULL DEFAULT 1,
is_current smallint NOT NULL DEFAULT 1,
original_name varchar(512) NOT NULL,
standard_name varchar(512),
file_ext varchar(16) NOT NULL,
mime_type varchar(64),
raw_size bigint NOT NULL,
final_size bigint NOT NULL,
file_hash char(64) NOT NULL,
raw_path varchar(512),
std_path varchar(512),
raw_expire_time timestamp,
width integer, height integer, quality smallint,
dpi integer,
ocr_status smallint NOT NULL DEFAULT 0,
ocr_result jsonb,
ocr_confidence numeric(4,3),
detected_material_code varchar(32),
detect_confidence numeric(4,3),
detect_source varchar(32),
detect_conflict smallint NOT NULL DEFAULT 0,
need_manual_place smallint NOT NULL DEFAULT 0,
vlm_called smallint NOT NULL DEFAULT 0,
validate_status smallint NOT NULL DEFAULT 0,
source smallint NOT NULL DEFAULT 1,
upload_ip varchar(64),
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
);
CREATE INDEX idx_file_submission ON collect_file (submission_id, material_code, is_current);
CREATE INDEX idx_task_material ON collect_file (task_id, material_code);
CREATE INDEX idx_hash ON collect_file (file_hash);
CREATE INDEX idx_raw_expire ON collect_file (raw_expire_time);
COMMENT ON TABLE collect_file IS '文件实体';
COMMENT ON COLUMN collect_file.is_current IS '是否最新有效版本';
COMMENT ON COLUMN collect_file.original_name IS '原始文件名';
COMMENT ON COLUMN collect_file.standard_name IS '规范文件名';
COMMENT ON COLUMN collect_file.raw_size IS '原始字节';
COMMENT ON COLUMN collect_file.final_size IS '处理后字节';
COMMENT ON COLUMN collect_file.file_hash IS 'SHA-256,用于查重';
COMMENT ON COLUMN collect_file.raw_path IS '对象存储key-原始';
COMMENT ON COLUMN collect_file.std_path IS '对象存储key-规范';
COMMENT ON COLUMN collect_file.raw_expire_time IS '原始文件清理时间';
COMMENT ON COLUMN collect_file.ocr_status IS '0未识别 1成功 2失败';
COMMENT ON COLUMN collect_file.ocr_result IS 'OCR结构化结果(不存明文证件号,存hash)';
COMMENT ON COLUMN collect_file.detected_material_code IS '材料识别结果(材料类型编码),见功能逻辑卷3.5-B';
COMMENT ON COLUMN collect_file.detect_confidence IS '材料识别置信度';
COMMENT ON COLUMN collect_file.detect_source IS '识别来源: FILENAME/形态KEYWORD/POSITION/VLM/MIXED';
COMMENT ON COLUMN collect_file.detect_conflict IS '是否与槽位冲突(1=冲突)';
COMMENT ON COLUMN collect_file.need_manual_place IS '是否需要人工归属(散图分拣用)';
COMMENT ON COLUMN collect_file.vlm_called IS '是否触发了VLM兜底(用于成本核算)';
COMMENT ON COLUMN collect_file.validate_status IS '0未校验 1通过 2警告 3错误';
COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入';
CREATE TABLE collect_validation_issue (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
submission_id bigint NOT NULL,
file_id bigint,
rule_code varchar(32) NOT NULL,
severity smallint NOT NULL,
field_key varchar(64),
title varchar(128) NOT NULL,
detail varchar(512),
expected_val varchar(256),
actual_val varchar(256),
auto_fixed smallint NOT NULL DEFAULT 0,
resolved smallint NOT NULL DEFAULT 0,
resolved_by bigint, resolved_time timestamp,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_issue_submission ON collect_validation_issue (submission_id, resolved);
CREATE INDEX idx_task_severity ON collect_validation_issue (task_id, severity, resolved);
COMMENT ON TABLE collect_validation_issue IS '校验问题';
COMMENT ON COLUMN collect_validation_issue.file_id IS '可为空(表单级问题)';
COMMENT ON COLUMN collect_validation_issue.rule_code IS 'VAL-01..VAL-12';
COMMENT ON COLUMN collect_validation_issue.severity IS '1信息 2警告 3错误';
COMMENT ON COLUMN collect_validation_issue.expected_val IS '期望值(如名册中的证件号)';
COMMENT ON COLUMN collect_validation_issue.actual_val IS '实际值';
COMMENT ON COLUMN collect_validation_issue.auto_fixed IS '是否已自动修复';
CREATE TABLE collect_audit_record (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
submission_id bigint NOT NULL,
audit_node varchar(32) NOT NULL,
auditor_id bigint NOT NULL,
action smallint NOT NULL,
reason_code varchar(32),
reason_text varchar(512),
material_codes varchar(256),
duration_sec integer,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_audit_submission ON collect_audit_record (submission_id, create_time);
CREATE INDEX idx_task_node ON collect_audit_record (task_id, audit_node, create_time);
COMMENT ON TABLE collect_audit_record IS '审核记录';
COMMENT ON COLUMN collect_audit_record.audit_node IS 'CLASS_AUDIT/ADMIN_AUDIT';
COMMENT ON COLUMN collect_audit_record.action IS '1通过 2驳回 3批量通过 4批量驳回 5转交 6代传';
COMMENT ON COLUMN collect_audit_record.reason_code IS '驳回原因编码';
COMMENT ON COLUMN collect_audit_record.material_codes IS '被驳回的材料项';
COMMENT ON COLUMN collect_audit_record.duration_sec IS '处理耗时';
CREATE TABLE collect_export_task (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
export_name varchar(256) NOT NULL,
scope_json jsonb,
split_size_mb integer NOT NULL DEFAULT 0,
desensitize smallint NOT NULL DEFAULT 0,
status smallint NOT NULL DEFAULT 0,
progress smallint NOT NULL DEFAULT 0,
file_count integer, total_size bigint,
result_files jsonb,
error_msg varchar(512),
expire_time timestamp,
create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
finish_time timestamp,
PRIMARY KEY (id)
);
CREATE INDEX idx_task ON collect_export_task (task_id, create_time);
CREATE INDEX idx_status ON collect_export_task (status);
COMMENT ON TABLE collect_export_task IS '导出任务';
COMMENT ON COLUMN collect_export_task.scope_json IS '范围:orgIds/userIds/仅通过终审/是否含原图';
COMMENT ON COLUMN collect_export_task.split_size_mb IS '0不分卷,>0按MB分卷';
COMMENT ON COLUMN collect_export_task.desensitize IS '是否脱敏';
COMMENT ON COLUMN collect_export_task.status IS '0排队 1处理中 2完成 3失败';
COMMENT ON COLUMN collect_export_task.progress IS '0-100';
COMMENT ON COLUMN collect_export_task.result_files IS '生成的包列表[{name,path,size,part}]';
COMMENT ON COLUMN collect_export_task.expire_time IS '下载有效期';
CREATE TABLE collect_file_process_log (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
file_id bigint NOT NULL,
step varchar(32) NOT NULL,
before_val varchar(512),
after_val varchar(512),
extra_json jsonb,
cost_ms integer,
success smallint NOT NULL DEFAULT 1,
error_msg varchar(512),
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_file ON collect_file_process_log (file_id, step);
COMMENT ON TABLE collect_file_process_log IS '文件处理日志';
COMMENT ON COLUMN collect_file_process_log.step IS 'NORMALIZE_NAME/COMPRESS/CONVERT/OCR/EXIF_FIX';
COMMENT ON COLUMN collect_file_process_log.extra_json IS '如 {beforeSize,afterSize,attempts,quality,finalLongSide}';
CREATE TABLE collect_notify_record (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint,
channel varchar(16) NOT NULL, -- WECHAT_SUBSCRIBE / WECHAT_MP / SMS / INBOX
biz_type varchar(32) NOT NULL, -- TASK_PUBLISH / DEADLINE_REMIND / REJECT / APPROVE / EXPORT_DONE / AUDIT_TIMEOUT
receiver_id bigint NOT NULL, -- 接收人 sys_user.id
receiver_role varchar(16), -- 接收人角色(便于按角色统计)
template_code varchar(64),
content varchar(1024),
status smallint NOT NULL DEFAULT 0, -- 0待发 1成功 2失败 3额度不足跳过
fail_reason varchar(512),
send_time timestamp,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_notify_task ON collect_notify_record (task_id, biz_type, create_time);
CREATE INDEX idx_notify_recv ON collect_notify_record (receiver_id, create_time);
COMMENT ON TABLE collect_notify_record IS '通知记录';
COMMENT ON COLUMN collect_notify_record.channel IS '渠道: 微信订阅消息/服务号/短信/站内';
COMMENT ON COLUMN collect_notify_record.biz_type IS '业务类型';
COMMENT ON COLUMN collect_notify_record.status IS '0待发 1成功 2失败 3额度不足跳过';
COMMENT ON COLUMN collect_notify_record.fail_reason IS '失败原因;额度不足时记录(应对R12订阅消息次数限制)';
-- 订阅额度余额(微信一次性订阅消息,发一条消耗一条)
CREATE TABLE collect_subscribe_quota (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
user_id bigint NOT NULL,
template_code varchar(64) NOT NULL,
total integer NOT NULL DEFAULT 0, -- 累计授权次数
used integer NOT NULL DEFAULT 0, -- 已消耗次数
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT uk_quota_user_tpl UNIQUE (user_id, template_code)
);
COMMENT ON TABLE collect_subscribe_quota IS '微信订阅消息额度余额';
COMMENT ON COLUMN collect_subscribe_quota.total IS '累计授权次数';
COMMENT ON COLUMN collect_subscribe_quota.used IS '已消耗次数;余额=total-used,不足时自动降级到服务号或短信';
CREATE TABLE collect_rule_template (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
template_name varchar(128) NOT NULL,
cert_type smallint,
form_schema jsonb,
materials jsonb,
naming_template varchar(256),
is_public smallint NOT NULL DEFAULT 0,
use_count integer NOT NULL DEFAULT 0,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
);
CREATE INDEX idx_ruletpl_tenant ON collect_rule_template (tenant_id, is_public);
COMMENT ON TABLE collect_rule_template IS '规则模板';
COMMENT ON COLUMN collect_rule_template.tenant_id IS '0=平台公共模板';
COMMENT ON COLUMN collect_rule_template.template_name IS '如 自主认定标准模板';
COMMENT ON COLUMN collect_rule_template.cert_type IS '1自主认定 2社会评价 3专项职业能力';
COMMENT ON COLUMN collect_rule_template.form_schema IS '表单字段模板';
COMMENT ON COLUMN collect_rule_template.materials IS '材料清单模板';
CREATE TABLE collect_cleanup_job (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
job_name varchar(128) NOT NULL,
source_type smallint NOT NULL DEFAULT 1,
source_path varchar(512) NOT NULL,
target_task_id bigint,
naming_template varchar(256) NOT NULL DEFAULT '{姓名}-{证件号}-{材料名}',
compress_config jsonb,
status smallint NOT NULL DEFAULT 0,
progress smallint NOT NULL DEFAULT 0,
report_json jsonb,
total_files integer, fixed_files integer, failed_files integer,
result_path varchar(512),
create_by bigint, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
finish_time timestamp,
PRIMARY KEY (id)
);
CREATE INDEX idx_cleanup_tenant ON collect_cleanup_job (tenant_id, create_time);
COMMENT ON TABLE collect_cleanup_job IS '历史数据整理任务';
COMMENT ON COLUMN collect_cleanup_job.source_type IS '1上传压缩包 2对象存储目录';
COMMENT ON COLUMN collect_cleanup_job.target_task_id IS '结果可导入某任务';
COMMENT ON COLUMN collect_cleanup_job.status IS '0排队 1解析中 2处理中 3完成 4失败';
COMMENT ON COLUMN collect_cleanup_job.report_json IS '体检报告:命名违规/证件号异常/体积超标/缺失/重复';
-- ============ 1b) 客户确认后新增(FR-509) ============
CREATE TABLE collect_join_request (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
task_id bigint NOT NULL,
real_name varchar(64) NOT NULL,
id_card_hash char(64) NOT NULL,
id_card_mask varchar(32) NOT NULL,
phone_hash char(64),
phone_mask varchar(32),
org_hint varchar(128),
reason varchar(500),
status smallint NOT NULL DEFAULT 0,
reject_reason varchar(500),
roster_id bigint,
audit_by bigint,
audit_time timestamp,
applicant_openid varchar(64) NOT NULL,
applicant_uid bigint,
source smallint NOT NULL DEFAULT 1,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_join_task_idcard ON collect_join_request (task_id, id_card_hash)
WHERE deleted = 0 AND status IN (0, 1);
CREATE INDEX idx_join_task_status ON collect_join_request (task_id, status, create_time);
CREATE INDEX idx_join_openid ON collect_join_request (applicant_openid);
COMMENT ON TABLE collect_join_request IS '名单加入申请';
COMMENT ON COLUMN collect_join_request.real_name IS '申请人自述姓名(明文,业务必需)';
COMMENT ON COLUMN collect_join_request.id_card_hash IS 'HMAC,与门禁一致,不存明文';
COMMENT ON COLUMN collect_join_request.id_card_mask IS '脱敏展示';
COMMENT ON COLUMN collect_join_request.org_hint IS '申请人自述班级(选填,可由管理员修正)';
COMMENT ON COLUMN collect_join_request.reason IS '申请说明';
COMMENT ON COLUMN collect_join_request.status IS '0待审 1已通过 2已驳回 3已失效(任务截止)';
COMMENT ON COLUMN collect_join_request.reject_reason IS '驳回原因(驳回时必填)';
COMMENT ON COLUMN collect_join_request.roster_id IS '审批通过后生成的名册项';
COMMENT ON COLUMN collect_join_request.applicant_openid IS '用于限流(每任务<=2次)与审批通过后自动绑定';
COMMENT ON COLUMN collect_join_request.source IS '1门禁失败页申请 2管理员代录';
-- ============ 2) 补列 ALTER ============
ALTER TABLE collect_task ADD COLUMN plan_scope_json jsonb;
ALTER TABLE collect_task ADD COLUMN plan_scope_confirmed smallint NOT NULL DEFAULT 0;
COMMENT ON COLUMN collect_task.plan_scope_json IS '本期报考范围快照(从名单聚合): {occupations:[{name,worktypes:[{name,levels:[]}]}], confirmed:true}';
COMMENT ON COLUMN collect_task.plan_scope_confirmed IS '管理员是否已确认范围(未确认则追加名单不告警)';
-- 同学号的补齐:官方模板 21 字段中的非身份字段全部建为正式列
-- 姓名、身份证号已有独立列(real_name / id_card_no),此处补齐其余 18 个
ALTER TABLE collect_roster
ADD COLUMN occupation varchar(64), -- 报考职业 G
ADD COLUMN worktype varchar(64), -- 报考工种 H
ADD COLUMN job_level varchar(32), -- 报考等级 I
ADD COLUMN education varchar(32), -- 学历 D
ADD COLUMN is_student smallint, -- 是否在校生 N 0否 1是
ADD COLUMN school varchar(128), -- 在读学校 O
ADD COLUMN major varchar(128), -- 所学专业 P
ADD COLUMN enroll_date date, -- 入学时间 Q YYYY-MM-DD
ADD COLUMN work_unit varchar(128), -- 工作单位 F 无则填"无"
ADD COLUMN is_retake smallint, -- 是否补考 J 0否 1是
ADD COLUMN retake_theory varchar(64), -- 补考理论 K
ADD COLUMN retake_practice varchar(64), -- 补考实操 L
ADD COLUMN retake_overall varchar(64), -- 补考综合 M
ADD COLUMN work_years smallint, -- 本职业工作年限 R
ADD COLUMN cert_occupation varchar(128), -- 已获证书职业工种 S
ADD COLUMN cert_level varchar(32), -- 已获证书等级 T
ADD COLUMN cert_no varchar(64), -- 已获证书编号 U
ADD COLUMN extras_json jsonb DEFAULT '{}'::jsonb; -- 逃生舱:仅存租户/机构额外追加的字段
-- 报考范围聚合与按工种统计必需的索引
CREATE INDEX idx_roster_scope ON collect_roster (tenant_id, task_id, occupation, worktype, job_level);
CREATE INDEX idx_roster_org ON collect_roster (task_id, org_id, deleted);
COMMENT ON COLUMN collect_roster.occupation IS '报考职业(锁定,由老师确定)';
COMMENT ON COLUMN collect_roster.worktype IS '报考工种(锁定;该职业无工种时填"无";专项职业能力为空)';
COMMENT ON COLUMN collect_roster.job_level IS '报考等级(锁定;专项职业能力为空)';
COMMENT ON COLUMN collect_roster.education IS '学历(下拉11项)';
COMMENT ON COLUMN collect_roster.is_student IS '是否在校生 0否 1是';
COMMENT ON COLUMN collect_roster.school IS '在读学校(在校生=是时必填)';
COMMENT ON COLUMN collect_roster.major IS '所学专业(在校生=是时必填)';
COMMENT ON COLUMN collect_roster.enroll_date IS '入学时间(在校生=是时必填,格式YYYY-MM-DD)';
COMMENT ON COLUMN collect_roster.work_unit IS '工作单位(没有填"无")';
COMMENT ON COLUMN collect_roster.is_retake IS '是否补考 0否 1是';
COMMENT ON COLUMN collect_roster.retake_theory IS '补考理论(级联)';
COMMENT ON COLUMN collect_roster.retake_practice IS '补考实操(级联)';
COMMENT ON COLUMN collect_roster.retake_overall IS '补考综合(级联)';
COMMENT ON COLUMN collect_roster.work_years IS '本职业工作年限(仅阿拉伯数字)';
COMMENT ON COLUMN collect_roster.cert_occupation IS '已获证书职业工种(选填)';
COMMENT ON COLUMN collect_roster.cert_level IS '已获证书等级(选填)';
COMMENT ON COLUMN collect_roster.cert_no IS '已获证书编号(选填)';
COMMENT ON COLUMN collect_roster.extras_json IS '租户/机构额外追加的字段(逃生舱,不参与核心统计)';
@@ -0,0 +1,138 @@
-- ═══════════════════════════════════════════════════════════════════
-- V2 · 标准表(PRD 未给 DDL,注明「从略、沿用现有框架实现」)
--
-- 为什么单列一个迁移而不是塞进 V1:
-- docs/db/schema-postgresql.sql 是文档交付物,口径是「20 张业务表」,
-- 已在 PG 16.15 实测零错误并写进验收标准。标准表另计,不动那个口径。
--
-- 命名约定(沿用 V1):
-- · 主键 id / 租户 tenant_id / 公共字段 create_by,create_time,update_by,update_time,deleted
-- · ⚠️ PG 的索引名与约束名必须 schema 内【全局唯一】,一律加表缩写前缀
-- · 需要「删了能重建同名」的唯一性,一律用部分唯一索引 + WHERE deleted = 0
-- ═══════════════════════════════════════════════════════════════════
-- ── 1) sys_role 角色 ────────────────────────────────────────────────
-- 内置角色在「开通租户」时按代码里的定义写入该租户,所以角色是租户隔离的,
-- 多租户拦截器能统一处理,不用给角色表开特例。
CREATE TABLE sys_role (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
role_code varchar(64) NOT NULL,
role_name varchar(64) NOT NULL,
role_type smallint NOT NULL DEFAULT 1,
data_scope smallint NOT NULL DEFAULT 4,
sort_no integer NOT NULL DEFAULT 0,
remark varchar(255),
status smallint NOT NULL DEFAULT 1,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_role_tenant_code ON sys_role (tenant_id, role_code) WHERE deleted = 0;
CREATE INDEX idx_role_tenant_status ON sys_role (tenant_id, status);
COMMENT ON TABLE sys_role IS '角色';
COMMENT ON COLUMN sys_role.role_code IS '角色码,如 SCHOOL_ADMIN;与代码常量 PermissionDef 对齐';
COMMENT ON COLUMN sys_role.role_type IS '1内置(不可删,权限由代码决定) 2自定义';
COMMENT ON COLUMN sys_role.data_scope IS '1全平台 2本租户 3本组织及下级 4仅本人';
COMMENT ON COLUMN sys_role.status IS '0停用 1正常';
-- ── 2) sys_user_role 用户与角色 ─────────────────────────────────────
CREATE TABLE sys_user_role (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
user_id bigint NOT NULL,
role_id bigint NOT NULL,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_userrole_user_role ON sys_user_role (user_id, role_id) WHERE deleted = 0;
CREATE INDEX idx_userrole_role ON sys_user_role (role_id);
CREATE INDEX idx_userrole_tenant_user ON sys_user_role (tenant_id, user_id);
COMMENT ON TABLE sys_user_role IS '用户与角色的关联';
COMMENT ON COLUMN sys_user_role.user_id IS 'sys_user.id';
COMMENT ON COLUMN sys_user_role.role_id IS 'sys_role.id';
-- ── 3) sys_role_permission 角色授权 ─────────────────────────────────
-- 权限码本身(有哪些权限、叫什么、属于哪个菜单)定义在代码里,见 PermissionDef。
-- 表里只存「这个角色被授了哪些码」,所以不需要 sys_permission 字典表——
-- 权限定义不会漂移,前端菜单与后端校验永远一致。
CREATE TABLE sys_role_permission (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL,
role_id bigint NOT NULL,
perm_code varchar(128) NOT NULL,
create_by bigint,
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint,
update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted smallint NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX uk_roleperm_role_code ON sys_role_permission (role_id, perm_code) WHERE deleted = 0;
CREATE INDEX idx_roleperm_tenant_role ON sys_role_permission (tenant_id, role_id);
COMMENT ON TABLE sys_role_permission IS '角色与权限码的关联(权限码定义在代码 PermissionDef)';
COMMENT ON COLUMN sys_role_permission.perm_code IS '权限码,如 task:create';
-- ── 4) sys_login_log 登录日志 ───────────────────────────────────────
-- PRD §10.3「审计留痕:登录、查看明文、下载、导出、代传、删除全部记录(含 IP、UA)」
-- 只增不改,所以没有 update_* 与 deleted 字段——日志表加软删除没有意义。
-- ⚠️ 会持续增长,需配定期归档(建议保留 12 个月,M7 上线前定策略)。
CREATE TABLE sys_login_log (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL DEFAULT 0,
user_id bigint,
username varchar(64),
login_type smallint NOT NULL DEFAULT 1,
success smallint NOT NULL DEFAULT 1,
fail_reason varchar(128),
ip varchar(64),
user_agent varchar(512),
trace_id varchar(64),
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_loginlog_user_time ON sys_login_log (user_id, create_time);
CREATE INDEX idx_loginlog_username_time ON sys_login_log (username, create_time);
CREATE INDEX idx_loginlog_time ON sys_login_log (create_time);
COMMENT ON TABLE sys_login_log IS '登录日志(审计留痕,只增不改)';
COMMENT ON COLUMN sys_login_log.login_type IS '1 PC 2 小程序 3 开放接口';
COMMENT ON COLUMN sys_login_log.success IS '0失败 1成功';
COMMENT ON COLUMN sys_login_log.fail_reason IS '失败原因;⚠️不记录密码或密码片段';
COMMENT ON COLUMN sys_login_log.username IS '冗余存登录名,方便未匹配到用户时也能审计';
-- ── 5) sys_operation_log 操作日志 ───────────────────────────────────
CREATE TABLE sys_operation_log (
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
tenant_id bigint NOT NULL DEFAULT 0,
user_id bigint,
username varchar(64),
module varchar(64),
action varchar(64),
request_method varchar(16),
request_uri varchar(512),
request_param text,
biz_code integer,
duration_ms integer,
ip varchar(64),
user_agent varchar(512),
trace_id varchar(64),
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE INDEX idx_oplog_tenant_time ON sys_operation_log (tenant_id, create_time);
CREATE INDEX idx_oplog_user_time ON sys_operation_log (user_id, create_time);
CREATE INDEX idx_oplog_module_time ON sys_operation_log (module, create_time);
COMMENT ON TABLE sys_operation_log IS '操作日志(审计留痕,只增不改)';
COMMENT ON COLUMN sys_operation_log.module IS '业务模块,如 tenant/org/user/task';
COMMENT ON COLUMN sys_operation_log.action IS '动作,如 create/update/delete/export/approve';
COMMENT ON COLUMN sys_operation_log.request_param IS '⚠️入库前必须脱敏:去掉密码/证件号明文';
COMMENT ON COLUMN sys_operation_log.biz_code IS '业务返回码,用于区分成功与业务失败';
COMMENT ON COLUMN sys_operation_log.duration_ms IS '耗时毫秒,用于定位慢操作';
COMMENT ON COLUMN sys_operation_log.trace_id IS '全链路追踪 ID';