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:
+133
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+152
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+303
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+298
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+14
@@ -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> {
|
||||
}
|
||||
+181
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+23
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+57
@@ -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>>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);
|
||||
}
|
||||
+61
@@ -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();
|
||||
}
|
||||
}
|
||||
+56
@@ -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';
|
||||
Reference in New Issue
Block a user