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:
@@ -0,0 +1,28 @@
|
|||||||
|
# 统一换行符:仓库内存 LF,检出时按平台
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows 专属脚本保留 CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# 二进制文件不做转换
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.jar binary
|
||||||
|
|
||||||
|
# 以下文件强制 LF(跨平台关键)
|
||||||
|
*.sql text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
|
||||||
|
# 语言统计不把文档算进去
|
||||||
|
docs/** linguist-documentation
|
||||||
|
docs/prototype/** linguist-vendored
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 数据信息填报系统 · 文档中心
|
# 数据信息填报系统
|
||||||
|
|
||||||
> 职业技能等级认定**报名材料收集 SaaS**
|
> 职业技能等级认定**报名材料收集 SaaS**
|
||||||
> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期
|
> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期
|
||||||
@@ -146,7 +146,7 @@ docker run -d --name pg-verify -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=fill
|
|||||||
# 执行 DDL
|
# 执行 DDL
|
||||||
docker exec -i pg-verify psql -U postgres -d filldb < docs/db/schema-postgresql.sql
|
docker exec -i pg-verify psql -U postgres -d filldb < docs/db/schema-postgresql.sql
|
||||||
|
|
||||||
# 验证结果:19 张表 / 167 列注释 / 61 索引,零错误
|
# 验证结果:20 张表 / 177 列注释 / 65 索引,零错误
|
||||||
docker exec -it pg-verify psql -U postgres -d filldb
|
docker exec -it pg-verify psql -U postgres -d filldb
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,6 +162,53 @@ python check2.py docs/prototype/admin-web.html
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六·二、把系统跑起来(三条命令)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 基础设施:PG 16 + Redis 7 + MinIO
|
||||||
|
cd deploy
|
||||||
|
copy .env.example .env # 首次
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 2) 后端:Flyway 自动建库 + 自动初始化账号
|
||||||
|
cd ../backend
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 3) 验收自测(35 项)
|
||||||
|
cd ..
|
||||||
|
python scripts/e2e-m0.py
|
||||||
|
```
|
||||||
|
|
||||||
|
开发环境初始账号(仅本地,由 `fill.seed.enabled` 控制):
|
||||||
|
|
||||||
|
| 账号 | 密码 | 角色 |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | `Admin@123` | 学校管理员(租户 `demo`) |
|
||||||
|
| `platform` | `Platform@123` | 平台运营 |
|
||||||
|
|
||||||
|
接口文档:<http://localhost:8080/doc.html>
|
||||||
|
|
||||||
|
### 代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/ Java 21 + Spring Boot 4.1.1
|
||||||
|
├── xt-common/ 统一响应 / 58 个错误码 / AES-GCM / HMAC / RSA
|
||||||
|
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块
|
||||||
|
deploy/ docker-compose + RSA 开发公钥
|
||||||
|
scripts/ DDL 同步、登录助手、M0 验收测试
|
||||||
|
frontend-pc/ (待建)Vue 3 + Element Plus
|
||||||
|
frontend-mp/ (待建)uni-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 搭环境前请先读
|
||||||
|
|
||||||
|
**`docs/07-开发实施计划.md` §9「版本兼容踩坑记录」**——
|
||||||
|
那里记了 10 条 Boot 4 / Flyway 10 / MyBatis-Plus 3.5.9 的真实坑,
|
||||||
|
其中多数是「**依赖在、功能不在,且不报错**」的类型,靠读文档和凭经验都躲不掉。
|
||||||
|
|
||||||
|
|
||||||
## 七、交付状态
|
## 七、交付状态
|
||||||
|
|
||||||
| 阶段 | 内容 | 状态 |
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|||||||
+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';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ── 本地开发环境变量 ──────────────────────────────
|
||||||
|
# 复制为 .env 后使用: copy .env.example .env
|
||||||
|
# ⚠️ .env 已在 .gitignore 中,不要提交真实密码
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
PG_DB=filldb
|
||||||
|
PG_USER=fill
|
||||||
|
PG_PASSWORD=fill_dev_pwd
|
||||||
|
PG_PORT=5432
|
||||||
|
|
||||||
|
# Redis(注意 16379,避开本机其他项目的 6379)
|
||||||
|
REDIS_PORT=16379
|
||||||
|
REDIS_PASSWORD=redis_dev_pwd
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_USER=minioadmin
|
||||||
|
MINIO_PASSWORD=minioadmin
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 数据信息填报系统 · 本地开发基础设施
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# cd deploy
|
||||||
|
# copy .env.example .env (首次)
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379
|
||||||
|
# (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们)
|
||||||
|
|
||||||
|
name: xiaotutongxue
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── PostgreSQL 16 ─────────────────────────────────
|
||||||
|
pg:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: xt-pg
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PG_DB:-filldb}
|
||||||
|
POSTGRES_USER: ${PG_USER:-fill}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-fill_dev_pwd}
|
||||||
|
# 中文排序与大小写:用 C 排序 + UTF8,避免 locale 差异导致索引行为不一致
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "${PG_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PG_USER:-fill} -d ${PG_DB:-filldb}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
# 超大图处理是内存密集的,DB 侧保留默认即可,但把慢查询记下来
|
||||||
|
- -c
|
||||||
|
- log_min_duration_statement=1000
|
||||||
|
- -c
|
||||||
|
- timezone=Asia/Shanghai
|
||||||
|
|
||||||
|
# ── Redis 7 ───────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: xt-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-16379}:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
- --requirepass
|
||||||
|
- "${REDIS_PASSWORD:-redis_dev_pwd}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-redis_dev_pwd} ping | grep PONG"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# ── MinIO(对象存储,S3 兼容)─────────────────────
|
||||||
|
# M0 阶段不写入,但先起好——本地/生产的存储层走同一套 S3 接口,
|
||||||
|
# 换阿里云 OSS 只改配置,不改代码。
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: xt-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "${MINIO_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg-data:
|
||||||
|
redis-data:
|
||||||
|
minio-data:
|
||||||
+8
-6
@@ -139,7 +139,7 @@
|
|||||||
| 场景 | 阈值 | 超限响应 |
|
| 场景 | 阈值 | 超限响应 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 登录 | 5 次/分钟/IP + 账号锁定 5 次 | `42901` |
|
| 登录 | 5 次/分钟/IP + 账号锁定 5 次 | `42901` |
|
||||||
| **门禁核验** | **同 openid 10 次/小时;同 IP 20 次/分钟** | `40007b` |
|
| **门禁核验** | **同 openid 10 次/小时;同 IP 20 次/分钟** | `40071` |
|
||||||
| 上传凭证 | 200 次/分钟/用户 | `42901` |
|
| 上传凭证 | 200 次/分钟/用户 | `42901` |
|
||||||
| OCR | 30 次/分钟/用户 | `42901` |
|
| OCR | 30 次/分钟/用户 | `42901` |
|
||||||
| 催报 | 10 次/分钟/任务 | `42901` |
|
| 催报 | 10 次/分钟/任务 | `42901` |
|
||||||
@@ -878,7 +878,7 @@
|
|||||||
**匹配逻辑**(服务端)
|
**匹配逻辑**(服务端)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 限流检查 → 超限返回 40007b
|
1. 限流检查 → 超限返回 40071
|
||||||
2. 规范化:姓名去空格、全角转半角;证件号转大写
|
2. 规范化:姓名去空格、全角转半角;证件号转大写
|
||||||
3. 按 (task_id, id_card_hash) 定位名册项
|
3. 按 (task_id, id_card_hash) 定位名册项
|
||||||
├─ 未命中 → 记录失败尝试(不记明文) → 40007 统一提示
|
├─ 未命中 → 记录失败尝试(不记明文) → 40007 统一提示
|
||||||
@@ -887,7 +887,7 @@
|
|||||||
├─ 形近字/同音字 → 返回候选让用户确认(FR-504c)
|
├─ 形近字/同音字 → 返回候选让用户确认(FR-504c)
|
||||||
└─ 不一致 → 40007 统一提示
|
└─ 不一致 → 40007 统一提示
|
||||||
4. 检查绑定状态
|
4. 检查绑定状态
|
||||||
├─ 已被他人绑定 → 40007c
|
├─ 已被他人绑定 → 40072
|
||||||
└─ 未绑定 → 写入 bind_user_id / bind_time / bind_type
|
└─ 未绑定 → 写入 bind_user_id / bind_time / bind_type
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2798,8 +2798,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
||||||
| `40006` | 疑似串档 | 转人工,**不自动修正** |
|
| `40006` | 疑似串档 | 转人工,**不自动修正** |
|
||||||
| `40007` | 门禁未匹配 | **统一提示**"没有找到你的信息",不透露哪项错 |
|
| `40007` | 门禁未匹配 | **统一提示**"没有找到你的信息",不透露哪项错 |
|
||||||
| `40007b` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" |
|
| `40071` | 门禁尝试过于频繁 | 提示"操作过于频繁,请稍后再试" |
|
||||||
| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记" |
|
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记" |
|
||||||
| `40008` | 任务已截止 / 已归档 / **已暂停** | 禁止提交 |
|
| `40008` | 任务已截止 / 已归档 / **已暂停** | 禁止提交 |
|
||||||
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
|
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
|
||||||
| `40010` | 驳回次数超上限 | 提示联系班主任 |
|
| `40010` | 驳回次数超上限 | 提示联系班主任 |
|
||||||
@@ -2835,8 +2835,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 |
|
| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 |
|
||||||
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
|
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
|
||||||
| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 |
|
| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 |
|
||||||
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
|
|
||||||
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
|
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
|
||||||
|
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
|
||||||
| `40101` | 未登录 / token 失效 | 静默重新登录 |
|
| `40101` | 未登录 / token 失效 | 静默重新登录 |
|
||||||
| `40301` | 无权限(含跨租户) | 跳转 403 |
|
| `40301` | 无权限(含跨租户) | 跳转 403 |
|
||||||
| `40302` | 签名 URL 已过期 | 重新获取 |
|
| `40302` | 签名 URL 已过期 | 重新获取 |
|
||||||
@@ -2862,6 +2862,8 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更 |
|
| 版本 | 日期 | 变更 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
|
||||||
|
| **V1.2** | **2026-09-28** | **错误码规范化**:`40007b` / `40007c` 带字母后缀,与 §1.3「`code` 为数字」的约定冲突(JSON 里会变成字符串,前端 `code === 0` 判断失效)。改为纯数字:**`40007b` → `40071`**(门禁限流)、**`40007c` → `40072`**(名单项已被绑定)。总码数不变,仍 **58 个**。 |
|
||||||
| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 |
|
| V1.0 | 2026-09-28 | 首版。PRD §8 的 96 个接口全部展开字段级定义;**补齐追溯矩阵审计发现的 10 个缺口(新增 21 个接口,含 1 个登记遗漏)**;错误码由 20 个扩至 **50 个**;新增 §2 枚举全集、§3 通用数据结构、§15 错误码全集 |
|
||||||
|
|
||||||
| **V1.1** | **2026-09-28** | **客户要求移除「联系方式展示」**:删除 `GET /api/v1/mp/roster/contact`(原用于门禁失败页展示班主任脱敏手机号)。<br>**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。<br>门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。<br>**接口总数 122 → 121** |
|
| **V1.1** | **2026-09-28** | **客户要求移除「联系方式展示」**:删除 `GET /api/v1/mp/roster/contact`(原用于门禁失败页展示班主任脱敏手机号)。<br>**理由**:学生在班级群里本来就有班主任联系方式,系统内再放一份既不必要(用户已知怎么找人)、有维护成本(换人换号要同步)、又多一处隐私暴露面。<br>门禁失败页改为只保留两条**系统内动作**:重新填写 / 申请加入名单(FR-509)。纯文案提示("如有疑问请联系老师")保留,因**不涉及任何联系方式数据**。<br>**接口总数 122 → 121** |
|
||||||
|
|||||||
+15
-7
@@ -814,7 +814,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
|
|||||||
r2 = rateLimit.check("gate:ip:" + ip, limit=20, window=60)
|
r2 = rateLimit.check("gate:ip:" + ip, limit=20, window=60)
|
||||||
IF NOT r1.allowed OR NOT r2.allowed:
|
IF NOT r1.allowed OR NOT r2.allowed:
|
||||||
audit.log(attempt, openid, ip, result=RATE_LIMITED)
|
audit.log(attempt, openid, ip, result=RATE_LIMITED)
|
||||||
RETURN ERROR(40007b, "操作过于频繁,请稍后再试",
|
RETURN ERROR(40071, "操作过于频繁,请稍后再试",
|
||||||
data={ retryAfterSec: max(r1.retryAfter, r2.retryAfter) })
|
data={ retryAfterSec: max(r1.retryAfter, r2.retryAfter) })
|
||||||
|
|
||||||
# ── 步骤 2:规范化 ───────────────────────────
|
# ── 步骤 2:规范化 ───────────────────────────
|
||||||
@@ -866,7 +866,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
|
|||||||
IF r.bind_user_id == currentUserId:
|
IF r.bind_user_id == currentUserId:
|
||||||
PASS # 幂等:自己绑过就放行
|
PASS # 幂等:自己绑过就放行
|
||||||
ELSE:
|
ELSE:
|
||||||
RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师")
|
RETURN ERROR(40072, "该信息已被登记,如有疑问请联系老师")
|
||||||
|
|
||||||
# 原子抢占:把"检查"和"写入"合成一条语句,杜绝并发窗口
|
# 原子抢占:把"检查"和"写入"合成一条语句,杜绝并发窗口
|
||||||
affected = UPDATE collect_roster
|
affected = UPDATE collect_roster
|
||||||
@@ -876,7 +876,7 @@ FUNCTION gate_match(realName, idCardNo, taskId, openid, ip):
|
|||||||
AND deleted = 0
|
AND deleted = 0
|
||||||
IF affected == 0:
|
IF affected == 0:
|
||||||
# 说明在"检查"和"写入"之间被别人抢了
|
# 说明在"检查"和"写入"之间被别人抢了
|
||||||
RETURN ERROR(40007c, "该信息已被登记,如有疑问请联系老师")
|
RETURN ERROR(40072, "该信息已被登记,如有疑问请联系老师")
|
||||||
|
|
||||||
# ── 步骤 6:返回预填数据 ─────────────────────
|
# ── 步骤 6:返回预填数据 ─────────────────────
|
||||||
RETURN OK({
|
RETURN OK({
|
||||||
@@ -910,7 +910,7 @@ IF r.bind_user_id IS NULL: # 检查通过
|
|||||||
| 场景 | 处理 |
|
| 场景 | 处理 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 名单有重复证件号 | 记数据问题、通知管理员,对外统一失败 |
|
| 名单有重复证件号 | 记数据问题、通知管理员,对外统一失败 |
|
||||||
| 名册项已被他人绑定 | `40007c`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) |
|
| 名册项已被他人绑定 | `40072`(**这一条可以明确提示**——因为用户已经提供对了信息,提示不泄露新信息) |
|
||||||
| openid 绑定过但名册项被删 | 解绑,重新走门禁 |
|
| openid 绑定过但名册项被删 | 解绑,重新走门禁 |
|
||||||
| 用户换微信(openid 变了) | 走"申请换绑"流程 → 管理员审批(见确认函 B1) |
|
| 用户换微信(openid 变了) | 走"申请换绑"流程 → 管理员审批(见确认函 B1) |
|
||||||
| 任务已截止/暂停 | 在门禁**之前**拦截,`40008` |
|
| 任务已截止/暂停 | 在门禁**之前**拦截,`40008` |
|
||||||
@@ -950,10 +950,10 @@ FUNCTION record_failure(taskId, openid, ip, hash):
|
|||||||
| 正确姓名+号码 | 通过,返回脱敏号与预填 |
|
| 正确姓名+号码 | 通过,返回脱敏号与预填 |
|
||||||
| 号码对、姓名错 | **统一失败提示**(文案与"号码错"完全一致) |
|
| 号码对、姓名错 | **统一失败提示**(文案与"号码错"完全一致) |
|
||||||
| 号码错 | 统一失败提示 |
|
| 号码错 | 统一失败提示 |
|
||||||
| 限流触发 | `40007b`,提示剩余等待时间 |
|
| 限流触发 | `40071`,提示剩余等待时间 |
|
||||||
| 形近字(曾依豪 vs 曾伊豪) | 返回确认候选,`needConfirmName: true` |
|
| 形近字(曾依豪 vs 曾伊豪) | 返回确认候选,`needConfirmName: true` |
|
||||||
| 员工 A 先绑,员工 B 再试 | B 得 `40007c` |
|
| 员工 A 先绑,员工 B 再试 | B 得 `40072` |
|
||||||
| **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40007c`**(压测必测) |
|
| **并发 100 个请求抢同一名册项** | **恰好 1 个成功,99 个得 `40072`**(压测必测) |
|
||||||
| 失败尝试查库 | **无明文证件号** |
|
| 失败尝试查库 | **无明文证件号** |
|
||||||
| 名单 5000 条时匹配耗时 | < 50ms(走 hash 索引) |
|
| 名单 5000 条时匹配耗时 | < 50ms(走 hash 索引) |
|
||||||
| 同 IP 20 人同时填报 | **全部通过**(不误伤 NAT) |
|
| 同 IP 20 人同时填报 | **全部通过**(不误伤 NAT) |
|
||||||
@@ -1441,4 +1441,12 @@ FUNCTION create_export(taskId, scope, options):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| V1.0 | 2026-09-28 | 首版。覆盖 6 个模块(M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出),全部为伪代码级说明;含模块依赖图、开发顺序建议、Golden Test Set 基线(15 项指标)、三条贯穿原则 |
|
| V1.0 | 2026-09-28 | 首版。覆盖 6 个模块(M1 命名 / M2 压缩 / M3 识别 / M4 门禁 / M5 校验 / M6 导出),全部为伪代码级说明;含模块依赖图、开发顺序建议、Golden Test Set 基线(15 项指标)、三条贯穿原则 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
|
||||||
|
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **DDL 修正(2026-09-28)**:`sys_tenant.contact_phone` / `sys_user.phone` / `collect_roster.phone` 由 `varchar(20)` 放宽到 `varchar(128)`。
|
||||||
|
> 原因:这三列存的是 AES-256-GCM 密文(约 52 字符),`varchar(20)` 是按明文长度建的,插入必失败。
|
||||||
|
|||||||
+8
-3
@@ -289,9 +289,9 @@ with unzip(result.zip) as z:
|
|||||||
|
|
||||||
| # | 场景 | 期望 |
|
| # | 场景 | 期望 |
|
||||||
|:--:|---|---|
|
|:--:|---|---|
|
||||||
| E1 | 门禁连错 11 次 | 第 11 次返回 `40007b`,提示稍后再试 |
|
| E1 | 门禁连错 11 次 | 第 11 次返回 `40071`,提示稍后再试 |
|
||||||
| E2 | 同一 IP 20 人同时门禁 | **全部通过**(NAT 不误伤) |
|
| E2 | 同一 IP 20 人同时门禁 | **全部通过**(NAT 不误伤) |
|
||||||
| E3 | 并发 100 请求抢同一名册项 | **恰好 1 个成功**,99 个 `40007c` |
|
| E3 | 并发 100 请求抢同一名册项 | **恰好 1 个成功**,99 个 `40072` |
|
||||||
| E4 | 门禁失败后查库 | **无明文证件号**,只有 hash |
|
| E4 | 门禁失败后查库 | **无明文证件号**,只有 hash |
|
||||||
| E5 | 学籍表 4 项材料证件号均与名册不符 | 标 `VAL-19` 疑似串档,**不自动改** |
|
| E5 | 学籍表 4 项材料证件号均与名册不符 | 标 `VAL-19` 疑似串档,**不自动改** |
|
||||||
| E6 | 学生提交后撤回,再改再提交 | 正常;驳回次数不因撤回增加 |
|
| E6 | 学生提交后撤回,再改再提交 | 正常;驳回次数不因撤回增加 |
|
||||||
@@ -382,7 +382,7 @@ with unzip(result.zip) as z:
|
|||||||
| F-500-10 | 门禁限流 | openid 10 次/小时;IP 20 次/分钟 |
|
| F-500-10 | 门禁限流 | openid 10 次/小时;IP 20 次/分钟 |
|
||||||
| F-500-11 | **并发绑定** | 100 并发抢同一项,恰好 1 个成功 |
|
| F-500-11 | **并发绑定** | 100 并发抢同一项,恰好 1 个成功 |
|
||||||
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
|
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
|
||||||
| F-500-13 | 已绑定提示 | `40007c`,**这一条明确提示** |
|
| F-500-13 | 已绑定提示 | `40072`,**这一条明确提示** |
|
||||||
|
|
||||||
### F-509 名单加入申请(客户确认新增)
|
### F-509 名单加入申请(客户确认新增)
|
||||||
|
|
||||||
@@ -608,3 +608,8 @@ with unzip(result.zip) as z:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **V1.1** | **2026-09-28** | **客户确认后修订:修正「文件归属」设计缺陷**。原设计把"文件名姓名/证件号与名册不符"处理为"自动按名册修正"(信息级),**会把传错的文件静默洗白**。修正为:<br>① 新增 **7 条归属断言 G-14b~G-14h**(Golden 29 → **36**);② 判定优先级改为 **证件号反查名册 > OCR 内容 > 姓名(仅辅助)**;③ 新增 **VAL-21 文件归属校验(阻断,且无"仍然继续")**;④ **姓名不再作为独立规则**(客户确认);⑤ 新增 **7 条功能验收 F-600-14~20**(104 → **111**)与 **4 条异常边界 E24~E27**(23 → **27**)<br>**修订后总计:36 条断言 / 111 条功能验收 / 27 条异常边界 / 6 条视觉 / 9 项性能 / 12 项安全** |
|
| **V1.1** | **2026-09-28** | **客户确认后修订:修正「文件归属」设计缺陷**。原设计把"文件名姓名/证件号与名册不符"处理为"自动按名册修正"(信息级),**会把传错的文件静默洗白**。修正为:<br>① 新增 **7 条归属断言 G-14b~G-14h**(Golden 29 → **36**);② 判定优先级改为 **证件号反查名册 > OCR 内容 > 姓名(仅辅助)**;③ 新增 **VAL-21 文件归属校验(阻断,且无"仍然继续")**;④ **姓名不再作为独立规则**(客户确认);⑤ 新增 **7 条功能验收 F-600-14~20**(104 → **111**)与 **4 条异常边界 E24~E27**(23 → **27**)<br>**修订后总计:36 条断言 / 111 条功能验收 / 27 条异常边界 / 6 条视觉 / 9 项性能 / 12 项安全** |
|
||||||
| V1.0 | 2026-09-28 | 首版。**29 条 Golden Assertions**(可直接写成自动化)、3 条端到端主流程(32 步)、23 条异常边界、**104 条功能验收项**(含 F-509 共 19 条)、6 条视觉验收、9 项性能、12 项安全;含里程碑签署表与 7 条 Go/No-Go 条件 |
|
| V1.0 | 2026-09-28 | 首版。**29 条 Golden Assertions**(可直接写成自动化)、3 条端到端主流程(32 步)、23 条异常边界、**104 条功能验收项**(含 F-509 共 19 条)、6 条视觉验收、9 项性能、12 项安全;含里程碑签署表与 7 条 Go/No-Go 条件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
|
||||||
|
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# 数据信息填报系统 · 开发实施计划
|
||||||
|
|
||||||
|
> **版本**:V1.1 **日期**:2026-09-28 **状态**:M0 后端骨架已完成并实测通过(35/35 验收项)
|
||||||
|
>
|
||||||
|
> 本文是**开工计划**,回答三件事:用什么技术、代码怎么放、M0 怎么落地。
|
||||||
|
> 任务清单以《05-开发任务拆解》为准(100 任务 / 387 人日 / 15 周),本文不重复。
|
||||||
|
> 一期范围以 PRD「一期范围声明」为准(**学校收集 + 自主认定**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 技术选型(已确认)
|
||||||
|
|
||||||
|
| 层 | 选型 | 版本 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 语言 | **Java** | 21(LTS) | 本机已装;Boot 4 一等公民 |
|
||||||
|
| 框架 | **Spring Boot** | **4.1.1** | 当前 GA(2026-08-20 发补丁),OSS 支持到 ~2028。<br>⚠️ 3.5.x 的免费补丁已于 2026-06 停止,故不选 |
|
||||||
|
| ORM | **MyBatis-Plus** | 3.5.17 | 用 `mybatis-plus-spring-boot4-starter`(官方 Boot 4 线) |
|
||||||
|
| 数据库 | **PostgreSQL** | 16 | 客户已确认纯 PG;DDL 已实测 |
|
||||||
|
| 连接池 | HikariCP | Boot 托管 | 不引 Druid,少一层配置 |
|
||||||
|
| 缓存 | **Redis** | 7 | 图形验证码 / 登录锁定 / Token 黑名单 / 限流 |
|
||||||
|
| 对象存储 | MinIO(本地)/ OSS(生产) | — | 走 **S3 兼容接口**抽象,本地 MinIO 零成本切换 |
|
||||||
|
| 鉴权 | **JWT**(jjwt) | 0.13.0 | 载荷见《03-接口设计》§1.2 |
|
||||||
|
| 密码 | BCrypt | spring-security-crypto | 只用加密库,**不引完整 Spring Security**(权限模型是自研的多租户 + 数据权限,用 Security 反而别扭) |
|
||||||
|
| 敏感字段 | AES-256-GCM + HMAC 索引 | JDK 自带 | PRD §10.3:证件号/手机号加密存储,`*_hash` 用于检索 |
|
||||||
|
| 图形验证码 | Hutool Captcha | 5.8.47 | 避免自己画 Java2D |
|
||||||
|
| Excel | EasyExcel | 4.0.3 | 名单/用户导入导出 |
|
||||||
|
| API 文档 | springdoc-openapi | 3.1.1 | Boot 4 线(**2.x 是 Boot 3 的,别混**) |
|
||||||
|
| PC 前端 | **Vue 3 + Vite + TS + Element Plus + Pinia** | — | 管理员/班主任 PC 端 |
|
||||||
|
| 小程序 | **uni-app**(Vue 3 + TS) | — | **一份代码 → 微信小程序 + H5**,直接落地 M0-13 的 C1 保底 |
|
||||||
|
| 图片处理 | 独立服务,**libvips** | M2 决定 | R11(1.8 亿像素 OOM)要求内存隔离,**不放进主应用** |
|
||||||
|
|
||||||
|
> ⚠️ **两个不要混的坑**
|
||||||
|
> 1. **springdoc**:Boot 3 用 2.x,Boot 4 用 3.x。混用会启动即报错。
|
||||||
|
> 2. **MyBatis-Plus**:`mybatis-plus-spring-boot3-starter` 与 `-boot4-starter` 是两个包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工程结构
|
||||||
|
|
||||||
|
```
|
||||||
|
xiaotutongxue/
|
||||||
|
├── docs/ # 产品文档(P1–P7 已交付,只读参考)
|
||||||
|
├── backend/ # Java 后端
|
||||||
|
│ ├── pom.xml # 父 POM(依赖版本统一在此)
|
||||||
|
│ ├── xt-common/ # 通用层:响应/异常/错误码/加密/工具
|
||||||
|
│ └── xt-server/ # 主应用:config/security/module + 启动类
|
||||||
|
├── frontend-pc/ # Vue 3 管理端
|
||||||
|
├── frontend-mp/ # uni-app(学生端 + 班主任端)
|
||||||
|
├── deploy/
|
||||||
|
│ ├── docker-compose.yml # PG16 + Redis + MinIO
|
||||||
|
│ └── db/ # DDL 与种子数据
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**包名**:`cn.tianbaotong.fill`(域名 `tianbaotong.cn` 反转)
|
||||||
|
|
||||||
|
**为什么后端要拆模块**:M2 的图片处理服务必须**独立部署**(R11 内存隔离),
|
||||||
|
从第一天就留好父 POM 的位置,比 M2 再搬家便宜得多。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 需要补的表(PRD 未给 DDL)
|
||||||
|
|
||||||
|
PRD §7.2 明确标注:`从略 = 标准表(角色/字典/日志),沿用现有框架实现`。
|
||||||
|
DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
|
||||||
|
|
||||||
|
| 表 | 用途 | 为什么 M0 就要 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sys_role` | 角色定义 | 登录要返回 `roleCodes`,菜单渲染靠它 |
|
||||||
|
| `sys_user_role` | 用户 ↔ 角色 | 一个用户可多角色 |
|
||||||
|
| `sys_permission` | 权限码 / 菜单树 | 登录返回 `menus[]` + `permissions[]` |
|
||||||
|
| `sys_role_permission` | 角色 ↔ 权限 | 菜单按角色过滤 |
|
||||||
|
| `sys_login_log` | 登录日志 | PRD §10.3 审计留痕硬要求 |
|
||||||
|
| `sys_operation_log` | 操作日志 | 同上 |
|
||||||
|
|
||||||
|
> ⚠️ **这 6 张不计入 PRD 的「20 张表」**,那是业务表口径。两处分开数,别混。
|
||||||
|
|
||||||
|
**内置角色**(来自《03-接口设计》§2.7):
|
||||||
|
|
||||||
|
| roleCode | 名称 | userType | 数据范围 |
|
||||||
|
|---|---|:--:|---|
|
||||||
|
| `PLATFORM_ADMIN` | 平台运营 | 1 | 全平台(跨租户) |
|
||||||
|
| `SCHOOL_ADMIN` | 学校管理员 | 2 | 本租户全部 |
|
||||||
|
| `HEAD_TEACHER` | 班主任 | 3 | 本人所带班级 |
|
||||||
|
| `DEPUTY_TEACHER` | 副班主任 | 3 | 本人所带班级 |
|
||||||
|
| `STUDENT` | 学生 | 4 | 仅本人 |
|
||||||
|
| `AUDITOR` | 审核员 | 2 | 本租户(只读 + 审核) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 编码约定(M0 定死,后续不变)
|
||||||
|
|
||||||
|
### 4.1 统一响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "msg": "success", "data": { } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **业务错误不占用 HTTP 状态码**——一律 HTTP 200 + `code: 4xxxx`
|
||||||
|
- 只有 `40101`(未登录)/ `40301`(无权限)/ `42901`(限流)用真 HTTP 状态码
|
||||||
|
- 分页响应直接放 `data` 里:`{ list, total, pageNum, pageSize }`
|
||||||
|
|
||||||
|
### 4.2 多租户
|
||||||
|
|
||||||
|
- `tenantId` 从 **JWT 解出**,**绝不接受客户端传参**
|
||||||
|
- MyBatis-Plus 拦截器自动给所有 `sys_*` / `collect_*` 查询追加 `tenant_id = ?`
|
||||||
|
- **平台运营(`tenantId=0`)例外**,可跨租户
|
||||||
|
- 任何 `UPDATE` / `DELETE` 也必须带租户条件(拦截器统一加,不靠人记得写)
|
||||||
|
|
||||||
|
### 4.3 软删除
|
||||||
|
|
||||||
|
- 字段名 **`deleted`**(`smallint`,`0` 正常 / `1` 已删)
|
||||||
|
- MyBatis-Plus 逻辑删除插件统一处理,业务 SQL **不写 `deleted = 0`**(手写 SQL 除外)
|
||||||
|
- **唯一索引必须带 `WHERE deleted = 0`**(PG 部分唯一索引),否则删了不能重建同名
|
||||||
|
|
||||||
|
### 4.4 命名
|
||||||
|
|
||||||
|
- 表:`sys_` / `collect_` 前缀,蛇形
|
||||||
|
- **PG 的索引名与约束名必须 schema 内全局唯一**(不是表内唯一),一律加表缩写前缀
|
||||||
|
- 时间统一 UTC+8;JSON 用 ISO 8601 带时区
|
||||||
|
|
||||||
|
### 4.5 提交规范
|
||||||
|
|
||||||
|
```
|
||||||
|
<类型>(<范围>): <简述>
|
||||||
|
|
||||||
|
类型: feat / fix / docs / refactor / test / chore / perf
|
||||||
|
范围: m0 / tenant / org / user / auth / mp / file / export ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. M0 执行计划(13 任务 / 40 人日)
|
||||||
|
|
||||||
|
分 4 批做,每批结束都有**可演示的东西**。
|
||||||
|
|
||||||
|
### 批 1 · 地基(M0-01 / 02 / 03)
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| M0-01 | `deploy/docker-compose.yml`(PG16 + Redis + MinIO)<br>`deploy/db/` 下 DDL 与种子数据 | 一条 `docker compose up -d` 起全套;<br>`schema-postgresql.sql` + 标准表**零错误执行** |
|
||||||
|
| M0-02 | 父 POM + `xt-common` + `xt-server` 骨架<br>统一响应、全局异常、错误码枚举<br>多租户拦截器、软删除、字段自动填充<br>JWT 工具、AES/HMAC 工具 | `mvn package` 通过;<br>应用能启动连上 PG + Redis;<br>`/actuator/health` 返回 UP |
|
||||||
|
| M0-03 | `frontend-pc` 与 `frontend-mp` 骨架<br>请求封装(统一解 `code`)、路由、权限指令 | 两个前端都能 `dev` 起来并调通 `/auth/me` |
|
||||||
|
|
||||||
|
### 批 2 · 数据与接口(M0-05 / 06 / 07 / 08)
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| M0-05 | 租户 / 组织 CRUD(接口 §6.1~§6.7) | 能开通租户 → 建三级组织树 |
|
||||||
|
| M0-06 | 用户与角色 CRUD + Excel 批量导入(接口 §5.6~§5.9) | 导入 42 个用户,返回体检报告(含 `headerRow`) |
|
||||||
|
| M0-07 | PC 登录(RSA 解密 + 图形验证码 + 失败锁定 + 审计日志) | 连错 5 次锁定;验证码 2 次后必填 |
|
||||||
|
| M0-08 | 小程序登录 + 手机号绑定(接口 §5.3 / `POST /mp/bind-phone`) | `code2session` 换 token(本地用 mock) |
|
||||||
|
|
||||||
|
### 批 3 · 界面(M0-09 / 10 / 11 / 12)
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| M0-09 | 管理端框架:布局 / 侧边导航 / 表格 / 表单组件 | 视觉对齐 `prototype/admin-web.html` |
|
||||||
|
| M0-10 | 组织管理页 + 用户管理页 | 增删改查 + 批量导入全通 |
|
||||||
|
| M0-11 | 登录页 + 按权限渲染菜单 | 三种角色登录看到不同菜单 |
|
||||||
|
| M0-12 | 小程序框架与首页骨架 | 视觉对齐 `prototype/student-app.html` |
|
||||||
|
|
||||||
|
### 批 4 · 工程化(M0-04 / 13)
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| M0-04 | CI/CD + 多环境(dev / test / prod)配置 | 一条命令发布到测试环境 |
|
||||||
|
| M0-13 | **H5 扫码填报保底预研**(只预研不开发) | 出一份结论:uni-app 的 H5 产物能否覆盖 `chooseMessageFile` 的替代路径 |
|
||||||
|
|
||||||
|
### M0 总验收(《05-开发任务拆解》原文)
|
||||||
|
|
||||||
|
- [ ] 能开通租户 → 建组织树 → 导入 42 个用户 → 各角色登录看到不同菜单
|
||||||
|
- [ ] 跨租户访问被拦截
|
||||||
|
- [ ] `schema-postgresql.sql` 在测试环境零错误执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键实现约束(写代码前必读)
|
||||||
|
|
||||||
|
这四条是文档里踩过坑总结的,**实现时不许放水**:
|
||||||
|
|
||||||
|
| # | 约束 | 出处 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **并发绑定必须写成 `UPDATE ... WHERE bind_user_id IS NULL`** | 门禁核验,防两人同时抢一个名册项 |
|
||||||
|
| 2 | **审批通过必须 `SELECT ... FOR UPDATE`** | 防重复审批 |
|
||||||
|
| 3 | **「无」是合法业务值,不是空值** | 学历"无"存 `"无"`;专项无工种存 `null` + `certType=3`;补考"无"存 `[]` |
|
||||||
|
| 4 | **导入类接口一律返回体检报告结构**,必须回传 `headerRow` | 实测两份真实文件表头在第 1 行 vs 第 6 行 |
|
||||||
|
|
||||||
|
> ⚠️ **一期范围**:`collectMode=2`(社会收集)与 `certType=2/3`(社会评价/专项职业能力)
|
||||||
|
> **一期不启用**,传入返回 `40044`。字段与枚举保留,二期直接开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 里程碑节奏(15 周)
|
||||||
|
|
||||||
|
| 里程碑 | 人日 | 累计 | 出口条件 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| **M0** 基础设施与账号 | 40 | 40 | 三端能登录,账号体系可用 |
|
||||||
|
| M1 任务与配置闭环 | 44 | 84 | 能建任务、配表单与材料规则 |
|
||||||
|
| M2 名册与门禁 | 58 | 142 | 名单导入 + 门禁核验可用 |
|
||||||
|
| M3 填报主流程 | 71 | 213 | 学生能传材料、系统能改名压缩 |
|
||||||
|
| M4 识别与校验 | 34 | 247 | 4 类材料识别 + 21 条 VAL 校验 |
|
||||||
|
| M5 审核闭环 | 56 | 303 | 班主任初审 / 管理员终审可用 |
|
||||||
|
| M6 导出与交付 | 32 | 335 | 汇总包可按规范打包下载 |
|
||||||
|
| M7 收尾与上线 | 52 | 387 | Golden Set 回归通过,上线 |
|
||||||
|
|
||||||
|
> **两个无法靠加人加速的单点**:压缩引擎(12 人日)、打包引擎(10 人日)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 变更记录
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更 |
|
||||||
|
|---|---|---|
|
||||||
|
| V1.0 | 2026-09-28 | 初稿。技术选型定版(Boot 4.1.1 + Java 21 + MP 3.5.17 + Vue 3 + uni-app);工程结构定版;补 6 张标准表清单;M0 分 4 批执行计划 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 版本兼容踩坑记录(M0 实测,全部真实踩过)
|
||||||
|
|
||||||
|
> 这一节的价值在于**每一条都是"不报错但行为不对"或"报错信息指向完全错误方向"的类型**,
|
||||||
|
> 靠读文档和凭经验都躲不掉。团队新同学搭环境前请先读这一节。
|
||||||
|
|
||||||
|
| # | 现象 | 根因 | 修法 |
|
||||||
|
|:--:|---|---|---|
|
||||||
|
| 1 | `spring-boot-starter-aop` 报 *'dependencies.dependency.version' is missing* | **Boot 4 把 starter 改名了** | 换成 `spring-boot-starter-aspectj` |
|
||||||
|
| 2 | Flyway 依赖在 classpath 里,**但迁移根本不跑,且不报任何错** | **Boot 4 把自动配置拆成了独立模块**(`spring-boot-flyway`),`flyway-core` 自己不会再触发自动装配 | 用 `spring-boot-starter-flyway` |
|
||||||
|
| 3 | 加了 starter 后启动报 *Unsupported Database: PostgreSQL 16.15* | **Flyway 10+ 把「数据库支持」也拆成了独立模块** | 再加 `flyway-database-postgresql` |
|
||||||
|
| 4 | 编译报 *找不到符号 TenantLineInnerInterceptor / PaginationInnerInterceptor* | **MyBatis-Plus 3.5.9+ 把 jsqlparser 相关插件抽到了独立模块**,`extension` 里不再有 | 再加 `mybatis-plus-jsqlparser`(Java 11+ 用这个;老项目用 `-4.9` 变体) |
|
||||||
|
| 5 | springdoc 启动即错 | **springdoc 3.x 对应 Boot 4,2.x 对应 Boot 3**,混用必炸 | Boot 4 用 `3.1.1` |
|
||||||
|
| 6 | 自定义 JSON 序列化器写不出来 | **Boot 4 默认 Jackson 3(`tools.jackson`)**,Jackson 2 并存 | 通用层刻意<b>不 import 任何 Jackson 类</b>,序列化配置集中在 `xt-server` |
|
||||||
|
| 7 | 登录时租户拦截器抛 `40101`,堆栈指向"未登录" | 登录流程走到"查角色"时**还没有登录上下文**,而租户拦截器是**默认拒绝**的 | 密码校验通过后**先 `UserContext.set`**,再查角色与租户 |
|
||||||
|
| 8 | 新开通租户的管理员**登不进去**,换个租户查又"数据不存在" | 平台运营(`tenantId=0`)建数据时,拦截器把调用者自己的 tenantId 填进了 INSERT | 用 `TenantScope.runAs(目标租户, ...)` 临时切上下文 |
|
||||||
|
| 9 | 开通租户报 *value too long for type character varying(20)* | **DDL 的 `phone` 列按明文长度建**,装不下 AES-GCM 密文(约 52 字符) | 三列改 `varchar(128)`(见 PRD V2.4 变更记录) |
|
||||||
|
| 10 | 响应体里多出一个契约之外的 `success` 字段 | Jackson 把 `ApiResult.isSuccess()` 当成布尔属性**自动序列化**了 | 删掉该方法,判断成功一律用 `code == 0` |
|
||||||
|
|
||||||
|
### 9.1 一条通用经验
|
||||||
|
|
||||||
|
> **Boot 4 / Flyway 10 / MP 3.5.9 这一批版本的改动方向是一致的:把单体拆成模块,让"不用的东西不进 classpath"。**
|
||||||
|
> 好处是包体积和启动速度;代价是**「依赖在、功能不在」这类静默失败变多了**。
|
||||||
|
>
|
||||||
|
> 应对方式:**每加一个新中间件,不要只看"依赖有没有解析成功",必须实测一次它的核心动作有没有发生**
|
||||||
|
> (Flyway 要看表建出来没有,MP 插件要看 SQL 里租户条件加上没有)。
|
||||||
|
> 本项目的做法是把这些验证固化成了 `scripts/e2e-m0.py`,一条命令跑完。
|
||||||
|
|
||||||
|
### 9.2 本地启动(三条命令)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 起基础设施(PG16 + Redis + MinIO)
|
||||||
|
cd deploy && copy .env.example .env && docker compose up -d
|
||||||
|
|
||||||
|
# 2) 起后端(Flyway 自动建库 + 自动初始化账号)
|
||||||
|
cd backend && mvn spring-boot:run
|
||||||
|
|
||||||
|
# 3) 验收自测
|
||||||
|
python scripts/e2e-m0.py
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化账号(仅 dev,由 `fill.seed.enabled` 控制):
|
||||||
|
|
||||||
|
| 账号 | 密码 | 角色 |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | `Admin@123` | 学校管理员(租户 `demo`) |
|
||||||
|
| `platform` | `Platform@123` | 平台运营 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. M0 完成情况(2026-09-28)
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| M0-01 | `deploy/docker-compose.yml`(PG16.15 + Redis7 + MinIO)<br>`docs/db/schema-postgresql.sql` 零错误执行 | ✅ 实测:26 张表(20 业务 + 5 标准 + 1 迁移记录) |
|
||||||
|
| M0-02 | 父 POM + `xt-common` + `xt-server`<br>统一响应 / 全局异常 / 58 个错误码 / 多租户拦截 / 软删除 / 字段填充 / AES-GCM / HMAC 索引 / RSA | ✅ 编译通过,应用 6.2 秒启动 |
|
||||||
|
| M0-05 | 租户开通与列表(§6.5~§6.6) | ✅ 含"一次完成建租户 + 发角色 + 建管理员" |
|
||||||
|
| M0-06 | 组织 CRUD 与组织树(§6.1~§6.3) | ✅ 三层树 + `ancestor_path` 自动维护 + 占用校验 |
|
||||||
|
| M0-07 | PC 登录(RSA + 图形验证码 + 失败锁定 + 等时比较防枚举 + 审计日志) | ✅ |
|
||||||
|
| M0-08 | 登出(Token 黑名单)、`/auth/me` | ✅ |
|
||||||
|
| M0-09~12 | PC / 小程序前端 | ⏳ 未开始(后端接口已就绪) |
|
||||||
|
| M0-03 / 04 / 13 | 前端骨架 / CI / H5 预研 | ⏳ 未开始 |
|
||||||
|
|
||||||
|
### 10.1 验收结果
|
||||||
|
|
||||||
|
`python scripts/e2e-m0.py` → **35 / 35 通过**,覆盖:
|
||||||
|
|
||||||
|
- 认证:登录 / 错误密码 / 账号枚举防护 / 未登录拦截 / 伪造 token
|
||||||
|
- 权限:两种角色登录看到不同菜单;学校管理员无平台权限
|
||||||
|
- 开通租户:建租户 + 发角色 + 建管理员;初始密码可登录;编码重复被拒
|
||||||
|
- 组织树:三层结构;编码唯一性;类型层级校验;有下级不可删
|
||||||
|
- **跨租户隔离**:新租户看不到其他租户的组织;越权调平台接口得 `40301`
|
||||||
|
|
||||||
|
### 10.2 尚未实现(不属 M0 后端范围)
|
||||||
|
|
||||||
|
| 项 | 计划 |
|
||||||
|
|---|---|
|
||||||
|
| 用户 CRUD 与 Excel 批量导入(§5.6~§5.9) | M0 剩余 |
|
||||||
|
| 图形验证码接口的端到端联调(接口已通) | 随前端一起验 |
|
||||||
|
| PC 管理端 / 小程序前端 | M0-03/09/10/11/12 |
|
||||||
|
| CI/CD、H5 预研 | M0-04 / M0-13 |
|
||||||
@@ -6,7 +6,7 @@ CREATE TABLE sys_tenant (
|
|||||||
tenant_name varchar(128) NOT NULL,
|
tenant_name varchar(128) NOT NULL,
|
||||||
tenant_type smallint NOT NULL DEFAULT 1,
|
tenant_type smallint NOT NULL DEFAULT 1,
|
||||||
contact_name varchar(64),
|
contact_name varchar(64),
|
||||||
contact_phone varchar(20),
|
contact_phone varchar(128),
|
||||||
package_id bigint,
|
package_id bigint,
|
||||||
expire_time timestamp,
|
expire_time timestamp,
|
||||||
status smallint NOT NULL DEFAULT 1,
|
status smallint NOT NULL DEFAULT 1,
|
||||||
@@ -26,7 +26,7 @@ COMMENT ON COLUMN sys_tenant.tenant_code IS '租户编码(短码,用于小程序
|
|||||||
COMMENT ON COLUMN sys_tenant.tenant_name 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.tenant_type IS '1学校 2认定机构 3报名点 4企业';
|
||||||
COMMENT ON COLUMN sys_tenant.contact_name IS '联系人';
|
COMMENT ON COLUMN sys_tenant.contact_name IS '联系人';
|
||||||
COMMENT ON COLUMN sys_tenant.contact_phone 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.package_id IS '套餐ID';
|
||||||
COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间';
|
COMMENT ON COLUMN sys_tenant.expire_time IS '到期时间';
|
||||||
COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用';
|
COMMENT ON COLUMN sys_tenant.status IS '0停用 1正常 2试用';
|
||||||
@@ -85,7 +85,7 @@ CREATE TABLE sys_user (
|
|||||||
username varchar(64) NOT NULL,
|
username varchar(64) NOT NULL,
|
||||||
password varchar(128),
|
password varchar(128),
|
||||||
real_name varchar(64) NOT NULL,
|
real_name varchar(64) NOT NULL,
|
||||||
phone varchar(20),
|
phone varchar(128),
|
||||||
phone_hash char(64),
|
phone_hash char(64),
|
||||||
open_id varchar(64),
|
open_id varchar(64),
|
||||||
union_id varchar(64),
|
union_id varchar(64),
|
||||||
@@ -111,7 +111,7 @@ COMMENT ON COLUMN sys_user.tenant_id IS '0=平台运营';
|
|||||||
COMMENT ON COLUMN sys_user.org_id IS '所属组织';
|
COMMENT ON COLUMN sys_user.org_id IS '所属组织';
|
||||||
COMMENT ON COLUMN sys_user.username IS '登录名(教师)';
|
COMMENT ON COLUMN sys_user.username IS '登录名(教师)';
|
||||||
COMMENT ON COLUMN sys_user.password IS 'BCrypt';
|
COMMENT ON COLUMN sys_user.password IS 'BCrypt';
|
||||||
COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储)';
|
COMMENT ON COLUMN sys_user.phone IS '手机号(AES加密存储;varchar(128)容纳密文,非明文长度)';
|
||||||
COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
|
COMMENT ON COLUMN sys_user.phone_hash IS '手机号HMAC,用于唯一检索';
|
||||||
COMMENT ON COLUMN sys_user.open_id IS '微信openid';
|
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_no IS '证件号(AES加密存储)';
|
||||||
@@ -263,7 +263,7 @@ CREATE TABLE collect_roster (
|
|||||||
id_card_no varchar(128) NOT NULL,
|
id_card_no varchar(128) NOT NULL,
|
||||||
id_card_hash char(64) NOT NULL,
|
id_card_hash char(64) NOT NULL,
|
||||||
id_card_mask varchar(32),
|
id_card_mask varchar(32),
|
||||||
phone varchar(20),
|
phone varchar(128),
|
||||||
phone_hash char(64),
|
phone_hash char(64),
|
||||||
ext_json jsonb,
|
ext_json jsonb,
|
||||||
bind_user_id bigint,
|
bind_user_id bigint,
|
||||||
|
|||||||
+57
-7
@@ -35,6 +35,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 一·三、开发进展(2026-09-28 起)
|
||||||
|
|
||||||
|
> **代码位置**:`backend/`(Java 21 + Spring Boot 4.1.1)、`deploy/`(基础设施)、`scripts/`(工具)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + Element Plus;小程序: uni-app |
|
||||||
|
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
|
||||||
|
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
|
||||||
|
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户 |
|
||||||
|
| M0 验收 | ✅ **`python scripts/e2e-m0.py` → 35/35 通过** |
|
||||||
|
| 待做 | 用户 CRUD 与 Excel 导入;PC/小程序前端;CI/CD;H5 预研 |
|
||||||
|
|
||||||
|
**关键实现决策(不要在后继开发中改回去)**
|
||||||
|
|
||||||
|
| 决策 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| **权限码定义在代码里**(`PermissionDef` / `BuiltinRole`),不建 `sys_permission` 表 | 权限码是前后端契约,放 DB 必然与代码漂移;一期无"权限配置界面"需求 |
|
||||||
|
| **租户拦截器默认拒绝**:匿名请求触及租户表直接抛 40101 | 漏加租户条件 = 越租户泄露全校身份证号,不可接受的失败模式;宁可让开发者立刻撞墙 |
|
||||||
|
| 匿名接口只靠 `@PublicApi` 放行,**不做路径白名单** | 两套机制会让"哪些接口公开"出现两个来源,改一处漏一处 |
|
||||||
|
| **登录时账号不存在也跑一次 BCrypt**(假哈希) | 否则响应时间差会泄露"账号是否存在",直接把密码喷洒成功率提高几个数量级 |
|
||||||
|
| 密码 RSA 加密传输 | 不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志 |
|
||||||
|
| 登出黑名单**存 token 的 SHA-256**,TTL 到自然过期 | token 本身是凭证,明文写进 Redis 等于复制了一份到别处 |
|
||||||
|
|
||||||
|
**四个必须实测的坑**(详见 `07-开发实施计划.md` §9,10 条实录)
|
||||||
|
|
||||||
|
Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
|
||||||
|
导致**「依赖在、功能不在」的静默失败变多**。典型:只加 `flyway-core` 时迁移**不跑也不报错**。
|
||||||
|
**规则:每加一个中间件,必须实测它的核心动作真的发生了,不能只看依赖解析成功。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 一·五、客户回执(2026-09-28 · 需求已冻结)
|
## 一·五、客户回执(2026-09-28 · 需求已冻结)
|
||||||
|
|
||||||
> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。
|
> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。
|
||||||
@@ -82,21 +115,21 @@
|
|||||||
|
|
||||||
| 文件 | 版本/大小 | 状态 |
|
| 文件 | 版本/大小 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `docs/数据信息填报系统PRD.md` | V2.3 / 173KB / 2816 行 | ✅ 含 115 条 FR、17 条 VAL、96 接口、19 表 DDL、实测附录 |
|
| `docs/数据信息填报系统PRD.md` | V2.4 / 189KB / 3019 行 | ✅ 含 **125 条 FR**、**21 条 VAL**、**121 个接口**、**20 表 DDL**、实测附录 |
|
||||||
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
|
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
|
||||||
| `docs/db/schema-postgresql.sql` | 34KB | ✅ **PG 16.15 实测零错误**(19 表 / 167 列注释 / 61 索引) |
|
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
|
||||||
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
|
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
|
||||||
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
|
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
|
||||||
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
|
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
|
||||||
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
|
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
|
||||||
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
|
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
|
||||||
| `docs/01-客户与机构确认函.md` | 18KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
|
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
|
||||||
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
|
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
|
||||||
| `docs/03-接口设计.md` | **102KB / 2626 行** | ✅ **P3 117 个接口**字段级定义 + 枚举全集 + 12 个 VO + 50 个错误码;**自校验通过** |
|
| `docs/03-接口设计.md` | **115KB / 2883 行** | ✅ **P3 121 个接口**字段级定义 + 枚举全集 + 12 个 VO + **58 个错误码**;§4 逐节声称数与表行数一致 |
|
||||||
| `docs/06-验收测试清单.md` | **29KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
|
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
|
||||||
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
|
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
|
||||||
| `docs/04-核心模块设计说明.md` | **56KB / 1218 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
|
| `docs/04-核心模块设计说明.md` | **69KB / 1453 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
|
||||||
| `docs/05-开发任务拆解.md` | **28KB / 468 行** | ✅ **P5 94 个任务 / 380 人日 / 14 周**,含关键路径与核算口径 |
|
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,6 +223,23 @@ A4 扫描件 300dpi(2480×3508)/q85 · 一寸照片固定 295×413 · 学籍表
|
|||||||
### 教训一:脚本审计前必须先验证脚本本身
|
### 教训一:脚本审计前必须先验证脚本本身
|
||||||
P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。
|
P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。
|
||||||
|
|
||||||
|
> **⚠️ 这条教训在 2026-09-28 写开发实施计划时又复现了一次。**
|
||||||
|
> 我为交接摘要重算交付物体量,用了三个正则去数"接口数",分别得到 **74 / 95 / 121**——
|
||||||
|
> 三个都"看起来对",但没有一个经过验证。
|
||||||
|
>
|
||||||
|
> 定位过程:**逐节对比「标题声称数」与「实际表行数」**,发现
|
||||||
|
> ① 我漏了 `| ⭐ GET | ... |` 这种 ⭐ 在方法**前面**的行;
|
||||||
|
> ② §4.9 / §4.11 是**索引节**(交叉引用别处已计数的接口),本就不该计入。
|
||||||
|
> 修正后逐节声称数与表行数**全部为 0 差**,合计 **121**。
|
||||||
|
>
|
||||||
|
> **加固**:`scripts/verify-docs.py` 现在会做「**逐节声称数 vs 实际行数**」的一致性检查,
|
||||||
|
> 而不是只看总数对不对——总数相等也可能是个别节多算、个别节少算恰好抵消。
|
||||||
|
>
|
||||||
|
> **可复用的规则**:
|
||||||
|
> 1. 数字类断言必须**交叉核对至少两种独立方法**;
|
||||||
|
> 2. 核对要**分块**(逐节/逐表),不能只看总和;
|
||||||
|
> 3. 相互矛盾的中间值**一个都不要先写进文档**——写错再改,比一开始就空着更容易误导人。
|
||||||
|
|
||||||
### 教训二:写新文档必须回查已有编号体系,不能凭记忆
|
### 教训二:写新文档必须回查已有编号体系,不能凭记忆
|
||||||
P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**:
|
P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**:
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更内容 | 变更人 |
|
| 版本 | 日期 | 变更内容 | 变更人 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷,M0 实测暴露)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按<b>明文手机号</b>长度建的;但 §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 <b>52 字符</b>,插入直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 <b>`varchar(128)`</b> 与 `id_card_no` 对齐。<br>**发现方式**:M0 开通租户实测时数据库报错暴露,非人工审查发现。<br>**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 | 开发 |
|
||||||
| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 |
|
| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 |
|
||||||
| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):<br>① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)<br>② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"<br>③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG<br>④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例<br>⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)<br>⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)<br>⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)<br>⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项<br>⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 |
|
| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):<br>① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)<br>② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"<br>③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG<br>④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例<br>⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)<br>⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)<br>⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)<br>⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项<br>⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 |
|
||||||
| **V1.2** | 2026-09-24 | **压缩决策 D1 落定**:① §6.3 P3 扫描件档由"建议放宽至 2M"改为 **≤1M 硬约束、无例外**;② 压缩机制改为"体积优先、清晰度可牺牲,但必须标记 + 人工抽检";③ 风险 R1 由"要不要压"改为"压完机构认不认";④ 待确认项 Q1 / Q2-d1 关闭;⑤ **拆出《功能逻辑卷》**,本文不再承载功能运转细节 | 产品 |
|
| **V1.2** | 2026-09-24 | **压缩决策 D1 落定**:① §6.3 P3 扫描件档由"建议放宽至 2M"改为 **≤1M 硬约束、无例外**;② 压缩机制改为"体积优先、清晰度可牺牲,但必须标记 + 人工抽检";③ 风险 R1 由"要不要压"改为"压完机构认不认";④ 待确认项 Q1 / Q2-d1 关闭;⑤ **拆出《功能逻辑卷》**,本文不再承载功能运转细节 | 产品 |
|
||||||
@@ -2911,8 +2912,8 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
||||||
| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 |
|
| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 |
|
||||||
| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",提供**重新填写 / 申请加入名单**(不透露是哪一项错) |
|
| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",提供**重新填写 / 申请加入名单**(不透露是哪一项错) |
|
||||||
| `40007b` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
|
| `40071` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
|
||||||
| `40007c` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
|
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
|
||||||
| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 |
|
| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 |
|
||||||
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
|
| `40009` | 重复提交(幂等键冲突) | 返回既有结果 |
|
||||||
| `40010` | 驳回次数超上限 | 提示联系班主任 |
|
| `40010` | 驳回次数超上限 | 提示联系班主任 |
|
||||||
@@ -3010,3 +3011,8 @@ ERROR: relation "collect_task" does not exist ← 上面第 1 个错误的
|
|||||||
---
|
---
|
||||||
|
|
||||||
**文档结束**
|
**文档结束**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **实现说明(2026-09-28)**:本文件中的 `40007b` / `40007c` 已规范化为 **`40071`** / **`40072`**。
|
||||||
|
> 原因:`code` 按 §1.3 约定是数字,带字母后缀会在 JSON 里变成字符串,破坏前端 `code === 0` 的判断。
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
清理 e2e-m0.py 产生的测试数据。
|
||||||
|
|
||||||
|
E2E 每跑一次就新建一个租户(编码 e2e######)并给它建角色与账号,
|
||||||
|
反复跑会把开发库堆满。这个脚本把 e2e* 租户及其关联数据清掉。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/dev-clean-e2e.py # 只看会删什么(默认 dry-run)
|
||||||
|
python scripts/dev-clean-e2e.py --apply # 真删
|
||||||
|
|
||||||
|
⚠️ 只在开发库上跑,不要对生产执行。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
# 子表在前、主表在后(无外键约束,顺序只为可读性与排查方便)
|
||||||
|
STEPS = [
|
||||||
|
("角色授权", "sys_role_permission"),
|
||||||
|
("用户角色", "sys_user_role"),
|
||||||
|
("登录日志", "sys_login_log"),
|
||||||
|
("组织", "sys_org"),
|
||||||
|
("角色", "sys_role"),
|
||||||
|
("用户", "sys_user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
TENANT_FILTER = "tenant_code LIKE 'e2e%'"
|
||||||
|
SUB_FILTER = f"tenant_id IN (SELECT id FROM sys_tenant WHERE {TENANT_FILTER})"
|
||||||
|
|
||||||
|
|
||||||
|
class Db:
|
||||||
|
def __init__(self, container: str, user: str, name: str):
|
||||||
|
self.container = container
|
||||||
|
self.user = user
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def scalar_rows(self, sql: str) -> list[str]:
|
||||||
|
cmd = ["docker", "exec", self.container, "psql", "-U", self.user, "-d", self.name,
|
||||||
|
"-v", "ON_ERROR_STOP=1", "-t", "-A", "-F", "|", "-c", sql]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(r.stderr.strip(), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return [x for x in r.stdout.split("\n") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)")
|
||||||
|
ap.add_argument("--container", default="xt-pg")
|
||||||
|
ap.add_argument("--user", default="fill")
|
||||||
|
ap.add_argument("--db", default="filldb")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
db = Db(args.container, args.user, args.db)
|
||||||
|
|
||||||
|
tenants = db.scalar_rows(
|
||||||
|
f"SELECT id || '|' || tenant_code || '|' || tenant_name "
|
||||||
|
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
|
||||||
|
if not tenants:
|
||||||
|
print("没有 e2e 测试租户,无需清理")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"找到 {len(tenants)} 个 e2e 测试租户:")
|
||||||
|
for r in tenants[:15]:
|
||||||
|
p = r.split("|")
|
||||||
|
print(" id=%-5s code=%-14s %s" % (p[0], p[1], p[2] if len(p) > 2 else ""))
|
||||||
|
if len(tenants) > 15:
|
||||||
|
print(f" ... 另有 {len(tenants) - 15} 个")
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print("\n这是预览。加 --apply 才会真正删除。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("\n开始清理:")
|
||||||
|
total = 0
|
||||||
|
for label, table in STEPS:
|
||||||
|
rows = db.scalar_rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id")
|
||||||
|
total += len(rows)
|
||||||
|
print(" %-8s %-20s 删除 %d 行" % (label, table, len(rows)))
|
||||||
|
|
||||||
|
rows = db.scalar_rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id")
|
||||||
|
total += len(rows)
|
||||||
|
print(" %-8s %-20s 删除 %d 行" % ("租户", "sys_tenant", len(rows)))
|
||||||
|
|
||||||
|
print(f"\n完成,共删除 {total} 行。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
开发辅助:RSA 加密登录并打印 token。
|
||||||
|
|
||||||
|
为什么需要它
|
||||||
|
------------
|
||||||
|
登录接口的密码字段是 RSA 加密的(防止明文密码进网关日志),
|
||||||
|
所以用 curl / Postman 直接调会很别扭——得先算出一段密文。
|
||||||
|
这个脚本把它变成一行命令。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/dev-login.py # 默认 admin / Admin@123
|
||||||
|
python scripts/dev-login.py platform Platform@123
|
||||||
|
python scripts/dev-login.py admin Admin@123 --base http://localhost:8080
|
||||||
|
|
||||||
|
依赖:cryptography (pip install cryptography)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DEFAULT_PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_encrypt(plain: str, pub_key_path: str) -> str:
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("缺少依赖:pip install cryptography")
|
||||||
|
|
||||||
|
with open(pub_key_path, "rb") as f:
|
||||||
|
public_key = serialization.load_pem_public_key(f.read())
|
||||||
|
|
||||||
|
# 必须与服务端 RsaCipher 的 "RSA/ECB/PKCS1Padding" 一致。
|
||||||
|
# 换成 OAEP 会表现为「密码永远错」,两边都改才能换。
|
||||||
|
cipher = public_key.encrypt(plain.encode("utf-8"), padding.PKCS1v15())
|
||||||
|
return base64.b64encode(cipher).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(url: str, payload: dict, timeout: int = 20) -> tuple[int, dict]:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data,
|
||||||
|
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return e.code, {"raw": body}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="RSA 加密登录,打印 token")
|
||||||
|
ap.add_argument("username", nargs="?", default="admin")
|
||||||
|
ap.add_argument("password", nargs="?", default="Admin@123")
|
||||||
|
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||||
|
ap.add_argument("--tenant-code", default=None, help="账号在多个租户下重名时必填")
|
||||||
|
ap.add_argument("--pub-key", default=DEFAULT_PUB_KEY)
|
||||||
|
ap.add_argument("--json", action="store_true", help="打印完整响应而非仅 token")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(args.pub_key):
|
||||||
|
sys.exit(f"找不到公钥:{args.pub_key}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": args.username,
|
||||||
|
"password": rsa_encrypt(args.password, args.pub_key),
|
||||||
|
}
|
||||||
|
if args.tenant_code:
|
||||||
|
payload["tenantCode"] = args.tenant_code
|
||||||
|
|
||||||
|
status, body = post_json(f"{args.base}/api/v1/auth/login", payload)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(body, ensure_ascii=False, indent=2))
|
||||||
|
elif body.get("code") == 0:
|
||||||
|
d = body["data"]
|
||||||
|
print(d["token"])
|
||||||
|
else:
|
||||||
|
print(f"[失败] HTTP {status} code={body.get('code')} msg={body.get('msg')}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0 if body.get("code") == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
M0 验收测试。
|
||||||
|
|
||||||
|
把《05-开发任务拆解》里 M0 的验收标准变成可重复执行的程序:
|
||||||
|
|
||||||
|
[x] 能开通租户 → 建组织树 → 各角色登录看到不同菜单
|
||||||
|
[x] 跨租户访问被拦截
|
||||||
|
[x] schema-postgresql.sql 在测试环境零错误执行 ← 由 Flyway 迁移保证
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/e2e-m0.py
|
||||||
|
python scripts/e2e-m0.py --base http://localhost:8080
|
||||||
|
|
||||||
|
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
|
||||||
|
|
||||||
|
依赖:cryptography
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def http(method: str, url: str, body=None, token: str | None = None,
|
||||||
|
timeout: int = 30) -> tuple[int, dict]:
|
||||||
|
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||||||
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return e.code, {"code": -1, "msg": raw[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_encrypt(plain: str) -> str:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
with open(PUB_KEY, "rb") as f:
|
||||||
|
pub = serialization.load_pem_public_key(f.read())
|
||||||
|
return base64.b64encode(pub.encrypt(plain.encode("utf-8"), padding.PKCS1v15())).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def login(base: str, username: str, password: str, tenant_code: str | None = None) -> dict:
|
||||||
|
payload = {"username": username, "password": rsa_encrypt(password)}
|
||||||
|
if tenant_code:
|
||||||
|
payload["tenantCode"] = tenant_code
|
||||||
|
try:
|
||||||
|
_, body = http("POST", f"{base}/api/v1/auth/login", payload)
|
||||||
|
except Exception as e: # 网络层失败也要报成一条用例,而不是崩掉
|
||||||
|
return {"code": -1, "msg": f"请求失败: {e}"}
|
||||||
|
return body or {"code": -1, "msg": "空响应"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 断言 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def data_of(body: dict) -> dict:
|
||||||
|
"""安全取 data:响应里 data 可能是 null。"""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return {}
|
||||||
|
d = body.get("data")
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_of(body: dict) -> list:
|
||||||
|
d = body.get("data")
|
||||||
|
if isinstance(d, list):
|
||||||
|
return d
|
||||||
|
if isinstance(d, dict) and isinstance(d.get("list"), list):
|
||||||
|
return d["list"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||||
|
if cond:
|
||||||
|
PASSED.append(name)
|
||||||
|
print(f" [PASS] {name}")
|
||||||
|
else:
|
||||||
|
FAILED.append((name, detail))
|
||||||
|
print(f" [FAIL] {name} {detail}")
|
||||||
|
return cond
|
||||||
|
|
||||||
|
|
||||||
|
def expect_code(name: str, body: dict, code: int) -> bool:
|
||||||
|
actual = body.get("code") if isinstance(body, dict) else None
|
||||||
|
return check(name, actual == code,
|
||||||
|
f"期望 code={code},实际 code={actual} msg={body.get('msg') if isinstance(body, dict) else body}")
|
||||||
|
|
||||||
|
|
||||||
|
def finish() -> int:
|
||||||
|
print("\n" + "=" * 66)
|
||||||
|
total = len(PASSED) + len(FAILED)
|
||||||
|
if FAILED:
|
||||||
|
print(f" 结果: {len(PASSED)}/{total} 通过 —— 有失败项")
|
||||||
|
print("\n 失败项:")
|
||||||
|
for name, detail in FAILED:
|
||||||
|
print(f" - {name}")
|
||||||
|
if detail:
|
||||||
|
print(f" {detail}")
|
||||||
|
else:
|
||||||
|
print(f" 结果: {len(PASSED)}/{total} 全部通过")
|
||||||
|
print("=" * 66)
|
||||||
|
return 0 if not FAILED else 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 用例 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
base = args.base.rstrip("/")
|
||||||
|
stamp = str(int(time.time()))[-6:]
|
||||||
|
new_code = f"e2e{stamp}"
|
||||||
|
|
||||||
|
if not os.path.exists(PUB_KEY):
|
||||||
|
sys.exit(f"找不到 RSA 公钥:{PUB_KEY}")
|
||||||
|
|
||||||
|
print("=" * 66)
|
||||||
|
print(" M0 验收测试")
|
||||||
|
print(f" 目标: {base}")
|
||||||
|
print("=" * 66)
|
||||||
|
|
||||||
|
# ── A. 认证 ───────────────────────────────────────
|
||||||
|
print("\n[A] 认证与鉴权")
|
||||||
|
|
||||||
|
body = login(base, "platform", "Platform@123")
|
||||||
|
if not expect_code("A1 平台运营登录成功", body, 0):
|
||||||
|
return finish()
|
||||||
|
platform_token = data_of(body).get("token")
|
||||||
|
|
||||||
|
# ⚠️ admin 这个账号名在多个租户下都存在(E2E 每次会给新租户也建一个 admin),
|
||||||
|
# 所以必须带 tenantCode —— 这正是服务端「拒绝猜测、要求指明单位」的设计。
|
||||||
|
body = login(base, "admin", "Admin@123", tenant_code="demo")
|
||||||
|
expect_code("A2 学校管理员登录成功(带租户码)", body, 0)
|
||||||
|
demo_token = data_of(body).get("token")
|
||||||
|
demo_menus = set(data_of(body).get("menus") or [])
|
||||||
|
|
||||||
|
# 覆盖「账号跨租户重名」这条保护。
|
||||||
|
# 首次运行时 admin 只存在于 demo 一个租户(此时正常登录是对的);
|
||||||
|
# 反复运行后会出现多个同名 admin(此时必须要求指明单位)。
|
||||||
|
# 两种都算通过,唯一不允许的是「随便挑一个」。
|
||||||
|
body = login(base, "admin", "Admin@123")
|
||||||
|
code_n = body.get("code")
|
||||||
|
msg_n = str(body.get("msg", ""))
|
||||||
|
check("A2b 同名账号跨租户时:要么唯一可直接登录,要么要求指明单位——绝不猜",
|
||||||
|
code_n == 0 or (code_n == 40012 and "多个单位" in msg_n),
|
||||||
|
f"code={code_n} msg={msg_n}")
|
||||||
|
|
||||||
|
expect_code("A3 密码错误返回 40012", login(base, "admin", "WrongPassword!1", "demo"), 40012)
|
||||||
|
|
||||||
|
# 用每次唯一的账号名,避免上一轮的失败计数触发验证码要求
|
||||||
|
ghost = f"ghost_{stamp}"
|
||||||
|
expect_code("A4 账号不存在也返回 40012(不泄露账号是否存在)",
|
||||||
|
login(base, ghost, "Whatever@123"), 40012)
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/auth/me")
|
||||||
|
expect_code("A5 未带 token 访问受保护接口返回 40101", b, 40101)
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/auth/me", token=demo_token)
|
||||||
|
expect_code("A6 /auth/me 正常返回", b, 0)
|
||||||
|
check("A7 返回角色码 SCHOOL_ADMIN", data_of(b).get("roleCodes") == ["SCHOOL_ADMIN"],
|
||||||
|
str(data_of(b).get("roleCodes")))
|
||||||
|
check("A8 学校管理员菜单不含 tenant(无平台权限)", "tenant" not in demo_menus,
|
||||||
|
f"menus={sorted(demo_menus)}")
|
||||||
|
|
||||||
|
body = login(base, "platform", "Platform@123")
|
||||||
|
plat_menus = set(data_of(body).get("menus") or [])
|
||||||
|
check("A9 平台运营菜单含 tenant", "tenant" in plat_menus, f"menus={sorted(plat_menus)}")
|
||||||
|
check("A10 两种角色菜单不同(权限渲染生效)", demo_menus != plat_menus,
|
||||||
|
f"demo={sorted(demo_menus)} platform={sorted(plat_menus)}")
|
||||||
|
|
||||||
|
# ── B. 开通租户 ───────────────────────────────────
|
||||||
|
print("\n[B] 开通租户")
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/tenants", token=demo_token)
|
||||||
|
expect_code("B1 学校管理员无租户管理权限(40301)", b, 40301)
|
||||||
|
|
||||||
|
_, b = http("POST", f"{base}/api/v1/tenants", {
|
||||||
|
"tenantName": f"E2E 测试学校 {stamp}",
|
||||||
|
"tenantCode": new_code,
|
||||||
|
"tenantType": 1,
|
||||||
|
"contactName": "测试联系人",
|
||||||
|
"contactPhone": "13900139000",
|
||||||
|
"expireTime": "2027-12-31",
|
||||||
|
"adminUsername": "admin",
|
||||||
|
"adminRealName": "E2E 管理员",
|
||||||
|
"adminPhone": "13900139001",
|
||||||
|
}, token=platform_token)
|
||||||
|
expect_code("B2 平台运营开通租户成功", b, 0)
|
||||||
|
created = data_of(b)
|
||||||
|
new_tenant_id = created.get("id")
|
||||||
|
new_pwd = created.get("adminInitialPassword")
|
||||||
|
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
|
||||||
|
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
|
||||||
|
str(created.get("miniProgramScene")))
|
||||||
|
if new_tenant_id is None:
|
||||||
|
return finish()
|
||||||
|
|
||||||
|
body = login(base, "admin", new_pwd or "x", tenant_code=new_code)
|
||||||
|
expect_code("B5 新租户管理员用初始密码可登录(证明租户作用域切换生效)", body, 0)
|
||||||
|
new_token = data_of(body).get("token")
|
||||||
|
check("B6 新管理员归属租户正确",
|
||||||
|
data_of(body).get("tenant", {}).get("tenantCode") == new_code,
|
||||||
|
str(data_of(body).get("tenant")))
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/tenants?pageNum=1&pageSize=50", token=platform_token)
|
||||||
|
expect_code("B7 租户列表可查", b, 0)
|
||||||
|
ids = [t.get("id") for t in list_of(b)]
|
||||||
|
check("B8 列表含新建租户", new_tenant_id in ids, f"ids={ids}")
|
||||||
|
|
||||||
|
_, b = http("POST", f"{base}/api/v1/tenants", {
|
||||||
|
"tenantName": "重复编码", "tenantCode": new_code, "tenantType": 1,
|
||||||
|
"contactName": "x", "contactPhone": "13900139000", "expireTime": "2027-12-31",
|
||||||
|
"adminUsername": "admin", "adminRealName": "x", "adminPhone": "13900139002",
|
||||||
|
}, token=platform_token)
|
||||||
|
expect_code("B9 租户编码重复被拒(40001)", b, 40001)
|
||||||
|
|
||||||
|
# ── C. 组织树 ─────────────────────────────────────
|
||||||
|
print("\n[C] 组织架构")
|
||||||
|
|
||||||
|
def mk_org(token, parent_id, name, otype, code):
|
||||||
|
_, r = http("POST", f"{base}/api/v1/orgs", {
|
||||||
|
"parentId": parent_id, "orgName": name, "orgType": otype, "orgCode": code,
|
||||||
|
}, token=token)
|
||||||
|
return r
|
||||||
|
|
||||||
|
b = mk_org(demo_token, None, f"E2E 示范学校 {stamp}", 1, f"ROOT{stamp}")
|
||||||
|
expect_code("C1 建学校节点", b, 0)
|
||||||
|
school_id = b.get("data")
|
||||||
|
|
||||||
|
b = mk_org(demo_token, school_id, f"E2E 机电系 {stamp}", 2, f"DEPT{stamp}")
|
||||||
|
expect_code("C2 建院系节点", b, 0)
|
||||||
|
dept_id = b.get("data")
|
||||||
|
|
||||||
|
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
|
||||||
|
expect_code("C3 建班级节点", b, 0)
|
||||||
|
class_id = b.get("data")
|
||||||
|
|
||||||
|
expect_code("C4 同级重复组织编码被拒(40001)",
|
||||||
|
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001)
|
||||||
|
expect_code("C5 组织类型层级颠倒被拒(40001)",
|
||||||
|
mk_org(demo_token, class_id, "层级颠倒", 1, f"BAD{stamp}"), 40001)
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/orgs/tree?withCount=true", token=demo_token)
|
||||||
|
expect_code("C6 组织树查询", b, 0)
|
||||||
|
tree = list_of(b)
|
||||||
|
|
||||||
|
def find(nodes, target):
|
||||||
|
for n in nodes:
|
||||||
|
if n.get("id") == target:
|
||||||
|
return n
|
||||||
|
hit = find(n.get("children") or [], target)
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
return None
|
||||||
|
|
||||||
|
check("C7 三层结构正确(班级挂在院系下)", find(tree, class_id) is not None,
|
||||||
|
f"树中未见 id={class_id}")
|
||||||
|
root = find(tree, school_id)
|
||||||
|
check("C8 学校 → 院系 层级正确",
|
||||||
|
root is not None and dept_id in [c.get("id") for c in (root.get("children") or [])],
|
||||||
|
f"children={[c.get('id') for c in (root.get('children') or [])] if root else None}")
|
||||||
|
|
||||||
|
_, b = http("DELETE", f"{base}/api/v1/orgs/{dept_id}", token=demo_token)
|
||||||
|
expect_code("C9 有下级的组织不可删除(40015)", b, 40015)
|
||||||
|
|
||||||
|
# ── D. 跨租户隔离 ─────────────────────────────────
|
||||||
|
print("\n[D] 跨租户隔离")
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/orgs/tree", token=new_token)
|
||||||
|
expect_code("D1 新租户可查自己的组织树", b, 0)
|
||||||
|
dumped = json.dumps(list_of(b), ensure_ascii=False)
|
||||||
|
check("D2 新租户看不到 demo 租户的组织(租户条件自动注入)",
|
||||||
|
str(school_id) not in dumped, f"新租户树里出现了 demo 的组织 id={school_id}")
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/tenants", token=new_token)
|
||||||
|
expect_code("D3 新租户管理员调租户列表被拒(40301)", b, 40301)
|
||||||
|
|
||||||
|
# ── E. 异常与安全 ─────────────────────────────────
|
||||||
|
print("\n[E] 异常与安全")
|
||||||
|
|
||||||
|
_, b = http("POST", f"{base}/api/v1/auth/login", {"username": "admin", "password": "not-encrypted"})
|
||||||
|
check("E1 密码未加密(非法密文)也被拒,不泄露内部信息",
|
||||||
|
b.get("code") in (40012, 40001), f"code={b.get('code')} msg={b.get('msg')}")
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/orgs/tree", token="garbage.token.value")
|
||||||
|
expect_code("E2 伪造 token 返回 40101", b, 40101)
|
||||||
|
|
||||||
|
_, b = http("GET", f"{base}/api/v1/orgs/abc", token=demo_token)
|
||||||
|
check("E3 路径参数类型错误返回 40001", b.get("code") == 40001, f"code={b.get('code')}")
|
||||||
|
|
||||||
|
_, b = http("POST", f"{base}/api/v1/orgs", {"orgName": "", "orgType": 99, "orgCode": ""},
|
||||||
|
token=demo_token)
|
||||||
|
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
|
||||||
|
f"code={b.get('code')}")
|
||||||
|
|
||||||
|
return finish()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
DDL 同步:docs/db/schema-postgresql.sql → backend/.../db/migration/V1__business_tables.sql
|
||||||
|
|
||||||
|
为什么要有这个脚本
|
||||||
|
------------------
|
||||||
|
文档里的 DDL 是**交付物**(已实测、写进验收标准),Flyway 迁移是**工程产物**。
|
||||||
|
两者必须字字相同,否则"文档说一套、代码跑另一套"——这是最难查的一类事故。
|
||||||
|
|
||||||
|
所以:迁移文件由脚本生成,禁止手改;改 DDL 一律先改文档,再跑本脚本。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/sync-ddl.py # 生成/更新迁移文件
|
||||||
|
python scripts/sync-ddl.py --check # 只校验是否同步(CI 用,不同步则退出码 1)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Windows 控制台默认不是 UTF-8
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SOURCE = os.path.join(ROOT, "docs", "db", "schema-postgresql.sql")
|
||||||
|
TARGET = os.path.join(
|
||||||
|
ROOT, "backend", "xt-server", "src", "main", "resources",
|
||||||
|
"db", "migration", "V1__business_tables.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
HEADER = """\
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
-- 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 表注释
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: str) -> str:
|
||||||
|
with io.open(path, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""统一行尾并去掉首尾空白,用于比对。"""
|
||||||
|
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def render(source_text: str) -> str:
|
||||||
|
return HEADER + normalize(source_text) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
check_only = "--check" in sys.argv
|
||||||
|
|
||||||
|
if not os.path.exists(SOURCE):
|
||||||
|
print(f"[X] 找不到源文件:{SOURCE}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
expected = render(read(SOURCE))
|
||||||
|
|
||||||
|
if check_only:
|
||||||
|
if not os.path.exists(TARGET):
|
||||||
|
print(f"[X] 迁移文件不存在:{TARGET}")
|
||||||
|
return 1
|
||||||
|
actual = io.open(TARGET, encoding="utf-8").read()
|
||||||
|
if normalize(actual) != normalize(expected):
|
||||||
|
print("[X] DDL 已漂移:文档与迁移文件不一致")
|
||||||
|
print(f" 文档 :{SOURCE}")
|
||||||
|
print(f" 迁移 :{TARGET}")
|
||||||
|
print(" 修复 :python scripts/sync-ddl.py")
|
||||||
|
return 1
|
||||||
|
print("[OK] DDL 已同步")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(TARGET), exist_ok=True)
|
||||||
|
with io.open(TARGET, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
f.write(expected)
|
||||||
|
|
||||||
|
tables = expected.count("CREATE TABLE ")
|
||||||
|
indexes = expected.count("CREATE INDEX ") + expected.count("CREATE UNIQUE INDEX ")
|
||||||
|
comments = expected.count("COMMENT ON COLUMN ")
|
||||||
|
print(f"[OK] 已生成 {os.path.relpath(TARGET, ROOT)}")
|
||||||
|
print(f" {tables} 表 / {indexes} 索引 / {comments} 列注释 / "
|
||||||
|
f"{len(expected.encode('utf-8'))} 字节")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
文档一致性校验。
|
||||||
|
|
||||||
|
被校的东西都是历史上真实出错过的:
|
||||||
|
1. 表格列数一致(错位会让 Markdown 渲染成一堆竖线)
|
||||||
|
2. 接口数:逐节「标题声称数」vs「实际表行数」——只看总数会漏掉
|
||||||
|
"这节多算 3 个、那节少算 3 个、总数恰好相等"这种情况
|
||||||
|
3. 错误码数量与文档声称一致
|
||||||
|
4. 关键数字跨文档一致(FR / VAL / 任务 / 人日 / 表数 / 接口)
|
||||||
|
5. 原型屏数与 figcaption 数一致
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/verify-docs.py # 校验,失败退出码 1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
D = os.path.join(ROOT, "docs")
|
||||||
|
BT = chr(96)
|
||||||
|
|
||||||
|
ok_count = 0
|
||||||
|
bad: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
global ok_count
|
||||||
|
ok_count += 1
|
||||||
|
print(f" [OK] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> None:
|
||||||
|
bad.append(msg)
|
||||||
|
print(f" [FAIL] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def read(*parts: str) -> str:
|
||||||
|
p = os.path.join(ROOT, *parts)
|
||||||
|
return io.open(p, encoding="utf-8").read() if os.path.exists(p) else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. 表格列数 ────────────────────────────────────────
|
||||||
|
def check_tables(name: str, text: str) -> None:
|
||||||
|
lines = text.split("\n")
|
||||||
|
bad_rows = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if lines[i].strip().startswith("|") and i + 1 < len(lines) \
|
||||||
|
and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()):
|
||||||
|
n = lines[i].count("|")
|
||||||
|
j = i + 2
|
||||||
|
while j < len(lines) and lines[j].strip().startswith("|"):
|
||||||
|
if lines[j].count("|") != n:
|
||||||
|
bad_rows += 1
|
||||||
|
j += 1
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
if bad_rows:
|
||||||
|
fail(f"{name}:{bad_rows} 行表格列数不一致")
|
||||||
|
else:
|
||||||
|
ok(f"{name}:表格列数一致")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. 接口数逐节核对 ──────────────────────────────────
|
||||||
|
def check_api_count() -> int:
|
||||||
|
s = read("docs", "03-接口设计.md")
|
||||||
|
if not s:
|
||||||
|
fail("找不到 03-接口设计.md")
|
||||||
|
return 0
|
||||||
|
sec = s[s.index("## §4"):s.index("## §5")]
|
||||||
|
parts = re.split(r"^(### 4\.\d+ .+)$", sec, flags=re.M)
|
||||||
|
|
||||||
|
claim_sum = actual_sum = 0
|
||||||
|
mismatches = []
|
||||||
|
i = 1
|
||||||
|
while i < len(parts) - 1:
|
||||||
|
title, body = parts[i].strip(), parts[i + 1]
|
||||||
|
i += 2
|
||||||
|
m = re.search(r"[((](\d+)[))]\s*$", title)
|
||||||
|
claim = int(m.group(1)) if m else None
|
||||||
|
# ⚠️ 允许 ⭐ 出现在方法之前(| ⭐ GET | ... |)
|
||||||
|
actual = len(re.findall(r"^\|\s*(?:⭐\s*)?(GET|POST|PUT|DELETE|PATCH)\s*\|", body, re.M))
|
||||||
|
|
||||||
|
# 索引节(交叉引用别处已计数的接口),不重复计入
|
||||||
|
if "索引" in title or "新增" in title:
|
||||||
|
continue
|
||||||
|
if claim is None:
|
||||||
|
continue
|
||||||
|
claim_sum += claim
|
||||||
|
actual_sum += actual
|
||||||
|
if claim != actual:
|
||||||
|
mismatches.append(f"{title[:30]} 声称{claim} 实际{actual}")
|
||||||
|
|
||||||
|
if mismatches:
|
||||||
|
fail(f"03 接口数逐节不一致:{'; '.join(mismatches)}")
|
||||||
|
elif claim_sum == actual_sum:
|
||||||
|
ok(f"03 接口数逐节一致 = {actual_sum}")
|
||||||
|
else:
|
||||||
|
fail(f"03 接口数:声称 {claim_sum} vs 实际 {actual_sum}")
|
||||||
|
return actual_sum
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. 错误码 ──────────────────────────────────────────
|
||||||
|
def check_error_codes() -> int:
|
||||||
|
s = read("docs", "03-接口设计.md")
|
||||||
|
sec = s[s.index("## §15"):]
|
||||||
|
codes = set(re.findall(
|
||||||
|
r"^\|\s*(?:⭐\s*)?" + re.escape(BT) + r"(\d{4,5})" + re.escape(BT) + r"\s*\|", sec, re.M))
|
||||||
|
total = len(codes) + 1 # + 成功码 0
|
||||||
|
claimed = re.search(r"本节共 \*\*(\d+) 个\*\*", s)
|
||||||
|
if claimed and int(claimed.group(1)) != total:
|
||||||
|
fail(f"03 错误码:实际 {total} vs 文档声称 {claimed.group(1)}")
|
||||||
|
else:
|
||||||
|
ok(f"03 错误码 = {total}")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. 跨文档关键数字 ──────────────────────────────────
|
||||||
|
def check_cross_doc(api: int, err: int) -> None:
|
||||||
|
docs = {
|
||||||
|
"PRD": read("docs", "数据信息填报系统PRD.md"),
|
||||||
|
"03": read("docs", "03-接口设计.md"),
|
||||||
|
"05": read("docs", "05-开发任务拆解.md"),
|
||||||
|
"README": read("README.md"),
|
||||||
|
"交接摘要": read("docs", "交接摘要.md"),
|
||||||
|
}
|
||||||
|
checks = [
|
||||||
|
("接口", str(api)),
|
||||||
|
("错误码", str(err)),
|
||||||
|
("任务", "100"),
|
||||||
|
("人日", "387"),
|
||||||
|
]
|
||||||
|
for label, value in checks:
|
||||||
|
hit = [n for n, t in docs.items() if value in t]
|
||||||
|
if len(hit) >= 3:
|
||||||
|
ok(f"{label} {value} 出现于 {len(hit)} 份文档")
|
||||||
|
else:
|
||||||
|
fail(f"{label} {value} 仅出现于 {hit}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. 原型屏数 ────────────────────────────────────────
|
||||||
|
def check_screens() -> None:
|
||||||
|
total = 0
|
||||||
|
for f in ["student-app.html", "teacher-app.html", "admin-web.html"]:
|
||||||
|
s = read("docs", "prototype", f)
|
||||||
|
total += len(re.findall(r"<figcaption", s, re.I))
|
||||||
|
claimed = re.search(r"合计屏数\s*:\s*(\d+)", read("docs", "交接摘要.md"))
|
||||||
|
if total:
|
||||||
|
ok(f"原型屏数(figcaption 计数)= {total}")
|
||||||
|
if claimed and int(claimed.group(1)) != total:
|
||||||
|
fail(f"原型屏数:实际 {total} vs 交接摘要声称 {claimed.group(1)}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 66)
|
||||||
|
print(" 文档一致性校验")
|
||||||
|
print("=" * 66)
|
||||||
|
|
||||||
|
print("\n[1] 表格列数")
|
||||||
|
for f in ["README.md"]:
|
||||||
|
check_tables(f, read(f))
|
||||||
|
for f in os.listdir(D):
|
||||||
|
if f.endswith(".md"):
|
||||||
|
check_tables(f, read("docs", f))
|
||||||
|
|
||||||
|
print("\n[2] 接口数(逐节核对)")
|
||||||
|
api = check_api_count()
|
||||||
|
|
||||||
|
print("\n[3] 错误码")
|
||||||
|
err = check_error_codes()
|
||||||
|
|
||||||
|
print("\n[4] 跨文档关键数字")
|
||||||
|
check_cross_doc(api, err)
|
||||||
|
|
||||||
|
print("\n[5] 原型屏数")
|
||||||
|
check_screens()
|
||||||
|
|
||||||
|
print("\n" + "=" * 66)
|
||||||
|
if bad:
|
||||||
|
print(f" 结果: {ok_count} 项通过,{len(bad)} 项失败")
|
||||||
|
for b in bad:
|
||||||
|
print(f" - {b}")
|
||||||
|
else:
|
||||||
|
print(f" 结果: {ok_count} 项全部通过")
|
||||||
|
print("=" * 66)
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user