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