From 9d7a8ca845d8355ce5bfa18d6177e07ebad9d29d Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Mon, 28 Sep 2026 17:28:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(m0):=20=E5=90=8E=E7=AB=AF=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=20+=20=E8=B4=A6=E5=8F=B7=E4=BD=93=E7=B3=BB=EF=BC=88M0?= =?UTF-8?q?=20=E9=AA=8C=E6=94=B6=2036/36=20=E9=80=9A=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 技术栈(已在 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 完成情况 --- .gitattributes | 28 + README.md | 51 +- backend/pom.xml | 133 ++++ backend/xt-common/pom.xml | 81 ++ .../fill/common/api/ApiResult.java | 78 ++ .../fill/common/api/ErrorCode.java | 181 +++++ .../fill/common/api/PageResult.java | 45 ++ .../fill/common/api/ValidateProblem.java | 57 ++ .../fill/common/context/LoginUser.java | 56 ++ .../fill/common/context/UserContext.java | 64 ++ .../fill/common/crypto/AesGcmCipher.java | 105 +++ .../fill/common/crypto/HmacIndex.java | 68 ++ .../fill/common/crypto/RsaCipher.java | 63 ++ .../fill/common/exception/BizException.java | 59 ++ .../exception/GlobalExceptionHandler.java | 152 ++++ .../fill/common/util/MaskUtil.java | 115 +++ backend/xt-server/pom.xml | 133 ++++ .../cn/tianbaotong/fill/FillApplication.java | 22 + .../tianbaotong/fill/config/CryptoConfig.java | 56 ++ .../fill/config/FillProperties.java | 92 +++ .../fill/config/MybatisPlusConfig.java | 97 +++ .../fill/config/OpenApiConfig.java | 50 ++ .../fill/config/TraceIdFilter.java | 49 ++ .../cn/tianbaotong/fill/config/WebConfig.java | 53 ++ .../auth/controller/AuthController.java | 60 ++ .../fill/module/auth/dto/LoginReq.java | 51 ++ .../fill/module/auth/dto/LoginVO.java | 56 ++ .../fill/module/auth/service/AuthService.java | 303 ++++++++ .../module/auth/service/CaptchaService.java | 74 ++ .../fill/module/auth/service/LoginGuard.java | 93 +++ .../fill/module/log/entity/SysLoginLog.java | 59 ++ .../module/log/entity/SysOperationLog.java | 64 ++ .../module/log/mapper/LoginLogMapper.java | 31 + .../module/log/mapper/OperationLogMapper.java | 7 + .../module/org/controller/OrgController.java | 67 ++ .../fill/module/org/dto/OrgTreeVO.java | 32 + .../fill/module/org/dto/OrgUpsertReq.java | 48 ++ .../fill/module/org/entity/SysOrg.java | 46 ++ .../fill/module/org/mapper/OrgMapper.java | 49 ++ .../fill/module/org/service/OrgService.java | 298 +++++++ .../tenant/controller/TenantController.java | 61 ++ .../module/tenant/dto/TenantCreateReq.java | 77 ++ .../fill/module/tenant/dto/TenantVO.java | 26 + .../fill/module/tenant/entity/SysTenant.java | 46 ++ .../module/tenant/mapper/TenantMapper.java | 14 + .../module/tenant/service/TenantService.java | 181 +++++ .../fill/module/user/entity/SysRole.java | 36 + .../module/user/entity/SysRolePermission.java | 23 + .../fill/module/user/entity/SysUser.java | 63 ++ .../fill/module/user/entity/SysUserRole.java | 23 + .../fill/module/user/mapper/RoleMapper.java | 7 + .../user/mapper/RolePermissionMapper.java | 7 + .../fill/module/user/mapper/UserMapper.java | 57 ++ .../module/user/mapper/UserRoleMapper.java | 61 ++ .../fill/security/AuthInterceptor.java | 110 +++ .../fill/security/BuiltinRole.java | 176 +++++ .../tianbaotong/fill/security/JwtService.java | 171 +++++ .../fill/security/PermissionChecker.java | 74 ++ .../fill/security/PermissionDef.java | 109 +++ .../tianbaotong/fill/security/PublicApi.java | 22 + .../fill/security/RequirePerm.java | 24 + .../fill/security/TokenBlacklist.java | 60 ++ .../tianbaotong/fill/support/BaseEntity.java | 55 ++ .../tianbaotong/fill/support/DataSeeder.java | 133 ++++ .../fill/support/FillMetaObjectHandler.java | 56 ++ .../fill/support/RoleProvisioner.java | 85 ++ .../fill/support/SensitiveCodec.java | 81 ++ .../fill/support/TenantEntity.java | 23 + .../tianbaotong/fill/support/TenantScope.java | 55 ++ .../cn/tianbaotong/fill/support/WebUtil.java | 56 ++ .../src/main/resources/application-dev.yml | 46 ++ .../src/main/resources/application.yml | 112 +++ .../db/migration/V1__business_tables.sql | 724 ++++++++++++++++++ .../db/migration/V2__standard_tables.sql | 138 ++++ deploy/.env.example | 19 + deploy/docker-compose.yml | 93 +++ docs/03-接口设计.md | 14 +- docs/04-核心模块设计说明.md | 22 +- docs/06-验收测试清单.md | 11 +- docs/07-开发实施计划.md | 299 ++++++++ docs/db/schema-postgresql.sql | 10 +- docs/交接摘要.md | 64 +- docs/数据信息填报系统PRD.md | 10 +- scripts/dev-clean-e2e.py | 99 +++ scripts/dev-login.py | 106 +++ scripts/e2e-m0.py | 328 ++++++++ scripts/sync-ddl.py | 102 +++ scripts/verify-docs.py | 201 +++++ 88 files changed, 7564 insertions(+), 32 deletions(-) create mode 100644 .gitattributes create mode 100644 backend/pom.xml create mode 100644 backend/xt-common/pom.xml create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ApiResult.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/PageResult.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ValidateProblem.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/UserContext.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/AesGcmCipher.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/HmacIndex.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/RsaCipher.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/BizException.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/GlobalExceptionHandler.java create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java create mode 100644 backend/xt-server/pom.xml create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/CryptoConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/MybatisPlusConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/TraceIdFilter.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginReq.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/CaptchaService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/LoginGuard.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysLoginLog.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgTreeVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgUpsertReq.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/entity/SysOrg.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/mapper/OrgMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantCreateReq.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenant.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRole.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRolePermission.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUserRole.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RoleMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RolePermissionMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserRoleMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionChecker.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PublicApi.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/RequirePerm.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/security/TokenBlacklist.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/BaseEntity.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/DataSeeder.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/FillMetaObjectHandler.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/SensitiveCodec.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantEntity.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantScope.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/WebUtil.java create mode 100644 backend/xt-server/src/main/resources/application-dev.yml create mode 100644 backend/xt-server/src/main/resources/application.yml create mode 100644 backend/xt-server/src/main/resources/db/migration/V1__business_tables.sql create mode 100644 backend/xt-server/src/main/resources/db/migration/V2__standard_tables.sql create mode 100644 deploy/.env.example create mode 100644 deploy/docker-compose.yml create mode 100644 docs/07-开发实施计划.md create mode 100644 scripts/dev-clean-e2e.py create mode 100644 scripts/dev-login.py create mode 100644 scripts/e2e-m0.py create mode 100644 scripts/sync-ddl.py create mode 100644 scripts/verify-docs.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..75f20db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +# 统一换行符:仓库内存 LF,检出时按平台 +* text=auto eol=lf + +# Windows 专属脚本保留 CRLF +*.bat text eol=crlf +*.cmd text eol=crlf + +# 二进制文件不做转换 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.jar binary + +# 以下文件强制 LF(跨平台关键) +*.sql text eol=lf +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +Dockerfile text eol=lf + +# 语言统计不把文档算进去 +docs/** linguist-documentation +docs/prototype/** linguist-vendored diff --git a/README.md b/README.md index 3f8d77f..ec3df49 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 数据信息填报系统 · 文档中心 +# 数据信息填报系统 > 职业技能等级认定**报名材料收集 SaaS** > **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期 @@ -146,7 +146,7 @@ docker run -d --name pg-verify -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=fill # 执行 DDL docker exec -i pg-verify psql -U postgres -d filldb < docs/db/schema-postgresql.sql -# 验证结果:19 张表 / 167 列注释 / 61 索引,零错误 +# 验证结果:20 张表 / 177 列注释 / 65 索引,零错误 docker exec -it pg-verify psql -U postgres -d filldb ``` @@ -162,6 +162,53 @@ python check2.py docs/prototype/admin-web.html --- +--- + +## 六·二、把系统跑起来(三条命令) + +```bash +# 1) 基础设施:PG 16 + Redis 7 + MinIO +cd deploy +copy .env.example .env # 首次 +docker compose up -d + +# 2) 后端:Flyway 自动建库 + 自动初始化账号 +cd ../backend +mvn spring-boot:run + +# 3) 验收自测(35 项) +cd .. +python scripts/e2e-m0.py +``` + +开发环境初始账号(仅本地,由 `fill.seed.enabled` 控制): + +| 账号 | 密码 | 角色 | +|---|---|---| +| `admin` | `Admin@123` | 学校管理员(租户 `demo`) | +| `platform` | `Platform@123` | 平台运营 | + +接口文档: + +### 代码结构 + +``` +backend/ Java 21 + Spring Boot 4.1.1 + ├── xt-common/ 统一响应 / 58 个错误码 / AES-GCM / HMAC / RSA + └── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块 +deploy/ docker-compose + RSA 开发公钥 +scripts/ DDL 同步、登录助手、M0 验收测试 +frontend-pc/ (待建)Vue 3 + Element Plus +frontend-mp/ (待建)uni-app +``` + +### ⚠️ 搭环境前请先读 + +**`docs/07-开发实施计划.md` §9「版本兼容踩坑记录」**—— +那里记了 10 条 Boot 4 / Flyway 10 / MyBatis-Plus 3.5.9 的真实坑, +其中多数是「**依赖在、功能不在,且不报错**」的类型,靠读文档和凭经验都躲不掉。 + + ## 七、交付状态 | 阶段 | 内容 | 状态 | diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..463cb12 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,133 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + cn.tianbaotong + fill-parent + 1.0.0-SNAPSHOT + pom + 数据信息填报系统 + 职业技能等级认定 · 报名材料收集 SaaS + + + xt-common + xt-server + + + + 21 + 21 + UTF-8 + UTF-8 + + + 3.5.17 + 0.13.0 + 5.8.47 + 4.0.3 + + 3.1.1 + + + + + + + cn.tianbaotong + xt-common + ${project.version} + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + cn.hutool + hutool-all + ${hutool.version} + + + + + com.alibaba + easyexcel + ${easyexcel.version} + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.release} + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + + diff --git a/backend/xt-common/pom.xml b/backend/xt-common/pom.xml new file mode 100644 index 0000000..7d0a994 --- /dev/null +++ b/backend/xt-common/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + cn.tianbaotong + fill-parent + 1.0.0-SNAPSHOT + + + xt-common + xt-common · 通用层 + 统一响应、错误码、异常、加解密、上下文。不依赖任何业务模块。 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.security + spring-security-crypto + + + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + + + io.jsonwebtoken + jjwt-jackson + + + + + cn.hutool + hutool-all + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ApiResult.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ApiResult.java new file mode 100644 index 0000000..cd95232 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ApiResult.java @@ -0,0 +1,78 @@ +package cn.tianbaotong.fill.common.api; + +import lombok.Getter; +import lombok.Setter; + +/** + * 统一响应体(《03-接口设计》§1.3)。 + * + *
+ * { "code": 0, "msg": "success", "data": { } }
+ * 
+ * + *

空值约定(§1.8):统一 {@code null},不用空字符串 {@code ""}。 + * 因此这里不加 {@code @JsonInclude(NON_NULL)}——null 要如实序列化出去。 + * + * @param data 的类型 + */ +@Getter +@Setter +public class ApiResult { + + /** 业务码。0 成功;4xxxx 客户端;5xxxx 服务端。见 {@link ErrorCode}。 */ + private int code; + + /** 提示文案。成功固定为 {@code "success"}。 */ + private String msg; + + /** 业务数据。无数据时为 {@code null}。 */ + private T data; + + public ApiResult() { + } + + public ApiResult(int code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + // ── 成功 ────────────────────────────────────── + + public static ApiResult ok() { + return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), null); + } + + public static ApiResult ok(T data) { + return new ApiResult<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), data); + } + + // ── 失败 ────────────────────────────────────── + + public static ApiResult fail(ErrorCode ec) { + return new ApiResult<>(ec.getCode(), ec.getMsg(), null); + } + + /** 覆盖默认文案(例如带上剩余锁定时间)。 */ + public static ApiResult fail(ErrorCode ec, String msg) { + return new ApiResult<>(ec.getCode(), msg, null); + } + + /** + * 带业务数据的失败。 + * + *

用于 §1.3 的 {@code 40001} (blocking/warnings 清单)、{@code 40007} + * (剩余尝试次数)等需要回传结构的场景。 + */ + public static ApiResult fail(ErrorCode ec, T data) { + return new ApiResult<>(ec.getCode(), ec.getMsg(), data); + } + + public static ApiResult fail(ErrorCode ec, String msg, T data) { + return new ApiResult<>(ec.getCode(), msg, data); + } + + // 注意:这里刻意不提供 isSuccess() 之类的方法。 + // Jackson 会把 isXxx() 当成属性序列化出去,给响应体加一个契约里没有的 "success" 字段。 + // 判断成功请用 code == ErrorCode.SUCCESS.getCode()。 +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java new file mode 100644 index 0000000..a6801b6 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java @@ -0,0 +1,181 @@ +package cn.tianbaotong.fill.common.api; + +import lombok.Getter; + +/** + * 错误码全集。 + * + *

权威来源:《03-接口设计》§15「错误码全集」,共 58 个(含成功码 {@code 0})。 + * 改这里之前先改文档,不要只改代码。 + * + *

为什么 code 是 int 而不是 String:§1.3 约定响应形如 + * {@code {"code": 0, "msg": "success", "data": {}}},前端判断用 {@code code === 0}。 + * 若混入 {@code "40007b"} 这类字符串码,JSON 类型会不一致、前端判断失效。 + * 因此原 {@code 40007b}/{@code 40007c} 已规范化为 {@code 40071}/{@code 40072}。 + * + *

HTTP 状态码规则(§1.3):业务错误一律 HTTP 200,只有以下 4 个用真 HTTP 码: + * {@link #UNAUTHORIZED} 401、{@link #FORBIDDEN} 403、{@link #RATE_LIMITED} 429、5xxxx 用 200。 + */ +@Getter +public enum ErrorCode { + + // ── 成功 ────────────────────────────────────── + SUCCESS(0, "success"), + + // ── 40001~40010 填报与文件 ──────────────────── + /** 存在阻断性校验问题,无法提交 */ + VALIDATION_BLOCKED(40001, "存在阻断性校验问题,无法提交"), + /** 文件格式不在白名单 */ + FILE_FORMAT_NOT_ALLOWED(40002, "文件格式不在白名单"), + /** 文件体积超上限且压缩受限 */ + FILE_TOO_LARGE(40003, "文件体积超上限且压缩受限"), + /** 身份证号校验位不通过 */ + ID_CARD_CHECKSUM_FAILED(40004, "身份证号校验位不通过"), + /** 证件号与名册不一致 */ + ID_CARD_MISMATCH_ROSTER(40005, "证件号与名册不一致"), + /** 疑似串档(转人工,不自动修正) */ + SUSPECTED_WRONG_OWNER(40006, "疑似串档"), + /** 门禁未匹配——统一提示,不透露哪项错 */ + GATE_NOT_MATCHED(40007, "没有找到你的信息,请核对后重试"), + /** 门禁尝试过于频繁(原 40007b) */ + GATE_RATE_LIMITED(40071, "操作过于频繁,请稍后再试"), + /** 名单项已被他人绑定(原 40007c) */ + GATE_ALREADY_BOUND(40072, "该信息已被登记"), + /** 任务已截止 / 已归档 / 已暂停 */ + TASK_NOT_OPEN(40008, "任务已截止、已归档或已暂停"), + /** 重复提交(幂等键冲突) */ + IDEMPOTENT_REPLAY(40009, "重复提交,已返回首次结果"), + /** 驳回次数超上限 */ + REJECT_LIMIT_EXCEEDED(40010, "驳回次数已达上限"), + + // ── 40011~40044 业务规则 ────────────────────── + /** 批量操作超过 200 条上限 */ + BATCH_LIMIT_EXCEEDED(40011, "批量操作超过 200 条上限"), + /** 账号或密码错误(不透露账号是否存在) */ + BAD_CREDENTIALS(40012, "账号或密码错误"), + /** 账号已锁定 */ + ACCOUNT_LOCKED(40013, "账号已锁定"), + /** 用户有在办任务,不可停用 */ + USER_HAS_ACTIVE_TASK(40014, "用户有在办任务,不可停用"), + /** 组织被占用,不可删除 */ + ORG_OCCUPIED(40015, "组织被占用,不可删除"), + /** 名册为空,不可发布 */ + ROSTER_EMPTY(40016, "名册为空,不可发布"), + /** 名册缺少「姓名」或「身份证号」列 */ + ROSTER_MISSING_KEY_COLUMN(40017, "名册缺少「姓名」或「身份证号」列"), + /** 未配置必传材料项 */ + NO_REQUIRED_MATERIAL(40018, "未配置必传材料项"), + /** 未设置覆盖班级或班主任 */ + NO_TASK_ORG(40019, "未设置覆盖班级或班主任"), + /** 命名规则试跑未通过 */ + NAMING_DRYRUN_FAILED(40020, "命名规则试跑未通过"), + /** 仅已发布任务可暂停 */ + ONLY_PUBLISHED_CAN_PAUSE(40021, "仅已发布任务可暂停"), + /** 仅已暂停任务可恢复 */ + ONLY_PAUSED_CAN_RESUME(40022, "仅已暂停任务可恢复"), + /** 存在待审单,不可归档 */ + PENDING_AUDIT_EXISTS(40023, "存在待审单,不可归档"), + /** 报考范围未覆盖全部名册 */ + PLAN_SCOPE_INCOMPLETE(40024, "报考范围未覆盖全部名册"), + /** 表单版本冲突 */ + FORM_VERSION_CONFLICT(40025, "表单版本冲突"), + /** 内置字段不可删除 */ + BUILTIN_FIELD_NOT_DELETABLE(40026, "内置字段不可删除"), + /** 材料项已有文件,不可删除 */ + MATERIAL_HAS_FILES(40027, "材料项已有文件,不可删除"), + /** 无法自动定位表头行 */ + HEADER_ROW_NOT_FOUND(40028, "无法自动定位表头行"), + /** 名册项已绑定/已提交,不可删除 */ + ROSTER_ITEM_IN_USE(40029, "名册项已绑定或已提交,不可删除"), + /** 已进入审核,需先撤回才能删材料 */ + AUDIT_IN_PROGRESS(40030, "已进入审核,需先撤回才能删材料"), + /** 已通过终审,学生不可撤回 */ + FINAL_APPROVED_NO_WITHDRAW(40031, "已通过终审,不可撤回"), + /** 存在红色问题,不允许通过 */ + RED_ISSUE_BLOCKS_APPROVE(40032, "存在红色问题,不允许通过"), + /** 已导出打包,不可撤回审核 */ + EXPORTED_NO_WITHDRAW(40033, "已导出打包,不可撤回审核"), + /** 已归档,不可撤回 */ + ARCHIVED_NO_WITHDRAW(40034, "已归档,不可撤回"), + /** 存在不合格文件,禁止导出 */ + INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"), + /** 字典项有下级,不可停用 */ + DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"), + /** 加入申请次数超限 */ + JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"), + /** 该证件号已有待审申请 */ + JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"), + /** 该证件号已在名册中 */ + ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"), + /** 申请状态已变更(非待审) */ + JOIN_STATUS_CHANGED(40040, "申请状态已变更"), + /** 名册中该证件号已被占用 */ + ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"), + /** 申请已通过,不可再驳回 */ + JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"), + /** 文件归属存疑,阻断提交且无「仍然继续」 */ + FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"), + /** 一期不支持该收集模式 / 认定类型 */ + SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"), + + // ── 认证与权限 ──────────────────────────────── + /** 未登录 / token 失效(真 HTTP 401) */ + UNAUTHORIZED(40101, "未登录或登录已失效"), + /** 无权限(含跨租户)(真 HTTP 403) */ + FORBIDDEN(40301, "无权限访问"), + /** 签名 URL 已过期 */ + SIGNED_URL_EXPIRED(40302, "签名 URL 已过期"), + /** 触发限流(真 HTTP 429) */ + RATE_LIMITED(42901, "请求过于频繁,请稍后再试"), + + // ── 50001~50007 服务端 ──────────────────────── + // 降级原则:任何服务端能力失效都不得阻断学生提交 + /** 文件处理失败(保留原图,允许重试) */ + FILE_PROCESS_FAILED(50001, "文件处理失败,请重试"), + /** OCR 服务异常(降级为人工填写) */ + OCR_FAILED(50002, "识别服务暂时不可用,请手动填写"), + /** 图片过大被熔断(转异步处理) */ + IMAGE_TOO_LARGE_CIRCUIT_BREAK(50003, "图片过大,已转后台处理"), + /** VLM 服务异常(降级为不判) */ + VLM_FAILED(50004, "辅助识别不可用"), + /** 导出打包失败(不重复计费) */ + EXPORT_FAILED(50005, "导出打包失败,请重试"), + /** 通知发送失败(走降级链) */ + NOTIFY_FAILED(50006, "通知发送失败"), + /** 存储写入失败(重试 3 次后提示用户重传) */ + STORAGE_FAILED(50007, "存储写入失败,请重传"), + + // ── 兜底 ────────────────────────────────────── + /** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */ + INTERNAL_ERROR(50000, "系统繁忙,请稍后再试"); + + /** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */ + private final int code; + /** 默认提示文案(前端可覆盖)。 */ + private final String msg; + + ErrorCode(int code, String msg) { + this.code = code; + this.msg = msg; + } + + /** + * 该错误码应使用的 HTTP 状态码。 + * + *

业务错误一律 200(§1.3:业务判定只看 {@code code}), + * 只有未登录 / 无权限 / 限流 / 5xxxx 才占用真 HTTP 状态码。 + */ + public int httpStatus() { + return switch (this) { + case UNAUTHORIZED -> 401; + case FORBIDDEN -> 403; + case RATE_LIMITED -> 429; + default -> code >= 50000 ? 500 : 200; + }; + } + + /** 是否业务错误(前端需要展示提示)。 */ + public boolean isBizError() { + return code >= 40000 && code < 50000; + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/PageResult.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/PageResult.java new file mode 100644 index 0000000..ce46d78 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/PageResult.java @@ -0,0 +1,45 @@ +package cn.tianbaotong.fill.common.api; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** + * 分页响应(《03-接口设计》§1.4)。 + * + *

⚠️ 分页结构直接放在 {@code data} 里,不是 {@code data.page}: + * + *

+ * { "code": 0, "msg": "success", "data": { "list": [], "total": 128, "pageNum": 1, "pageSize": 20 } }
+ * 
+ * + * @param 列表元素类型 + */ +@Getter +@Setter +public class PageResult { + + private List list; + private long total; + private int pageNum; + private int pageSize; + + public PageResult() { + } + + public PageResult(List list, long total, int pageNum, int pageSize) { + this.list = list; + this.total = total; + this.pageNum = pageNum; + this.pageSize = pageSize; + } + + public static PageResult of(List list, long total, int pageNum, int pageSize) { + return new PageResult<>(list, total, pageNum, pageSize); + } + + public static PageResult empty(int pageNum, int pageSize) { + return new PageResult<>(List.of(), 0L, pageNum, pageSize); + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ValidateProblem.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ValidateProblem.java new file mode 100644 index 0000000..5c74050 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ValidateProblem.java @@ -0,0 +1,57 @@ +package cn.tianbaotong.fill.common.api; + +import java.util.List; + +/** + * 校验问题清单(《03-接口设计》§1.3 的 {@code 40001} 标准结构)。 + * + *
+ * {
+ *   "canSubmit": false,
+ *   "blocking": [ { "materialCode": "SCHOOL_ROLL", "ruleCode": "VAL-05", "title": "缺少材料:学籍表" } ],
+ *   "warnings": [ { "materialCode": "PHOTO1",     "ruleCode": "VAL-08", "title": "照片分辨率偏低" } ]
+ * }
+ * 
+ * + *

blocking 与 warnings 的区别(对应 §2.5 的红/黄分级): + *

    + *
  • {@code blocking} 红色——阻断提交,必须先处理
  • + *
  • {@code warnings} 黄色——可 {@code confirmWarning} 强制提交
  • + *
+ */ +public record ValidateProblem( + boolean canSubmit, + List blocking, + List warnings +) { + + /** + * 单条问题。 + * + * @param materialCode 材料编码,如 {@code SCHOOL_ROLL};非材料类问题为 null + * @param ruleCode 规则编号,如 {@code VAL-05};参数校验类问题为 null + * @param field 字段名,仅参数校验类问题使用(如 {@code phone}) + * @param title 面向用户的描述 + */ + public record Item(String materialCode, String ruleCode, String field, String title) { + + /** 材料类问题。 */ + public static Item ofMaterial(String materialCode, String ruleCode, String title) { + return new Item(materialCode, ruleCode, null, title); + } + + /** 参数校验类问题。 */ + public static Item ofField(String field, String title) { + return new Item(null, null, field, title); + } + } + + /** 仅参数校验失败时使用(无材料上下文)。 */ + public static ValidateProblem ofFieldErrors(List blocking) { + return new ValidateProblem(false, blocking, List.of()); + } + + public static ValidateProblem ofMaterialIssues(List blocking, List warnings) { + return new ValidateProblem(blocking.isEmpty(), blocking, warnings); + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java new file mode 100644 index 0000000..256510f --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java @@ -0,0 +1,56 @@ +package cn.tianbaotong.fill.common.context; + +import java.util.List; +import java.util.Set; + +/** + * 当前登录用户(由 JWT 解出,见《03-接口设计》§1.2 的 JWT Payload)。 + * + *

安全要点:{@code tenantId} 只能来自这里(服务端解签), + * 绝不接受客户端通过参数传入——否则越租户只需改一个数字。 + * + * @param uid 用户 ID + * @param tenantId 租户 ID。{@code 0} 表示平台运营,是唯一可跨租户的身份 + * @param orgIds 数据权限范围(本人 / 本班 / 本院系 / 全租户) + * @param roles 角色码数组 + * @param userType {@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生 + */ +public record LoginUser( + Long uid, + Long tenantId, + List orgIds, + Set roles, + Integer userType +) { + + /** 平台运营租户号,是唯一允许跨租户访问的 tenantId。 */ + public static final long PLATFORM_TENANT_ID = 0L; + + public boolean isPlatformAdmin() { + return tenantId != null && tenantId == PLATFORM_TENANT_ID; + } + + public boolean hasRole(String roleCode) { + return roles != null && roles.contains(roleCode); + } + + public boolean hasAnyRole(String... roleCodes) { + if (roles == null || roles.isEmpty()) { + return false; + } + for (String r : roleCodes) { + if (roles.contains(r)) { + return true; + } + } + return false; + } + + /** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */ + public boolean coversOrg(Long orgId) { + if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) { + return true; + } + return orgId != null && orgIds != null && orgIds.contains(orgId); + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/UserContext.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/UserContext.java new file mode 100644 index 0000000..46228ff --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/UserContext.java @@ -0,0 +1,64 @@ +package cn.tianbaotong.fill.common.context; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; + +/** + * 当前请求的登录用户上下文。 + * + *

由鉴权拦截器在请求进入时写入、请求结束时必须清理(见 {@link #clear()})。 + * 线程池复用下若忘记清理,下一个请求会继承上一个用户的身份——这是最危险的越权漏洞之一。 + * + *

为什么不直接用 Spring Security 的 SecurityContextHolder: + * 本项目的权限模型是「多租户 + 组织树数据范围」,与 Spring Security 的 + * Authority 模型不匹配;硬套要写大量自定义 Voter,不如自己维护一个明确的小上下文。 + */ +public final class UserContext { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private UserContext() { + } + + public static void set(LoginUser user) { + HOLDER.set(user); + } + + /** 取当前用户,未登录返回 {@code null}。 */ + public static LoginUser get() { + return HOLDER.get(); + } + + /** 取当前用户,未登录直接抛 40101。 */ + public static LoginUser require() { + LoginUser u = HOLDER.get(); + if (u == null) { + throw new BizException(ErrorCode.UNAUTHORIZED); + } + return u; + } + + public static Long uid() { + return require().uid(); + } + + /** 当前租户 ID。平台运营返回 {@code 0}。 */ + public static Long tenantId() { + return require().tenantId(); + } + + public static boolean isPlatformAdmin() { + LoginUser u = HOLDER.get(); + return u != null && u.isPlatformAdmin(); + } + + /** + * 请求结束时清理。 + * + *

⚠️ 拦截器的 {@code afterCompletion} 里务必调用,且要放在 finally 语义的位置, + * 保证异常路径也会执行。 + */ + public static void clear() { + HOLDER.remove(); + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/AesGcmCipher.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/AesGcmCipher.java new file mode 100644 index 0000000..3b20bdd --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/AesGcmCipher.java @@ -0,0 +1,105 @@ +package cn.tianbaotong.fill.common.crypto; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 敏感字段加密(AES-256-GCM)。 + * + *

用于 {@code sys_user.id_card_no} / {@code phone} 等列的落库加密(《PRD》§10.3)。 + * + *

为什么是 GCM 而不是 CBC:GCM 自带完整性校验,密文被篡改会解密失败, + * 而不是解出一段看似合法但已被修改的明文。CBC 需要额外配 MAC,用错的人很多。 + * + *

密文格式:{@code Base64( IV(12B) || ciphertext || tag(16B) )}。 + * IV 每次加密随机生成并前置——GCM 下 IV 复用会直接泄露明文异或值,绝不能固定 IV。 + * + *

检索怎么办:加密列无法做等值查询,检索走 {@link HmacIndex} 生成的 {@code *_hash} 列。 + */ +public final class AesGcmCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_BITS = 128; + private static final int KEY_LENGTH_BYTES = 32; + + private final SecretKey key; + private final SecureRandom random = new SecureRandom(); + + /** + * @param keyBytes 32 字节(256 位)密钥 + */ + public AesGcmCipher(byte[] keyBytes) { + if (keyBytes == null || keyBytes.length != KEY_LENGTH_BYTES) { + throw new IllegalArgumentException( + "AES-256 需要 " + KEY_LENGTH_BYTES + " 字节密钥,实际 " + + (keyBytes == null ? "null" : keyBytes.length + " 字节")); + } + this.key = new SecretKeySpec(keyBytes, "AES"); + } + + /** + * 从 Base64 密钥串构造。 + * + *

生成密钥:{@code openssl rand -base64 32} + */ + public static AesGcmCipher fromBase64(String base64Key) { + return new AesGcmCipher(Base64.getDecoder().decode(base64Key)); + } + + /** 加密。{@code null} / 空串原样返回,避免把"没填"变成一段密文。 */ + public String encrypt(String plain) { + if (plain == null || plain.isEmpty()) { + return plain; + } + try { + byte[] iv = new byte[IV_LENGTH]; + random.nextBytes(iv); + + Cipher c = Cipher.getInstance(TRANSFORMATION); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = c.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + throw new IllegalStateException("AES 加密失败", e); + } + } + + /** + * 解密。 + * + *

解密失败抛 {@link IllegalStateException}——这说明数据损坏或密钥被换过, + * 是真实的系统故障,不是业务错误,不该被当成"用户输入有问题"处理。 + */ + public String decrypt(String encoded) { + if (encoded == null || encoded.isEmpty()) { + return encoded; + } + try { + byte[] all = Base64.getDecoder().decode(encoded); + if (all.length <= IV_LENGTH) { + throw new IllegalStateException("密文长度不足"); + } + byte[] iv = new byte[IV_LENGTH]; + System.arraycopy(all, 0, iv, 0, IV_LENGTH); + + Cipher c = Cipher.getInstance(TRANSFORMATION); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = c.doFinal(all, IV_LENGTH, all.length - IV_LENGTH); + return new String(pt, StandardCharsets.UTF_8); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("AES 解密失败(数据损坏或密钥不匹配)", e); + } + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/HmacIndex.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/HmacIndex.java new file mode 100644 index 0000000..4ef83a2 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/HmacIndex.java @@ -0,0 +1,68 @@ +package cn.tianbaotong.fill.common.crypto; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +/** + * 敏感字段的确定性索引(HMAC-SHA256)。 + * + *

加密列({@link AesGcmCipher})无法做等值查询——每次加密的 IV 都不同,同一个身份证号 + * 两次加密得到的密文完全不一样,{@code WHERE id_card_no = ?} 永远匹配不到。 + * + *

解法:同时存一份 HMAC(列名 {@code *_hash},{@code char(64)}), + * 检索与去重都走它。HMAC 是确定性的,同样的输入永远得到同样的输出。 + * + *

为什么是 HMAC 而不是 SHA-256:身份证号的取值空间很小 + * (18 位、前 6 位是地区码、中间 8 位是生日、只剩最后 4 位不定), + * 裸 SHA-256 用彩虹表可以秒破。加盐密钥(pepper)是必需的,不是可选项。 + * + *

门禁防枚举的配合:{@code /mp/roster/match} 匹配失败时只落 hash、不落明文 + * (见《03-接口设计》§5.4),即使数据库被拖走也不泄露身份证号。 + */ +public final class HmacIndex { + + private static final String ALGORITHM = "HmacSHA256"; + + private final SecretKeySpec key; + private final HexFormat hex = HexFormat.of(); + + /** + * @param secret HMAC 密钥。生成:{@code openssl rand -base64 48} + */ + public HmacIndex(byte[] secret) { + if (secret == null || secret.length < 16) { + throw new IllegalArgumentException("HMAC 密钥至少 16 字节,建议 32 字节以上"); + } + this.key = new SecretKeySpec(secret, ALGORITHM); + } + + public static HmacIndex fromBase64(String base64Key) { + return new HmacIndex(java.util.Base64.getDecoder().decode(base64Key)); + } + + /** + * 计算索引值,返回 64 位小写十六进制(与 DDL 的 {@code char(64)} 列对齐)。 + * + *

入参先做规范化(去首尾空白 + 转大写)——身份证号末位可能是 {@code x} 或 {@code X}, + * 不统一就会导致同一个人算出两个不同的 hash。 + */ + public String hash(String raw) { + if (raw == null) { + return null; + } + String normalized = raw.trim().toUpperCase(); + if (normalized.isEmpty()) { + return null; + } + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(key); + byte[] out = mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8)); + return hex.formatHex(out); + } catch (Exception e) { + throw new IllegalStateException("HMAC 计算失败", e); + } + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/RsaCipher.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/RsaCipher.java new file mode 100644 index 0000000..a9cd232 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/crypto/RsaCipher.java @@ -0,0 +1,63 @@ +package cn.tianbaotong.fill.common.crypto; + +import javax.crypto.Cipher; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +/** + * 登录密码的传输解密(RSA)。 + * + *

《03-接口设计》§5.1:密码由前端 RSA 公钥加密后传输,服务端用私钥解开再比对 BCrypt。 + * 这不是替代 HTTPS,而是防止日志与 APM 里出现明文密码——网关、Nginx、链路追踪 + * 都会记录请求体,明文密码一旦进日志就是永久泄露。 + * + *

算法约定:{@code RSA/ECB/PKCS1Padding},与前端 {@code jsencrypt} 的默认行为一致。 + * 若前端改用 OAEP,这里必须同步改——两边不匹配的表现是"密码永远错",很难查。 + */ +public final class RsaCipher { + + private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + + private final PrivateKey privateKey; + + /** + * @param pkcs8Base64 PKCS#8 格式私钥的 Base64(不含 PEM 头尾) + */ + public RsaCipher(String pkcs8Base64) { + try { + String cleaned = pkcs8Base64 + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] der = Base64.getDecoder().decode(cleaned); + this.privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (Exception e) { + throw new IllegalArgumentException("RSA 私钥格式不正确(需要 PKCS#8)", e); + } + } + + /** + * 解密前端传来的密码。 + * + * @param base64Cipher 前端加密后的 Base64 串 + * @return 明文密码 + * @throws IllegalArgumentException 密文非法(前端未加密、公钥不匹配、被截断) + */ + public String decrypt(String base64Cipher) { + if (base64Cipher == null || base64Cipher.isBlank()) { + throw new IllegalArgumentException("密码密文为空"); + } + try { + byte[] cipherBytes = Base64.getDecoder().decode(base64Cipher.trim()); + Cipher c = Cipher.getInstance(TRANSFORMATION); + c.init(Cipher.DECRYPT_MODE, privateKey); + return new String(c.doFinal(cipherBytes), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("密码解密失败", e); + } + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/BizException.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/BizException.java new file mode 100644 index 0000000..a963ebf --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/BizException.java @@ -0,0 +1,59 @@ +package cn.tianbaotong.fill.common.exception; + +import cn.tianbaotong.fill.common.api.ErrorCode; + +/** + * 业务异常。所有可预期的业务失败都用它抛出,由 {@link GlobalExceptionHandler} 统一转成响应体。 + * + *

不要用 {@code RuntimeException} 表达业务失败——那样会走兜底的 50000,前端拿不到具体码。 + */ +public class BizException extends RuntimeException { + + private final ErrorCode errorCode; + + /** 需要随错误一起返回给前端的结构化数据(可为 null)。 */ + private final transient Object payload; + + public BizException(ErrorCode errorCode) { + super(errorCode.getMsg()); + this.errorCode = errorCode; + this.payload = null; + } + + public BizException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.payload = null; + } + + public BizException(ErrorCode errorCode, String message, Object payload) { + super(message); + this.errorCode = errorCode; + this.payload = payload; + } + + public BizException(ErrorCode errorCode, Object payload) { + super(errorCode.getMsg()); + this.errorCode = errorCode; + this.payload = payload; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public Object getPayload() { + return payload; + } + + /** + * 业务异常不需要堆栈。 + * + *

构造堆栈是 JVM 上最贵的操作之一,而业务失败(如"账号密码错误") + * 在限流/暴力破解场景下可能是高频路径。这里主动跳过填充。 + */ + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/GlobalExceptionHandler.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..58928b6 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,152 @@ +package cn.tianbaotong.fill.common.exception; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.api.ValidateProblem; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.ArrayList; +import java.util.List; + +/** + * 全局异常处理。 + * + *

两条铁律: + *

    + *
  1. 业务错误一律 HTTP 200,只有未登录/无权限/限流/5xxxx 才占用真 HTTP 状态码(§1.3)
  2. + *
  3. 任何异常都要变成标准响应体,绝不把堆栈或内部细节泄露给前端
  4. + *
+ */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // ── 业务异常 ────────────────────────────────── + + @ExceptionHandler(BizException.class) + public ResponseEntity> handleBiz(BizException e, HttpServletRequest req) { + ErrorCode ec = e.getErrorCode(); + log.info("业务异常 code={} uri={} msg={}", ec.getCode(), req.getRequestURI(), e.getMessage()); + + ApiResult body = e.getPayload() != null + ? ApiResult.fail(ec, e.getMessage(), e.getPayload()) + : ApiResult.fail(ec, e.getMessage()); + return ResponseEntity.status(ec.httpStatus()).body(body); + } + + // ── 参数校验 ────────────────────────────────── + + /** {@code @RequestBody @Valid} 校验失败。 */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBodyInvalid(MethodArgumentNotValidException e) { + return badRequest(toFieldErrors(e.getBindingResult().getFieldErrors())); + } + + /** 表单/查询参数绑定校验失败。 */ + @ExceptionHandler(BindException.class) + public ResponseEntity> handleBind(BindException e) { + return badRequest(toFieldErrors(e.getBindingResult().getFieldErrors())); + } + + /** {@code @Validated} 在方法参数上的校验失败。 */ + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraint(ConstraintViolationException e) { + List items = e.getConstraintViolations().stream() + .map(v -> ValidateProblem.Item.ofField(lastNode(v.getPropertyPath().toString()), v.getMessage())) + .toList(); + return badRequest(items); + } + + /** 必填查询参数缺失。 */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { + return badRequest(List.of(ValidateProblem.Item.ofField( + e.getParameterName(), "缺少必填参数:" + e.getParameterName()))); + } + + /** 参数类型不匹配(如把 abc 传给 int64)。 */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { + return badRequest(List.of(ValidateProblem.Item.ofField( + e.getName(), "参数格式不正确:" + e.getName()))); + } + + /** 请求体不是合法 JSON。 */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + log.warn("请求体解析失败: {}", e.getMessage()); + return badRequest(List.of(ValidateProblem.Item.ofField(null, "请求体格式不正确"))); + } + + // ── 协议层 ──────────────────────────────────── + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "请求方法不支持")); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在")); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleUploadTooLarge(MaxUploadSizeExceededException e) { + return ResponseEntity.ok(ApiResult.fail(ErrorCode.FILE_TOO_LARGE)); + } + + // ── 兜底 ────────────────────────────────────── + + /** + * 未预期异常。 + * + *

⚠️ 对外只返回"系统繁忙",真实堆栈只进日志。 + * 把异常详情返回给前端等于免费给攻击者做信息系统测绘。 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e, HttpServletRequest req) { + log.error("未预期异常 uri={} method={}", req.getRequestURI(), req.getMethod(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResult.fail(ErrorCode.INTERNAL_ERROR)); + } + + // ── 工具 ────────────────────────────────────── + + private static ResponseEntity> badRequest(List items) { + return ResponseEntity.ok(ApiResult.fail( + ErrorCode.VALIDATION_BLOCKED, + ErrorCode.VALIDATION_BLOCKED.getMsg(), + ValidateProblem.ofFieldErrors(items))); + } + + private static List toFieldErrors(List errors) { + List items = new ArrayList<>(errors.size()); + for (FieldError fe : errors) { + items.add(ValidateProblem.Item.ofField(fe.getField(), fe.getDefaultMessage())); + } + return items; + } + + /** 从 {@code login.username} 取 {@code username}。 */ + private static String lastNode(String path) { + int i = path.lastIndexOf('.'); + return i < 0 ? path : path.substring(i + 1); + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java new file mode 100644 index 0000000..d539078 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java @@ -0,0 +1,115 @@ +package cn.tianbaotong.fill.common.util; + +import java.security.SecureRandom; + +/** + * 脱敏展示(《03-接口设计》§1.7)。 + * + *

硬规则:身份证号与手机号一律不返回明文,只返回脱敏值。 + * 审核场景确实要看完整证件号时,走单独的 {@code GET /audit/submissions/{id}/idcard} + * 接口,并单独记录审计日志。 + * + *

本类是纯函数,无状态。 + */ +public final class MaskUtil { + + private static final SecureRandom RANDOM = new SecureRandom(); + + /** 初始密码字符集:去掉了容易混淆的 0/O/1/l/I。 */ + private static final String PWD_LOWER = "abcdefghijkmnpqrstuvwxyz"; + private static final String PWD_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + private static final String PWD_DIGIT = "23456789"; + private static final String PWD_SYMBOL = "!@#$%^&*"; + + private MaskUtil() { + } + + /** + * 身份证号脱敏。 + * + *

+     * 361029200912100010  →  3610292009******10
+     * 
+ * + *

保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。 + * 前 10 位保留是为了让审核人员能一眼核对地区与年份,又不暴露完整号码。 + */ + public static String maskIdCard(String idCard) { + if (idCard == null || idCard.isBlank()) { + return null; + } + String s = idCard.trim(); + if (s.length() < 8) { + return "******"; + } + int keepHead = Math.min(10, s.length() - 4); + return s.substring(0, keepHead) + "******" + s.substring(s.length() - 2); + } + + /** + * 手机号脱敏。 + * + *

+     * 13800138888  →  138****8888
+     * 
+ */ + public static String maskPhone(String phone) { + if (phone == null || phone.isBlank()) { + return null; + } + String s = phone.trim(); + if (s.length() < 8) { + return "****"; + } + return s.substring(0, 3) + "****" + s.substring(s.length() - 4); + } + + /** + * 姓名脱敏(用于公开列表等不需要全名的场景)。 + * + *
+     * 曾伊豪  →  曾**
+     * 张三    →  张*
+     * 
+ */ + public static String maskName(String name) { + if (name == null || name.isBlank()) { + return null; + } + String s = name.trim(); + if (s.length() == 1) { + return s; + } + return s.charAt(0) + "*".repeat(s.length() - 1); + } + + /** + * 生成初始密码(用于 {@code POST /users} 与批量导入)。 + * + *

长度 8,保证四类字符各至少一个——很多系统的初始密码会因为随机 + * 恰好没生成数字或符号而被弱密码策略拦下,返工很烦。 + */ + public static String generateInitialPassword() { + StringBuilder sb = new StringBuilder(8); + sb.append(pick(PWD_LOWER)); + sb.append(pick(PWD_UPPER)); + sb.append(pick(PWD_DIGIT)); + sb.append(pick(PWD_SYMBOL)); + for (int i = 4; i < 8; i++) { + sb.append(pick(PWD_LOWER + PWD_UPPER + PWD_DIGIT)); + } + // 打乱位置,避免"符号总在第 4 位"这种可预测模式 + char[] arr = sb.toString().toCharArray(); + for (int i = arr.length - 1; i > 0; i--) { + int j = RANDOM.nextInt(i + 1); + char t = arr[i]; + arr[i] = arr[j]; + arr[j] = t; + } + return new String(arr); + } + + private static char pick(String s) { + return s.charAt(RANDOM.nextInt(s.length())); + } +} diff --git a/backend/xt-server/pom.xml b/backend/xt-server/pom.xml new file mode 100644 index 0000000..67fceb0 --- /dev/null +++ b/backend/xt-server/pom.xml @@ -0,0 +1,133 @@ + + + 4.0.0 + + + cn.tianbaotong + fill-parent + 1.0.0-SNAPSHOT + + + xt-server + xt-server · 主应用 + Web 层与业务模块。图片处理服务在 M2 拆出,不在这里。 + + + + cn.tianbaotong + xt-common + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-aspectj + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.apache.commons + commons-pool2 + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + + + + com.baomidou + mybatis-plus-jsqlparser + + + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + + + + + com.alibaba + easyexcel + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java new file mode 100644 index 0000000..6673011 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java @@ -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; + +/** + * 数据信息填报系统 · 主应用。 + * + *

一期范围:学校收集 + 自主认定(见 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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/CryptoConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/CryptoConfig.java new file mode 100644 index 0000000..f5216b2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/CryptoConfig.java @@ -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; + +/** + * 加密组件装配。 + * + *

密钥全部来自 {@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")); + } + + /** + * 密码哈希。 + * + *

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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java new file mode 100644 index 0000000..849d2b8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java @@ -0,0 +1,92 @@ +package cn.tianbaotong.fill.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 本项目业务配置(前缀 {@code fill})。 + * + *

密钥类字段({@code jwt.secret} / {@code crypto.*} / {@code rsa.private-key}) + * 在 dev 走 {@code application-dev.yml}(开发专用,可入库), + * 在 test/prod 必须走环境变量,见 {@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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/MybatisPlusConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/MybatisPlusConfig.java new file mode 100644 index 0000000..efb7773 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/MybatisPlusConfig.java @@ -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 插件装配。 + * + *

插件顺序不能乱:多租户 → 分页 → 防全表更新/删除。 + * 多租户必须在分页之前——否则分页的 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; + } + + /** + * 租户条件注入器。 + * + *

安全姿态:默认拒绝。若当前请求没有登录用户,且 SQL 触及租户表, + * {@link #getTenantId()} 会直接抛 {@code 40101},而不是"不加条件放行"。 + * + *

这样设计的原因:漏加租户条件 = 越租户泄露全校身份证号,是不可接受的失败模式。 + * 宁可让开发者在写匿名接口时立刻撞墙,也不能让漏洞悄悄上线。 + * 确实需要跨租户查询的匿名接口(如登录、门禁),必须显式加 + * {@code @InterceptorIgnore(tenantLine = "true")} —— 显式声明本身就是一次代码审查点。 + */ + static class FillTenantLineHandler implements TenantLineHandler { + + /** + * 没有 {@code tenant_id} 列的表,必须排除,否则 SQL 会直接报错。 + * + *

新增表时务必回来看这里:只有确实不带租户维度的全局表才能加进来, + * 业务表永远不加——业务表不加租户条件就是越权漏洞。 + */ + private static final Set 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(); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java new file mode 100644 index 0000000..8f81062 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java @@ -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})。 + * + *

⚠️ 生产环境必须关闭——文档页面会把全部接口、字段、枚举、 + * 错误码结构暴露出来,等于给攻击者一份完整的信息系统测绘报告。 + * 在 {@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,填在这里即可调试后续接口"))); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/TraceIdFilter.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/TraceIdFilter.java new file mode 100644 index 0000000..e733b90 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/TraceIdFilter.java @@ -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。 + * + *

PRD §10.5 要求「traceId 贯穿上传 → 处理 → 校验 → 审核 → 导出」。 + * 这里只做最基础的一环:每个请求分配 ID 并塞进日志 MDC, + * 之后文件处理与打包是把同一个 ID 透传下去。 + * + *

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); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java new file mode 100644 index 0000000..4522941 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/WebConfig.java @@ -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 层装配。 + * + *

拦截器只挂在 {@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/**"); + } + + /** + * 跨域配置。 + * + *

⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}——写 {@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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java new file mode 100644 index 0000000..ed81dd0 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/controller/AuthController.java @@ -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 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 captcha() { + return ApiResult.ok(captchaService.generate()); + } + + @GetMapping("/me") + @Operation(summary = "当前用户", description = "返回用户、租户、角色、菜单与细粒度权限码") + public ApiResult me() { + return ApiResult.ok(authService.me()); + } + + @PostMapping("/logout") + @Operation(summary = "登出", description = "把 token 加入黑名单,TTL 到其自然过期为止") + public ApiResult logout(HttpServletRequest request) { + Object token = request.getAttribute(AuthInterceptor.ATTR_TOKEN); + authService.logout(token == null ? null : String.valueOf(token)); + return ApiResult.ok(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginReq.java new file mode 100644 index 0000000..8fd9211 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginReq.java @@ -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; + + /** + * 密码。 + * + *

⚠️ 传的是RSA 公钥加密后的 Base64,不是明文。 + * 目的不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志。 + */ + @NotBlank(message = "请输入密码") + @Schema(description = "密码(RSA 公钥加密后的 Base64,非明文)", requiredMode = Schema.RequiredMode.REQUIRED) + private String password; + + /** + * 租户码。 + * + *

本项目对文档的补充字段:{@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginVO.java new file mode 100644 index 0000000..4d9ccac --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/dto/LoginVO.java @@ -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 共用)。 + * + *

{@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 roleCodes; + + /** 菜单键集合,前端据此渲染导航。 */ + private Set menus; + + /** 细粒度权限码,前端据此控制按钮显隐。 */ + private Set 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java new file mode 100644 index 0000000..fbc55b4 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java @@ -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; + + /** + * 等时比较用的假哈希。 + * + *

为什么需要:BCrypt 比对约 50~100ms。若"账号不存在"时直接 return, + * 而"账号存在但密码错"时要跑完 BCrypt,攻击者用响应时间就能判断账号是否存在 + * ——这就是账号枚举漏洞,会直接把后续的密码喷洒成功率提高几个数量级。 + * + *

做法:账号不存在时也拿这个假哈希跑一次比对,把两条路径的耗时拉平。 + */ + 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 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 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); + } + + // ── 内部 ────────────────────────────────────── + + /** + * 定位用户。 + * + *

三种情况: + *

    + *
  1. 客户端给了租户码 → 精确定位
  2. + *
  3. 该账号全局唯一 → 直接使用
  4. + *
  5. 该账号在多个租户下重名 → 拒绝登录并提示补租户码
  6. + *
+ * 第 3 条绝不"随便挑一条"——那会让用户登进别人的学校。 + */ + private SysUser locateUser(LoginReq req, String account) { + List 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 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; + } + + /** + * 写登录日志。 + * + *

⚠️ 失败原因只记类别({@code NO_SUCH_USER} / {@code BAD_PASSWORD} / {@code LOCKED}), + * 绝不记密码,也绝不把失败原因返回给客户端。 + * 库里记细是为了给安全分析用;接口返回粗是为了不给攻击者反馈。 + */ + 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 tenantByCode(String code) { + return Wrappers.lambdaQuery().eq(SysTenant::getTenantCode, code); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/CaptchaService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/CaptchaService.java new file mode 100644 index 0000000..77937a2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/CaptchaService.java @@ -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; + +/** + * 图形验证码。 + * + *

为什么需要它:登录接口的 {{@code 40012} 提示"账号或密码错误"本身是安全的 + * (不区分账号是否存在),但攻击者可以拿一个密码字典跑几万个账号—— + * 没有验证码的话,密码喷洒(password spraying)几乎零成本。 + * + *

为什么只在连续失败后才要求(§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()); + } + + /** + * 校验验证码。 + * + *

无论成功失败都立即删除——一是防重放(同一张验证码不可能用两次), + * 二是防爆破(攻击者不能拿同一张图反复试)。 + */ + 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/LoginGuard.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/LoginGuard.java new file mode 100644 index 0000000..9d44bf6 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/LoginGuard.java @@ -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 次)。 + * + *

为什么锁定要按账号而不是按 IP:IP 可以换(代理池), + * 但攻击者想打的那个账号是固定的。按账号锁才能真正保护目标账号。 + * 反过来,纯按 IP 锁会误伤整个学校的出口 NAT——一所学校几百人共用一个公网 IP。 + * + *

为什么用 Redis 而不是内存:多实例部署时内存计数各算各的, + * 5 次变 5×N 次;且重启就清零。 + * + *

计数器与锁分开存:计数器的 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(); + } + + /** + * 记一次失败。达到阈值则加锁。 + * + *

计数器 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(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysLoginLog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysLoginLog.java new file mode 100644 index 0000000..430647d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysLoginLog.java @@ -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})。 + * + *

⚠️ 不继承 {@code BaseEntity}:日志表是只增不改的, + * 既没有 {@code update_by/update_time},也没有 {@code deleted}—— + * 日志加软删除没有业务含义("删除一条登录记录"本身就是审计事故), + * 且多两个列会让每次插入都写无意义的数据。 + * + *

{@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; + + /** + * 失败原因。 + * + *

⚠️ 绝不记录密码,也绝不记录"密码错了"与"账号不存在"的区别—— + * 这份日志能覆盖到账号枚举攻击的完整清单,本身就是高敏感数据。 + */ + private String failReason; + + private String ip; + + private String userAgent; + + private String traceId; + + private LocalDateTime createTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java new file mode 100644 index 0000000..82bb30c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/entity/SysOperationLog.java @@ -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}。 + * + *

PRD §10.3 要求记录:登录、查看明文、下载、导出、代传、删除。 + * 「查看明文」单独记一条是刻意的——身份证号明文是被内部人泄露的首要风险点, + * 有这个日志才能回答"谁在什么时候看了谁的材料"。 + */ +@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; + + /** + * 请求参数。 + * + *

⚠️ 入库前必须脱敏:去掉 {@code password}、证件号明文等。 + * 操作日志的目的是追责,不是复制一份业务数据——把敏感信息抄进来 + * 等于给系统加了一个无鉴权的高危数据副本。 + */ + private String requestParam; + + /** 业务返回码,用于区分成功与业务失败。 */ + private Integer bizCode; + + /** 耗时毫秒。 */ + private Integer durationMs; + + private String ip; + + private String userAgent; + + private String traceId; + + private LocalDateTime createTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java new file mode 100644 index 0000000..a9c2989 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/LoginLogMapper.java @@ -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 { + + /** + * 写一条登录日志(跳过租户拦截)。 + * + *

为什么必须绕过:登录失败时根本没有登录上下文, + * 多租户拦截器会为 INSERT 补 {@code tenant_id} 列,而它拿不到值 → 抛 40101。 + * 结果就是「越需要审计的场景(登录失败/账号不存在)越写不进日志」, + * 把审计能力丢在最关键的地方。 + * + *

风险可控:这里由 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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java new file mode 100644 index 0000000..4d4cf4d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/mapper/OperationLogMapper.java @@ -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 { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java new file mode 100644 index 0000000..9f48223 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/controller/OrgController.java @@ -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> 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 create(@Valid @RequestBody OrgUpsertReq req) { + return ApiResult.ok(orgService.create(req)); + } + + @PutMapping("/{id}") + @RequirePerm(PermissionDef.ORG_MANAGE) + @Operation(summary = "修改组织", description = "变更层级时服务端自动重算整棵子树的 ancestor_path") + public ApiResult 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 delete(@PathVariable Long id) { + orgService.delete(id); + return ApiResult.ok(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgTreeVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgTreeVO.java new file mode 100644 index 0000000..a57151b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgTreeVO.java @@ -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 children = new ArrayList<>(); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgUpsertReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgUpsertReq.java new file mode 100644 index 0000000..0dc216d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/dto/OrgUpsertReq.java @@ -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} 不在请求里——它由服务端维护,客户端传了也会被忽略。 */ +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/entity/SysOrg.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/entity/SysOrg.java new file mode 100644 index 0000000..2eea131 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/entity/SysOrg.java @@ -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})。 + * + *

典型形态是三级:学校 → 院系/专业 → 班级。 + * 「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/}。 + * + *

⚠️ 由服务端维护,客户端传了也会被忽略(《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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/mapper/OrgMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/mapper/OrgMapper.java new file mode 100644 index 0000000..9a58926 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/mapper/OrgMapper.java @@ -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 { + + /** + * 按组织统计学生数。 + * + *

只查一次、在内存里聚合,而不是每个节点查一次—— + * 一所学校 30 个班就是 30 次往返,组织树页面会明显卡顿。 + * + *

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 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgService.java new file mode 100644 index 0000000..b730c67 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/org/service/OrgService.java @@ -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; + + // ── 树 ──────────────────────────────────────── + + /** + * 组织树。 + * + *

一次查全部再在内存组装,而不是递归查询:组织树规模是「一所学校几百个节点」量级, + * 一次查完更快也更好控制;递归查询在深度上没有上界,容易被构造出深树打爆。 + */ + public List tree(boolean withCount, Long rootId) { + List all = orgMapper.selectList(Wrappers.lambdaQuery() + .orderByAsc(SysOrg::getSortNo) + .orderByAsc(SysOrg::getId)); + + Map leaderNames = loadLeaderNames(all); + Map counts = withCount ? loadStudentCounts() : Map.of(); + + Map byId = new LinkedHashMap<>(); + for (SysOrg o : all) { + OrgTreeVO vo = toVo(o, leaderNames, counts); + byId.put(vo.getId(), vo); + } + + List 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); + } + } + + /** + * 删除组织。 + * + *

约束(§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 blockers = new ArrayList<>(); + + Long childCount = orgMapper.selectCount(Wrappers.lambdaQuery() + .eq(SysOrg::getParentId, id)); + if (childCount != null && childCount > 0) { + blockers.add(childCount + " 个下级组织"); + } + + Long userCount = userMapper.selectCount(Wrappers.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.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 children = orgMapper.selectList(Wrappers.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 loadLeaderNames(List orgs) { + List ids = orgs.stream() + .map(SysOrg::getLeaderUserId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + if (ids.isEmpty()) { + return Map.of(); + } + Map map = new HashMap<>(); + for (SysUser u : userMapper.selectByIds(ids)) { + map.put(u.getId(), u.getRealName()); + } + return map; + } + + private Map loadStudentCounts() { + Map 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 leaderNames, Map 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java new file mode 100644 index 0000000..61ce4b7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/controller/TenantController.java @@ -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)。 + * + *

⚠️ 全部接口仅平台运营可用。 + */ +@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> 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 detail(@PathVariable Long id) { + return ApiResult.ok(tenantService.detail(id)); + } + + @PostMapping + @RequirePerm(PermissionDef.TENANT_MANAGE) + @Operation(summary = "开通租户", + description = "一次完成建租户 + 发放 6 个内置角色 + 创建首个管理员。初始密码仅此一次返回") + public ApiResult create(@Valid @RequestBody TenantCreateReq req) { + return ApiResult.ok(tenantService.create(req)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantCreateReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantCreateReq.java new file mode 100644 index 0000000..65dc893 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantCreateReq.java @@ -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)。 + * + *

一次请求同时完成三件事:建租户、发放 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantVO.java new file mode 100644 index 0000000..1e05504 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/dto/TenantVO.java @@ -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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenant.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenant.java new file mode 100644 index 0000000..13a462c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/entity/SysTenant.java @@ -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})。 + * + *

⚠️ 本表不继承 {@code TenantEntity}——它自己就是租户维度, + * 表里没有 {@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java new file mode 100644 index 0000000..da26f40 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/mapper/TenantMapper.java @@ -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。 + * + *

注意:{@code sys_tenant} 没有 {@code tenant_id} 列,多租户拦截器已将其列入免过滤表, + * 所以这里的查询天然是跨租户的——开通租户的功能只有平台运营能用 + * (由 {@code @RequirePerm(TENANT_MANAGE)} 在 Controller 层把关)。 + */ +public interface TenantMapper extends BaseMapper { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantService.java new file mode 100644 index 0000000..1b2415e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/tenant/service/TenantService.java @@ -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)。 + * + *

⚠️ 本模块的所有接口都只有平台运营能访问({@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 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.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 page = tenantMapper.selectPage(new Page<>(num, size), query); + List 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.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; + /** 初始密码,仅此一次返回。 */ + 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRole.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRole.java new file mode 100644 index 0000000..410e6b0 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRole.java @@ -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})。 + * + *

内置角色在「开通租户」时按 {@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRolePermission.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRolePermission.java new file mode 100644 index 0000000..8bd5b66 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysRolePermission.java @@ -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})。 + * + *

只存「这个角色被授了哪些码」,权限码本身的定义在代码里 + * ({@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java new file mode 100644 index 0000000..dc51608 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUser.java @@ -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})。 + * + *

敏感字段处理: + *

    + *
  • {@code phone} / {@code idCardNo} —— AES-256-GCM 加密存储, + * 读写必须走 {@code AesGcmCipher},不要直接 {@code get}/{@code set} 明文
  • + *
  • {@code phoneHash} / {@code idCardHash} —— 同值的 HMAC,用于等值检索与去重
  • + *
+ * 这样设计后,{@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; + + /** 手机号,AES 加密存储。 */ + private String phone; + + /** 手机号 HMAC(char(64)),用于检索。 */ + private String phoneHash; + + private String openId; + + private String unionId; + + /** 证件号,AES 加密存储。 */ + 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUserRole.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUserRole.java new file mode 100644 index 0000000..91107c2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/entity/SysUserRole.java @@ -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})。 + * + *

{@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RoleMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RoleMapper.java new file mode 100644 index 0000000..d445517 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RoleMapper.java @@ -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 { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RolePermissionMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..e37e4dd --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/RolePermissionMapper.java @@ -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 { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java new file mode 100644 index 0000000..479ee1a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserMapper.java @@ -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 { + + /** + * 按登录名跨租户查找用户。 + * + *

⚠️ 这是全系统唯一需要关闭租户过滤的用户查询,只用于登录—— + * 登录时还没有 token,拿不到 {@code tenantId},而账号唯一性是 + * {@code (tenant_id, username)} 而非全局。 + * + *

{@code @InterceptorIgnore(tenantLine = "true")} 是显式声明, + * 本身就是一次代码审查点:看到这行就该问"为什么需要跨租户"。 + * + *

返回列表而不是单条:同一个登录名可能存在于多个租户 + * (如两个学校都有 {@code admin})。调用方按以下规则处理: + *

    + *
  • 0 条 → 账号不存在
  • + *
  • 1 条 → 直接用
  • + *
  • >1 条 → 必须要求客户端指定租户码,否则拒绝登录并提示
  • + *
+ * 绝不"随便挑一条"——那会导致用户登进别人的学校。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM sys_user + WHERE username = #{username} AND deleted = 0 + ORDER BY id + """) + List 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 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 selectByPhoneHash(@Param("tenantId") Long tenantId, + @Param("phoneHash") String phoneHash); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserRoleMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserRoleMapper.java new file mode 100644 index 0000000..c3d0074 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/user/mapper/UserRoleMapper.java @@ -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 { + + /** + * 查一个用户的角色码。 + * + *

绕过 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 selectRoleCodesByUserId(@Param("userId") Long userId); + + /** 查一批用户的角色码,用于列表页一次性装配,避免 N+1。 */ + @Select(""" + + """) + List selectRoleCodesByUserIds(@Param("userIds") List 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java new file mode 100644 index 0000000..e17cfb9 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/AuthInterceptor.java @@ -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 → 写上下文 → 校验权限。 + * + *

⚠️ {@link #afterCompletion} 必须清理上下文。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} 校验。 + * + *

标在方法上优先,其次看类上——便于"整个 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 }。 */ + 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java new file mode 100644 index 0000000..637c639 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java @@ -0,0 +1,176 @@ +package cn.tianbaotong.fill.security; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 内置角色定义。 + * + *

数据来源:《PRD》§3.1 角色定义 + §3.2 权限矩阵 + 《03-接口设计》§2.7 的 {@code roleCode} 枚举。 + * + *

开通租户时,服务按这里为租户写入 {@code sys_role} 与 {@code sys_role_permission}。 + * 之后租户可自定义角色,但内置角色的权限由代码决定、不可在界面上改—— + * 否则一次误操作就能把管理员锁在系统外。 + */ +@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}。 + * + *

决定「本班/本院系/全租户」这类过滤,由服务层按角色取用。 + */ + 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 permissions; + private final Set menus; + + BuiltinRole(String code, String name, UserType userType, DataScope dataScope, + int sortNo, Set permissions, Set 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 permissionCodes() { + Set codes = new LinkedHashSet<>(); + permissions.forEach(p -> codes.add(p.getCode())); + return codes; + } + + /** 开通租户时默认创建的 6 个内置角色。 */ + public static Set 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 mergePermissions(Iterable roleCodes) { + Set merged = new LinkedHashSet<>(); + for (String rc : roleCodes) { + BuiltinRole r = ofCode(rc); + if (r != null) { + merged.addAll(r.permissionCodes()); + } + } + return merged; + } + + /** 合并多个角色的菜单(用户可多角色)。 */ + public static Set mergeMenus(Iterable roleCodes) { + Set merged = new LinkedHashSet<>(); + for (String rc : roleCodes) { + BuiltinRole r = ofCode(rc); + if (r != null) { + merged.addAll(r.getMenus()); + } + } + return merged; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java new file mode 100644 index 0000000..2625d55 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java @@ -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 签发与校验。 + * + *

载荷结构见《03-接口设计》§1.2。签名算法 HS256——单服务自签自验,不需要非对称。 + * + *

有效期: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。 + * + *

任何解析失败(签名不对、过期、格式错、issuer 不匹配)一律抛 + * {@code 40101},不区分原因——区分原因等于告诉攻击者"你的签名算法猜对了,只是过期了"。 + */ + 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 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 readRoles(Claims c) { + Object raw = c.get(CLAIM_ROLES); + if (!(raw instanceof List list)) { + return Set.of(); + } + Set 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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionChecker.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionChecker.java new file mode 100644 index 0000000..38ce306 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionChecker.java @@ -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; + +/** + * 权限判定。 + * + *

为什么不查库:内置角色的权限由代码决定({@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 currentPermissions() { + LoginUser user = UserContext.require(); + if (user.isPlatformAdmin()) { + return PermissionDef.allCodes(); + } + return BuiltinRole.mergePermissions(user.roles()); + } + + /** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */ + public Set currentMenus() { + LoginUser user = UserContext.require(); + return BuiltinRole.mergeMenus(user.roles()); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java new file mode 100644 index 0000000..46e866b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java @@ -0,0 +1,109 @@ +package cn.tianbaotong.fill.security; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 权限码与菜单定义。 + * + *

为什么定义在代码里而不是数据库:权限码是前后端的契约—— + * 后端用它做校验,前端用它控制按钮显隐。若存 DB 且各有各的版本, + * 会出现"按钮显示出来了、点了报 40301"这种最难查的问题。放代码里, + * 前后端从同一个枚举生成,物理上不可能漂移。 + * + *

角色是租户可自定义的,所以「角色 → 权限」的授权关系存 DB({@code sys_role_permission}); + * 而「有哪些权限」是系统固定的,在这里。 + * + *

菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单 + * 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。 + */ +@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 allCodes() { + Set set = new LinkedHashSet<>(); + Arrays.stream(values()).forEach(p -> set.add(p.code)); + return set; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PublicApi.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PublicApi.java new file mode 100644 index 0000000..99be916 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PublicApi.java @@ -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; + +/** + * 标记无需登录即可访问的接口。 + * + *

用法约束:能不带就尽量别带。标了这个注解的接口必须自己保证数据范围—— + * 因为多租户拦截器在匿名请求下是默认拒绝的(见 {@code FillTenantLineHandler}), + * 触及租户表的匿名查询会直接抛 40101,除非该 Mapper 方法显式标注 + * {@code @InterceptorIgnore(tenantLine = "true")}。 + * + *

目前只有 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 { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/RequirePerm.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/RequirePerm.java new file mode 100644 index 0000000..4f82ebf --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/RequirePerm.java @@ -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; + +/** + * 声明接口所需的权限码。 + * + *

例:{@code @RequirePerm(PermissionDef.TASK_MANAGE)}。 + * 可标多个,语义是 OR(满足其一即可)—— + * 因为多数场景是"管理员或班主任都能做",AND 会让调用方被迫拆接口。 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequirePerm { + + /** 需要的权限码,满足任一即可。 */ + PermissionDef[] value(); + + /** 是否要求全部满足。默认 false(任一满足)。 */ + boolean requireAll() default false; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/TokenBlacklist.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/TokenBlacklist.java new file mode 100644 index 0000000..4a38063 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/TokenBlacklist.java @@ -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; + +/** + * 登出黑名单。 + * + *

JWT 是无状态的,签发后服务端无法"收回"。登出后到自然过期之间(PC 最长 8 小时) + * 若不处理,被盗的 token 仍能用——所以登出必须落一份黑名单。 + * + *

两个实现细节: + *

    + *
  1. 存 SHA-256 而不是原文。token 本身就是凭证,把它明文写进 Redis + * 等于把凭证复制了一份到另一个地方——Redis 的访问日志、备份、 + * 运维的 {@code KEYS *} 都会把它带出去。
  2. + *
  3. TTL 只到 token 自然过期。过期后 JWT 自己就失效了, + * 再留着黑名单只是白占内存。这样集合大小天然有上界。
  4. + *
+ */ +@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); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/BaseEntity.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/BaseEntity.java new file mode 100644 index 0000000..98ded14 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/BaseEntity.java @@ -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 的通用约定)。 + * + *

字段含义: + *

    + *
  • {@code create_by / create_time / update_by / update_time} —— 谁在什么时候建/改的
  • + *
  • {@code deleted} —— 软删除,客户明确要求字段就叫这个名字({@code 0} 正常 / {@code 1} 已删)
  • + *
+ * + *

为什么用填充而不是靠 DB 默认值:{@code create_by} 需要当前登录用户的 ID, + * DB 没有上下文;而 {@code create_time} 若一半靠 DB、一半靠应用,会出现同一事务内 + * 两条记录的创建时间不一致(DB 取的是语句执行时刻,应用取的是 NOW())。 + * 统一由应用填充,时间口径一致。 + * + *

使用 {@code @Getter/@Setter} 而非 {@code @Data}:{@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/DataSeeder.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/DataSeeder.java new file mode 100644 index 0000000..af286fd --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/DataSeeder.java @@ -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; + +/** + * 初始数据装配(幂等,可重复执行)。 + * + *

为什么用 Java 而不是 SQL 种子脚本: + *

    + *
  1. 密码要 BCrypt 哈希,写死在 SQL 里意味着所有人共用同一个哈希, + * 而且换个环境就得手工重算;
  2. + *
  3. 内置角色的权限来自 {@link BuiltinRole} 代码常量, + * 在 SQL 里复制一份必然与服务端校验逻辑漂移。
  4. + *
+ * + *

⚠️ 生产环境应设 {@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.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.lambdaQuery() + .eq(SysUser::getTenantId, tenantId) + .eq(SysUser::getUsername, username) + .last("LIMIT 1")); + } + + /** 保留:主应用启动时用到的常量,避免未使用告警。 */ + @SuppressWarnings("unused") + private static List noop() { + return List.of(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/FillMetaObjectHandler.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/FillMetaObjectHandler.java new file mode 100644 index 0000000..2235839 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/FillMetaObjectHandler.java @@ -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; + +/** + * 公共字段自动填充。 + * + *

用 {@code strictInsertFill} / {@code strictUpdateFill} 而不是 {@code setFieldValByName}: + * 前者只在字段为 null 时填充,后者会无条件覆盖。 + * 这在导入历史数据(需要指定 create_time)的场景下差别很大。 + * + *

{@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()); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java new file mode 100644 index 0000000..63ce6aa --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/RoleProvisioner.java @@ -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; + +/** + * 内置角色的发放。 + * + *

「开通租户」与「数据初始化」都要做同一件事:为新租户写入 6 个内置角色 + 授权。 + * 抽出来是为了保证两处行为一致——否则手工开通的租户和初始化出来的租户权限不一样, + * 这种差异在测试环境几乎发现不了。 + * + *

⚠️ 调用前必须保证 {@code UserContext} 已切到目标租户 + * (见 {@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.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 roles) { + roles.forEach(br -> bindRole(tenantId, userId, br)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/SensitiveCodec.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/SensitiveCodec.java new file mode 100644 index 0000000..ade5bbe --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/SensitiveCodec.java @@ -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; + +/** + * 敏感字段读写封装。 + * + *

PRD §10.3 要求身份证号与手机号「AES 加密存储 + HMAC 索引检索」。 + * 这个组合的用法很容易记错——写法是「同时写密文和 hash」,查法是「只查 hash,不查密文」。 + * 把两步收在一个地方,避免某个调用点只写了密文、漏了 hash, + * 结果该用户永远搜不到(数据看起来还在,只是"消失"了)。 + * + *

⚠️ 加密列({@code phone} / {@code id_card_no})永远不要用于 WHERE 条件: + * 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()); + } + + /** + * 规范化。 + * + *

必须与 {@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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantEntity.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantEntity.java new file mode 100644 index 0000000..8a9015b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantEntity.java @@ -0,0 +1,23 @@ +package cn.tianbaotong.fill.support; + +import com.baomidou.mybatisplus.annotation.TableField; +import lombok.Getter; +import lombok.Setter; + +/** + * 带租户维度的业务实体基类。 + * + *

继承它的表,SQL 会被自动追加 {@code tenant_id = ?}(见 {@code MybatisPlusConfig} + * 的 {@code FillTenantLineHandler})。这也是为什么租户隔离不靠"开发者记得写条件"—— + * 写漏了会被拦截器补上,而不是变成越权漏洞。 + * + *

⚠️ {@code tenant_id} 由 {@link cn.tianbaotong.fill.support.FillMetaObjectHandler} + * 从登录上下文自动填充,业务代码不要手工设置——手工设置意味着可以向其他租户写数据。 + */ +@Getter +@Setter +public abstract class TenantEntity extends BaseEntity { + + @TableField(value = "tenant_id", fill = com.baomidou.mybatisplus.annotation.FieldFill.INSERT) + private Long tenantId; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantScope.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantScope.java new file mode 100644 index 0000000..0f848c8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/TenantScope.java @@ -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; + +/** + * 临时切换租户作用域。 + * + *

解决什么问题:平台运营({@code tenantId=0})给某个租户建数据时, + * 多租户拦截器会拿当前上下文的 {@code tenantId}(也就是 0)去填充 INSERT—— + * 结果是新建的管理员被写进了平台租户,而不是目标租户。 + * 这类 bug 不会报错,只会让新租户"开通了但登不进去",非常难查。 + * + *

解法:在「为新租户建数据」的代码段里,把上下文临时切成目标租户, + * 让所有隐式的租户填充自然落到正确的租户上。 + * + *

为什么不在这些 Mapper 上加 {@code @InterceptorIgnore}: + * 那会关掉租户条件,等于放弃保护;而切上下文只是把"当前是谁"换成正确的那个, + * 拦截器的保护依然完整。 + * + *

⚠️ 必须成对使用,且要保证异常路径也恢复——所以用 {@code try/finally} 包起来, + * 并且只通过 {@link #callAs} / {@link #runAs} 暴露(不提供裸的 set/restore)。 + */ +public final class TenantScope { + + private TenantScope() { + } + + /** 在指定租户的上下文中执行并返回结果。 */ + public static T callAs(long tenantId, Long uid, Set roles, Supplier 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 roles, Runnable action) { + callAs(tenantId, uid, roles, () -> { + action.run(); + return null; + }); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/WebUtil.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/WebUtil.java new file mode 100644 index 0000000..969c2da --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/WebUtil.java @@ -0,0 +1,56 @@ +package cn.tianbaotong.fill.support; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Web 层杂项工具。 + */ +public final class WebUtil { + + private WebUtil() { + } + + /** + * 取客户端真实 IP。 + * + *

⚠️ 安全提醒:{@code X-Forwarded-For} 是客户端可以伪造的请求头。 + * 这里信任它,前提是部署在受控反向代理(Nginx)之后,且代理会覆盖而非追加该头。 + * 若直接暴露到公网,攻击者可以随便伪造 IP 来绕过基于 IP 的限流。 + * + *

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