Compare commits
16 Commits
0bbac7d228
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad577b29 | |||
| 739c0a34a4 | |||
| 1e71c164af | |||
| 17c92b29c3 | |||
| 2ff17cd0c9 | |||
| a9b2dd2a16 | |||
| 4e68d0e5da | |||
| 623e6393d5 | |||
| e94e76ba0f | |||
| 31e7483464 | |||
| 624a05aa66 | |||
| b516a7c47b | |||
| 9f6d26c650 | |||
| 7df078e0ea | |||
| 1f3c485ceb | |||
| 5b877451eb |
@@ -70,3 +70,7 @@ backend/data/
|
||||
application-local.yml
|
||||
# 本地私有配置(云存储密钥等)—— 只提交 .example 模板
|
||||
xt-local.yml
|
||||
|
||||
# 生产环境变量(含 JWT / AES / RSA / 七牛真凭据)—— 只提交 .example 模板
|
||||
deploy/prod.env
|
||||
deploy/prod-rsa.pem
|
||||
|
||||
+5
-6
@@ -131,13 +131,12 @@ variables:
|
||||
script:
|
||||
- cd frontend-mp
|
||||
- pnpm install --frozen-lockfile
|
||||
# ⚠️ H5 是**后端托管**的(没有独立 dev server),所以它的构建产物是
|
||||
# e2e 阶段起服务时的输入,不能跳过
|
||||
# ⚠️ 2026-10 起 H5 **不再由后端托管**(改由 nginx/网关托管,见 deploy/nginx/xt.conf),
|
||||
# 所以这个 job 的定位变了:它不再是 e2e 的输入,而是**编译检查** ——
|
||||
# 学生端没有单独的 typecheck job(vue-tsc 有 2 条已知报错),
|
||||
# "能不能 build 出 H5"就是它最实际的把关点。
|
||||
# (产物也不留 artifact:e2e 不消费它,发版时由部署流程自己构建。)
|
||||
- pnpm build:h5
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend-mp/dist/build/h5/
|
||||
expire_in: 1 day
|
||||
|
||||
# ══════════════ 阶段 3:全套接口断言 ══════════════
|
||||
|
||||
|
||||
@@ -3,33 +3,43 @@
|
||||
面向 AI 编码助手的项目指南。**用简体中文交流、用中文写文档。**
|
||||
|
||||
需求已冻结:**文档是需求源,代码是它的实现**,不要按"更合理"去改业务口径。
|
||||
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(§一·二十、§一·二十一)
|
||||
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(**§一·四十二、§一·四十三**)
|
||||
与 `## 二、已锁定的铁律`;那里面大量决定"读代码看不出来、但都有理由"。
|
||||
|
||||
> ⚠️ **2026-10 需求收敛(见 §一·三十六)**:只做 **4 个角色**(平台运营 / 学校管理员 / 班主任 / 学生)、
|
||||
> **10 个 PC 菜单**、**不设二期**。删掉的东西不要再"顺手加回来":副班主任 / 审核员两个角色、
|
||||
> 历史整理器、加入申请(含学生端自助申请入口)、`collect_task_org.deputy_user_ids`。
|
||||
|
||||
## 一、四个可运行部分
|
||||
|
||||
| 目录 | 是什么 | 入口 |
|
||||
|---|---|---|
|
||||
| `backend/` | Java 21 + Spring Boot 4.1.1 + MyBatis-Plus + PG16 + Redis7 | `xt-server/`(`xt-common/` 放响应体/错误码/加解密) |
|
||||
| `frontend-pc/` | 管理端 Vue3 + Arco Design Vue(基于 **Gi Admin Pro**) | `pnpm dev` → 5173 |
|
||||
| `frontend-pc/` | 管理端 **纯 Vue 3 + Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭) | `pnpm dev` → 5173 |
|
||||
| `frontend-mp/` | 学生端 **uni-app**(H5 与微信小程序同一套代码) | `pnpm build:h5` / `pnpm dev:mp-weixin` |
|
||||
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
|
||||
| `deploy/` | 本地基础设施(PG + Redis + **nginx**,**没有对象存储**) | `docker compose up -d` |
|
||||
|
||||
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
|
||||
|
||||
**一期范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`,
|
||||
别顺手把二期功能实现出来。
|
||||
**范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`。
|
||||
**不设二期** —— 与「二期」有关的任何设计(社会收集、社会评价、专项职业能力、数据范围可配等)都**不做**,
|
||||
别顺手实现;OCR 与材料识别是「缓做,等供应商选型」。
|
||||
|
||||
## 二、常用命令(备注里是容易猜错的地方)
|
||||
|
||||
### 基础设施
|
||||
|
||||
```bash
|
||||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
|
||||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
|
||||
```
|
||||
|
||||
⚠️ 本机还有别的项目的 `pg-verify`(55432) 与 `redis-local`(6379),**不要动它们**。
|
||||
|
||||
⚠️ **H5 由 `xt-nginx` 托管(80),不由后端托管**(2026-10 调整,见 §一·四十三):
|
||||
它把 `frontend-mp/dist/build/h5` 当静态站发出去,并把 `/api` 反代到后端 8080 ——
|
||||
两者**同源**,这是微信授权与 H5 的接口地址(`location.origin`)共同要求的。
|
||||
起它之前先 `cd frontend-mp && pnpm build:h5`(挂的是产物目录,空目录 = 页面 404)。
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
@@ -38,11 +48,10 @@ mvn -DskipTests package # 改了 Java 必须重新打包
|
||||
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
- ⚠️ **工作目录必须是 `backend/`**,两处相对路径依赖它:
|
||||
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
|
||||
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
||||
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(不提交的本地私有配置)在那里,
|
||||
是靠相对路径找到的(H5 静态目录那条相对路径已于 2026-10 随托管一起移走,见 §一·四十三)
|
||||
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
|
||||
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 V6;迁移文件发布后不许改)
|
||||
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 **V19**;迁移文件发布后不许改,只能新加)
|
||||
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
|
||||
(模板 `backend/application-local.yml.example`)。
|
||||
**不要改用 `spring.config.import`** —— 它优先级低于 profile 文件,会静默失效(见 `application.yml` 注释)
|
||||
@@ -56,18 +65,23 @@ pnpm typecheck # vue-tsc pnpm lint # eslint
|
||||
|
||||
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
|
||||
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
|
||||
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据。
|
||||
⚠️ `frontend-pc/.agents/skills/project-api/SKILL.md` 仍以 Mock 为主线(Gi 模板原文),
|
||||
该文件开头已加"本仓库不用 Mock"的说明 —— 看它时先看那段
|
||||
- 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加
|
||||
- **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。
|
||||
改那里的代码前按它的表加载对应 skill,不要另起一套写法
|
||||
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据
|
||||
- 接口模块都在 `src/apis/xt/`(按业务域分文件);**没有 auto-import**,组件与 API 全显式 `import`
|
||||
- ⚠️ **2026-10 已从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI**,因此:
|
||||
- **`pnpm lint:style`(Stylelint)已移除**,只剩 `pnpm lint`(ESLint);`pnpm typecheck` 现在 **0 错**
|
||||
- 权限判断统一写 `userStore.has('码')`(**没有 `v-hasPerm` 指令**),字符串必须与后端 `PermissionDef` **逐字一致**
|
||||
- `n-tag` 的 `type` 必须过 **`@/utils/tag.ts`** 转换:`apis/xt/*.ts` 里的展示映射仍是 Arco 色词
|
||||
(`green`/`arcoblue`/`gold`/`gray`),直接透传会**静默变灰**且不报错
|
||||
- 图标:`scripts/extract-pc-icons.py` 生成 `src/icons/icon-data.ts`(37 个);
|
||||
`AppIcon` 对缺失图标是**空占位、不报错** → **图标集升级后要重跑该脚本**
|
||||
- **PC 前端有独立的规范来源:`frontend-pc/AGENTS.md` + `frontend-pc/.agents/`**(已按 Naive UI 重写)。
|
||||
改那里的代码前先读它,不要另起一套写法
|
||||
|
||||
### 学生端(uni-app)
|
||||
|
||||
```bash
|
||||
cd frontend-mp && pnpm install
|
||||
pnpm build:h5 # → dist/build/h5(由后端托管)
|
||||
pnpm build:h5 # → dist/build/h5(由 xt-nginx 托管,不由后端托管)
|
||||
pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
```
|
||||
|
||||
@@ -75,11 +89,16 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
`vite.config.ts` 加了 `/api` 代理,`.env.development` 把 `VITE_API_BASE` 设成空串,
|
||||
于是请求走相对路径、由代理转发到 8080。**改一行不用再 `build:h5`**。
|
||||
⚠️ 三端行为不同,改动这块前先看 `src/config/index.ts` 的注释:
|
||||
dev server → 代理;构建产物(后端托管)→ `location.origin`;小程序 → 写死地址
|
||||
dev server → 代理;构建产物(由 nginx 托管、与 `/api` 同源)→ `location.origin`;小程序 → 写死地址
|
||||
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
|
||||
- 构建时 Dart Sass 的 `legacy-js-api` DEPRECATION 属正常
|
||||
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的
|
||||
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的。
|
||||
⚠️ PC 端不同:重搭后 `pnpm typecheck` 是 **0 错** —— 那边一有报错就是新引入的
|
||||
- 当前节奏:**先做 H5**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
|
||||
- ⚠️ **小程序端目前学生用不了(F-130,P0)**:前端**从来没有调用过 `/mp/login`**(token 永远是空 → 门禁 40101),
|
||||
而后端那条路也不通 —— `scene` 带 taskId 时 `/mp/login` 必然 500(`MpLoginService.java:127` 匿名查租户表被租户拦截器拒)。
|
||||
实测细节见 `docs/11` §5.4 / §6。**除登录外整条链路(落地页/门禁/表单/压缩上传/红黄校验/提交)都是好的**
|
||||
—— 用开发者工具自动化(`wmp_*` 工具族)可以跑,`wx.login()` 在页面上下文里能拿到**真 code**
|
||||
|
||||
## 三、怎么验证(本仓库最不显然的一点)
|
||||
|
||||
@@ -90,7 +109,9 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
||||
```
|
||||
|
||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
|
||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1712 条断言**,以最近一次
|
||||
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`,
|
||||
之后又补了 `e2e-m3-flow.py` —— 填报链路的覆盖缺口,见 `docs/11-填报流程端到端测试计划.md`),
|
||||
或一条命令 `python scripts/verify-all.py --with-e2e`
|
||||
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
||||
@@ -105,9 +126,20 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
- 文档一致性:`python scripts/verify-docs.py`
|
||||
- 接线检查:`python scripts/check-wiring.py`(**"声明了但没接上"** ——
|
||||
权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、
|
||||
`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上)。
|
||||
`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上、
|
||||
**PC 端 `user.has('码')` 引用的权限码 ↔ `PermissionDef`**(第 ⑦ 项)、
|
||||
**模板里的 `<n-*>` 组件有没有漏 import**(第 ⑧ 项 —— 漏了会被当未知元素渲染,样式整块丢、
|
||||
编译与 lint 都不红)、
|
||||
**`.svg` 资产是不是合法 XML**(第 ⑨ 项 —— SVG 注释里出现连续两个减号就 not well-formed,
|
||||
图标变裂图而控制台一句话都不打;2026-10 换品牌标时连踩两次)、
|
||||
**后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案**(第 ⑩ 项 —— 缺一个文案,
|
||||
日志页会**原样回显英文码**;做这一项时才发现 `AUTO_CLOSE` 是字符串字面量写的,
|
||||
所以"提成常量"是让这个检查真正生效的前提)。
|
||||
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
||||
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)
|
||||
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
|
||||
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
|
||||
改成了 `user.has`,第 ⑦ 项曾因此"一个码都扫不到却判为通过"。
|
||||
加/改这类检查时**必须反向验证一次**(故意写错,它得红)
|
||||
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
|
||||
(9 项;**跳过项会如实列出来**,不是静默跳过)
|
||||
|
||||
@@ -136,7 +168,8 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
|
||||
## 五、环境与流程坑
|
||||
|
||||
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:8080`,
|
||||
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:80`,
|
||||
即 `xt-nginx`;**不是后端的 8080** —— 指到 8080 的表现是"接口好用、页面 404"。
|
||||
配在 `application-dev.yml` 的 `mp-h5`)。**硬刷新后首屏常要 1~2 分钟**,
|
||||
探测要给足,否则会把"还在加载"误判成失败
|
||||
- 打桩登录(仅 dev:`fill.mp-h5.stub-enabled`,**生产必须 false**,代码里还会再判一次 profile):
|
||||
@@ -156,7 +189,7 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
|
||||
- 不要提交:`backend/data/`、`dist/`、`application-local.yml`、`deploy/.env`
|
||||
(都已在 `.gitignore`,但 `git add -A` 容易顺手带上 —— 先 `git status` 看一眼)
|
||||
- `deploy/` 里**只有** `.env.example` 与 `docker-compose.yml` 被 git 跟踪
|
||||
- `deploy/` 里被 git 跟踪的只有 `.env.example`、`docker-compose.yml` 与 `nginx/xt.conf`
|
||||
(`*.pem` 在 ignore 里,`deploy/rsa-public-key-dev.pem` 是本地开发公钥)
|
||||
- 改动接口 / 库表 / FR-VAL 编号 → 重跑 `python scripts/verify-docs.py`,
|
||||
并同步 `docs/00-交付计划.md` 与 `docs/交接摘要.md`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 数据信息填报系统
|
||||
# 深学在线
|
||||
|
||||
> 职业技能等级认定**报名材料收集 SaaS**
|
||||
> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期
|
||||
> **范围(2026-10 收敛)**:只做「学校收集 + 自主认定」(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力 **不做**(传 `collectMode=2`、`certType=2,3` 返回 `40044`);**不设二期**
|
||||
> 学生在微信小程序填报 · 班主任在手机初审 · 管理员在 PC 终审与导出
|
||||
> 最后更新:2026-09-28 | **状态:需求已冻结(客户全部确认,可开工)**
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 |
|
||||
| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 |
|
||||
| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** |
|
||||
| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 29 条 Golden 断言 + 85 条功能验收 |
|
||||
| **测试** | `11-填报流程端到端测试计划.md`(填报链路 E2E)→ `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 链路覆盖矩阵 + 判据口径;36 条 Golden 断言 + 111 条功能验收 |
|
||||
| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 |
|
||||
|
||||
### 全部文档
|
||||
@@ -38,15 +38,16 @@
|
||||
| 00 | `00-交付计划.md` | 8 KB | 7 项计划与进度追踪、风险跟踪 | ✅ |
|
||||
| 01 | `01-客户与机构确认函.md` | 18 KB | **23 项已全部确认** + 客户回执 + 落地动作清单 | ✅ **需求冻结** |
|
||||
| 02 | `02-需求追溯矩阵.md` | 10 KB | 115 条 FR ↔ 原型/库表/接口 的自审报告 | ✅ |
|
||||
| 03 | `03-接口设计.md` | 112 KB | **121 个接口**字段级定义、枚举全集、12 个 VO、**57 个错误码** | ✅ |
|
||||
| 03 | `03-接口设计.md` | — | **119 个接口**字段级定义、枚举全集、12 个 VO、**54 个错误码** | ✅ |
|
||||
| 04 | `04-核心模块设计说明.md` | 56 KB | **6 个模块**伪代码级说明 + 依赖图 + 26 项回归基线 | ✅ |
|
||||
| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ |
|
||||
| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ |
|
||||
| 11 | `11-填报流程端到端测试计划.md` | 45 KB | **填报链路覆盖矩阵(A~J 十组)+ 判据速查 + 已定案缺陷 F-125~F-129**;配套 `scripts/e2e-m3-flow.py` | ✅ |
|
||||
| — | `交接摘要.md` | 11 KB | **状态源**:客户回执、13 条铁律、已完成、待办、环境、教训 | ✅ |
|
||||
| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ |
|
||||
| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ |
|
||||
| — | `db/schema-postgresql.sql` | 36 KB | 可执行 PG DDL(20 表),**已在 PostgreSQL 16 实测零错误** | ✅ |
|
||||
| — | `prototype/`(4 份) | 240 KB | **31 屏**交互原型:学生端 11 / 班主任端 9 / 管理端 11 | ✅ |
|
||||
| — | `prototype/`(4 份) | — | **29 屏**交互原型:学生端 10 / 班主任端 9 / 管理端 10 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
```
|
||||
┌─ 学生端(小程序)─┐ ┌─ 班主任端(手机)─┐ ┌─ 管理端(PC)─┐
|
||||
名单 ──→ 门禁核验 ──→ 填报 ──→ 卡片流初审 ──→ 终审 ──→ 导出规范包
|
||||
└─✗ 失败 → 申请加入名单 → 管理员审批 → 入册放行
|
||||
└─✗ 失败 → 只提示核对 + "请联系老师加你进名单"(老师手工加人)
|
||||
▲ ▲ ▲ ▲
|
||||
│ │ │ │
|
||||
报考范围 识别 命名+压缩 汇总 Excel
|
||||
@@ -95,9 +96,9 @@
|
||||
| 滑动交互 | 班主任端**只做右滑通过**,驳回必须点按钮 |
|
||||
| 批量通过 | **只带无问题项**,红黄一律排除 |
|
||||
| 动态表单 | 内置字段(固定列可统计)+ 扩展字段(`extras_json`) |
|
||||
| 名单外的人 | **申请加入名单 → 管理员逐条审批**;通道只解决"进名单",**不绕过门禁** |
|
||||
| 名单外的人 | **不做自助通道**(2026-10 撤销):由老师在任务里**手工加人**(名册追加导入),**不绕过门禁** |
|
||||
| 归属判定优先级 | **证件号反查名册 > OCR 内容 > 姓名(仅辅助,不做独立规则)** |
|
||||
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写 / 申请加入名单」两条系统内出路 |
|
||||
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写」一条系统内出路 + 一句"请联系老师" |
|
||||
| 校验位的作用 | **失败 = 不是真实号码 → 可自动改**;**通过 = 真人 → 改了就是改身份 → 必须阻断** |
|
||||
| 审批粒度 | 批量通过**不提供"全选"**,驳回**必须逐条填原因**(身份认定误操作代价极高) |
|
||||
|
||||
@@ -167,7 +168,9 @@ python check2.py docs/prototype/admin-web.html
|
||||
## 六·二、把系统跑起来(三条命令)
|
||||
|
||||
```bash
|
||||
# 1) 基础设施:PG 16 + Redis 7 + MinIO
|
||||
# 1) 基础设施:PG 16 + Redis 7 + nginx(没有对象存储,MinIO 已删)
|
||||
# nginx(:80)发学生端 H5 并把 /api 反代到后端 8080 —— 起它之前先
|
||||
# `cd frontend-mp && pnpm build:h5`,否则它挂的是个空目录(页面 404)
|
||||
cd deploy
|
||||
copy .env.example .env # 首次
|
||||
docker compose up -d
|
||||
@@ -194,12 +197,12 @@ python scripts/e2e-m0.py
|
||||
|
||||
```
|
||||
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
|
||||
├── xt-common/ 统一响应 / 54 个错误码 / AES-GCM / HMAC / RSA
|
||||
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块(26 张表,Flyway 已到 V19)
|
||||
deploy/ docker-compose(PG/Redis/nginx)+ nginx/xt.conf(H5 站点)+ RSA 开发公钥
|
||||
scripts/ E2E 验收(38 个脚本)、接线检查、DDL 同步、图标抽取
|
||||
frontend-pc/ PC 管理端:**纯 Vue 3 + Naive UI**(pnpm dev → 5173)
|
||||
frontend-mp/ 学生端 / 教师端:uni-app(H5 与微信小程序同一套代码)
|
||||
```
|
||||
|
||||
### ⚠️ 搭环境前请先读
|
||||
@@ -214,8 +217,8 @@ frontend-mp/ (待建)uni-app
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| **需求** | 主 PRD(**125 FR** / 20 VAL / **20 张表**)+ 功能逻辑卷 | ✅ |
|
||||
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**121**)+ 模块设计(6) | ✅ |
|
||||
| **原型** | **31 屏**(学生 **11** / 班主任 9 / 管理 **11**)= 31 | ✅ |
|
||||
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**119**)+ 模块设计(6) | ✅ |
|
||||
| **原型** | **29 屏**(学生 **10** / 班主任 9 / 管理 **10**)= 29 | ✅ |
|
||||
| **计划** | **100 任务 / 387 人日 / 15 周**(一期范围已收窄) | ✅ |
|
||||
| **测试** | **36** 条 Golden 断言 + **111** 条功能验收 | ✅ |
|
||||
| **待外部** | ~~客户确认函(23 项)~~ | ✅ **已确认,按建议执行** |
|
||||
@@ -225,7 +228,7 @@ frontend-mp/ (待建)uni-app
|
||||
| 项 | 确认结论 |
|
||||
|---|---|
|
||||
| **A1** | ✅ 认定机构接受 300dpi 输出 |
|
||||
| **B1** | ✅ 增加「申请加入名单 → 管理员审批」通道(**唯一功能增量,已落地**) |
|
||||
| **B1** | ⚠️ **已回退**(2026-10 需求收敛):加入申请通道整体撤销(模块/表/接口/页面/错误码一并删除)。它是确认函里唯一的功能增量,建议补变更确认单 |
|
||||
| **B3** | ✅ 字段三分类:姓名/证件号/报考三项锁定,其余可核对修改并留痕 |
|
||||
|
||||
**其余 20 项均与现有设计一致,零改动。** 需求已冻结,可按 `05-开发任务拆解.md` 的 **M0** 开工。
|
||||
@@ -257,7 +260,7 @@ frontend-mp/ (待建)uni-app
|
||||
|---|---|
|
||||
| 数据库 | PostgreSQL 16(DDL 已实测通过) |
|
||||
| 开发顺序 | **M1 命名引擎 → M4 门禁 → M2 压缩 → M5 校验 → M3 识别 → M6 导出** |
|
||||
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)已落地 |
|
||||
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)**已于 2026-10 需求回退取消** |
|
||||
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG 图标、**无 emoji** |
|
||||
| 最长的两个单点 | 压缩引擎 12 人日、打包引擎 10 人日(**加人无效**) |
|
||||
| 回归门禁 | 改动后必须复现 **15 项基线指标**(对应 29 条断言),偏差即回归 |
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
<artifactId>fill-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>数据信息填报系统</name>
|
||||
<name>深学在线</name>
|
||||
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
|
||||
|
||||
<modules>
|
||||
|
||||
@@ -5,7 +5,7 @@ import lombok.Getter;
|
||||
/**
|
||||
* 错误码全集。
|
||||
*
|
||||
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>58 个</b>(含成功码 {@code 0})。
|
||||
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>59 个</b>(含成功码 {@code 0})。
|
||||
* <b>改这里之前先改文档,不要只改代码。</b>
|
||||
*
|
||||
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
|
||||
@@ -101,22 +101,14 @@ public enum ErrorCode {
|
||||
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, "一期仅支持学校收集 + 自主认定"),
|
||||
/** 角色还有账号在用,不可删除 */
|
||||
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
|
||||
|
||||
// ── 认证与权限 ────────────────────────────────
|
||||
/** 未登录 / token 失效(真 HTTP 401) */
|
||||
@@ -146,7 +138,7 @@ public enum ErrorCode {
|
||||
STORAGE_FAILED(50007, "存储写入失败,请重传"),
|
||||
|
||||
// ── 兜底 ──────────────────────────────────────
|
||||
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
|
||||
/** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
|
||||
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
|
||||
|
||||
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
|
||||
|
||||
@@ -68,9 +68,24 @@ public record LoginUser(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个人是不是<b>租户级管理员</b>({@code user_type = 2},即学校 / 机构管理员)。
|
||||
*
|
||||
* <p>⚠️ 判 {@code user_type} 而<b>不判角色码</b>。上一版这里写的是
|
||||
* {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")},有两个问题:
|
||||
* <ol>
|
||||
* <li>「审核员」({@code AUDITOR})角色已删除;</li>
|
||||
* <li>租户可以自建角色,而"自定义角色 + {@code user_type=2}"同样是租户管理员 ——
|
||||
* 按角色码判会让这批人退化成普通用户(表现是"什么都点不动",且不报任何错)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
public boolean isTenantAdmin() {
|
||||
return userType != null && userType == 2;
|
||||
}
|
||||
|
||||
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
|
||||
public boolean coversOrg(Long orgId) {
|
||||
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
|
||||
if (isPlatformAdmin() || isTenantAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return orgId != null && orgIds != null && orgIds.contains(orgId);
|
||||
|
||||
+21
@@ -18,6 +18,7 @@ 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.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -107,6 +108,26 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有任何 handler 能匹配这个路径。
|
||||
*
|
||||
* <p><b>⚠️ 为什么和上面那个分开写</b>:两者对客户端是同一件事(404),触发条件却不同 ——
|
||||
* {@link NoResourceFoundException} 由<b>静态资源处理器</b>抛出(映射在,文件不在),
|
||||
* 而本异常是<b>连 handler 都没有</b>时由 {@code DispatcherServlet.noHandlerFound} 抛出。
|
||||
*
|
||||
* <p>2026-10 关掉静态资源映射({@code spring.web.resources.add-mappings=false},
|
||||
* H5 改由 nginx 托管)之后,落到这里的情况变多了:以前 {@code /**} 上挂着资源处理器,
|
||||
* 任何路径都有 handler,所以走的是上面那条;现在凡是没匹配上 Controller 的路径
|
||||
* (比如老的 H5 地址 {@code /}、{@code /assets/xxx.js})都走这条。
|
||||
* 当时没补这条的表现是:<b>访问一个不存在的路径得到 500「系统繁忙」</b>,
|
||||
* 还在日志里刷 {@code ERROR 未预期异常} —— 一个正常的 404 被报成了服务端故障。
|
||||
*/
|
||||
@ExceptionHandler(NoHandlerFoundException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleNoHandler(NoHandlerFoundException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResult.fail(ErrorCode.VALIDATION_BLOCKED, "接口不存在"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResult<Void>> handleUploadTooLarge(MaxUploadSizeExceededException e) {
|
||||
return ResponseEntity.ok(ApiResult.fail(ErrorCode.FILE_TOO_LARGE));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 接口响应禁止缓存:{@code /api/**} → {@code Cache-Control: no-store}。
|
||||
*
|
||||
* <h3>为什么必须有这个类</h3>
|
||||
* 接口响应一旦被某个中间层缓存,就会出现"刚提交完再进去看还是旧状态"——
|
||||
* 微信 webview、运营商代理、企业网关都可能干这件事,而且<b>只在真机/穿透上复现</b>,
|
||||
* 本机开发机上永远看不到。所以这里显式声明,不指望中间层"默认不缓存"。
|
||||
*
|
||||
* <h3>它现在只管接口了(2026-10 架构调整)</h3>
|
||||
* 它曾经还管静态资源({@code /assets/**} 永久缓存、{@code *.html} 每次回源),
|
||||
* 那时 H5 是<b>后端托管</b>的({@code spring.web.resources.static-locations})。
|
||||
* 现在 H5 由 nginx 提供({@code deploy/nginx/xt.conf},与 {@code /api} 同源),
|
||||
* 所以<b>静态资源的缓存策略整体归 nginx</b> —— 静态文件的事由静态服务器负责,
|
||||
* 拆成两处只会"改一处漏一处"。后端因此退回纯 API 服务
|
||||
* ({@code spring.web.resources.add-mappings=false},见 {@code application.yml})。
|
||||
*
|
||||
* <p>⚠️ 但这段历史不是废话,它解释了 nginx 那份配置里 <b>为什么 {@code /assets/}
|
||||
* 与 {@code *.html} 必须显式写缓存头</b>:没有显式过期时间时,浏览器会启用
|
||||
* <b>启发式缓存</b>(RFC 9111 §4.2.2:按 {@code Last-Modified} 年龄的 10% 自行推断保鲜期),
|
||||
* 后果是<b>发版后学生继续打开旧页面</b>(旧 {@code index.html} 里写死了旧 chunk 名),
|
||||
* 而且只在"已经打开过页面的设备"上复现,开发机上硬刷新一次就没了,极易被当成偶发。
|
||||
* 搬配置时别把这两条丢掉。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ApiNoStoreFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
// 只有接口响应禁止缓存。静态资源(H5)不经过本服务,走 nginx。
|
||||
if (request.getRequestURI().startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 静态资源的缓存策略 + 接口响应禁止缓存。
|
||||
*
|
||||
* <h3>为什么必须有这个类</h3>
|
||||
* 没有它的时候,静态资源只有 {@code Last-Modified}、<b>没有 {@code Cache-Control}、没有 {@code ETag}</b>。
|
||||
* 这会触发浏览器的 <b>启发式缓存</b>(RFC 9111 §4.2.2:没有显式过期时间时,
|
||||
* 浏览器按 {@code Last-Modified} 年龄的 10% 自行推断一个保鲜期)。
|
||||
*
|
||||
* <p>后果不是"慢一点",而是<b>发版后学生看到旧页面</b>:
|
||||
* 旧 {@code index.html} 被缓存 → 它里面写死了旧 {@code /assets/index-<旧哈希>.js} →
|
||||
* 学生打开的是我们上个月的那一版,而我们在后台怎么改都没用。
|
||||
* 排查这个现象极其费时 —— 因为它<b>只在"已经打开过页面的设备"上复现</b>,
|
||||
* 开发机上硬刷新一次就没了,于是很容易被当成"偶发"。
|
||||
*
|
||||
* <p>实测踩过:材料上传的前端界面明明已构建、chunk 也已正确下发
|
||||
* (从 8080 下载 9606 字节,与磁盘文件 SHA-256 一致、里面确实有那段代码),
|
||||
* 但浏览器执行的是旧 chunk —— 页面里就是没有材料区。
|
||||
*
|
||||
* <h3>策略</h3>
|
||||
* <ul>
|
||||
* <li>{@code /assets/**}:文件名带内容哈希,内容变了名字就变,
|
||||
* 所以可以<b>永久缓存</b>({@code immutable})。这是唯一的性能红利所在:
|
||||
* 这些包加起来好几 MB,学生不该每次发版都重下。</li>
|
||||
* <li>{@code *.html} 与 {@code /}:<b>每次都必须回源校验</b>({@code no-cache})。
|
||||
* 注意 {@code no-cache} ≠ 不缓存,而是"用之前先问一下"——
|
||||
* 配合 {@code Last-Modified} 会走 304,只花一个往返、不重传正文,这是对的。</li>
|
||||
* <li>{@code /api/**}:{@code no-store}。接口响应<b>绝不能</b>被缓存 ——
|
||||
* 微信 webview 里"刚提交完再进去看还是旧状态"这种问题,
|
||||
* 十有八九是某个中间层把 GET 响应缓存了。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class StaticCacheHeaderFilter extends OncePerRequestFilter {
|
||||
|
||||
/** 一年。带内容哈希的资源可以按"永久"对待。 */
|
||||
private static final String IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri.startsWith("/assets/")) {
|
||||
response.setHeader("Cache-Control", IMMUTABLE);
|
||||
} else if (uri.startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
} else if (uri.equals("/") || uri.endsWith(".html")) {
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 数据信息填报系统 · 主应用。
|
||||
* 深学在线 · 主应用。
|
||||
*
|
||||
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
|
||||
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.tianbaotong.fill.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ import java.util.Map;
|
||||
* @param miniProgram 微信小程序(填报二维码)
|
||||
* @param mpH5 H5 填报端(网页授权)
|
||||
* @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com)
|
||||
* @param cors 跨域白名单
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "fill")
|
||||
public record FillProperties(
|
||||
@@ -36,9 +38,46 @@ public record FillProperties(
|
||||
Storage storage,
|
||||
MiniProgram miniProgram,
|
||||
MpH5 mpH5,
|
||||
TenantDomain tenantDomain
|
||||
TenantDomain tenantDomain,
|
||||
Cors cors
|
||||
) {
|
||||
|
||||
/**
|
||||
* 跨域白名单(Spring 的 {@code allowedOriginPatterns} 语义:可写
|
||||
* {@code http://localhost:*}、{@code https://*.example.com})。
|
||||
*
|
||||
* <h3>⚠️ "本站自己的地址"也必须在这里 —— 这条最反直觉,但它是确定的</h3>
|
||||
*
|
||||
* <p>浏览器的 CORS <b>只约束跨源请求</b>,可它<b>给同源的 POST/PUT/DELETE 也带
|
||||
* {@code Origin} 头</b>(GET 不带)。而 Spring MVC 的判据是"请求里有没有 Origin",
|
||||
* <b>不是</b>"是不是真跨源":只要带了 Origin 又不在白名单里,直接回
|
||||
* {@code 403 Invalid CORS request}({@code DefaultCorsProcessor#processRequest},
|
||||
* Spring Framework 7.0.9 里<b>没有</b>同源短路)。
|
||||
*
|
||||
* <p>于是"前后端同源部署 = 不需要 CORS"是错的:登录、上传、提交全是 POST,
|
||||
* 全都会被这一条拒掉。更麻烦的是它<b>只在浏览器里复现</b> ——
|
||||
* 所有 E2E 脚本都是 Python,不带 Origin 头,所以测试全绿
|
||||
* (2026-10 生产事故,见 {@code docs/交接摘要.md} §一·四十四)。
|
||||
*
|
||||
* <p>所以配置要按"浏览器地址栏里会出现哪些源"来写,而不是按"谁会跨域调我"来写。
|
||||
*
|
||||
* @param allowedOrigins 允许的源(pattern)。生产写真实域名,
|
||||
* 换域名/加域名时改这里或给 {@code XT_CORS_ALLOWED_ORIGINS}
|
||||
*/
|
||||
public record Cors(List<String> allowedOrigins) {
|
||||
|
||||
/** 本机开发用的兜底值(base 的 {@code application.yml} 里也有一份,这里防 null)。 */
|
||||
public static final List<String> DEFAULTS = List.of(
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.*.*:*");
|
||||
|
||||
/** 没配时退回本机开发值;**生产不许为空**,否则同源 POST 会 403。 */
|
||||
public List<String> patternsOrDefault() {
|
||||
return allowedOrigins == null || allowedOrigins.isEmpty() ? DEFAULTS : allowedOrigins;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
|
||||
*
|
||||
|
||||
@@ -26,7 +26,7 @@ public class OpenApiConfig {
|
||||
public OpenAPI fillOpenApi() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("数据信息填报系统 API")
|
||||
.title("深学在线 API")
|
||||
.version("1.0.0")
|
||||
.description("""
|
||||
职业技能等级认定 · 报名材料收集 SaaS
|
||||
|
||||
@@ -66,7 +66,7 @@ public class StorageConfig {
|
||||
*/
|
||||
private void selfCheck(QiniuStorageService svc) {
|
||||
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
|
||||
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
|
||||
try {
|
||||
svc.put(path, payload, "text/plain; charset=utf-8");
|
||||
if (!svc.exists(path)) {
|
||||
|
||||
@@ -2,11 +2,14 @@ package cn.tianbaotong.fill.config;
|
||||
|
||||
import cn.tianbaotong.fill.security.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Web 层装配。
|
||||
*
|
||||
@@ -14,11 +17,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
* {@code /doc.html}、{@code /v3/api-docs/**} 不走业务鉴权,
|
||||
* 它们由各自的机制保护(actuator 只在内网暴露,文档在生产环境关闭)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
private final FillProperties props;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
@@ -29,25 +34,38 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨域配置。
|
||||
* 跨域配置(白名单来自 {@code fill.cors.allowed-origins})。
|
||||
*
|
||||
* <p><b>⚠️ 生产环境必须收紧 {@code allowedOriginPatterns}</b>——写 {@code *} 配合
|
||||
* {@code allowCredentials} 在浏览器规范下无效,而改成 {@code originPatterns} 放行
|
||||
* 全部则等于取消同源策略。生产配置见 {@code application-prod.yml} 里的
|
||||
* {@code fill.cors.allowed-origins}。
|
||||
* <h3>⚠️ 为什么"同源部署"也离不开这张白名单</h3>
|
||||
*
|
||||
* <p>浏览器的 CORS 只约束跨源请求,但<b>同源的 POST/PUT/DELETE 也会带
|
||||
* {@code Origin} 头</b>;而 Spring MVC 只看"有没有 Origin",不看"是不是真跨源",
|
||||
* 不在白名单里就回 {@code 403 Invalid CORS request}。登录、上传、提交全是 POST ——
|
||||
* 于是<b>本站自己的地址必须列在这里</b>,否则"前后端同源"这个部署形态反而一定登不上。
|
||||
*
|
||||
* <p>这条曾经写死成 localhost/内网,生产域名 {@code https://admin.shenxue.jxit.com}
|
||||
* 不在里面,表现就是"接口地址明明通、POST 一律 403",而后端日志里一个字都没有
|
||||
* (403 是 CORS 处理器在进 Controller 之前回的)。详见 {@code docs/交接摘要.md} §一·四十四。
|
||||
*
|
||||
* <p>⚠️ 启动时把生效的白名单打出来(见下面的 info 日志):这类事故里最先要看的就是
|
||||
* "到底放了谁",而白名单藏在配置里、又只在浏览器里才生效,不打印就只能猜。
|
||||
*/
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
FillProperties.Cors cors = props.cors();
|
||||
List<String> patterns = cors == null
|
||||
? FillProperties.Cors.DEFAULTS
|
||||
: cors.patternsOrDefault();
|
||||
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns(
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.*.*:*"
|
||||
)
|
||||
.allowedOriginPatterns(patterns.toArray(String[]::new))
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.exposedHeaders("X-Trace-Id")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
|
||||
log.info("CORS 白名单(/api/**):{} —— 浏览器的同源 POST 也会校验,"
|
||||
+ "部署域名必须在这里,否则登录会 403「Invalid CORS request」", patterns);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-8
@@ -10,18 +10,17 @@ import java.util.List;
|
||||
* 审核范围:<b>"这个人能审哪些班"</b>。
|
||||
*
|
||||
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
|
||||
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
|
||||
* 这里把它翻译成一次查询。两级回退的顺序是<b>刻意的</b>:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
|
||||
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
|
||||
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
|
||||
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
|
||||
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
|
||||
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
|
||||
* <p>⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
|
||||
* 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
|
||||
* 需要多人协审时,正确做法是给其中一人指定为该班初审人,
|
||||
* 而不是再引入一个"协办人"字段。
|
||||
*
|
||||
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
|
||||
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
|
||||
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
|
||||
</if>
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
ORDER BY cto.org_id
|
||||
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
|
||||
AND cto.status = 1
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
""")
|
||||
|
||||
-2
@@ -102,7 +102,6 @@ public interface AuditTodoMapper {
|
||||
AND cto.status = 1
|
||||
AND (
|
||||
cto.head_user_id = #{auditorId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||
)
|
||||
)
|
||||
@@ -193,7 +192,6 @@ public interface AuditTodoMapper {
|
||||
AND cto.status = 1
|
||||
AND (
|
||||
cto.head_user_id = #{auditorId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
|
||||
)
|
||||
)
|
||||
|
||||
+17
-5
@@ -158,6 +158,13 @@ public class AuthService {
|
||||
Set.copyOf(roleCodes),
|
||||
user.getUserType());
|
||||
|
||||
// ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
|
||||
// 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
|
||||
// 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
|
||||
// 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
|
||||
// (现象是"刚登录什么都没有,刷新一下又好了")。
|
||||
UserContext.set(principal);
|
||||
|
||||
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
|
||||
|
||||
SysUser touch = new SysUser();
|
||||
@@ -183,9 +190,9 @@ public class AuthService {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
|
||||
LoginVO vo = buildProfile(user, roleCodes);
|
||||
vo.setPermissions(permissionChecker.currentPermissions());
|
||||
return vo;
|
||||
// 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
|
||||
// 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
|
||||
return buildProfile(user, roleCodes);
|
||||
}
|
||||
|
||||
// ── 修改自己的密码 ─────────────────────────────
|
||||
@@ -358,8 +365,13 @@ public class AuthService {
|
||||
}
|
||||
|
||||
vo.setRoleCodes(roleCodes);
|
||||
vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
|
||||
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
|
||||
// ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
|
||||
// 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
|
||||
// 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
|
||||
// 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
|
||||
// 所以漏在这里就等于"要刷新一次才看得见菜单"。
|
||||
vo.setMenus(permissionChecker.currentMenus());
|
||||
vo.setPermissions(permissionChecker.currentPermissions());
|
||||
return vo;
|
||||
}
|
||||
|
||||
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
|
||||
import cn.tianbaotong.fill.module.cleanup.service.CleanupImportService;
|
||||
import cn.tianbaotong.fill.module.cleanup.service.CleanupService;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 历史数据整理器(《03-接口设计》§13.2)—— 差异化获客功能。
|
||||
*
|
||||
* <p>权限用 {@code task:manage}:整理出来的东西最终会变成正式任务(名册 + 材料),
|
||||
* 与"创建与配置收集任务"是同一批人的职责。
|
||||
*/
|
||||
@Tag(name = "历史数据整理器")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/cleanup/jobs")
|
||||
@RequiredArgsConstructor
|
||||
public class CleanupController {
|
||||
|
||||
private final CleanupService cleanupService;
|
||||
private final CleanupImportService importService;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||
@Operation(summary = "创建整理任务(上传压缩包)",
|
||||
description = "用**与正式收集完全相同的规则**做规范化。"
|
||||
+ "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
|
||||
+ "**有名单才能做证件号校验、疑似串档检测** —— 而「疑似串档 1 例」"
|
||||
+ "正是报告里最有价值的一条。"
|
||||
+ "一期只支持 zip(云盘链接与文件夹上传返回 40044)")
|
||||
public ApiResult<CleanupDtos.CreateVO> create(
|
||||
@RequestParam String jobName,
|
||||
@RequestParam(defaultValue = "1") Integer sourceType,
|
||||
@RequestParam(value = "file", required = false) MultipartFile file,
|
||||
@RequestParam(required = false) String namingTemplate,
|
||||
@RequestParam(required = false) Long rosterTaskId) throws IOException {
|
||||
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
|
||||
req.setJobName(jobName);
|
||||
req.setSourceType(sourceType);
|
||||
req.setNamingTemplate(namingTemplate);
|
||||
req.setRosterTaskId(rosterTaskId);
|
||||
return ApiResult.ok(cleanupService.create(req,
|
||||
file == null ? null : file.getBytes()));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||
@Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
|
||||
description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
|
||||
+ "方便自动化脚本与排障,不必手搓 multipart")
|
||||
public ApiResult<CleanupDtos.CreateVO> createJson(@RequestBody Map<String, Object> body)
|
||||
throws IOException {
|
||||
CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
|
||||
req.setJobName(body.get("jobName") == null ? null : String.valueOf(body.get("jobName")));
|
||||
req.setSourceType(body.get("sourceType") == null ? 1
|
||||
: Integer.valueOf(String.valueOf(body.get("sourceType"))));
|
||||
req.setNamingTemplate(body.get("namingTemplate") == null ? null
|
||||
: String.valueOf(body.get("namingTemplate")));
|
||||
req.setRosterTaskId(body.get("rosterTaskId") == null ? null
|
||||
: Long.valueOf(String.valueOf(body.get("rosterTaskId"))));
|
||||
String b64 = body.get("zipBase64") == null ? null : String.valueOf(body.get("zipBase64"));
|
||||
byte[] bytes = b64 == null || b64.isBlank()
|
||||
? null : java.util.Base64.getDecoder().decode(b64);
|
||||
return ApiResult.ok(cleanupService.create(req, bytes));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@Operation(summary = "整理任务列表")
|
||||
public ApiResult<CleanupDtos.JobPageVO> list(
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return ApiResult.ok(cleanupService.list(status, keyword, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@Operation(summary = "整理进度",
|
||||
description = "compressRatio 在没算出来时给「—」而不是 0% —— "
|
||||
+ "0% 看起来像「一点没压缩」,而真相是「还没算」")
|
||||
public ApiResult<CleanupDtos.JobVO> detail(@PathVariable Long id) {
|
||||
return ApiResult.ok(cleanupService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/report")
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@Operation(summary = "体检报告 ⭐",
|
||||
description = "报告里最重要的两个数字是 **autoHandled / needManual** ——"
|
||||
+ "「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。"
|
||||
+ "它们由服务端算出来,不让前端去减(前端减出来的数字早晚会分叉)。"
|
||||
+ "⚠️ 一期没有 OCR,所以 `idcardConflicts` 的判定一律是 `NEED_CONFIRM` ——"
|
||||
+ "唯一的证据是文件名,而文件名本身正是这次要修的东西(用它改数据是循环论证)")
|
||||
public ApiResult<CleanupDtos.ReportVO> report(@PathVariable Long id) {
|
||||
return ApiResult.ok(cleanupService.report(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/result")
|
||||
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
|
||||
@Operation(summary = "下载整理结果(zip 流)")
|
||||
public ResponseEntity<byte[]> result(@PathVariable Long id) {
|
||||
byte[] bytes = cleanupService.resultBytes(id);
|
||||
String name = "cleanup-" + id + "-result.zip";
|
||||
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
|
||||
.body(bytes);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/import")
|
||||
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||
@Operation(summary = "把整理结果导入为正式任务",
|
||||
description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
|
||||
+ "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
|
||||
public ApiResult<CleanupDtos.ImportVO> importJob(@PathVariable Long id,
|
||||
@RequestBody CleanupDtos.ImportReq req) {
|
||||
return ApiResult.ok(importService.importAsTask(id, req));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePerm(PermissionDef.CLEANUP_RUN)
|
||||
@Operation(summary = "只解析不落库(排障用)",
|
||||
description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
|
||||
+ "调规则或排查「为什么这个人没认出来」时用它 —— "
|
||||
+ "不然只能看最终报告,而报告是**汇总后的结果**,看不出是哪一步错了")
|
||||
public ApiResult<List<Map<String, Object>>> parsePreview(
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
return ApiResult.ok(cleanupService.dryRunParse(file.getBytes()));
|
||||
}
|
||||
}
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 历史数据整理器(《03-接口设计》§13.2)。
|
||||
*/
|
||||
public final class CleanupDtos {
|
||||
|
||||
private CleanupDtos() {
|
||||
}
|
||||
|
||||
/** 创建整理任务。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "创建历史整理任务")
|
||||
public static class CreateReq {
|
||||
|
||||
private String jobName;
|
||||
|
||||
/** {@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
|
||||
private Integer sourceType;
|
||||
|
||||
private String uploadToken;
|
||||
|
||||
private String objectKey;
|
||||
|
||||
private String sourceUrl;
|
||||
|
||||
private String namingTemplate;
|
||||
|
||||
private Object compressConfig;
|
||||
|
||||
/**
|
||||
* 关联到**已有任务的名册**。
|
||||
*
|
||||
* <p>⚠️ 关键参数:没有名册就只能做文件名规范化;<b>有名单才能做证件号校验、
|
||||
* 疑似串档检测</b> —— 而"疑似串档 1 例"正是报告里最有价值的一条。
|
||||
*/
|
||||
private Long rosterTaskId;
|
||||
}
|
||||
|
||||
/** 创建结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CreateVO {
|
||||
|
||||
private Long jobId;
|
||||
|
||||
private Integer status;
|
||||
|
||||
/** 估时(秒)。按文件数估:解析 + 压缩,经验值约每 10 个文件 1 秒 */
|
||||
private Integer estimatedSeconds;
|
||||
}
|
||||
|
||||
/** 整理任务(列表与详情共用)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class JobVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String jobName;
|
||||
|
||||
private Integer sourceType;
|
||||
|
||||
private Long rosterTaskId;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String statusText;
|
||||
|
||||
private Integer progress;
|
||||
|
||||
private Integer totalFiles;
|
||||
|
||||
private Integer processedFiles;
|
||||
|
||||
private Integer parsedStudents;
|
||||
|
||||
private Integer parsedMaterials;
|
||||
|
||||
private Long rawSizeBytes;
|
||||
|
||||
private Long finalSizeBytes;
|
||||
|
||||
/** 形如 {@code 91.5%};没算出来时给 {@code —} 而不是 0%(0% 看起来像"没压缩") */
|
||||
private String compressRatio;
|
||||
|
||||
/** 磁盘上的整理结果路径(不是可下载 URL —— 下载走 `/jobs/{id}/result`)。 */
|
||||
private String resultPath;
|
||||
|
||||
private String errorMsg;
|
||||
|
||||
private Long targetTaskId;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime finishTime;
|
||||
}
|
||||
|
||||
/** 列表。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class JobPageVO {
|
||||
|
||||
private List<JobVO> list = new ArrayList<>();
|
||||
|
||||
private long total;
|
||||
}
|
||||
|
||||
// ── 体检报告(契约 §13.2 的核心)──────────────
|
||||
|
||||
/** 报告汇总。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ReportSummary {
|
||||
|
||||
private int totalFiles;
|
||||
|
||||
/** 文件名不规范(双扩展名、缺姓名/证件号等)。 */
|
||||
private int nameInvalid;
|
||||
|
||||
private int sizeExceeded;
|
||||
|
||||
private int formatConverted;
|
||||
|
||||
/** 证件号与名册不一致的份数。 */
|
||||
private int idcardMismatch;
|
||||
|
||||
/** 其中系统自动修正的。 */
|
||||
private int idcardAutoFixed;
|
||||
|
||||
/** 其中需要人工确认的。 */
|
||||
private int idcardNeedConfirm;
|
||||
|
||||
/**
|
||||
* 疑似串档。
|
||||
*
|
||||
* <p>判据(契约 §13.2 的例子):某个学生的**多项**材料证件号与名册**均不同**,
|
||||
* 且这些材料彼此一致 —— 那说明"整个文件夹可能被换过"。
|
||||
* 单项不符只是录入笔误,多项一致才指向"人不对"。
|
||||
*/
|
||||
private int crossFileSuspected;
|
||||
|
||||
private int lowResolution;
|
||||
|
||||
/** 无法归位(既没匹配到名册,也没解析出姓名)。 */
|
||||
private int orphanImages;
|
||||
}
|
||||
|
||||
/** 报告里的一条重点。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Highlight {
|
||||
|
||||
/** {@code CROSS_FILE} / {@code LOW_RESOLUTION} / {@code IDCARD_MISMATCH} / {@code ORPHAN}。 */
|
||||
private String type;
|
||||
|
||||
/** {@code 1} 提示 / {@code 2} 警告 / {@code 3} 必须人工。 */
|
||||
private Integer level;
|
||||
|
||||
private Long rosterId;
|
||||
|
||||
private String realName;
|
||||
|
||||
private String title;
|
||||
|
||||
private String materialCode;
|
||||
|
||||
private String detail;
|
||||
|
||||
private String suggestion;
|
||||
}
|
||||
|
||||
/** 改名样例。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class NameFixSample {
|
||||
|
||||
private String original;
|
||||
|
||||
private String fixed;
|
||||
|
||||
private String rule;
|
||||
}
|
||||
|
||||
/** 证件号冲突明细。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class IdcardConflict {
|
||||
|
||||
private Long rosterId;
|
||||
|
||||
private String realName;
|
||||
|
||||
/** 名册里的脱敏值。 */
|
||||
private String rosterValue;
|
||||
|
||||
/** 从文件名里读到的脱敏值。 */
|
||||
private String fileNameValue;
|
||||
|
||||
/** 按文件名修正 / 按名册修正 / 待人工。 */
|
||||
private String verdict;
|
||||
|
||||
private String verdictText;
|
||||
}
|
||||
|
||||
/** 体检报告。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ReportVO {
|
||||
|
||||
private Long jobId;
|
||||
|
||||
private ReportSummary summary = new ReportSummary();
|
||||
|
||||
/**
|
||||
* 系统全自动处理了多少个文件。
|
||||
*
|
||||
* <p>契约里写明:<b>报告里最重要的一句话是 {@code autoHandled / needManual}</b>
|
||||
* ——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
|
||||
* 所以这两个数字**必须由服务端算出来**,不能让前端自己减。
|
||||
*/
|
||||
private int autoHandled;
|
||||
|
||||
private int needManual;
|
||||
|
||||
private List<Highlight> highlights = new ArrayList<>();
|
||||
|
||||
private List<NameFixSample> nameFixedSamples = new ArrayList<>();
|
||||
|
||||
private List<IdcardConflict> idcardConflicts = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 导入为正式任务。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "把整理结果导入为正式任务")
|
||||
public static class ImportReq {
|
||||
|
||||
private String taskName;
|
||||
|
||||
private Integer collectMode;
|
||||
|
||||
private String batchNo;
|
||||
|
||||
/**
|
||||
* 专业名称(选填)。
|
||||
*
|
||||
* <p>契约 §13.2 的导入请求里没有这个字段,但 `TaskService.create` 要求它 ——
|
||||
* 而"专业名"必须有人给。所以:给了就用它,没给就**继承关联名册任务的专业**
|
||||
* (历史材料与那份名册本来就属于同一个专业),都没有时明确报错让用户填。
|
||||
* 这条补字段记在 F-44。
|
||||
*/
|
||||
private String majorName;
|
||||
|
||||
private Boolean importRoster;
|
||||
|
||||
private Boolean importFiles;
|
||||
|
||||
private Boolean markAsSubmitted;
|
||||
}
|
||||
|
||||
/** 导入结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ImportVO {
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private int rosterCount;
|
||||
|
||||
private int fileCount;
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.entity;
|
||||
|
||||
import cn.tianbaotong.fill.support.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 历史整理任务({@code collect_cleanup_job},《03-接口设计》§13.2)。
|
||||
*
|
||||
* <p>⚠️ V1 建这张表时漏了 {@code update_time} / {@code deleted},
|
||||
* 而实体继承 {@code BaseEntity}(带 {@code @TableLogic} 与自动填充)——
|
||||
* 少了这两列的表现是**运行时**才炸。V13 补齐,并被
|
||||
* {@code scripts/check-entity-columns.py} 提前拦住过(F-24/F-35 的同类)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("collect_cleanup_job")
|
||||
public class CollectCleanupJob extends BaseEntity {
|
||||
|
||||
/** {@code 0} 排队 / {@code 1} 解析中 / {@code 2} 处理中 / {@code 3} 已完成 / {@code 4} 失败。 */
|
||||
public static final int STATUS_QUEUED = 0;
|
||||
public static final int STATUS_PARSING = 1;
|
||||
public static final int STATUS_PROCESSING = 2;
|
||||
public static final int STATUS_DONE = 3;
|
||||
public static final int STATUS_FAILED = 4;
|
||||
|
||||
/** 来源:{@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
|
||||
public static final int SOURCE_ZIP = 1;
|
||||
public static final int SOURCE_FOLDER = 2;
|
||||
public static final int SOURCE_CLOUD = 3;
|
||||
|
||||
private String jobName;
|
||||
|
||||
private Integer sourceType;
|
||||
|
||||
/** 压缩包在存储里的相对路径({@code sourceType=1})。 */
|
||||
private String sourcePath;
|
||||
|
||||
/** 云盘链接({@code sourceType=3},一期不启用)。 */
|
||||
private String sourceUrl;
|
||||
|
||||
/**
|
||||
* 关联的已有任务名册(契约 §13.2 的 {@code rosterTaskId})。
|
||||
*
|
||||
* <p>⚠️ **这是关键参数**:没有名册就只能做文件名规范化;
|
||||
* <b>有名单才能做证件号校验、疑似串档检测</b> ——
|
||||
* 而"疑似串档 1 例"正是报告里最有价值的那一条。
|
||||
*/
|
||||
private Long rosterTaskId;
|
||||
|
||||
private String namingTemplate;
|
||||
|
||||
/** 压缩参数(JSON,缺省见契约 §3.11)。 */
|
||||
private String compressConfig;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Integer progress;
|
||||
|
||||
/** 体检报告(JSON)—— 契约 §13.2 的 report 结构整体存这里。 */
|
||||
private String reportJson;
|
||||
|
||||
private Integer totalFiles;
|
||||
|
||||
private Integer processedFiles;
|
||||
|
||||
private Integer fixedFiles;
|
||||
|
||||
private Integer failedFiles;
|
||||
|
||||
private Integer parsedStudents;
|
||||
|
||||
private Integer parsedMaterials;
|
||||
|
||||
private Long rawSizeBytes;
|
||||
|
||||
private Long finalSizeBytes;
|
||||
|
||||
/** 整理结果的压缩包路径。 */
|
||||
private String resultPath;
|
||||
|
||||
/** 导入成正式任务后的任务 id。 */
|
||||
private Long targetTaskId;
|
||||
|
||||
private LocalDateTime finishTime;
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 历史整理任务。
|
||||
*
|
||||
* <p>只需要 MyBatis-Plus 的默认方法 —— 列表用 `selectPage` + 条件构造器就够了,
|
||||
* 不为一个查询再写一段手写 SQL(那会多一处要维护的口径)。
|
||||
*/
|
||||
public interface CleanupJobMapper extends BaseMapper<CollectCleanupJob> {
|
||||
}
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.service;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
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 cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
|
||||
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
|
||||
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
|
||||
import cn.tianbaotong.fill.module.file.service.FileProcessService;
|
||||
import cn.tianbaotong.fill.module.file.storage.StorageService;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import cn.tianbaotong.fill.module.task.service.TaskService;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* 把整理结果导入为正式任务(《03-接口设计》§13.2 的 `/jobs/{id}/import`)。
|
||||
*
|
||||
* <h3>这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"</h3>
|
||||
*
|
||||
* <p>导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备,
|
||||
* 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据",
|
||||
* 而重建最容易出的两类错必须在这里挡住:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>重复导入</b>:客户手滑点两次是常事。多出来的任务很难被发现
|
||||
* (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。
|
||||
* 所以导入是**幂等**的(看 {@code target_task_id})。</li>
|
||||
* <li><b>证件号缺失</b>:解析不出证件号的人**不能建名册行** ——
|
||||
* `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑),
|
||||
* 而且没有证件号的人永远过不了门禁、也过不了名册校验。
|
||||
* 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>一期能做到哪一步(说清边界,不假装完整)</h3>
|
||||
*
|
||||
* <p>能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。
|
||||
* <b>不能建</b>:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍
|
||||
* "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单"
|
||||
* 的服务端入口(代传接口要 multipart 逐个传)。
|
||||
* 所以 {@code importFiles} 参数**会被如实拒绝**(而不是安静地忽略它)——
|
||||
* 安静忽略会让客户以为文件已经进去了。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CleanupImportService {
|
||||
|
||||
private final CleanupJobMapper jobMapper;
|
||||
private final TaskService taskService;
|
||||
private final TaskMapper taskMapper;
|
||||
private final CollectRosterMapper rosterMapper;
|
||||
private final StorageService storageService;
|
||||
private final SensitiveCodec codec;
|
||||
private final FileProcessService processService;
|
||||
private final CleanupService cleanupService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CleanupDtos.ImportVO importAsTask(Long jobId, CleanupDtos.ImportReq req) {
|
||||
LoginUser me = UserContext.require();
|
||||
CollectCleanupJob job = jobMapper.selectById(jobId);
|
||||
if (job == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
|
||||
}
|
||||
if (job.getStatus() == null || job.getStatus() != CollectCleanupJob.STATUS_DONE) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理还没完成,暂时不能导入");
|
||||
}
|
||||
// ★ 参数校验必须在**幂等判断之前**。
|
||||
//
|
||||
// 原来它排在幂等分支之后,于是同一个整理任务第二次导入时,
|
||||
// 请求会先命中"已经导过了,直接返回原任务",参数校验压根没跑到 ——
|
||||
// `importFiles=true`(一期做不到)因此被安静地接受。
|
||||
// 一次不合法的请求不该因为"恰好之前导过一次"而变成成功。
|
||||
if (req == null || req.getTaskName() == null || req.getTaskName().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写新任务名称");
|
||||
}
|
||||
if (req.getBatchNo() == null || req.getBatchNo().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写批次号");
|
||||
}
|
||||
if (Boolean.TRUE.equals(req.getImportFiles())) {
|
||||
// 如实拒绝而不是安静忽略 —— 见类注释
|
||||
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
|
||||
"一期导入整理结果时还不能自动挂文件(需要把每个文件按模板重新规范化后挂到填报单,"
|
||||
+ "那条服务端入口还没做)。请先导入名册,再让学生/老师把文件传上来");
|
||||
}
|
||||
if (req.getCollectMode() != null && req.getCollectMode() != 1 && req.getCollectMode() != 2) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"collectMode 只能是 1(学校收集)或 2(自主认定)");
|
||||
}
|
||||
|
||||
// ★ 幂等:已经导过就直接回原来的任务,不新建
|
||||
if (job.getTargetTaskId() != null) {
|
||||
var existed = taskMapper.selectById(job.getTargetTaskId());
|
||||
if (existed != null) {
|
||||
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
|
||||
vo.setTaskId(existed.getId());
|
||||
vo.setRosterCount((int) nz(rosterMapper.selectCount(
|
||||
Wrappers.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, existed.getId()))));
|
||||
vo.setFileCount(0);
|
||||
log.info("整理结果重复导入,返回已有任务 jobId={} taskId={}", jobId, existed.getId());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
|
||||
// 一期范围:只做学校收集(契约 §13.2 的 collectMode 也是 1/2)
|
||||
// 缺省给 1(学校收集):一期**只**支持它,而缺省值若是 2,
|
||||
// 不传这个字段的调用方会收到一个"你传了二期功能"的 40044 —— 那读起来像调用方的错。
|
||||
//
|
||||
// mode=2 由 `TaskService.create` 的 assertScopeSupported 拦(40044),
|
||||
// 不在这里再拦一遍:范围的权威定义只有一处,两处早晚会不一致。
|
||||
int mode = req.getCollectMode() == null ? 1 : req.getCollectMode();
|
||||
|
||||
// 建任务(草稿状态:让用户先去核对名册与材料,确认后再发布)
|
||||
//
|
||||
// ⚠️ **必须走 TaskService.create,不能自己 insert**:
|
||||
// `collect_task.task_code` 是 NOT NULL 且由它生成,绕过它插入会报
|
||||
// `null value in column "task_code"`,而对外表现只是"系统繁忙"(50000)。
|
||||
// 更不能自己再写一份编码生成逻辑 —— 两处各生成一次编码迟早会撞号。
|
||||
TaskUpsertReq treq = new TaskUpsertReq();
|
||||
treq.setTaskName(req.getTaskName().trim());
|
||||
treq.setBatchNo(req.getBatchNo().trim());
|
||||
treq.setCollectMode(mode);
|
||||
// ⚠️ 一期范围是 collectMode=1(学校收集)+ **certType=1(自主认定)** ——
|
||||
// 权威定义在 `TaskService.assertScopeSupported`。
|
||||
// 我按"2=自主认定"的直觉写过 2,被 40044 拦下(拦得对)。
|
||||
treq.setCertType(1);
|
||||
treq.setMajorName(majorNameOf(job, req));
|
||||
treq.setStartTime(LocalDate.now().toString());
|
||||
treq.setEndTime(LocalDate.now().plusMonths(6).toString());
|
||||
if (job.getNamingTemplate() != null && !job.getNamingTemplate().isBlank()) {
|
||||
treq.setNamingTemplate(job.getNamingTemplate());
|
||||
}
|
||||
Long newTaskId = taskService.create(treq).getId();
|
||||
|
||||
// 名册:按整理结果里"能确定是谁"的人建
|
||||
int rosterCount = 0;
|
||||
int skipped = 0;
|
||||
if (req.getImportRoster() == null || Boolean.TRUE.equals(req.getImportRoster())) {
|
||||
Map<String, String[]> students = studentsOf(jobId, job);
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (Map.Entry<String, String[]> en : students.entrySet()) {
|
||||
String name = en.getKey();
|
||||
String idCard = en.getValue()[0];
|
||||
String orgHint = en.getValue()[1];
|
||||
if (name == null || name.isBlank() || idCard == null || idCard.isBlank()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// 同一个人只建一行(历史包里同一个人常有多个文件夹)
|
||||
if (!seen.add(name + "|" + idCard)) {
|
||||
continue;
|
||||
}
|
||||
CollectRoster r = new CollectRoster();
|
||||
r.setTaskId(newTaskId);
|
||||
r.setRealName(name);
|
||||
// 三列一起写:密文 + hash(门禁靠它查)+ 脱敏值(展示)
|
||||
r.setIdCardNo(codec.encrypt(idCard));
|
||||
r.setIdCardHash(codec.hash(idCard));
|
||||
r.setIdCardMask(maskOf(idCard));
|
||||
r.setSeqNo(rosterCount + 1);
|
||||
r.setSubmitStatus(CollectRoster.SUBMIT_NONE);
|
||||
rosterMapper.insert(r);
|
||||
rosterCount++;
|
||||
}
|
||||
}
|
||||
|
||||
CollectCleanupJob upd = new CollectCleanupJob();
|
||||
upd.setId(jobId);
|
||||
upd.setTargetTaskId(newTaskId);
|
||||
jobMapper.updateById(upd);
|
||||
|
||||
log.info("整理结果导入为任务:jobId={} taskId={} 名册 {} 人(跳过 {})经办人={}",
|
||||
jobId, newTaskId, rosterCount, skipped, me.uid());
|
||||
|
||||
CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
|
||||
vo.setTaskId(newTaskId);
|
||||
vo.setRosterCount(rosterCount);
|
||||
vo.setFileCount(0);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从整理结果里取出「姓名 → (证件号, 班级线索)」。
|
||||
*
|
||||
* <p>⚠️ 直接从**整理结果包**里重新解析,而不是读报告 JSON:
|
||||
* 报告里的证件号是**脱敏值**(那是给人看的),拿它建名册会建成一堆星号。
|
||||
* 这条"报告不能当数据源"的界线要说清 —— 否则以后有人图省事去读报告。
|
||||
*/
|
||||
private Map<String, String[]> studentsOf(Long jobId, CollectCleanupJob job) {
|
||||
Map<String, String[]> out = new LinkedHashMap<>();
|
||||
byte[] zip = storageService.getBytes(job.getResultPath());
|
||||
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
|
||||
StandardCharsets.UTF_8)) {
|
||||
ZipEntry e;
|
||||
while ((e = zin.getNextEntry()) != null) {
|
||||
if (e.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
// 结果包的目录结构是我们自己定的:班级/姓名/材料/规范文件名
|
||||
// 而规范文件名里带证件号 → 从路径就能取回"姓名 + 证件号"
|
||||
HistoryNameParser.Hint h = HistoryNameParser.parse(e.getName());
|
||||
String name = h.studentName();
|
||||
if (name == null || h.idCard() == null) {
|
||||
continue;
|
||||
}
|
||||
out.putIfAbsent(name, new String[]{h.idCard(), h.orgHint()});
|
||||
drain(zin);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"读整理结果失败:" + ex.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void drain(java.io.InputStream in) throws IOException {
|
||||
byte[] buf = new byte[8192];
|
||||
while (in.read(buf) > 0) {
|
||||
// 只为了推进 zip 流
|
||||
}
|
||||
}
|
||||
|
||||
private long nz(Long v) {
|
||||
return v == null ? 0L : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新任务的"专业名称"从哪来。
|
||||
*
|
||||
* <p>三级回退:
|
||||
* <ol>
|
||||
* <li>请求里显式给了就用它;</li>
|
||||
* <li>否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业,
|
||||
* 而这是最可信的来源(比让用户再填一遍更不容易填错);</li>
|
||||
* <li>都没有就明确报错,让用户填。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、
|
||||
* 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。
|
||||
*/
|
||||
private String majorNameOf(CollectCleanupJob job, CleanupDtos.ImportReq req) {
|
||||
if (req.getMajorName() != null && !req.getMajorName().isBlank()) {
|
||||
return req.getMajorName().trim();
|
||||
}
|
||||
if (job.getRosterTaskId() != null) {
|
||||
var src = taskMapper.selectById(job.getRosterTaskId());
|
||||
if (src != null && src.getMajorName() != null && !src.getMajorName().isBlank()) {
|
||||
return src.getMajorName();
|
||||
}
|
||||
}
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"请填写专业名称(也可以给整理任务关联一份已有名册,专业名会从它继承)");
|
||||
}
|
||||
|
||||
private String maskOf(String idCard) {
|
||||
if (idCard == null || idCard.length() < 14) {
|
||||
return idCard;
|
||||
}
|
||||
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
|
||||
}
|
||||
}
|
||||
-765
@@ -1,765 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.service;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
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 cn.tianbaotong.fill.common.util.IdCardUtil;
|
||||
import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
|
||||
import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
|
||||
import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
|
||||
import cn.tianbaotong.fill.module.file.service.FileProcessService;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||
import cn.tianbaotong.fill.support.TenantScope;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* 历史数据整理(《03-接口设计》§13.2)—— 差异化获客功能。
|
||||
*
|
||||
* <h3>它解决的真实痛点</h3>
|
||||
*
|
||||
* <p>客户手里已经有几百个 G 的历史材料,形态是"人长出来的":
|
||||
* 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。
|
||||
* 要收进系统得先规范化,而**人工改名几百份是不可能的**。
|
||||
*
|
||||
* <h3>这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")</h3>
|
||||
*
|
||||
* <p><b>做</b>:
|
||||
* <ul>
|
||||
* <li>解压 zip → 逐条路径解析(姓名 / 证件号 / 材料类型 / 班级线索);</li>
|
||||
* <li>与名册比对:证件号一致 / 不一致 / 名册里没这个人;</li>
|
||||
* <li><b>疑似串档检测</b>:某人的多项材料证件号与名册<b>均不同</b>且彼此一致 →
|
||||
* "整个文件夹可能被换过"(这是报告里最有价值的一条);</li>
|
||||
* <li>按模板生成规范文件名,产出整理后的 zip;</li>
|
||||
* <li>体检报告(含 {@code autoHandled / needManual} 两个关键数字)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>不做</b>(并且理由都不是"来不及"):
|
||||
* <ul>
|
||||
* <li><b>不读图片内容</b>(没有 OCR/VLM,M4 未做)——
|
||||
* 所以报告里的 {@code ocrValue} 一律为空,判定只用"文件名 + 名册"两条证据。
|
||||
* 编一个假的 OCR 结果会让 {@code idcardAutoFixed} 这个数字失去意义;</li>
|
||||
* <li><b>不做云盘链接</b>({@code sourceType=3} → 40044)——
|
||||
* 要接网盘 SDK 与各家授权,一期不做;</li>
|
||||
* <li><b>不做文件夹上传</b>({@code sourceType=2} → 40044)——
|
||||
* 浏览器上传文件夹要 {@code webkitdirectory} + 后端逐文件落盘,
|
||||
* 而现在客户手上给的形态就是压缩包。**但列已经建好了**(V13),
|
||||
* 以后接上不用再动表。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>为什么"有名单 vs 没名单"是两种完全不同的结果</h3>
|
||||
*
|
||||
* <p>没有 {@code rosterTaskId} 时只能做文件名规范化,报告里
|
||||
* {@code idcardMismatch / crossFileSuspected} 恒为 0 ——
|
||||
* 而这恰恰是客户愿意为这个功能付钱的那条信息。所以创建接口要**主动提示**
|
||||
* (见 {@code create} 里的 warnings),而不是安静地少做一半。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CleanupService {
|
||||
|
||||
/** 单个文件体积上限(解压炸弹防护):50MB。 */
|
||||
private static final long MAX_ENTRY_BYTES = 50L * 1024 * 1024;
|
||||
|
||||
/** 整包解压后的总上限(解压炸弹防护):2GB。 */
|
||||
private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024;
|
||||
|
||||
/** 最多处理多少个文件(防止误传一个几万文件的包把服务拖死)。 */
|
||||
private static final int MAX_FILES = 20000;
|
||||
|
||||
private final CleanupJobMapper jobMapper;
|
||||
private final CollectRosterMapper rosterMapper;
|
||||
private final SensitiveCodec codec;
|
||||
private final FileProcessService processService;
|
||||
private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
|
||||
private final ExecutorService exportExecutor;
|
||||
|
||||
// ── 创建 ──────────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CleanupDtos.CreateVO create(CleanupDtos.CreateReq req, byte[] zipBytes) {
|
||||
LoginUser me = UserContext.require();
|
||||
if (req == null || req.getJobName() == null || req.getJobName().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写整理任务名称");
|
||||
}
|
||||
int sourceType = req.getSourceType() == null ? CollectCleanupJob.SOURCE_ZIP : req.getSourceType();
|
||||
if (sourceType == CollectCleanupJob.SOURCE_CLOUD || sourceType == CollectCleanupJob.SOURCE_FOLDER) {
|
||||
// 一期只做压缩包 —— 理由见类注释。**不静默降级**:
|
||||
// 安静地按"压缩包"处理一个云盘链接,用户会以为功能坏了
|
||||
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
|
||||
sourceType == CollectCleanupJob.SOURCE_CLOUD
|
||||
? "一期暂不支持云盘链接,请把材料打包成 zip 上传"
|
||||
: "一期暂不支持直接上传文件夹,请压缩成 zip 再上传");
|
||||
}
|
||||
if (zipBytes == null || zipBytes.length == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请上传要整理的压缩包");
|
||||
}
|
||||
if (req.getRosterTaskId() != null) {
|
||||
// 名册必须真的存在,否则报告会安静地少掉最关键的两项
|
||||
Long n = rosterMapper.selectCount(Wrappers.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, req.getRosterTaskId()));
|
||||
if (n == null || n == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"指定的名册任务里一个人都没有,无法做证件号校验");
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩包落到存储里(整理是异步的,请求线程里的字节数组活不到那时)
|
||||
String rel = String.format("cleanup/%d/%d-src.zip", me.tenantId(), System.currentTimeMillis());
|
||||
storageService.put(rel, zipBytes, "application/zip");
|
||||
|
||||
CollectCleanupJob job = new CollectCleanupJob();
|
||||
job.setJobName(req.getJobName().trim().length() > 120
|
||||
? req.getJobName().trim().substring(0, 120) : req.getJobName().trim());
|
||||
job.setSourceType(sourceType);
|
||||
job.setSourcePath(rel);
|
||||
job.setRosterTaskId(req.getRosterTaskId());
|
||||
job.setNamingTemplate(req.getNamingTemplate() == null || req.getNamingTemplate().isBlank()
|
||||
? "{姓名}-{证件号}-{材料名}" : req.getNamingTemplate().trim());
|
||||
job.setCompressConfig(req.getCompressConfig() == null
|
||||
? null : JSONUtil.toJsonStr(req.getCompressConfig()));
|
||||
job.setStatus(CollectCleanupJob.STATUS_QUEUED);
|
||||
job.setProgress(0);
|
||||
jobMapper.insert(job);
|
||||
|
||||
Long tenantId = me.tenantId();
|
||||
Long uid = me.uid();
|
||||
final Long jobId = job.getId();
|
||||
// ★ 异步要把租户上下文带过去:拦截器从 ThreadLocal 取 tenantId,
|
||||
// 新线程里没有 → 一句 SQL 都跑不了(表现为 40101 或"查不到数据")
|
||||
//
|
||||
// ⚠️ 并且要把 **job 对象本身**传进去,不要在异步线程里回查 ——
|
||||
// 异步线程与请求线程是两个事务,而请求线程此刻**还没提交**,
|
||||
// 回查必然查不到(表现是"整理任务不存在",整次整理白跑)。
|
||||
// 这类时序问题不能用 sleep 解决(它只是把概率变小),
|
||||
// 去掉"回查"这个动作才是真的解决。
|
||||
exportExecutor.execute(() -> TenantScope.runAs(tenantId, uid, Set.of(), () -> {
|
||||
try {
|
||||
run(job);
|
||||
} catch (Throwable e) {
|
||||
log.error("历史整理失败 jobId={}", jobId, e);
|
||||
markFailed(jobId, e);
|
||||
}
|
||||
}));
|
||||
|
||||
CleanupDtos.CreateVO vo = new CleanupDtos.CreateVO();
|
||||
vo.setJobId(jobId);
|
||||
vo.setStatus(CollectCleanupJob.STATUS_QUEUED);
|
||||
// 估时:解压 + 解析 + 压缩,经验值约每 10 个文件 1 秒(下限 10 秒)
|
||||
vo.setEstimatedSeconds(Math.max(10, zipBytes.length / 1024 / 100));
|
||||
log.info("创建历史整理任务 jobId={} 名称={} 包体={}KB 名册任务={}",
|
||||
jobId, job.getJobName(), zipBytes.length / 1024, req.getRosterTaskId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 查询 ──────────────────────────────────────
|
||||
|
||||
public CleanupDtos.JobPageVO list(Integer status, String keyword, int pageNum, int pageSize) {
|
||||
var q = Wrappers.<CollectCleanupJob>lambdaQuery()
|
||||
.eq(status != null, CollectCleanupJob::getStatus, status)
|
||||
.like(keyword != null && !keyword.isBlank(), CollectCleanupJob::getJobName, keyword)
|
||||
.orderByDesc(CollectCleanupJob::getId);
|
||||
var page = jobMapper.selectPage(
|
||||
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
|
||||
Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100)), q);
|
||||
CleanupDtos.JobPageVO vo = new CleanupDtos.JobPageVO();
|
||||
vo.setList(page.getRecords().stream().map(this::toVo).toList());
|
||||
vo.setTotal(page.getTotal());
|
||||
return vo;
|
||||
}
|
||||
|
||||
public CleanupDtos.JobVO detail(Long id) {
|
||||
CollectCleanupJob j = requireJob(id);
|
||||
return toVo(j);
|
||||
}
|
||||
|
||||
public CleanupDtos.ReportVO report(Long id) {
|
||||
CollectCleanupJob j = requireJob(id);
|
||||
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"整理还没完成(" + statusText(j.getStatus()) + "),暂时没有报告");
|
||||
}
|
||||
if (j.getReportJson() == null || j.getReportJson().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这份整理没有留下报告");
|
||||
}
|
||||
try {
|
||||
return JSONUtil.toBean(j.getReportJson(), CleanupDtos.ReportVO.class);
|
||||
} catch (RuntimeException e) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "报告内容损坏,无法解析");
|
||||
}
|
||||
}
|
||||
|
||||
/** 整理结果的压缩包字节(下载用)。 */
|
||||
public byte[] resultBytes(Long id) {
|
||||
CollectCleanupJob j = requireJob(id);
|
||||
if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE
|
||||
|| j.getResultPath() == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理结果还没生成");
|
||||
}
|
||||
return storageService.getBytes(j.getResultPath());
|
||||
}
|
||||
|
||||
// ── 主体:解压 → 解析 → 比对 → 重打包 ────────────
|
||||
|
||||
private void run(CollectCleanupJob job) {
|
||||
Long jobId = job.getId();
|
||||
progress(jobId, CollectCleanupJob.STATUS_PARSING, 5);
|
||||
|
||||
byte[] zip = storageService.getBytes(job.getSourcePath());
|
||||
List<Entry> entries = readZip(zip);
|
||||
if (entries.isEmpty()) {
|
||||
throw new IllegalStateException("压缩包里没有文件(是不是传了一个空包?)");
|
||||
}
|
||||
|
||||
progress(jobId, CollectCleanupJob.STATUS_PARSING, 20);
|
||||
|
||||
// 名册:有 rosterTaskId 才有。按"姓名 → 名册行"和"证件号 hash → 名册行"两路索引,
|
||||
// 因为解析出来的线索可能只有姓名(文件名里没号码)或只有号码
|
||||
Map<String, CollectRoster> byName = new HashMap<>();
|
||||
Map<String, CollectRoster> byHash = new HashMap<>();
|
||||
if (job.getRosterTaskId() != null) {
|
||||
for (CollectRoster r : rosterMapper.selectList(Wrappers.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, job.getRosterTaskId()))) {
|
||||
if (r.getRealName() != null) {
|
||||
byName.putIfAbsent(r.getRealName().trim(), r);
|
||||
}
|
||||
if (r.getIdCardHash() != null) {
|
||||
byHash.putIfAbsent(r.getIdCardHash(), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Resolved> resolved = new ArrayList<>();
|
||||
Set<String> usedNames = new java.util.LinkedHashSet<>();
|
||||
for (Entry e : entries) {
|
||||
resolved.add(resolve(e, byName, byHash, job.getNamingTemplate(), usedNames));
|
||||
}
|
||||
|
||||
progress(jobId, CollectCleanupJob.STATUS_PROCESSING, 60);
|
||||
|
||||
// 重打包(按解析出的"班级/姓名/材料"建目录,文件名用规范名)
|
||||
String outRel = String.format("cleanup/%d/%d-result.zip", UserContext.tenantId(), jobId);
|
||||
long finalBytes;
|
||||
try {
|
||||
finalBytes = writeResultZip(resolved);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("生成整理结果失败:" + ex.getMessage(), ex);
|
||||
}
|
||||
storageService.put(outRel, lastResult, "application/zip");
|
||||
|
||||
// 体检报告
|
||||
CleanupDtos.ReportVO report = buildReport(jobId, resolved);
|
||||
long rawBytes = entries.stream().mapToLong(x -> x.bytes.length).sum();
|
||||
int students = (int) resolved.stream().map(Resolved::rosterId).filter(java.util.Objects::nonNull)
|
||||
.distinct().count();
|
||||
int materials = (int) resolved.stream().map(Resolved::materialCode)
|
||||
.filter(java.util.Objects::nonNull).distinct().count();
|
||||
|
||||
CollectCleanupJob upd = new CollectCleanupJob();
|
||||
upd.setId(jobId);
|
||||
upd.setStatus(CollectCleanupJob.STATUS_DONE);
|
||||
upd.setProgress(100);
|
||||
upd.setTotalFiles(entries.size());
|
||||
upd.setProcessedFiles(resolved.size());
|
||||
upd.setFixedFiles((int) resolved.stream().filter(Resolved::renamed).count());
|
||||
upd.setFailedFiles((int) resolved.stream().filter(x -> x.rosterId == null).count());
|
||||
upd.setParsedStudents(students);
|
||||
upd.setParsedMaterials(materials);
|
||||
upd.setRawSizeBytes(rawBytes);
|
||||
upd.setFinalSizeBytes(finalBytes);
|
||||
upd.setResultPath(outRel);
|
||||
upd.setReportJson(JSONUtil.toJsonStr(report));
|
||||
upd.setFinishTime(java.time.LocalDateTime.now());
|
||||
jobMapper.updateById(upd);
|
||||
|
||||
log.info("历史整理完成 jobId={} 文件={} 自动处理={} 待人工={} 包体 {}KB → {}KB",
|
||||
jobId, entries.size(), report.getAutoHandled(), report.getNeedManual(),
|
||||
rawBytes / 1024, finalBytes / 1024);
|
||||
}
|
||||
|
||||
/** 一条 zip 条目。 */
|
||||
private record Entry(String path, byte[] bytes) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 zip 到内存。
|
||||
*
|
||||
* <p>为什么整包进内存而不是流式:整理要**两遍**(先解析全部、再按解析结果重打包),
|
||||
* 而 zip 不能随机访问(除非落盘再读)。客户的历史包按 PRD 的规模是几百 MB,
|
||||
* 内存够;但**必须有上限**,否则一个恶意的解压炸弹能把服务打死 ——
|
||||
* 见 {@link #MAX_TOTAL_BYTES} 与 {@link #MAX_ENTRY_BYTES}。
|
||||
*/
|
||||
private List<Entry> readZip(byte[] zip) {
|
||||
List<Entry> out = new ArrayList<>();
|
||||
long total = 0;
|
||||
try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
|
||||
StandardCharsets.UTF_8)) {
|
||||
ZipEntry e;
|
||||
while ((e = zin.getNextEntry()) != null) {
|
||||
if (e.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
// 跳过 macOS 的元数据文件:客户用 Mac 压的包一定会带,混进来会让报告虚高
|
||||
String name = e.getName();
|
||||
if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")
|
||||
|| name.contains("/._")) {
|
||||
continue;
|
||||
}
|
||||
long declared = e.getSize();
|
||||
if (declared > MAX_ENTRY_BYTES) {
|
||||
throw new IllegalStateException("包里的单个文件超过 " + (MAX_ENTRY_BYTES / 1024 / 1024)
|
||||
+ "MB:" + name + "(请先在本地压缩一下)");
|
||||
}
|
||||
byte[] data = readEntry(zin);
|
||||
if (data.length > MAX_ENTRY_BYTES) {
|
||||
throw new IllegalStateException("解压后单个文件过大:" + name);
|
||||
}
|
||||
total += data.length;
|
||||
if (total > MAX_TOTAL_BYTES) {
|
||||
throw new IllegalStateException("解压后总量超过 "
|
||||
+ (MAX_TOTAL_BYTES / 1024 / 1024 / 1024) + "GB,请分批整理");
|
||||
}
|
||||
if (out.size() >= MAX_FILES) {
|
||||
throw new IllegalStateException("包里的文件超过 " + MAX_FILES + " 个,请分批整理");
|
||||
}
|
||||
out.add(new Entry(name, data));
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("压缩包读不了(是不是坏了或不是 zip):" + ex.getMessage(), ex);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private byte[] readEntry(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
// 边读边判上限:不能等读完再看长度(那时内存已经吃掉了)
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
bos.write(buf, 0, n);
|
||||
if (bos.size() > MAX_ENTRY_BYTES) {
|
||||
throw new IOException("条目解压后超过 " + (MAX_ENTRY_BYTES / 1024 / 1024) + "MB");
|
||||
}
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个文件的解析与比对结果。
|
||||
*
|
||||
* <p>用"普通类 + 字段"而不是 record:这个对象在解析过程中是**逐步填**的
|
||||
* (先填解析结果、再填名册匹配、最后填规范文件名),record 那种一次成型的形状不合适。
|
||||
*
|
||||
* <p>⚠️ 但有访问器:方法引用(`Resolved::rosterId`)需要它 ——
|
||||
* 直接引用字段在 Java 里是要写 lambda 的,而 lambda 在这里读起来更啰嗦。
|
||||
*/
|
||||
private static final class Resolved {
|
||||
String path;
|
||||
byte[] bytes;
|
||||
String studentName;
|
||||
String idCard;
|
||||
String materialCode;
|
||||
Long rosterId;
|
||||
String rosterName;
|
||||
String orgHint;
|
||||
String standardName;
|
||||
boolean renamed;
|
||||
String issue;
|
||||
|
||||
Long rosterId() {
|
||||
return rosterId;
|
||||
}
|
||||
|
||||
String materialCode() {
|
||||
return materialCode;
|
||||
}
|
||||
|
||||
boolean renamed() {
|
||||
return renamed;
|
||||
}
|
||||
}
|
||||
|
||||
private Resolved resolve(Entry e, Map<String, CollectRoster> byName,
|
||||
Map<String, CollectRoster> byHash, String template,
|
||||
Set<String> usedNames) {
|
||||
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
|
||||
Resolved r = new Resolved();
|
||||
r.path = e.path();
|
||||
r.bytes = e.bytes();
|
||||
r.studentName = h.studentName();
|
||||
r.idCard = h.idCard();
|
||||
r.materialCode = h.materialCode();
|
||||
r.orgHint = h.orgHint();
|
||||
|
||||
// 与名册比对:先用证件号(更可靠),再用姓名
|
||||
CollectRoster matched = null;
|
||||
if (r.idCard != null) {
|
||||
matched = byHash.get(codec.hash(r.idCard));
|
||||
}
|
||||
if (matched == null && r.studentName != null) {
|
||||
matched = byName.get(r.studentName);
|
||||
}
|
||||
if (matched != null) {
|
||||
r.rosterId = matched.getId();
|
||||
r.rosterName = matched.getRealName();
|
||||
// 证件号冲突:文件名里的号码与名册不一致。
|
||||
// ⚠️ 这里**只记冲突、不改数据** —— 改谁都需要证据(没有 OCR,
|
||||
// 我们只有"文件名"一条证据,而文件名本身就是要修的东西)
|
||||
if (r.idCard != null && matched.getIdCardHash() != null
|
||||
&& !codec.hash(r.idCard).equals(matched.getIdCardHash())) {
|
||||
r.issue = "IDCARD_MISMATCH";
|
||||
}
|
||||
} else if (r.studentName != null || r.idCard != null) {
|
||||
r.issue = "ORPHAN";
|
||||
} else {
|
||||
r.issue = "ORPHAN";
|
||||
}
|
||||
|
||||
// 规范文件名:优先用**名册**里的姓名与证件号(名册才是权威),
|
||||
// 名册里没有这个人时退回文件名里解析到的
|
||||
String name = r.rosterName != null ? r.rosterName : r.studentName;
|
||||
String idc = r.idCard;
|
||||
String matName = r.materialCode == null ? "待归位"
|
||||
: HistoryNameParser.materialNameOf(r.materialCode);
|
||||
String ext = extOf(e.path());
|
||||
Map<String, String> vars = new LinkedHashMap<>();
|
||||
vars.put("姓名", name == null ? "未知姓名" : name);
|
||||
vars.put("证件号", idc == null ? "未知证件号" : idc);
|
||||
vars.put("材料名", matName);
|
||||
vars.put("班级", r.orgHint == null ? "" : r.orgHint);
|
||||
// ⚠️ ext 必须自带点号:renderName 的既有调用点是 `"." + ext`,
|
||||
// 传裸扩展名不会报错,只会产出"身份证jpg"这种打不开的文件
|
||||
String rendered = processService.renderName(template, vars, "." + ext);
|
||||
// 同名去重:历史包里"身份证(1).jpg"这种重复很常见,
|
||||
// 直接覆盖会让客户少一份材料(而报告上看起来一切正常)
|
||||
r.standardName = uniqueName(usedNames, rendered);
|
||||
r.renamed = !r.standardName.equals(lastSegment(e.path()));
|
||||
return r;
|
||||
}
|
||||
|
||||
private static String lastSegment(String path) {
|
||||
String p = path.replace('\\', '/');
|
||||
int i = p.lastIndexOf('/');
|
||||
return i < 0 ? p : p.substring(i + 1);
|
||||
}
|
||||
|
||||
private static String extOf(String path) {
|
||||
String s = lastSegment(path);
|
||||
int dot = s.lastIndexOf('.');
|
||||
return dot > 0 ? s.substring(dot + 1) : "bin";
|
||||
}
|
||||
|
||||
private static String uniqueName(Set<String> used, String name) {
|
||||
if (used.add(name)) {
|
||||
return name;
|
||||
}
|
||||
int dot = name.lastIndexOf('.');
|
||||
String base = dot > 0 ? name.substring(0, dot) : name;
|
||||
String ext = dot > 0 ? name.substring(dot) : "";
|
||||
for (int i = 2; i < 1000; i++) {
|
||||
String cand = base + "(" + i + ")" + ext;
|
||||
if (used.add(cand)) {
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 上一次生成的整理结果(供 put 用;避免在 record 里传引用的别扭写法)。 */
|
||||
private byte[] lastResult;
|
||||
|
||||
private long writeResultZip(List<Resolved> resolved) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
try (org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream zip =
|
||||
new org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream(bos)) {
|
||||
// ★ UTF-8 文件名:与导出包同口径。不设 EFS 标志位时 Windows 会按本地代码页猜,
|
||||
// 那个 bug 只在客户机器上出现
|
||||
zip.setEncoding(StandardCharsets.UTF_8.name());
|
||||
zip.setUseZip64(org.apache.commons.compress.archivers.zip.Zip64Mode.AsNeeded);
|
||||
for (Resolved r : resolved) {
|
||||
// 目录:班级/姓名/材料 —— 与导出包同一形状,客户拿到就能直接用
|
||||
String org = r.orgHint == null || r.orgHint.isBlank() ? "未分班" : r.orgHint;
|
||||
String name = r.rosterName != null ? r.rosterName
|
||||
: (r.studentName == null ? "未知姓名" : r.studentName);
|
||||
String mat = r.materialCode == null ? "待归位"
|
||||
: HistoryNameParser.materialNameOf(r.materialCode);
|
||||
String entryName = org + "/" + name + "/" + mat + "/" + r.standardName;
|
||||
var ze = new org.apache.commons.compress.archivers.zip.ZipArchiveEntry(entryName);
|
||||
ze.setSize(r.bytes.length);
|
||||
zip.putArchiveEntry(ze);
|
||||
zip.write(r.bytes);
|
||||
zip.closeArchiveEntry();
|
||||
}
|
||||
}
|
||||
byte[] out = bos.toByteArray();
|
||||
lastResult = out;
|
||||
return out.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 体检报告。
|
||||
*
|
||||
* <p>两个关键数字必须算对:
|
||||
* <ul>
|
||||
* <li>{@code autoHandled}:系统全自动处理了 —— 即"规范化成功且能对上名册"的;
|
||||
* <li>{@code needManual}:必须人工判断的 —— 串档 + 无法归位 + 待人工确认的证件号冲突。
|
||||
* </ul>
|
||||
* 契约里写明「报告里最重要的一句话是 autoHandled / needManual」,
|
||||
* 所以它们**由服务端算**,不让前端去减(前端减出来的数字早晚会分叉)。
|
||||
*/
|
||||
private CleanupDtos.ReportVO buildReport(Long jobId, List<Resolved> resolved) {
|
||||
CleanupDtos.ReportVO vo = new CleanupDtos.ReportVO();
|
||||
vo.setJobId(jobId);
|
||||
CleanupDtos.ReportSummary s = vo.getSummary();
|
||||
s.setTotalFiles(resolved.size());
|
||||
|
||||
// 串档检测:同一个人有 ≥2 项材料,**全部**与名册证件号不一致,
|
||||
// 且这些材料彼此一致(说明"整个文件夹被换过")
|
||||
Map<Long, List<Resolved>> byRoster = new HashMap<>();
|
||||
for (Resolved r : resolved) {
|
||||
if (r.rosterId != null) {
|
||||
byRoster.computeIfAbsent(r.rosterId, k -> new ArrayList<>()).add(r);
|
||||
}
|
||||
}
|
||||
int auto = 0;
|
||||
int manual = 0;
|
||||
for (Resolved r : resolved) {
|
||||
if (r.issue == null) {
|
||||
auto++;
|
||||
} else if ("IDCARD_MISMATCH".equals(r.issue)) {
|
||||
s.setIdcardMismatch(s.getIdcardMismatch() + 1);
|
||||
s.setIdcardNeedConfirm(s.getIdcardNeedConfirm() + 1);
|
||||
manual++;
|
||||
} else {
|
||||
s.setOrphanImages(s.getOrphanImages() + 1);
|
||||
manual++;
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<Long, List<Resolved>> en : byRoster.entrySet()) {
|
||||
List<Resolved> list = en.getValue();
|
||||
long mismatched = list.stream().filter(x -> "IDCARD_MISMATCH".equals(x.issue)).count();
|
||||
if (list.size() >= 2 && mismatched == list.size()) {
|
||||
s.setCrossFileSuspected(s.getCrossFileSuspected() + 1);
|
||||
CollectRoster ro = rosterMapper.selectById(en.getKey());
|
||||
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
|
||||
hl.setType("CROSS_FILE");
|
||||
hl.setLevel(3);
|
||||
hl.setRosterId(en.getKey());
|
||||
hl.setRealName(ro == null ? null : ro.getRealName());
|
||||
hl.setTitle("疑似串档");
|
||||
hl.setDetail("该生 " + list.size() + " 项材料的证件号与名册均不同,且 "
|
||||
+ list.size() + " 项彼此一致");
|
||||
hl.setSuggestion("可能整个文件夹被换过,请核对学生本人");
|
||||
vo.getHighlights().add(hl);
|
||||
}
|
||||
}
|
||||
|
||||
// 改名样例(给客户看"系统到底做了什么"):最多 5 条
|
||||
for (Resolved r : resolved) {
|
||||
if (vo.getNameFixedSamples().size() >= 5) {
|
||||
break;
|
||||
}
|
||||
if (r.renamed) {
|
||||
CleanupDtos.NameFixSample sample = new CleanupDtos.NameFixSample();
|
||||
sample.setOriginal(lastSegment(r.path));
|
||||
sample.setFixed(r.standardName);
|
||||
sample.setRule("VAL-06");
|
||||
vo.getNameFixedSamples().add(sample);
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号冲突明细
|
||||
for (Resolved r : resolved) {
|
||||
if (!"IDCARD_MISMATCH".equals(r.issue)) {
|
||||
continue;
|
||||
}
|
||||
CollectRoster ro = r.rosterId == null ? null : rosterMapper.selectById(r.rosterId);
|
||||
CleanupDtos.IdcardConflict c = new CleanupDtos.IdcardConflict();
|
||||
c.setRosterId(r.rosterId);
|
||||
c.setRealName(ro == null ? r.studentName : ro.getRealName());
|
||||
c.setRosterValue(ro == null ? null : ro.getIdCardMask());
|
||||
c.setFileNameValue(maskOf(r.idCard));
|
||||
// ⚠️ 判定只能是"待人工":没有 OCR,唯一的证据是文件名,
|
||||
// 而文件名本身正是这次要修的东西 —— 用它去改数据是循环论证
|
||||
c.setVerdict("NEED_CONFIRM");
|
||||
c.setVerdictText("文件名里的证件号与名册不一致;没有识别结果可交叉验证,请人工确认");
|
||||
vo.getIdcardConflicts().add(c);
|
||||
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
|
||||
hl.setType("IDCARD_MISMATCH");
|
||||
hl.setLevel(2);
|
||||
hl.setRosterId(r.rosterId);
|
||||
hl.setRealName(c.getRealName());
|
||||
hl.setTitle("证件号与名册不一致");
|
||||
hl.setDetail("文件名:" + c.getFileNameValue() + ",名册:" + c.getRosterValue());
|
||||
hl.setSuggestion("核对本人证件号后改名册或改文件名");
|
||||
vo.getHighlights().add(hl);
|
||||
}
|
||||
|
||||
// 无法归位
|
||||
for (Resolved r : resolved) {
|
||||
if (!"ORPHAN".equals(r.issue)) {
|
||||
continue;
|
||||
}
|
||||
CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
|
||||
hl.setType("ORPHAN");
|
||||
hl.setLevel(2);
|
||||
hl.setRealName(r.studentName);
|
||||
hl.setTitle("无法归位");
|
||||
hl.setDetail("从路径「" + r.path + "」里没能确定这是谁的材料");
|
||||
hl.setSuggestion("把文件名改成「姓名-证件号-材料名」或放进以姓名命名的文件夹");
|
||||
vo.getHighlights().add(hl);
|
||||
}
|
||||
|
||||
vo.setAutoHandled(auto);
|
||||
vo.setNeedManual(manual);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String maskOf(String idCard) {
|
||||
if (idCard == null || idCard.length() < 14) {
|
||||
return null;
|
||||
}
|
||||
return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private CollectCleanupJob requireJob(Long id) {
|
||||
CollectCleanupJob j = jobMapper.selectById(id);
|
||||
if (j == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
private void progress(Long jobId, int status, int pct) {
|
||||
CollectCleanupJob upd = new CollectCleanupJob();
|
||||
upd.setId(jobId);
|
||||
upd.setStatus(status);
|
||||
upd.setProgress(pct);
|
||||
jobMapper.updateById(upd);
|
||||
}
|
||||
|
||||
private void markFailed(Long jobId, Throwable e) {
|
||||
try {
|
||||
CollectCleanupJob upd = new CollectCleanupJob();
|
||||
upd.setId(jobId);
|
||||
upd.setStatus(CollectCleanupJob.STATUS_FAILED);
|
||||
upd.setProgress(0);
|
||||
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
upd.setReportJson(JSONUtil.createObj().set("error", clip(msg, 400)).toString());
|
||||
upd.setFinishTime(java.time.LocalDateTime.now());
|
||||
jobMapper.updateById(upd);
|
||||
} catch (RuntimeException inner) {
|
||||
log.error("标记整理失败时又出错 jobId={}", jobId, inner);
|
||||
}
|
||||
}
|
||||
|
||||
private static String clip(String s, int max) {
|
||||
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
|
||||
}
|
||||
|
||||
private CleanupDtos.JobVO toVo(CollectCleanupJob j) {
|
||||
CleanupDtos.JobVO vo = new CleanupDtos.JobVO();
|
||||
vo.setId(j.getId());
|
||||
vo.setJobName(j.getJobName());
|
||||
vo.setSourceType(j.getSourceType());
|
||||
vo.setRosterTaskId(j.getRosterTaskId());
|
||||
vo.setStatus(j.getStatus());
|
||||
vo.setStatusText(statusText(j.getStatus()));
|
||||
vo.setProgress(j.getProgress());
|
||||
vo.setTotalFiles(j.getTotalFiles());
|
||||
vo.setProcessedFiles(j.getProcessedFiles());
|
||||
vo.setParsedStudents(j.getParsedStudents());
|
||||
vo.setParsedMaterials(j.getParsedMaterials());
|
||||
vo.setRawSizeBytes(j.getRawSizeBytes());
|
||||
vo.setFinalSizeBytes(j.getFinalSizeBytes());
|
||||
vo.setResultPath(j.getResultPath());
|
||||
vo.setTargetTaskId(j.getTargetTaskId());
|
||||
vo.setCreateTime(j.getCreateTime());
|
||||
vo.setFinishTime(j.getFinishTime());
|
||||
if (j.getRawSizeBytes() != null && j.getRawSizeBytes() > 0 && j.getFinalSizeBytes() != null) {
|
||||
double ratio = 100.0 * (1 - (double) j.getFinalSizeBytes() / j.getRawSizeBytes());
|
||||
if (ratio < 0) {
|
||||
// ⚠️ **负数压缩率是没有意义的,不能就这么显示出去。**
|
||||
//
|
||||
// 什么时候会负:包里的文件**本身就已经是压缩过的**(jpg/zip 里的图片),
|
||||
// 或者总量很小 —— 重新打一个 zip 的**包头开销**比省下来的还多。
|
||||
// 实测一个 3 个小文件(各 40 字节)的包:raw=120B、final=802B → `-568.3%`。
|
||||
//
|
||||
// 显示 `-568.3%` 的问题不是"不好看",而是**客户会以为系统算错了**
|
||||
// (而它没算错,"压缩"这个词在小包上本来就不适用)。
|
||||
// 所以这里给 `—` —— 与"还没算"同一个符号:**都是"这个数说明不了什么"**。
|
||||
vo.setCompressRatio("—");
|
||||
} else {
|
||||
vo.setCompressRatio(Math.round(ratio * 10) / 10.0 + "%");
|
||||
}
|
||||
} else {
|
||||
// — 而不是 0%:0% 看起来像"一点没压缩",而真相是"还没算"
|
||||
vo.setCompressRatio("—");
|
||||
}
|
||||
if (j.getStatus() != null && j.getStatus() == CollectCleanupJob.STATUS_FAILED
|
||||
&& j.getReportJson() != null && j.getReportJson().contains("\"error\"")) {
|
||||
vo.setErrorMsg(JSONUtil.parseObj(j.getReportJson()).getStr("error"));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String statusText(Integer status) {
|
||||
return switch (status == null ? 0 : status) {
|
||||
case CollectCleanupJob.STATUS_QUEUED -> "排队中";
|
||||
case CollectCleanupJob.STATUS_PARSING -> "解析中";
|
||||
case CollectCleanupJob.STATUS_PROCESSING -> "处理中";
|
||||
case CollectCleanupJob.STATUS_DONE -> "已完成";
|
||||
case CollectCleanupJob.STATUS_FAILED -> "失败";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
|
||||
/** 供测试/排查用:只跑解析这一步(不落库、不打包)。 */
|
||||
public List<Map<String, Object>> dryRunParse(byte[] zip) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (Entry e : readZip(zip)) {
|
||||
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("path", e.path());
|
||||
m.put("studentName", h.studentName());
|
||||
m.put("idCard", h.idCard() == null ? null : maskOf(h.idCard()));
|
||||
m.put("idCardValid", h.idCard() != null && IdCardUtil.isValid(h.idCard()));
|
||||
m.put("materialCode", h.materialCode());
|
||||
m.put("orgHint", h.orgHint());
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 保留:给"文件夹来源"分支留的口子(一期走不到)。 */
|
||||
static String normalizePath(String p) {
|
||||
return IdentityNormalizer.toHalfWidth(p).replace('\\', '/');
|
||||
}
|
||||
}
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.cleanup.service;
|
||||
|
||||
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 从**历史文件名与目录名**里猜出"这是谁、哪项材料"。
|
||||
*
|
||||
* <h3>这个类的全部难点:客户的历史文件夹是"人长出来的",不是机器生成的</h3>
|
||||
*
|
||||
* <p>实测(PRD §5.9 的背景)见到的形态:
|
||||
* <pre>
|
||||
* 24中技数控1班/张三/身份证.jpg.jpg
|
||||
* 24中技数控1班/张三-361029200907295110/一寸照片.png
|
||||
* 24数控/张三 身份证/IMG_2034.jpg
|
||||
* .\李四\学籍表\学籍表(1).JPG
|
||||
* </pre>
|
||||
* 所以解析必须**从右往左找最有信息量的那段**,而不是"按固定层级取"——
|
||||
* 后者在换一个班之后一定失效。
|
||||
*
|
||||
* <h3>两条不准做的事</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>不猜身份证号</b>:只认**完整 18 位且校验位正确**的。文件名里出现
|
||||
* 一串 18 位数字很可能是别的编号(学号、订单号),猜错会把材料挂到别人身上 ——
|
||||
* 而"疑似串档"恰恰是这份报告最有价值的一条,不能自己先制造出来。</li>
|
||||
* <li><b>不把"猜不出"当成失败</b>:猜不出就归到"待人工归位",
|
||||
* 由报告里那一条 {@code needManual} 说清。硬塞一个分类会让报告撒谎。</li>
|
||||
* </ol>
|
||||
*/
|
||||
public final class HistoryNameParser {
|
||||
|
||||
private HistoryNameParser() {
|
||||
}
|
||||
|
||||
/** 18 位身份证号(末位可为 X)。 */
|
||||
private static final Pattern ID_CARD = Pattern.compile("(\\d{17}[\\dXx])");
|
||||
|
||||
/** 材料关键词 → 材料名(客户的历史命名习惯,与内置材料的别名有重叠但不完全相同)。 */
|
||||
private static final String[][] MATERIAL_HINTS = {
|
||||
{"身份证", "IDCARD"}, {"正面", "IDCARD"}, {"反面", "IDCARD"}, {"人像面", "IDCARD"},
|
||||
{"国徽面", "IDCARD"},
|
||||
{"一寸", "PHOTO1"}, {"照片", "PHOTO1"}, {"证件照", "PHOTO1"}, {"免冠", "PHOTO1"},
|
||||
{"学籍", "SCHOOL_ROLL"}, {"学籍表", "SCHOOL_ROLL"},
|
||||
{"工作证明", "WORK_PROOF"}, {"在职", "WORK_PROOF"}, {"证明", "WORK_PROOF"},
|
||||
{"备案", "XJW_BA"},
|
||||
{"其他", "OTHER_PROOF"}, {"其它", "OTHER_PROOF"}, {"佐证", "OTHER_PROOF"},
|
||||
};
|
||||
|
||||
/** 一个文件路径解析出来的线索。 */
|
||||
public record Hint(String studentName, String idCard, String materialCode,
|
||||
String materialName, String orgHint, String fileName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一条相对路径。
|
||||
*
|
||||
* @param relativePath 形如 {@code 24中技数控1班/张三/身份证.jpg}
|
||||
*/
|
||||
public static Hint parse(String relativePath) {
|
||||
String p = relativePath.replace('\\', '/');
|
||||
String[] segs = p.split("/");
|
||||
// 去掉空段(形如 `./李四/...` 会切出一个空串)
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String s : segs) {
|
||||
if (s != null && !s.isBlank() && !".".equals(s.trim())) {
|
||||
parts.add(s.trim());
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty()) {
|
||||
return new Hint(null, null, null, null, null, null);
|
||||
}
|
||||
String fileName = parts.get(parts.size() - 1);
|
||||
// 目录段(不含文件名)
|
||||
List<String> dirs = parts.size() > 1 ? parts.subList(0, parts.size() - 1) : List.of();
|
||||
|
||||
String idCard = null;
|
||||
String name = null;
|
||||
String orgHint = null;
|
||||
|
||||
// ① 身份证号:从**文件名与所有目录段**里找第一个合法的 18 位
|
||||
//
|
||||
// 为什么目录段也要找:常见的形态是"张三-361029200907295110"当文件夹名,
|
||||
// 此时文件名只是 `身份证.jpg`。
|
||||
Set<String> pool = new LinkedHashSet<>();
|
||||
pool.add(fileName);
|
||||
pool.addAll(dirs);
|
||||
for (String s : pool) {
|
||||
Matcher m = ID_CARD.matcher(s);
|
||||
while (m.find()) {
|
||||
String cand = m.group(1);
|
||||
if (cn.tianbaotong.fill.common.util.IdCardUtil.isValid(cand)) {
|
||||
idCard = cand.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idCard != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ② 姓名:优先"带身份证号的那一段"里、号码前面的中文
|
||||
// 其次取最靠近文件的目录名(班级 → 姓名 → 文件 是最常见的结构)
|
||||
for (String s : pool) {
|
||||
if (idCard != null && s.contains(idCard)) {
|
||||
String before = s.substring(0, s.indexOf(idCard));
|
||||
name = chineseName(before);
|
||||
if (name != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (name == null && looksLikePersonSegment(stripExt(fileName))) {
|
||||
// 从文件名里找(`张三-身份证.jpg`)
|
||||
name = chineseName(stripExt(fileName));
|
||||
}
|
||||
if (name == null && !dirs.isEmpty()) {
|
||||
// 从最深的目录往上找第一个像姓名的(2~4 个汉字)
|
||||
for (int i = dirs.size() - 1; i >= 0; i--) {
|
||||
if (!looksLikePersonSegment(dirs.get(i))) {
|
||||
continue;
|
||||
}
|
||||
String cand = chineseName(dirs.get(i));
|
||||
if (cand != null) {
|
||||
name = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 班级:最浅的那一层目录(通常就是班级),但**排除**看起来是姓名的那一层
|
||||
for (String s : dirs) {
|
||||
if (name != null && s.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
if (s.contains("班") || s.contains("级") || s.contains("数控")
|
||||
|| s.contains("机电") || s.contains("专业")) {
|
||||
orgHint = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 材料:在"文件名 + 各级目录名"里找关键词(**文件名优先**,它更具体)
|
||||
String materialCode = null;
|
||||
String materialName = null;
|
||||
for (String s : pool) {
|
||||
for (String[] kv : MATERIAL_HINTS) {
|
||||
if (s.contains(kv[0])) {
|
||||
materialCode = kv[1];
|
||||
materialName = kv[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (materialCode != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Hint(name, idCard, materialCode, materialName, orgHint, fileName);
|
||||
}
|
||||
|
||||
/** 这段文本是不是"材料文件夹名"(里面出现了材料关键词)。 */
|
||||
private static boolean looksLikeMaterial(String s) {
|
||||
if (s == null) {
|
||||
return false;
|
||||
}
|
||||
for (String[] kv : MATERIAL_HINTS) {
|
||||
if (s.contains(kv[0])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 这个片段**整体**看起来像"一个人的名字"吗。
|
||||
*
|
||||
* <p>与 {@link #chineseName} 的分工:这里判断"这段文本是不是人名所在的片段",
|
||||
* 那里从片段里抠出候选字。两条排除规则:
|
||||
* <ol>
|
||||
* <li>**含数字 → 不是人名**:"24中技数控1班" 会被抠出 "中技数控",
|
||||
* 而那是班级不是人(真实人名里不会出现阿拉伯数字);</li>
|
||||
* <li>**含班级/学校词 → 不是人名**("班"、"级"、"专业"、"学校"、"学院")。</li>
|
||||
* </ol>
|
||||
*/
|
||||
static boolean looksLikePersonSegment(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
if (s.matches(".*\\d.*")) {
|
||||
return false;
|
||||
}
|
||||
for (String marker : new String[]{"班", "级", "专业", "学校", "学院", "系", "组"}) {
|
||||
if (s.contains(marker)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一段文本里取"看起来是人名"的中文片段。
|
||||
*
|
||||
* <p>规则保守:**2~4 个连续汉字**,且不包含材料/班级关键词。
|
||||
* 放宽到 1 个汉字会把"张"这种姓当成名字;
|
||||
* 放宽到 5 个会把"数控加工中心"(专业名)当成名字。
|
||||
*/
|
||||
static String chineseName(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// 去掉常见的分隔符与噪声
|
||||
String t = s.replaceAll("[-_()()【】\\[\\]\\s]+", "");
|
||||
// ⚠️ 取**完整的**中文串,不要 {2,4} 截断 ——
|
||||
// "新建文件夹"(5 字)被截成 "新建文件" 之后,噪声词表里查不到它,
|
||||
// 于是那个文件被归到"新建文件"名下,而报告上看起来还挺像回事。
|
||||
Matcher m = Pattern.compile("[\\u4e00-\\u9fa5]+").matcher(t);
|
||||
while (m.find()) {
|
||||
String cand = m.group();
|
||||
// 人名是 2~4 个汉字;连续中文超过 4 个字说明这是个标签("新建文件夹"),不是人
|
||||
if (cand.length() < 2 || cand.length() > 4) {
|
||||
continue;
|
||||
}
|
||||
if (isNoiseWord(cand)) {
|
||||
continue;
|
||||
}
|
||||
// 材料词("身份证"、"一寸照片"、"学籍表")出现在目录名里时,
|
||||
// 它指的是"这是哪项材料",不是"这是谁" —— 由材料词表负责判断
|
||||
if (looksLikeMaterial(s)) {
|
||||
continue;
|
||||
}
|
||||
return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明显不是姓名的词(材料名 / 班级词 / 目录名)。
|
||||
*
|
||||
* <p>⚠️ <b>判据必须收得很紧,因为中文人名与业务词高度重叠</b>。
|
||||
* 我第一版写的是 {@code w.contains(n)},于是「<b>整理</b>甲」这种名字里
|
||||
* 只要含"整理"就被当噪声滤掉 —— 表现是**所有文件都归不了位**,
|
||||
* 报告里冒出一堆 orphan,而串档检测也因此失去基准(全都对不上名册)。
|
||||
*
|
||||
* <p>⚠️ <b>只做精确匹配,不做包含</b>。我中间写过一版"长度 ≤2 的词被包含就算噪声",
|
||||
* 而"整理"正好是 2 个字 —— 于是「<b>整理</b>甲」又被滤掉了。
|
||||
* 教训是:<b>中文人名与业务短词无法用"包含"区分</b>,
|
||||
* 这条规则只要存在,早晚会吃掉一个真名,而失败是静默的(文件变成"无法归位")。
|
||||
*
|
||||
* <p>"是不是材料/班级词"由 {@link #MATERIAL_HINTS} 与下面这份词表**各自**判断:
|
||||
* 前者管材料目录名("身份证"、"一寸照片"),后者管纯噪声词。
|
||||
* 职责分开之后,这边只需要回答"这个字面量本身是不是一个业务词"。
|
||||
*/
|
||||
private static boolean isNoiseWord(String w) {
|
||||
String[] noise = {"身份证", "一寸", "照片", "学籍", "工作", "证明", "备案", "其他", "其它",
|
||||
"佐证", "材料", "扫描", "复印", "副本", "正本", "原图", "最终", "新版", "旧版",
|
||||
"中技", "数控", "机电", "专业", "班级", "学院", "学校", "高级", "中级", "初级",
|
||||
"认定", "报名", "收集", "整理", "待整理", "已整理", "未整理", "补充", "补交",
|
||||
// 目录名噪声:这些是"文件夹"而不是人,混进候选会让归位结果看起来可信但实际是错的
|
||||
"新建", "文件夹", "新建文件夹", "未命名", "待定", "临时", "归档", "历史",
|
||||
"材料汇总", "待整理材料", "杂项", "其它材料", "其他材料"};
|
||||
for (String n : noise) {
|
||||
if (w.equals(n)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String stripExt(String s) {
|
||||
int dot = s.lastIndexOf('.');
|
||||
return dot > 0 ? s.substring(0, dot) : s;
|
||||
}
|
||||
|
||||
/** 材料码 → 中文名(写进规范文件名用)。 */
|
||||
public static String materialNameOf(String code) {
|
||||
for (String[] kv : MATERIAL_HINTS) {
|
||||
if (kv[1].equals(code)) {
|
||||
return kv[0];
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 把解析到的证件号规范化(全角转半角 + 去空白),与其它入口同口径。 */
|
||||
public static String normalizeIdCard(String raw) {
|
||||
return IdentityNormalizer.idCard(raw);
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
|
||||
import cn.tianbaotong.fill.module.join.service.JoinService;
|
||||
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 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-接口设计》§8.7.3 ~ §8.7.5)。
|
||||
*
|
||||
* <p>权限码是现成的 {@code join:audit}({@code PermissionDef.JOIN_REQUEST_AUDIT})——
|
||||
* 不需要新加:它就是为这条通道定义的。
|
||||
*
|
||||
* <p>⚠️ <b>没有"批量通过"接口</b>,这是刻意的(契约 §8.7.5 的取舍):
|
||||
* 批量通过要求管理员先逐条看过(列表已提供信息),而**驳回必须逐条填原因**;
|
||||
* 且批量通过**不提供"全选"快捷键**。理由与"批量通过只带绿色项"同源 ——
|
||||
* 身份认定环节的误操作代价极高。
|
||||
*/
|
||||
@Tag(name = "管理端 · 名单加入申请")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/roster/join-requests")
|
||||
@RequiredArgsConstructor
|
||||
public class JoinRequestController {
|
||||
|
||||
private final JoinService joinService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
|
||||
@Operation(summary = "申请待办列表",
|
||||
description = "默认只看待审(status=0)。每条带 waitHours(等最久的排最前)与 "
|
||||
+ "duplicateHint(该证件号在**其他任务**的名册里出现过,帮判断是不是本校学生)")
|
||||
public ApiResult<JoinDtos.AdminListVO> list(@RequestParam(required = false) Long taskId,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) Long orgId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long pageNum,
|
||||
@RequestParam(required = false) Long pageSize) {
|
||||
return ApiResult.ok(joinService.list(taskId, status, orgId, keyword, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
|
||||
@Operation(summary = "通过(建名册项 + 自动绑定)",
|
||||
description = "事务内做三件事:建名册项、把申请人绑上去(免他重输)、回写申请。"
|
||||
+ "不传 orgId 时用学生的自述班级匹配组织名,匹配不上(或撞名)会要求手选。"
|
||||
+ "notifySent 恒为 false(通知渠道未实现),需要人工告知学生")
|
||||
public ApiResult<JoinDtos.ApproveVO> approve(@PathVariable Long id,
|
||||
@RequestBody(required = false) JoinDtos.ApproveReq req) {
|
||||
return ApiResult.ok(joinService.approve(id, req));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
|
||||
@Operation(summary = "驳回(原因必填)",
|
||||
description = "原因会原样给学生看,所以要说清「哪里不对、该怎么办」。"
|
||||
+ "已通过的申请不能驳回(40042)—— 要撤销请删名册项")
|
||||
public ApiResult<JoinDtos.RejectVO> reject(@PathVariable Long id,
|
||||
@RequestBody JoinDtos.RejectReq req) {
|
||||
return ApiResult.ok(joinService.reject(id, req));
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
|
||||
import cn.tianbaotong.fill.module.join.service.JoinService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 学生侧的名单加入申请(《03-接口设计》§8.7.1 / §8.7.2)。
|
||||
*
|
||||
* <p>权限:登录即可(学生)。**不额外挂权限码** —— 这条通道就是给
|
||||
* "门禁没匹配上、什么权限都没有"的人用的,要求权限码等于把门关上。
|
||||
* 限次(同 openid 每任务 ≤2 次)在服务层做,那才是这条通道的闸门。
|
||||
*/
|
||||
@Tag(name = "填报端 · 名单加入申请")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/mp/roster/join-request")
|
||||
@RequiredArgsConstructor
|
||||
public class MpJoinRequestController {
|
||||
|
||||
private final JoinService joinService;
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "提交加入申请",
|
||||
description = "**只解决「进名单」,不绕过门禁** —— 申请期间学生仍不能填报。"
|
||||
+ "证件号立刻转 HMAC + 脱敏,明文不落库。"
|
||||
+ "已在名册(40039)/ 已有待审申请(40038)/ 超过 2 次(40037)都会被拒")
|
||||
public ApiResult<JoinDtos.SubmitVO> submit(@RequestBody JoinDtos.SubmitReq req) {
|
||||
return ApiResult.ok(joinService.submit(req));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "我的申请(含状态与驳回原因)",
|
||||
description = "通过之后 canFillNow=true:说明已经自动绑定,学生返回重新核验就能直接进")
|
||||
public ApiResult<Map<String, Object>> mine(@RequestParam(required = false) Long taskId) {
|
||||
return ApiResult.ok(joinService.myRequests(taskId));
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 名单加入申请(《03-接口设计》§8.7.1 ~ §8.7.5)。
|
||||
*
|
||||
* <p>学生侧与管理侧各有一份形状:学生只看得到自己的申请与状态,
|
||||
* 管理侧要看到"谁、什么证件号、等多久、有没有重复线索"。
|
||||
*/
|
||||
public final class JoinDtos {
|
||||
|
||||
private JoinDtos() {
|
||||
}
|
||||
|
||||
// ── 学生侧 ────────────────────────────────────
|
||||
|
||||
/** 提交申请(§8.7.1)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "提交加入申请")
|
||||
public static class SubmitReq {
|
||||
|
||||
private Long taskId;
|
||||
|
||||
/** 姓名(明文存储,业务必需)。 */
|
||||
private String realName;
|
||||
|
||||
/** 身份证号 —— **服务端立刻转 HMAC + 脱敏,明文不落库**。 */
|
||||
private String idCardNo;
|
||||
|
||||
private String phone;
|
||||
|
||||
/** 自述班级。 */
|
||||
private String orgHint;
|
||||
|
||||
private String reason;
|
||||
}
|
||||
|
||||
/** 提交结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class SubmitVO {
|
||||
|
||||
private Long requestId;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String statusText;
|
||||
|
||||
/** 预计多久处理(给个量级,不是承诺)。 */
|
||||
private Integer estimatedHours;
|
||||
|
||||
/** 还剩几次申请机会 —— 说清上限,比等他撞上 40037 再报错友好。 */
|
||||
private Integer remainAttempts;
|
||||
}
|
||||
|
||||
/** 学生看自己的申请(§8.7.2)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class MyRequestVO {
|
||||
|
||||
private Long requestId;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String taskName;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String statusText;
|
||||
|
||||
private String realName;
|
||||
|
||||
private String idCardMask;
|
||||
|
||||
private String orgHint;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime auditTime;
|
||||
|
||||
/** 驳回原因(学生需要知道为什么) */
|
||||
private String rejectReason;
|
||||
|
||||
/** 通过后写入的名册项 */
|
||||
private Long rosterId;
|
||||
|
||||
/** 通过之后能不能马上填 —— 界面据此给"返回重新核验"的按钮 */
|
||||
private Boolean canFillNow;
|
||||
}
|
||||
|
||||
// ── 管理侧 ────────────────────────────────────
|
||||
|
||||
/** 待办列表里的一条。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AdminItemVO {
|
||||
|
||||
private Long requestId;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String taskName;
|
||||
|
||||
private String realName;
|
||||
|
||||
private String idCardMask;
|
||||
|
||||
private String phoneMask;
|
||||
|
||||
private String orgHint;
|
||||
|
||||
private String reason;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String statusText;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 已等待小时数(保留 1 位)—— 列表按它倒序,等最久的在最前 */
|
||||
private Double waitHours;
|
||||
|
||||
/** 该证件号在**其他任务**的名册里出现过(帮管理员判断是不是本校学生) */
|
||||
private DupHint duplicateHint;
|
||||
|
||||
private Long rosterId;
|
||||
}
|
||||
|
||||
/** 重复线索。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class DupHint {
|
||||
|
||||
/** 一期只有 {@code IN_OTHER_TASK} */
|
||||
private String type;
|
||||
|
||||
private String taskName;
|
||||
|
||||
private String realName;
|
||||
}
|
||||
|
||||
/** 待办列表。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AdminListVO {
|
||||
|
||||
private List<AdminItemVO> list = new ArrayList<>();
|
||||
|
||||
private long total;
|
||||
|
||||
private Counts counts;
|
||||
|
||||
/** 待审太多时的提醒(FR-509c:单任务待审 > 50 条) */
|
||||
private Alert alert;
|
||||
}
|
||||
|
||||
/** 三个计数。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Counts {
|
||||
|
||||
private long pending;
|
||||
|
||||
private long approved;
|
||||
|
||||
private long rejected;
|
||||
}
|
||||
|
||||
/** 提醒。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Alert {
|
||||
|
||||
/** {@code 2} = 警告级 */
|
||||
private Integer level;
|
||||
|
||||
private String text;
|
||||
}
|
||||
|
||||
/** 审批通过(§8.7.4)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "通过加入申请")
|
||||
public static class ApproveReq {
|
||||
|
||||
/** 指定班级。不传则取学生的自述班级(匹配不上就报错让他手选)。 */
|
||||
private Long orgId;
|
||||
|
||||
/** 修正姓名(管理员核对后可改) */
|
||||
private String realName;
|
||||
|
||||
/** 修正证件号 */
|
||||
private String idCardNo;
|
||||
|
||||
/** 序号(默认追加到末尾) */
|
||||
private Integer seqNo;
|
||||
}
|
||||
|
||||
/** 通过结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ApproveVO {
|
||||
|
||||
private Long requestId;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Long rosterId;
|
||||
|
||||
private Boolean rosterCreated;
|
||||
|
||||
/** ⚠️ 一期恒为 false(通知渠道 M5-12 还没做)—— **不写 true** */
|
||||
private Boolean notifySent;
|
||||
|
||||
/** true = 该 openid 已绑定,学生再次进入直接放行 */
|
||||
private Boolean canFillNow;
|
||||
}
|
||||
|
||||
/** 驳回(§8.7.5)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "驳回加入申请")
|
||||
public static class RejectReq {
|
||||
|
||||
/** **必填** —— 会原样推送给学生 */
|
||||
private String reason;
|
||||
|
||||
/** 快捷原因码:{@code NOT_THIS_SCHOOL} / {@code INFO_MISMATCH} / {@code NOT_IN_SCOPE} / {@code OTHER} */
|
||||
private String reasonCode;
|
||||
}
|
||||
|
||||
/** 驳回结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class RejectVO {
|
||||
|
||||
private Long requestId;
|
||||
|
||||
private Integer status;
|
||||
|
||||
/** 一期恒为 false,见 {@link ApproveVO#getNotifySent()} */
|
||||
private Boolean notifySent;
|
||||
}
|
||||
}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.entity;
|
||||
|
||||
import cn.tianbaotong.fill.support.TenantEntity;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 名单加入申请(对应 {@code collect_join_request},FR-509 · 客户确认后新增)。
|
||||
*
|
||||
* <h3>这条通道解决什么、不解决什么</h3>
|
||||
*
|
||||
* <p>门禁不通过的人原先只能"联系老师",老师再手工加名单 —— 几百人的批次下老师负担极重。
|
||||
* 这里给一条<b>受控通道</b>:学生自助申请 → 管理员审批 → 自动入册。
|
||||
*
|
||||
* <p>⚠️ <b>关键边界:它只解决"进名单",不绕过门禁。</b>
|
||||
* 申请期间学生仍然不能填报 —— 否则门禁就不再是准入门槛,
|
||||
* 而是"填个表就能过"的形式主义,那整个产品的根基(凭证件号认人)就没了。
|
||||
*
|
||||
* <h3>证件号与手机号都只有 HMAC 与脱敏值</h3>
|
||||
*
|
||||
* <p>与 {@code collect_roster} 同一口径:{@code id_card_hash} 用于等值查重与审批后写名册,
|
||||
* {@code id_card_mask} 用于展示。<b>明文不落库</b> —— 申请来自未核验的人,
|
||||
* 更不该把明文留在库里。
|
||||
*
|
||||
* <p>唯一索引 {@code uk_join_task_idcard} 的作用:一个任务下同一证件号
|
||||
* 只能有一条"待审或已通过"的申请,避免同一个人反复堆申请。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("collect_join_request")
|
||||
public class CollectJoinRequest extends TenantEntity {
|
||||
|
||||
/** {@code 0} 待审核 / {@code 1} 已通过 / {@code 2} 已驳回。 */
|
||||
public static final int STATUS_PENDING = 0;
|
||||
public static final int STATUS_APPROVED = 1;
|
||||
public static final int STATUS_REJECTED = 2;
|
||||
|
||||
/** 来源:{@code 1} 学生自助申请(一期只有这一种)。 */
|
||||
public static final int SOURCE_STUDENT = 1;
|
||||
|
||||
/** 同任务最多申请几次(契约 §8.7.1:同 openid 每任务 ≤2 次)。 */
|
||||
public static final int MAX_ATTEMPTS = 2;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private String realName;
|
||||
|
||||
/** HMAC 的证件号索引(无法反推,用于查重与写名册时定位)。 */
|
||||
private String idCardHash;
|
||||
|
||||
/**
|
||||
* 证件号 <b>AES-GCM 密文</b>(V7 迁移新增)。
|
||||
*
|
||||
* <p>为什么申请表也要存密文:审批通过要往 {@code collect_roster} 插一行,
|
||||
* 而那一行的 {@code id_card_no} 是 <b>NOT NULL</b> 的密文列。
|
||||
* 只存 hash 的话,审批那一步会直接撞
|
||||
* {@code null value in column "id_card_no" violates not-null constraint}(500)。
|
||||
*
|
||||
* <p>三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、
|
||||
* 让管理员重输(等于把学生写错一位的风险复制给管理员)、
|
||||
* 不写这一列(DDL 不允许,且审核看完整号时正是解这一列)。
|
||||
*
|
||||
* <p>⚠️ <b>是密文,不是明文</b> —— 明文只在提交请求的内存里存在过。
|
||||
*/
|
||||
private String idCardCipher;
|
||||
|
||||
private String idCardMask;
|
||||
|
||||
private String phoneHash;
|
||||
|
||||
private String phoneMask;
|
||||
|
||||
/** 自述班级(老师核对时用)。 */
|
||||
private String orgHint;
|
||||
|
||||
private String reason;
|
||||
|
||||
private Integer status;
|
||||
|
||||
/** 驳回原因:<b>会原样推送给学生</b>,所以要写得能看懂。 */
|
||||
private String rejectReason;
|
||||
|
||||
/** 通过后写入的名册项 id。 */
|
||||
private Long rosterId;
|
||||
|
||||
private Long auditBy;
|
||||
|
||||
private LocalDateTime auditTime;
|
||||
|
||||
/** 申请人 openid(这条通道按 openid 限次与去重)。 */
|
||||
private String applicantOpenid;
|
||||
|
||||
/** 申请人 uid(通过后写入名册的 {@code bind_user_id})。 */
|
||||
private Long applicantUid;
|
||||
|
||||
private Integer source;
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 加入申请。
|
||||
*
|
||||
* <p>带 SQL 的地方一律**显式写 {@code tenant_id}**:这些查询会在
|
||||
* "还没登录"或"跨任务核对"的路径上用到,而多租户拦截器只能保证
|
||||
* "有登录上下文时"注入条件 —— 显式写一遍是给自己留的证据。
|
||||
* (对照 {@code MpSceneMapper}:匿名接口那一族也是这个口径。)
|
||||
*/
|
||||
public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest> {
|
||||
|
||||
/** 同一个人(openid)在这个任务下已经申请过几次 —— 含已驳回的(驳回后重填是允许的)。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM collect_join_request
|
||||
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
|
||||
AND applicant_openid = #{openid} AND deleted = 0
|
||||
""")
|
||||
int countByApplicant(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("openid") String openid);
|
||||
|
||||
/** 某个证件号在本任务下有没有"待审或已通过"的申请(唯一索引同口径)。 */
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM collect_join_request
|
||||
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
|
||||
AND id_card_hash = #{hash} AND status IN (0, 1) AND deleted = 0
|
||||
""")
|
||||
int countActiveByIdCard(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("hash") String hash);
|
||||
|
||||
/**
|
||||
* 这个证件号在**其他任务**的名册里出现过吗(帮管理员判断是不是本校学生)。
|
||||
*
|
||||
* <p>返回 taskId 与姓名;没有就返回空列表。**只查同一租户** ——
|
||||
* 跨租户的"是不是本校学生"是别人的数据,不能拿来做判断依据。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT r.task_id AS taskId, r.real_name AS realName
|
||||
FROM collect_roster r
|
||||
WHERE r.tenant_id = #{tenantId} AND r.id_card_hash = #{hash}
|
||||
AND r.task_id <> #{taskId} AND r.deleted = 0
|
||||
ORDER BY r.task_id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
List<DupHint> findInOtherTasks(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("hash") String hash);
|
||||
|
||||
/** 跨任务核对的返回行。 */
|
||||
class DupHint {
|
||||
private Long taskId;
|
||||
private String realName;
|
||||
|
||||
public Long getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(Long v) {
|
||||
this.taskId = v;
|
||||
}
|
||||
|
||||
public String getRealName() {
|
||||
return realName;
|
||||
}
|
||||
|
||||
public void setRealName(String v) {
|
||||
this.realName = v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 该通知谁去审批这条申请(《08》F-102:以前一个都不通知)。
|
||||
*
|
||||
* <p>三类人,去重后返回:
|
||||
* <ol>
|
||||
* <li>这个任务覆盖班级的**班主任**({@code collect_task_org.head_user_id});</li>
|
||||
* <li>这些班级的**副班主任**({@code deputy_user_ids} 存的是
|
||||
* {@code ,1,2,} 这种带逗号的串,所以要用 {@code LIKE '%,id,%'} 判);</li>
|
||||
* <li>本租户的**学校管理员** —— 兜底:任务没配班主任时也得有人管这件事。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>⚠️ 不去查"谁有 join:audit 权限":权限是"能不能做",收件人是"该谁做"。
|
||||
* 一个学校有 50 个班主任,把申请推给全部 50 个等于推给没人。
|
||||
*
|
||||
* <p>⚠️ {@code collect_task_org} **没有 {@code deleted} 列**(V1 建表就没给,
|
||||
* 全项目只有 5 张表这样,见《08》F-14 的排查记录)—— 所以这里不能写
|
||||
* {@code cto.deleted = 0},否则整条 SQL 直接报"列不存在"。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT DISTINCT uid FROM (
|
||||
SELECT cto.head_user_id AS uid
|
||||
FROM collect_task_org cto
|
||||
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
|
||||
AND cto.status = 1
|
||||
UNION
|
||||
SELECT CAST(TRIM(pid) AS BIGINT) AS uid
|
||||
FROM collect_task_org cto,
|
||||
LATERAL regexp_split_to_table(COALESCE(cto.deputy_user_ids, ''), ',') AS pid
|
||||
WHERE cto.tenant_id = #{tenantId} AND cto.task_id = #{taskId}
|
||||
AND cto.status = 1
|
||||
AND TRIM(pid) <> ''
|
||||
UNION
|
||||
SELECT u.id AS uid
|
||||
FROM sys_user u
|
||||
JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
|
||||
JOIN sys_role ro ON ro.id = ur.role_id AND ro.deleted = 0
|
||||
WHERE u.tenant_id = #{tenantId} AND u.deleted = 0 AND u.status = 1
|
||||
AND ro.role_code = 'SCHOOL_ADMIN'
|
||||
) x
|
||||
WHERE uid IS NOT NULL
|
||||
""")
|
||||
List<Long> auditUserIdsOfTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||
|
||||
/**
|
||||
* 审批通过后回写申请单。
|
||||
*
|
||||
* <p>条件里带 {@code status = 0}:**"查"与"写"之间可能有人已经处理过**,
|
||||
* 影响行数为 0 就说明被别人抢先了(并发下两个管理员同时点通过)。
|
||||
* 这比"先查后写"可靠 —— 后者在并发时会给同一个人建两条名册。
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE collect_join_request
|
||||
SET status = 1, roster_id = #{rosterId}, audit_by = #{auditor},
|
||||
audit_time = NOW(), update_time = NOW()
|
||||
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
|
||||
""")
|
||||
int markApproved(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||
@Param("rosterId") Long rosterId, @Param("auditor") Long auditor);
|
||||
|
||||
/** 驳回的回写(同样带状态条件)。 */
|
||||
@Update("""
|
||||
UPDATE collect_join_request
|
||||
SET status = 2, reject_reason = #{reason}, audit_by = #{auditor},
|
||||
audit_time = NOW(), update_time = NOW()
|
||||
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
|
||||
""")
|
||||
int markRejected(@Param("tenantId") Long tenantId, @Param("id") Long id,
|
||||
@Param("reason") String reason, @Param("auditor") Long auditor);
|
||||
}
|
||||
-547
@@ -1,547 +0,0 @@
|
||||
package cn.tianbaotong.fill.module.join.service;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
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.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
|
||||
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
|
||||
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
|
||||
import cn.tianbaotong.fill.module.notify.entity.CollectMessage;
|
||||
import cn.tianbaotong.fill.module.notify.service.NotificationService;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
|
||||
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 名单加入申请(FR-509,《03-接口设计》§8.7)。
|
||||
*
|
||||
* <h3>这条通道的四条边界(都是刻意的,不要"顺手放宽")</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>只解决"进名单",不绕过门禁。</b>申请期间学生仍然不能填报 ——
|
||||
* 否则门禁就变成"填个表就能过"的形式主义,整套"凭证件号认人"的根基就没了。</li>
|
||||
* <li><b>每人每任务最多 2 次</b>({@code 40037})。不设上限的话,
|
||||
* 一个输错号码的学生能刷出几十条申请,管理员的待办里全是同一个人。</li>
|
||||
* <li><b>证件号已在名册 → {@code 40039} 让他回去重新核验</b>,而不是受理申请。
|
||||
* 这种情况说明他<b>只是姓名输错了</b>,受理申请等于让管理员去纠正一个
|
||||
* 本来自己就能改对的问题。这一步不违反"统一失败提示":
|
||||
* 学生已经主动提供了正确证件号,此时说"你已在名单中"**不泄露任何新信息**。</li>
|
||||
* <li><b>审批通过时自动绑定</b>({@code bind_user_id} = 申请人)——
|
||||
* 不然学生还得再输一遍姓名与证件号,而这两项刚刚才由他提供过。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>明文只在内存里存在</h3>
|
||||
*
|
||||
* <p>证件号与手机号在这里<b>立刻</b>转成 HMAC + 脱敏值,
|
||||
* 落库的只有 hash 与 mask(与 {@code collect_roster} 同一口径)。
|
||||
* 申请来自**尚未核验**的人,更不该把明文留在库里。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JoinService {
|
||||
|
||||
/** 与账号模块同一口径的手机号校验。 */
|
||||
private static final Pattern PHONE = Pattern.compile("^1[3-9]\\d{9}$");
|
||||
|
||||
/** 预计处理时长(给个量级,不是承诺)。 */
|
||||
private static final int ESTIMATED_HOURS = 24;
|
||||
|
||||
/** 待审超过这个数就在列表上提醒(FR-509c)。 */
|
||||
private static final int ALERT_PENDING = 50;
|
||||
|
||||
private final CollectJoinRequestMapper joinMapper;
|
||||
private final CollectRosterMapper rosterMapper;
|
||||
private final TaskMapper taskMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final SensitiveCodec codec;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
// ── 学生侧 ─────────────────────────────────────
|
||||
|
||||
/** 提交申请(§8.7.1)。 */
|
||||
public JoinDtos.SubmitVO submit(JoinDtos.SubmitReq req) {
|
||||
if (req == null || req.getTaskId() == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
|
||||
}
|
||||
String name = IdentityNormalizer.name(req.getRealName());
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写姓名");
|
||||
}
|
||||
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
|
||||
if (idCard == null || !idCard.matches("^\\d{17}[\\dXx]$")) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写 18 位身份证号");
|
||||
}
|
||||
String phone = req.getPhone() == null ? "" : req.getPhone().trim();
|
||||
if (!PHONE.matcher(phone).matches()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写正确的手机号(老师要能联系到你)");
|
||||
}
|
||||
|
||||
Long tenantId = UserContext.tenantId();
|
||||
Long uid = UserContext.uid();
|
||||
CollectTask task = taskMapper.selectById(req.getTaskId());
|
||||
if (task == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||
}
|
||||
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
|
||||
throw new BizException(ErrorCode.TASK_NOT_OPEN, "任务当前不在收集期,暂时不能申请加入");
|
||||
}
|
||||
|
||||
SysUser me = userMapper.selectById(uid);
|
||||
String openid = me == null || me.getOpenId() == null ? "" : me.getOpenId();
|
||||
if (openid.isBlank()) {
|
||||
// 没有 openid 就没法按"人"限次(契约是"同 openid 每任务 ≤2 次")。
|
||||
// 与其放宽成"不限次",不如明确拒绝 —— 放宽的口子会被反复利用。
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"当前登录方式不支持提交申请,请从微信里重新进入");
|
||||
}
|
||||
|
||||
int used = joinMapper.countByApplicant(tenantId, req.getTaskId(), openid);
|
||||
if (used >= CollectJoinRequest.MAX_ATTEMPTS) {
|
||||
throw new BizException(ErrorCode.JOIN_REQUEST_LIMIT);
|
||||
}
|
||||
|
||||
String hash = codec.hash(idCard);
|
||||
|
||||
// ① 已在名册 → 让他回去重新核验(不是受理申请)
|
||||
List<CollectRoster> inRoster = rosterMapper.selectByHash(tenantId, req.getTaskId(), hash);
|
||||
if (!inRoster.isEmpty()) {
|
||||
throw new BizException(ErrorCode.ALREADY_IN_ROSTER);
|
||||
}
|
||||
// ② 同证件号已有待审或已通过的申请
|
||||
if (joinMapper.countActiveByIdCard(tenantId, req.getTaskId(), hash) > 0) {
|
||||
throw new BizException(ErrorCode.JOIN_REQUEST_DUPLICATE);
|
||||
}
|
||||
|
||||
CollectJoinRequest r = new CollectJoinRequest();
|
||||
r.setTaskId(req.getTaskId());
|
||||
r.setRealName(name);
|
||||
r.setIdCardHash(hash);
|
||||
// ★ 密文也要存:审批通过时要原样写进名册的 id_card_no(NOT NULL 的密文列)。
|
||||
// 只存 hash 的话审批那一步会撞非空约束 —— 这正是本轮发现并修掉的 F-19。
|
||||
r.setIdCardCipher(codec.encrypt(idCard));
|
||||
r.setIdCardMask(MaskUtil.maskIdCard(idCard));
|
||||
r.setPhoneHash(codec.hash(phone));
|
||||
r.setPhoneMask(MaskUtil.maskPhone(phone));
|
||||
r.setOrgHint(req.getOrgHint() == null ? null
|
||||
// ⚠️ 自述班级**不能**用 IdentityNormalizer.name() 规范化:那个方法会去掉空格
|
||||
// (姓名要"张 三"→"张三"),而班级名里的空格是**有意义**的
|
||||
// ("24中技数控1班 645451" 去掉空格就再也匹配不上组织了)。
|
||||
// 这里只做全角转半角 + 去首尾空白。
|
||||
: IdentityNormalizer.toHalfWidth(req.getOrgHint()).trim());
|
||||
r.setReason(req.getReason() == null ? null : req.getReason().trim());
|
||||
r.setStatus(CollectJoinRequest.STATUS_PENDING);
|
||||
r.setApplicantOpenid(openid);
|
||||
r.setApplicantUid(uid);
|
||||
r.setSource(CollectJoinRequest.SOURCE_STUDENT);
|
||||
joinMapper.insert(r);
|
||||
|
||||
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
|
||||
notifyAuditors(task, r, name);
|
||||
|
||||
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
|
||||
vo.setRequestId(r.getId());
|
||||
vo.setStatus(CollectJoinRequest.STATUS_PENDING);
|
||||
vo.setStatusText("已提交,等待学校审核");
|
||||
vo.setEstimatedHours(ESTIMATED_HOURS);
|
||||
vo.setRemainAttempts(CollectJoinRequest.MAX_ATTEMPTS - used - 1);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 学生看自己的申请(§8.7.2)。 */
|
||||
public Map<String, Object> myRequests(Long taskId) {
|
||||
Long uid = UserContext.uid();
|
||||
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
|
||||
.eq(CollectJoinRequest::getApplicantUid, uid)
|
||||
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
|
||||
.orderByDesc(CollectJoinRequest::getId);
|
||||
List<CollectJoinRequest> rows = joinMapper.selectList(q);
|
||||
|
||||
Map<Long, String> taskNames = new HashMap<>();
|
||||
List<Long> taskIds = rows.stream().map(CollectJoinRequest::getTaskId).distinct().toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
|
||||
taskNames.put(t.getId(), t.getTaskName());
|
||||
}
|
||||
}
|
||||
|
||||
List<JoinDtos.MyRequestVO> list = new ArrayList<>();
|
||||
for (CollectJoinRequest r : rows) {
|
||||
JoinDtos.MyRequestVO vo = new JoinDtos.MyRequestVO();
|
||||
vo.setRequestId(r.getId());
|
||||
vo.setTaskId(r.getTaskId());
|
||||
vo.setTaskName(taskNames.get(r.getTaskId()));
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setStatusText(statusText(r.getStatus()));
|
||||
vo.setRealName(r.getRealName());
|
||||
vo.setIdCardMask(r.getIdCardMask());
|
||||
vo.setOrgHint(r.getOrgHint());
|
||||
vo.setCreateTime(r.getCreateTime());
|
||||
vo.setAuditTime(r.getAuditTime());
|
||||
vo.setRejectReason(r.getRejectReason());
|
||||
vo.setRosterId(r.getRosterId());
|
||||
// 通过 = 已经绑好了 → 学生重新核验就能直接进
|
||||
vo.setCanFillNow(r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED
|
||||
&& r.getRosterId() != null);
|
||||
list.add(vo);
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("list", list);
|
||||
out.put("total", list.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
private String statusText(Integer status) {
|
||||
return switch (status == null ? 0 : status) {
|
||||
case CollectJoinRequest.STATUS_PENDING -> "待审核";
|
||||
case CollectJoinRequest.STATUS_APPROVED -> "已通过,可以开始填报了";
|
||||
case CollectJoinRequest.STATUS_REJECTED -> "未通过";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
|
||||
// ── 管理侧 ─────────────────────────────────────
|
||||
|
||||
/** 待办列表(§8.7.3)。 */
|
||||
public JoinDtos.AdminListVO list(Long taskId, Integer status, Long orgId, String keyword,
|
||||
Long pageNum, Long pageSize) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
int st = status == null ? CollectJoinRequest.STATUS_PENDING : status;
|
||||
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
|
||||
long ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
|
||||
|
||||
// orgId 筛的是"自述班级":申请还没有正式班级,只能按学生自己写的筛
|
||||
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
|
||||
.eq(CollectJoinRequest::getStatus, st)
|
||||
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
|
||||
.like(keyword != null && !keyword.isBlank(), CollectJoinRequest::getRealName,
|
||||
keyword == null ? "" : keyword.trim())
|
||||
.orderByAsc(CollectJoinRequest::getCreateTime);
|
||||
|
||||
var page = joinMapper.selectPage(
|
||||
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q);
|
||||
|
||||
Map<Long, String> taskNames = new HashMap<>();
|
||||
List<Long> taskIds = page.getRecords().stream()
|
||||
.map(CollectJoinRequest::getTaskId).distinct().toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
|
||||
taskNames.put(t.getId(), t.getTaskName());
|
||||
}
|
||||
}
|
||||
|
||||
JoinDtos.AdminListVO out = new JoinDtos.AdminListVO();
|
||||
List<JoinDtos.AdminItemVO> list = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (CollectJoinRequest r : page.getRecords()) {
|
||||
JoinDtos.AdminItemVO vo = new JoinDtos.AdminItemVO();
|
||||
vo.setRequestId(r.getId());
|
||||
vo.setTaskId(r.getTaskId());
|
||||
vo.setTaskName(taskNames.get(r.getTaskId()));
|
||||
vo.setRealName(r.getRealName());
|
||||
vo.setIdCardMask(r.getIdCardMask());
|
||||
vo.setPhoneMask(r.getPhoneMask());
|
||||
vo.setOrgHint(r.getOrgHint());
|
||||
vo.setReason(r.getReason());
|
||||
vo.setStatus(r.getStatus());
|
||||
vo.setStatusText(statusText(r.getStatus()));
|
||||
vo.setCreateTime(r.getCreateTime());
|
||||
vo.setRosterId(r.getRosterId());
|
||||
if (r.getCreateTime() != null) {
|
||||
long mins = Duration.between(r.getCreateTime(), now).toMinutes();
|
||||
vo.setWaitHours(Math.round(mins / 6.0) / 10.0);
|
||||
}
|
||||
// 重复线索:这个证件号在别的任务的名册里出现过吗
|
||||
List<CollectJoinRequestMapper.DupHint> dup =
|
||||
joinMapper.findInOtherTasks(tenantId, r.getTaskId(), r.getIdCardHash());
|
||||
if (!dup.isEmpty()) {
|
||||
JoinDtos.DupHint hint = new JoinDtos.DupHint();
|
||||
hint.setType("IN_OTHER_TASK");
|
||||
hint.setTaskName(taskNames.getOrDefault(dup.get(0).getTaskId(),
|
||||
"任务 #" + dup.get(0).getTaskId()));
|
||||
hint.setRealName(dup.get(0).getRealName());
|
||||
vo.setDuplicateHint(hint);
|
||||
}
|
||||
list.add(vo);
|
||||
}
|
||||
out.setList(list);
|
||||
out.setTotal(page.getTotal());
|
||||
|
||||
JoinDtos.Counts counts = new JoinDtos.Counts();
|
||||
counts.setPending(countByStatus(taskId, CollectJoinRequest.STATUS_PENDING));
|
||||
counts.setApproved(countByStatus(taskId, CollectJoinRequest.STATUS_APPROVED));
|
||||
counts.setRejected(countByStatus(taskId, CollectJoinRequest.STATUS_REJECTED));
|
||||
out.setCounts(counts);
|
||||
|
||||
if (counts.getPending() > ALERT_PENDING) {
|
||||
JoinDtos.Alert alert = new JoinDtos.Alert();
|
||||
alert.setLevel(2);
|
||||
alert.setText("待审申请已达 " + counts.getPending() + " 条,建议集中处理");
|
||||
out.setAlert(alert);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private long countByStatus(Long taskId, int status) {
|
||||
return joinMapper.selectCount(Wrappers.<CollectJoinRequest>lambdaQuery()
|
||||
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
|
||||
.eq(CollectJoinRequest::getStatus, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过(§8.7.4):建名册项 + 自动绑定 + 回写申请。
|
||||
*
|
||||
* <p><b>事务</b>:名册建了而申请没回写(或反过来),学生会看到"申请还在待审"
|
||||
* 但名册里已经有他了 —— 他接着再申请一次就撞 {@code 40038}。两件事必须同生同死。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public JoinDtos.ApproveVO approve(Long id, JoinDtos.ApproveReq req) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
Long uid = UserContext.uid();
|
||||
CollectJoinRequest r = joinMapper.selectById(id);
|
||||
if (r == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
|
||||
}
|
||||
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
|
||||
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
||||
}
|
||||
|
||||
// 班级:优先用管理员指定的;没指定就用学生的自述班级去匹配组织名(匹配不上必须手选)
|
||||
Long orgId = req == null ? null : req.getOrgId();
|
||||
if (orgId == null) {
|
||||
orgId = matchOrgByName(r.getOrgHint());
|
||||
}
|
||||
if (orgId == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"请指定这个学生要加入的班级"
|
||||
+ (r.getOrgHint() == null ? "" : "(他自述是「" + r.getOrgHint() + "」)"));
|
||||
}
|
||||
if (rosterMapper.countOrg(tenantId, orgId) == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
|
||||
}
|
||||
|
||||
// 证件号:允许管理员核对后修正(学生可能把号码写错了)
|
||||
String idCard = req != null && req.getIdCardNo() != null && !req.getIdCardNo().isBlank()
|
||||
? IdentityNormalizer.idCard(req.getIdCardNo()) : null;
|
||||
String hash = r.getIdCardHash();
|
||||
if (idCard != null) {
|
||||
hash = codec.hash(idCard);
|
||||
}
|
||||
String name = req != null && req.getRealName() != null && !req.getRealName().isBlank()
|
||||
? IdentityNormalizer.name(req.getRealName()) : r.getRealName();
|
||||
|
||||
// 名册里这个证件号被占了吗(可能刚被老师手工加进去)
|
||||
if (!rosterMapper.selectByHash(tenantId, r.getTaskId(), hash).isEmpty()) {
|
||||
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN);
|
||||
}
|
||||
|
||||
CollectRoster row = new CollectRoster();
|
||||
row.setTaskId(r.getTaskId());
|
||||
row.setOrgId(orgId);
|
||||
row.setSeqNo(req == null ? null : req.getSeqNo());
|
||||
row.setRealName(name);
|
||||
row.setIdCardMask(MaskUtil.maskIdCard(idCard != null ? idCard : r.getIdCardMask()));
|
||||
row.setIdCardHash(hash);
|
||||
// 名册的 id_card_no 是 **NOT NULL 的密文列**:管理员修正了就用新的,否则搬申请里那份
|
||||
if (idCard != null) {
|
||||
row.setIdCardNo(codec.encrypt(idCard));
|
||||
} else if (r.getIdCardCipher() != null && !r.getIdCardCipher().isBlank()) {
|
||||
row.setIdCardNo(r.getIdCardCipher());
|
||||
} else {
|
||||
// V7 之前提交的申请没有密文列。**不能静默建一条没有证件号的名册行** ——
|
||||
// 那条行以后永远过不了名册校验,而且没人知道为什么。
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"这条申请缺少证件号信息,请让申请人在小程序里重新提交一次");
|
||||
}
|
||||
row.setPhone(r.getPhoneMask());
|
||||
row.setPhoneHash(r.getPhoneHash());
|
||||
// 来源留痕:老师日后问"这个人是哪来的",能答得上来
|
||||
row.setExtJson(JSONUtil.createObj()
|
||||
.set("source", "JOIN_REQUEST")
|
||||
.set("joinRequestId", r.getId())
|
||||
.set("orgHint", r.getOrgHint())
|
||||
.toString());
|
||||
// ★ 自动绑定(FR-509g):学生刚提供过姓名与证件号,不该让他再输一遍
|
||||
row.setBindUserId(r.getApplicantUid());
|
||||
row.setBindTime(LocalDateTime.now());
|
||||
// bind_type = 3(来自申请)—— 与契约 §8.7.4 一致;1 门禁 / 2 管理员指定
|
||||
row.setBindType(3);
|
||||
row.setSubmitStatus(CollectRoster.SUBMIT_NONE);
|
||||
rosterMapper.insert(row);
|
||||
|
||||
int affected = joinMapper.markApproved(tenantId, id, row.getId(), uid);
|
||||
if (affected == 0) {
|
||||
// 并发下别人先处理了 —— 整笔回滚(名册那一行也不能留)
|
||||
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
||||
}
|
||||
|
||||
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
|
||||
|
||||
// ★ 通知学生(《08》F-102):以前这里只写一行 log.warn,
|
||||
// 学生要自己反复进小程序看结果 —— 而这条链路的另一端(通知渠道)
|
||||
// 早在 M5 就做好了,只是没接上。
|
||||
boolean notifySent = false;
|
||||
if (r.getApplicantUid() != null) {
|
||||
try {
|
||||
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
|
||||
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
|
||||
"JOIN_APPROVE",
|
||||
NotificationService.NotifyRequest.vars(
|
||||
"任务名", taskNameOf(r.getTaskId()),
|
||||
"姓名", name),
|
||||
"加入名单申请已通过",
|
||||
CollectMessage.LINK_TASK, r.getTaskId(),
|
||||
NotificationService.TPL_JOIN_APPROVE)
|
||||
.withExtra("你现在可以扫码核验身份,然后开始填报了。"));
|
||||
notifySent = nr.pushSent();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("加入申请通过的通知失败(不影响审批结果):request={}", id, e);
|
||||
}
|
||||
}
|
||||
|
||||
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
|
||||
vo.setRequestId(id);
|
||||
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
|
||||
vo.setRosterId(row.getId());
|
||||
vo.setRosterCreated(true);
|
||||
// ⚠️ 语义与驳回接口一致:这是"**微信推送**发出去了没有"。
|
||||
// 站内信一定写得进去,所以学生一定能在消息中心看到结果。
|
||||
vo.setNotifySent(notifySent);
|
||||
vo.setCanFillNow(r.getApplicantUid() != null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知该审批这条申请的人(班主任 / 副班主任 / 学校管理员兜底)。
|
||||
*
|
||||
* <p>《08》F-102:提交申请之后**一个通知都没有** —— 老师只能自己想到去翻审批页。
|
||||
* 而"申请加入名单"本来就是一条"学生等着、老师不知道"的链路,
|
||||
* 没有通知等于这条路走不通(学生会一直等,或者去问班主任"你看到我申请了吗")。
|
||||
*/
|
||||
private void notifyAuditors(CollectTask task, CollectJoinRequest r, String name) {
|
||||
try {
|
||||
var uids = joinMapper.auditUserIdsOfTask(UserContext.tenantId(), r.getTaskId());
|
||||
if (uids.isEmpty()) {
|
||||
log.warn("加入申请无人可通知(该任务没配班主任、租户也没有管理员):request={}", r.getId());
|
||||
return;
|
||||
}
|
||||
for (Long to : uids) {
|
||||
notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
|
||||
to, CollectMessage.BIZ_JOIN_AUDIT,
|
||||
"JOIN_SUBMIT",
|
||||
NotificationService.NotifyRequest.vars(
|
||||
"任务名", task.getTaskName(),
|
||||
"姓名", name,
|
||||
"班级", r.getOrgHint()),
|
||||
"有新的加入名单申请",
|
||||
// 跳任务而不是跳申请单:审批页是按任务筛的(学生端 SCENE 也只带 taskId)
|
||||
CollectMessage.LINK_TASK, r.getTaskId(), null));
|
||||
}
|
||||
log.info("加入申请已通知 {} 位审批人:request={}", uids.size(), r.getId());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("加入申请的通知失败(不影响学生提交):request={}", r.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String taskNameOf(Long taskId) {
|
||||
CollectTask t = taskId == null ? null : taskMapper.selectById(taskId);
|
||||
return t == null ? null : t.getTaskName();
|
||||
}
|
||||
|
||||
private String nz(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
|
||||
private Long matchOrgByName(String orgHint) {
|
||||
if (orgHint == null || orgHint.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Long tenantId = UserContext.tenantId();
|
||||
var rows = orgNameToId(tenantId, orgHint.trim());
|
||||
return rows.size() == 1 ? rows.get(0) : null;
|
||||
}
|
||||
|
||||
private List<Long> orgNameToId(Long tenantId, String name) {
|
||||
// 直接查 sys_org:几十个节点,一次取回来在内存里匹配
|
||||
return rosterMapper.selectOrgIdsByName(tenantId, name);
|
||||
}
|
||||
|
||||
/** 驳回(§8.7.5)。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public JoinDtos.RejectVO reject(Long id, JoinDtos.RejectReq req) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
Long uid = UserContext.uid();
|
||||
if (req == null || req.getReason() == null || req.getReason().isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写驳回原因(学生要知道为什么)");
|
||||
}
|
||||
CollectJoinRequest r = joinMapper.selectById(id);
|
||||
if (r == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
|
||||
}
|
||||
if (r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED) {
|
||||
// 已通过不能"驳回" —— 要撤销请删名册项(否则名册里那个人就成了没人认领的行)
|
||||
throw new BizException(ErrorCode.JOIN_ALREADY_APPROVED);
|
||||
}
|
||||
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
|
||||
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
||||
}
|
||||
String reason = req.getReasonCode() == null || req.getReasonCode().isBlank()
|
||||
? req.getReason().trim()
|
||||
: "[" + req.getReasonCode() + "] " + req.getReason().trim();
|
||||
int affected = joinMapper.markRejected(tenantId, id, reason, uid);
|
||||
if (affected == 0) {
|
||||
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
|
||||
}
|
||||
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
|
||||
|
||||
// ★ 通知学生(《08》F-102):驳回原因学生必须看到,
|
||||
// 否则他会以为"申请丢了"而反复重提(每人每任务只能提 2 次)。
|
||||
boolean notifySent = false;
|
||||
if (r.getApplicantUid() != null) {
|
||||
try {
|
||||
var nr = notificationService.notify(NotificationService.NotifyRequest.ofTemplate(
|
||||
r.getApplicantUid(), CollectMessage.BIZ_JOIN_AUDIT,
|
||||
"JOIN_APPROVE",
|
||||
NotificationService.NotifyRequest.vars(
|
||||
"任务名", taskNameOf(r.getTaskId()),
|
||||
"姓名", r.getRealName(),
|
||||
"驳回原因", reason),
|
||||
"加入名单申请未通过",
|
||||
CollectMessage.LINK_TASK, r.getTaskId(),
|
||||
NotificationService.TPL_JOIN_APPROVE)
|
||||
.withExtra("原因:" + reason));
|
||||
notifySent = nr.pushSent();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("加入申请驳回的通知失败(不影响驳回结果):request={}", id, e);
|
||||
}
|
||||
}
|
||||
|
||||
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
|
||||
vo.setRequestId(id);
|
||||
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
|
||||
vo.setNotifySent(notifySent);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -147,9 +147,8 @@ public class LogController {
|
||||
@Operation(summary = "登录日志",
|
||||
description = "Query:`tenantId` `userId` `userType` `result` `from` `to` + 分页。"
|
||||
+ "范围同操作日志(`log:viewAll` 可跨租户,学校管理员恒为本租户)。"
|
||||
+ "⚠️ 一期的登录日志**只有 PC 账号密码登录**(`loginType=PASSWORD`)—— "
|
||||
+ "学生从小程序/H5 进来走的是微信授权,目前不写这张表(见 F-31)。"
|
||||
+ "所以「学生登录记录为空」是已知情况,不是查询条件写错了")
|
||||
+ "登录方式 `loginType`:`PASSWORD`(PC 账号密码)/ `WECHAT`(学生从小程序、H5 走微信授权)。"
|
||||
+ "⚠️ `location`(IP 归属地)一期**恒为 null** —— 没接 IP 库,返回 null 而不是编一个")
|
||||
public ApiResult<PageResult<LogDtos.LoginLogVO>> logins(
|
||||
@RequestParam(required = false) Long tenantId,
|
||||
@RequestParam(required = false) Long userId,
|
||||
|
||||
@@ -49,6 +49,25 @@ public final class LogDtos {
|
||||
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 请求方法({@code GET} / {@code POST} / {@code PUT} / {@code DELETE})。
|
||||
*
|
||||
* <p>与 {@link #requestUri}、{@link #extraJson} 一起是「日志详情」要用的三列 ——
|
||||
* 它们都是**轻字段**(实测最大 45 / 125 字节级),所以列表直接带上,
|
||||
* 不需要为详情再开一个按 id 查的接口。
|
||||
*/
|
||||
private String requestMethod;
|
||||
|
||||
/** 请求路径(不含 query string)。 */
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* 补充上下文(JSON 串),如批量操作命中的 id 列表 / 停用组织时的 {@code deactivated}。
|
||||
*
|
||||
* <p>⚠️ 不是每一条都有(一期只有一部分动作带它)。
|
||||
*/
|
||||
private String extraJson;
|
||||
|
||||
private Integer bizCode;
|
||||
|
||||
private String traceId;
|
||||
@@ -74,11 +93,28 @@ public final class LogDtos {
|
||||
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 登录者所属租户({@code 0} = 平台)。
|
||||
*
|
||||
* <p>平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是哪所学校的 ——
|
||||
* 而"某个账号什么时候登进来的"这个问题,在平台视角下**一定**要连着"哪个单位"一起回答。
|
||||
* {@code sys_login_log.tenant_id} 本来就有(V2 建表时的 {@code NOT NULL DEFAULT 0}),
|
||||
* 只是这个 VO 此前没把它带出来。
|
||||
*/
|
||||
private Long tenantId;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String userName;
|
||||
|
||||
/** 用户类型:1 超管 / 2 管理员 / 3 教师 / 4 学生 */
|
||||
/**
|
||||
* 用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
|
||||
*
|
||||
* <p>⚠️ 这里以前写的是「1 超管 / 2 管理员 / 3 教师 / 4 学生」,**是错的**。
|
||||
* 权威来源是 {@code BuiltinRole.UserType} 与 {@code UserService.USER_TYPE_NAMES},
|
||||
* 库列注释({@code sys_user.user_type})也与它们一致 ——
|
||||
* 别按 `教师/学生` 那套去映射,本仓库曾因此把班主任当成社会人员拒掉过一次。
|
||||
*/
|
||||
private Integer userType;
|
||||
|
||||
/** {@code PASSWORD} / {@code WECHAT} / {@code PHONE} */
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public interface LoginLogMapper extends BaseMapper<SysLoginLog> {
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT l.id, l.user_id AS userId,
|
||||
SELECT l.id, l.tenant_id AS tenantId, l.user_id AS userId,
|
||||
COALESCE(u.real_name, l.username) AS userName,
|
||||
u.user_type AS userType,
|
||||
CASE l.login_type
|
||||
|
||||
+2
@@ -63,6 +63,8 @@ public interface OperationLogMapper extends BaseMapper<SysOperationLog> {
|
||||
l.module, l.action, l.target_type AS targetType, l.target_id AS targetId,
|
||||
l.before_val AS beforeVal, l.after_val AS afterVal,
|
||||
l.ip, l.user_agent AS userAgent, l.biz_code AS bizCode, l.trace_id AS traceId,
|
||||
l.request_method AS requestMethod, l.request_uri AS requestUri,
|
||||
l.extra_json AS extraJson,
|
||||
l.create_time AS createTime
|
||||
FROM sys_operation_log l
|
||||
LEFT JOIN sys_user u ON u.id = l.user_id AND u.deleted = 0
|
||||
|
||||
+51
-3
@@ -67,6 +67,16 @@ public class OperationLogService {
|
||||
public static final String MODULE_JOIN = "JOIN";
|
||||
public static final String MODULE_USER = "USER";
|
||||
public static final String MODULE_FILE = "FILE";
|
||||
/** 角色与授权。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
||||
public static final String MODULE_ROLE = "ROLE";
|
||||
/**
|
||||
* 租户(平台运营的「开通 / 修改 / 停用学校」)。
|
||||
*
|
||||
* <p>⚠️ 这类日志的 {@code tenant_id} 是 <b>0</b>(平台身份),不是被操作的学校 ——
|
||||
* 因为写日志用的是<b>操作者</b>的上下文。查"这所学校被谁停过"要用
|
||||
* {@code target_type=TENANT & target_id=<租户 id>},不能用 tenant_id 筛。
|
||||
*/
|
||||
public static final String MODULE_TENANT = "TENANT";
|
||||
|
||||
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
|
||||
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
|
||||
@@ -78,6 +88,20 @@ public class OperationLogService {
|
||||
public static final String ACTION_RESUME_TASK = "RESUME_TASK";
|
||||
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
|
||||
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
|
||||
/**
|
||||
* 定时任务**自动**截止。
|
||||
*
|
||||
* <p>⚠️ 必须与 {@link #ACTION_CLOSE_TASK} 分开:出了争议时第一个要看的就是
|
||||
* 「这个任务是到点自己关的,还是管理员手动关的」。
|
||||
*/
|
||||
public static final String ACTION_AUTO_CLOSE = "AUTO_CLOSE";
|
||||
/**
|
||||
* 任务状态变更(兜底动作名)。
|
||||
*
|
||||
* <p>只有在调用方**说不出**是哪个动作时才会用到;`patchStatus` 的注释解释了
|
||||
* 为什么"发布"与"恢复"不能共用它(两者都是 →收集中,靠状态推会记成同一个动作)。
|
||||
*/
|
||||
public static final String ACTION_STATUS_CHANGE = "STATUS_CHANGE";
|
||||
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
|
||||
/** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
|
||||
public static final String ACTION_DELETE_TASK = "DELETE_TASK";
|
||||
@@ -87,16 +111,40 @@ public class OperationLogService {
|
||||
public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD";
|
||||
public static final String ACTION_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL";
|
||||
public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS";
|
||||
public static final String ACTION_JOIN_APPROVE = "JOIN_APPROVE";
|
||||
public static final String ACTION_JOIN_REJECT = "JOIN_REJECT";
|
||||
/** 新建自定义角色。 */
|
||||
public static final String ACTION_CREATE_ROLE = "CREATE_ROLE";
|
||||
/** 改角色名称 / 用户类型 / 备注(角色码不可改)。 */
|
||||
public static final String ACTION_UPDATE_ROLE = "UPDATE_ROLE";
|
||||
/** 删除自定义角色(软删)。 */
|
||||
public static final String ACTION_DELETE_ROLE = "DELETE_ROLE";
|
||||
/** 改角色的权限码(授权变更 —— 留痕里记变更前后的码清单)。 */
|
||||
public static final String ACTION_GRANT_ROLE = "GRANT_ROLE";
|
||||
/** 改角色的可见菜单。 */
|
||||
public static final String ACTION_UPDATE_ROLE_MENU = "UPDATE_ROLE_MENU";
|
||||
/** 停用 / 启用角色。 */
|
||||
public static final String ACTION_TOGGLE_ROLE = "TOGGLE_ROLE";
|
||||
/** 开通租户(新建一所学校 + 它的内置角色与管理员账号)。 */
|
||||
public static final String ACTION_CREATE_TENANT = "CREATE_TENANT";
|
||||
/** 改租户信息与套餐(名称 / 类型 / 联系人 / 到期)。 */
|
||||
public static final String ACTION_UPDATE_TENANT = "UPDATE_TENANT";
|
||||
/**
|
||||
* 停用租户 —— <b>该校所有学生与老师立刻登不进来</b>,
|
||||
* 是这套系统里单个动作影响面最大的一类,所以单独一个动作码而不是并进 UPDATE。
|
||||
*/
|
||||
public static final String ACTION_DISABLE_TENANT = "DISABLE_TENANT";
|
||||
/** 恢复启用租户。 */
|
||||
public static final String ACTION_ENABLE_TENANT = "ENABLE_TENANT";
|
||||
|
||||
public static final String TARGET_ROSTER = "ROSTER";
|
||||
public static final String TARGET_TASK = "TASK";
|
||||
public static final String TARGET_SUBMISSION = "SUBMISSION";
|
||||
public static final String TARGET_FILE = "FILE";
|
||||
public static final String TARGET_EXPORT = "EXPORT";
|
||||
public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
|
||||
public static final String TARGET_USER = "USER";
|
||||
/** 角色({@code sys_role.id})。 */
|
||||
public static final String TARGET_ROLE = "ROLE";
|
||||
/** 租户({@code sys_tenant.id})。平台级动作的 target,见 {@link #MODULE_TENANT}。 */
|
||||
public static final String TARGET_TENANT = "TENANT";
|
||||
|
||||
/**
|
||||
* 记一条操作日志(**不抛异常**)。
|
||||
|
||||
+3
-1
@@ -104,7 +104,9 @@ public class MaterialDefController {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
|
||||
List<Map<String, Object>> codes = new java.util.ArrayList<>();
|
||||
for (String code : MaterialCatalog.allCodes()) {
|
||||
// ⚠️ 用 selectableCodes() 而不是 allCodes():学信网备案表 / 其它佐证材料
|
||||
// 属已取消的二期,**读得到、选不到**(历史任务里配过的材料项仍能显示名字)。
|
||||
for (String code : MaterialCatalog.selectableCodes()) {
|
||||
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("code", code);
|
||||
|
||||
+21
-16
@@ -73,11 +73,29 @@ public final class MaterialCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部已知材料码,按 §2.1 的顺序。 */
|
||||
/**
|
||||
* 全部已知材料码,按 §2.1 的顺序。
|
||||
*
|
||||
* <p>⚠️ 这是<b>全集</b>(含只用于读历史数据的两类),不是"能新建的清单"。
|
||||
* 界面下拉要用 {@link #selectableCodes()}。
|
||||
*/
|
||||
public static List<String> allCodes() {
|
||||
return KINDS.stream().map(Kind::code).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* <b>一期实际可选</b>的材料码(4 类,全都有实测样本)。
|
||||
*
|
||||
* <p>为什么与 {@link #allCodes()} 分开:{@code XJW_BA}(学信网备案表)与
|
||||
* {@code OTHER_PROOF}(其它佐证材料)属于**已取消的二期**,样本为零。
|
||||
* 它们保留在 {@link #KINDS} 里只为了一件真实的事 ——
|
||||
* 历史任务可能配过这两类材料,删掉目录会让那些材料项**悬空**(界面显示不出名字)。
|
||||
* 所以:**读得到、选不到**。
|
||||
*/
|
||||
public static List<String> selectableCodes() {
|
||||
return List.of("PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF");
|
||||
}
|
||||
|
||||
public static Kind of(String code) {
|
||||
return code == null ? null : BY_CODE.get(code);
|
||||
}
|
||||
@@ -134,10 +152,10 @@ public final class MaterialCatalog {
|
||||
public record TemplateItem(String code, String visibleWhen) {
|
||||
}
|
||||
|
||||
/** §7.10 的三个内置模板。 */
|
||||
/** 内置模板。**一期只有学校收集一种** —— 社会收集与专项职业能力已随二期一起取消。 */
|
||||
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>();
|
||||
|
||||
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明 / 学信网备案表。 */
|
||||
/** FR-402 的级联:在校生看学籍表,非在校生看工作证明。 */
|
||||
private static final String WHEN_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
|
||||
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}";
|
||||
|
||||
@@ -150,17 +168,6 @@ public final class MaterialCatalog {
|
||||
new TemplateItem("IDCARD", null),
|
||||
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
|
||||
new TemplateItem("WORK_PROOF", WHEN_NOT_STUDENT)));
|
||||
// 社会收集:没有学籍表,多一张学信网备案表(非在校生才有)
|
||||
TEMPLATES.put("TPL_SOCIAL", List.of(
|
||||
new TemplateItem("PHOTO1", null),
|
||||
new TemplateItem("IDCARD", null),
|
||||
new TemplateItem("WORK_PROOF", null),
|
||||
new TemplateItem("XJW_BA", WHEN_NOT_STUDENT)));
|
||||
// 专项职业能力:只要三样
|
||||
TEMPLATES.put("TPL_ZHUANXIANG", List.of(
|
||||
new TemplateItem("PHOTO1", null),
|
||||
new TemplateItem("IDCARD", null),
|
||||
new TemplateItem("WORK_PROOF", null)));
|
||||
}
|
||||
|
||||
public static List<TemplateItem> template(String templateCode) {
|
||||
@@ -179,8 +186,6 @@ public final class MaterialCatalog {
|
||||
m.put("templateCode", code);
|
||||
m.put("label", switch (code) {
|
||||
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
|
||||
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
|
||||
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
|
||||
default -> code;
|
||||
});
|
||||
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
|
||||
|
||||
-5
@@ -84,7 +84,6 @@ public interface TeacherDashboardMapper {
|
||||
</if>
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
GROUP BY o.id, o.org_name
|
||||
@@ -114,7 +113,6 @@ public interface TeacherDashboardMapper {
|
||||
</if>
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
</script>
|
||||
@@ -142,7 +140,6 @@ public interface TeacherDashboardMapper {
|
||||
</if>
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
</script>
|
||||
@@ -177,7 +174,6 @@ public interface TeacherDashboardMapper {
|
||||
</if>
|
||||
AND (
|
||||
cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||
)
|
||||
</script>
|
||||
@@ -200,7 +196,6 @@ public interface TeacherDashboardMapper {
|
||||
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
|
||||
WHERE cto.task_id = t.id AND cto.status = 1
|
||||
AND (cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
|
||||
)
|
||||
ORDER BY t.end_time ASC
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ public class MpH5AuthService {
|
||||
if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) {
|
||||
h5Mapper.insertUserRole(tenantId, created.getId(), roleId);
|
||||
}
|
||||
log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***",
|
||||
log.info("微信首次登录(H5 网页授权 / 小程序共用),已建学生账号:tenant={} uid={} openid={}***",
|
||||
tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length())));
|
||||
return created;
|
||||
}
|
||||
|
||||
+26
-10
@@ -14,6 +14,7 @@ import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.security.JwtService;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import cn.tianbaotong.fill.support.TenantScope;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -31,6 +32,7 @@ import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 小程序端登录与手机号绑定(《03-接口设计》§5.3)。
|
||||
@@ -123,17 +125,31 @@ public class MpLoginService {
|
||||
|
||||
// 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 ——
|
||||
// 前端据此决定进"填报页"还是"先核验身份",省掉一次请求
|
||||
//
|
||||
// ⚠️ 这段查询**必须**放进租户上下文:roster/task 都是租户表,
|
||||
// 而登录是匿名请求(还没有登录态)。不做 TenantScope 的话,
|
||||
// 租户拦截器会因为"拿不到 tenantId"直接拒绝 ——
|
||||
// 症状是 /mp/login 带 taskId 的 scene 一律 500,而只带 t{tid} 的码却正常
|
||||
// (那条路径根本不走到这里),很难从现象定位到原因。
|
||||
if (s.taskId() != null) {
|
||||
CollectRoster r = rosterMapper.selectOne(Wrappers.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, s.taskId())
|
||||
.eq(CollectRoster::getBindUserId, user.getId())
|
||||
.last("limit 1"));
|
||||
if (r != null) {
|
||||
CollectTask t = taskMapper.selectById(s.taskId());
|
||||
Map<String, Object> br = new LinkedHashMap<>();
|
||||
br.put("taskId", s.taskId());
|
||||
br.put("taskName", t == null ? null : t.getTaskName());
|
||||
br.put("rosterId", r.getId());
|
||||
Map<String, Object> br = TenantScope.callAs(
|
||||
s.tenantId(), user.getId(), Set.of("STUDENT"), () -> {
|
||||
CollectRoster r = rosterMapper.selectOne(
|
||||
Wrappers.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, s.taskId())
|
||||
.eq(CollectRoster::getBindUserId, user.getId())
|
||||
.last("limit 1"));
|
||||
if (r == null) {
|
||||
return null;
|
||||
}
|
||||
CollectTask t = taskMapper.selectById(s.taskId());
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("taskId", s.taskId());
|
||||
m.put("taskName", t == null ? null : t.getTaskName());
|
||||
m.put("rosterId", r.getId());
|
||||
return m;
|
||||
});
|
||||
if (br != null) {
|
||||
vo.setBoundRoster(br);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ public final class NotifyDtos {
|
||||
@Schema(description = "登记订阅消息额度")
|
||||
public static class SubscribeReq {
|
||||
|
||||
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / JOIN_APPROVE */
|
||||
/** 模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY */
|
||||
private String templateCode;
|
||||
|
||||
/** 本次授权次数(微信返回,默认 1) */
|
||||
|
||||
-1
@@ -30,7 +30,6 @@ public class CollectMessage extends TenantEntity {
|
||||
public static final int BIZ_APPROVE = 4;
|
||||
public static final int BIZ_URGE = 5;
|
||||
public static final int BIZ_EXPORT = 6;
|
||||
public static final int BIZ_JOIN_AUDIT = 7;
|
||||
|
||||
/** 点开消息跳哪儿。 */
|
||||
public static final String LINK_SUBMISSION = "SUBMISSION";
|
||||
|
||||
-2
@@ -99,7 +99,6 @@ public interface UrgeMapper {
|
||||
</if>
|
||||
<if test="scopeUserId != null">
|
||||
AND (cto.head_user_id = #{scopeUserId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{scopeUserId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
|
||||
</if>
|
||||
ORDER BY o.org_name, r.id
|
||||
@@ -314,7 +313,6 @@ public interface UrgeMapper {
|
||||
AND r.task_id = #{taskId}
|
||||
</if>
|
||||
AND (cto.head_user_id = #{userId}
|
||||
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
|
||||
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
|
||||
</script>
|
||||
""")
|
||||
|
||||
+3
-3
@@ -72,14 +72,14 @@ public class NotificationService {
|
||||
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
|
||||
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
|
||||
* </ul>
|
||||
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
|
||||
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
|
||||
* ⚠️ 删掉「加入申请」之后,两套同名的那一个({@code JOIN_APPROVE})已经不存在了:
|
||||
* 上一版这里专门写过"同名纯属巧合",现在两套码**没有任何重名**,
|
||||
* 但仍要记住它们是两个命名空间(见《08》F-106)。
|
||||
*/
|
||||
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
|
||||
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
|
||||
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
|
||||
public static final String TPL_EXPORT_READY = "EXPORT_READY";
|
||||
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
|
||||
|
||||
private final CollectMessageMapper messageMapper;
|
||||
private final SubscribeQuotaMapper quotaMapper;
|
||||
|
||||
-4
@@ -69,10 +69,6 @@ public final class NotifyTemplates {
|
||||
"导出包 {导出包名} 已生成,请到「导出中心」下载。"
|
||||
+ "(下载链接有有效期,过期请重新导出)",
|
||||
"导出完成"),
|
||||
new Def("JOIN_APPROVE", 7, "加入名单申请已处理",
|
||||
"你申请加入 {任务名} 名单的请求已处理,请查看结果。", "加入名单审批"),
|
||||
new Def("JOIN_SUBMIT", 7, "有新的加入名单申请",
|
||||
"{班级} 的 {姓名} 申请加入 {任务名} 名单,请审批。", "发给班主任/管理员"),
|
||||
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
|
||||
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
|
||||
"驳回次数达上限时发给学校管理员(契约 §11.4)")
|
||||
|
||||
+5
-4
@@ -162,17 +162,18 @@ public interface ScheduleMapper {
|
||||
/**
|
||||
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
|
||||
*
|
||||
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` /
|
||||
* `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
|
||||
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` 优先,
|
||||
* 为空回退到组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
|
||||
* 发给所有人等于没人负责。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
|
||||
FROM collect_task_org cto
|
||||
LEFT JOIN sys_org o
|
||||
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||
JOIN sys_user u ON u.deleted = 0 AND u.status = 1
|
||||
AND (u.id = cto.head_user_id
|
||||
OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%'))
|
||||
AND u.id = COALESCE(cto.head_user_id, o.leader_user_id)
|
||||
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
|
||||
""")
|
||||
List<Map<String, Object>> classAuditors(@Param("tenantId") Long tenantId,
|
||||
|
||||
+50
-3
@@ -71,6 +71,7 @@ public class ScheduleJobs {
|
||||
private final cn.tianbaotong.fill.module.tenant.service.TenantUsageService usageService;
|
||||
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
|
||||
private final cn.tianbaotong.fill.module.file.mapper.FileMapper fileMapper;
|
||||
private final cn.tianbaotong.fill.support.RoleProvisioner roleProvisioner;
|
||||
|
||||
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
|
||||
private static final int RAW_CLEAN_BATCH = 200;
|
||||
@@ -487,6 +488,50 @@ public class ScheduleJobs {
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════ ⑨ 内置角色授权同步(每日 03:20)══════════════
|
||||
|
||||
/**
|
||||
* 把每个租户的**内置角色授权对齐到代码**(二期「角色与授权」的前置)。
|
||||
*
|
||||
* <p>为什么必须有这一步:内置角色的权限由代码决定({@code BuiltinRole}),
|
||||
* 而库里的 {@code sys_role_permission} 只在**开通租户那一刻**写过一次。
|
||||
* 于是代码里给某个内置角色加了/减了一条权限之后,**已经开通的租户永远停在旧的那套** ——
|
||||
* 而权限判定本身不读这张表(它按代码算),所以这个不一致**不会报错、也不会被任何测试抓到**,
|
||||
* 只会在有人查库、或将来改成"从库读"的时候炸出来。
|
||||
*
|
||||
* <p>幂等:{@code provisionBuiltinRoles} 只在值真的不同才写。
|
||||
* 03:20 挑的是所有定时任务都不在的时间段(口径见各任务的 cron)。
|
||||
*/
|
||||
@Scheduled(cron = "0 20 3 * * *")
|
||||
public void syncBuiltinRoleGrants() {
|
||||
try {
|
||||
List<Map<String, Object>> tenants = TenantScope.callAs(PLATFORM, PLATFORM, Set.of(),
|
||||
snapshotMapper::activeTenants);
|
||||
assertTenantPresent(tenants, "role");
|
||||
int done = 0;
|
||||
for (Map<String, Object> row : tenants) {
|
||||
Long tenantId = longOf(row, "tenantId");
|
||||
if (tenantId == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 写数据必须在"摆好租户上下文"这一步:否则 tenant_id 会被写成 0
|
||||
// (角色发到平台租户下,那个学校的人永远看不到,而且不报错)
|
||||
TenantScope.runAs(tenantId, PLATFORM, Set.of(SYSTEM_ROLE),
|
||||
() -> roleProvisioner.provisionBuiltinRoles(tenantId));
|
||||
done++;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("内置角色授权同步失败:tenant={} 原因={}", tenantId, e.toString());
|
||||
}
|
||||
}
|
||||
if (done > 0) {
|
||||
log.info("内置角色授权同步:{} 个租户", done);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("内置角色授权同步任务失败(下一轮会重试)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private int remindExpire(Long tenantId, String tenantName, int daysLeft) {
|
||||
var receivers = scheduleMapper.usersOfRole(tenantId,
|
||||
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
|
||||
@@ -527,13 +572,14 @@ public class ScheduleJobs {
|
||||
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
|
||||
* 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。
|
||||
*
|
||||
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw})
|
||||
* @param job 任务名({@code close} / {@code urge} / {@code timeout} / {@code raw}
|
||||
* / {@code usage} / {@code retry} / {@code expire} / {@code role})
|
||||
* @return 这一轮做了什么(给人看的说明)
|
||||
*/
|
||||
public String runNow(String job) {
|
||||
if (job == null || job.isBlank()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire");
|
||||
"请指定要跑的任务:close / urge / timeout / raw / usage / retry / expire / role");
|
||||
}
|
||||
switch (job.trim().toLowerCase()) {
|
||||
case "close" -> autoCloseExpiredTasks();
|
||||
@@ -543,8 +589,9 @@ public class ScheduleJobs {
|
||||
case "usage" -> snapshotUsage();
|
||||
case "retry" -> retryFailedProcess();
|
||||
case "expire" -> remindTenantExpiring();
|
||||
case "role" -> syncBuiltinRoleGrants();
|
||||
default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire");
|
||||
"不认识的任务名「" + job + "」:只有 close / urge / timeout / raw / usage / retry / expire / role");
|
||||
}
|
||||
return "已手动触发一轮:" + job;
|
||||
}
|
||||
|
||||
+1
-5
@@ -52,15 +52,11 @@ public class TaskOrgSaveReq {
|
||||
* 该班初审人(班主任)。
|
||||
*
|
||||
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
|
||||
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。
|
||||
* <b>持有班主任角色</b>的启用账号(见 {@code TaskOrgService})。
|
||||
*/
|
||||
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
|
||||
private Long headUserId;
|
||||
|
||||
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
|
||||
@Schema(description = "副班主任")
|
||||
private List<Long> deputyUserIds = new ArrayList<>();
|
||||
|
||||
@Min(value = 0, message = "应填人数不能为负")
|
||||
@Schema(description = "应填人数,用于与名册实际人数比对告警")
|
||||
private Integer expectedCount;
|
||||
|
||||
@@ -4,9 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
|
||||
*
|
||||
@@ -34,11 +31,6 @@ public class TaskOrgVO {
|
||||
|
||||
private String headUserName;
|
||||
|
||||
/** 副班主任 / 协办人。 */
|
||||
private List<Long> deputyUserIds = new ArrayList<>();
|
||||
|
||||
private List<String> deputyUserNames = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 应填人数。
|
||||
*
|
||||
|
||||
-11
@@ -44,17 +44,6 @@ public class CollectTaskOrg implements Serializable {
|
||||
/** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
|
||||
private Long headUserId;
|
||||
|
||||
/**
|
||||
* 副班主任 / 协办人,逗号分隔。
|
||||
*
|
||||
* <p>⚠️ 存的是<b>首尾都带逗号</b>的形式,如 {@code ,3,7,},不是 {@code 3,7}。
|
||||
* 原因:用 {@code LIKE '%,3,%'} 精确匹配某个 id 时,
|
||||
* 裸逗号分隔的 {@code 3,17} 会被子串误命中。
|
||||
* 首尾补逗号后每个 id 都被逗号夹住,不可能误匹配 ——
|
||||
* 这是个很小的选择,但错了会表现为"莫名其妙的协办人能看到别人的班"。
|
||||
*/
|
||||
private String deputyUserIds;
|
||||
|
||||
/** 应填人数(冗余,由名册汇总)。 */
|
||||
private Integer expectedCount;
|
||||
|
||||
|
||||
+6
-4
@@ -18,9 +18,14 @@ import java.util.List;
|
||||
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
|
||||
*
|
||||
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
|
||||
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
|
||||
* 全部业务表):任务本身不在此列(由 {@code TaskService} 删);
|
||||
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
|
||||
*
|
||||
* <p>⚠️ <b>删模块时必须回来删这里的一行</b>:2026-10 删掉「加入申请」模块、
|
||||
* V19 把 {@code collect_join_request} 表 DROP 掉之后,这里的
|
||||
* {@code deleteJoinRequests} 还在删那张表 —— 表现是**归档任务删不掉,接口返回 50000**,
|
||||
* 而报错信息完全不提是哪一行 SQL 引起的。表清单少了这张之后是 13 张。
|
||||
*
|
||||
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
|
||||
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
|
||||
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
|
||||
@@ -75,9 +80,6 @@ public interface TaskCascadeMapper {
|
||||
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
|
||||
int deleteSubmissions(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
|
||||
int deleteJoinRequests(@Param("taskId") Long taskId);
|
||||
|
||||
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
|
||||
int deleteRoster(@Param("taskId") Long taskId);
|
||||
|
||||
|
||||
+3
-4
@@ -13,7 +13,7 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
||||
/**
|
||||
* 某人在本租户下"能看到的"任务 id。
|
||||
*
|
||||
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确
|
||||
* <p><b>谁需要这个</b>:班主任。《03-接口设计》§7.1 明确
|
||||
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
|
||||
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
|
||||
*
|
||||
@@ -21,8 +21,8 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
||||
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
|
||||
* 不该要求每条都显式指定一遍。
|
||||
*
|
||||
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是
|
||||
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
|
||||
* <p>⚠️ 这里以前还有一条 {@code deputy_user_ids}(副班主任)分支,
|
||||
* 随该字段与角色一起删掉了 —— 现在**一个班只有一个初审人**。
|
||||
*
|
||||
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
|
||||
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
|
||||
@@ -38,7 +38,6 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
||||
AND cto.tenant_id = #{tenantId}
|
||||
AND (
|
||||
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
|
||||
OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
|
||||
)
|
||||
""")
|
||||
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
|
||||
|
||||
-3
@@ -136,9 +136,6 @@ public class TaskCloneService {
|
||||
copy.setOrgId(o.getOrgId());
|
||||
copy.setExpectedCount(o.getExpectedCount());
|
||||
copy.setHeadUserId(o.getHeadUserId());
|
||||
// ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id ——
|
||||
// 按"应该叫这个"猜字段名,编译期才会发现
|
||||
copy.setDeputyUserIds(o.getDeputyUserIds());
|
||||
copy.setStatus(o.getStatus());
|
||||
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
|
||||
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
|
||||
|
||||
+18
-83
@@ -27,7 +27,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -43,9 +42,11 @@ import java.util.Set;
|
||||
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
|
||||
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
|
||||
* 就出现在 A 校的审批流里。</li>
|
||||
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。
|
||||
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER})。
|
||||
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
|
||||
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li>
|
||||
* 学籍材料。放开这一条等于把授权能力变成任意授权。
|
||||
* ⚠️ 这里以前还接受 {@code DEPUTY_TEACHER}(副班主任);
|
||||
* 该角色与「协办人」字段一起删掉了,现在是**一个班一个初审人**。</li>
|
||||
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
|
||||
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
|
||||
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
|
||||
@@ -60,8 +61,8 @@ import java.util.Set;
|
||||
@RequiredArgsConstructor
|
||||
public class TaskOrgService {
|
||||
|
||||
/** 可以被指定为初审人的角色。 */
|
||||
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER", "DEPUTY_TEACHER");
|
||||
/** 可以被指定为初审人的角色。删掉副班主任后只剩班主任一个。 */
|
||||
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER");
|
||||
|
||||
/**
|
||||
* 覆盖范围可改的任务状态:草稿 / 收集中 / 已暂停。
|
||||
@@ -122,9 +123,9 @@ public class TaskOrgService {
|
||||
"任务已" + statusText(status) + ",覆盖班级不可再改");
|
||||
}
|
||||
|
||||
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req);
|
||||
normalizeAndValidate(req);
|
||||
|
||||
persist(taskId, req, deputiesOf);
|
||||
persist(taskId, req);
|
||||
|
||||
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
|
||||
req.getOrgs().size());
|
||||
@@ -133,9 +134,10 @@ public class TaskOrgService {
|
||||
/**
|
||||
* 参数归一化与全部校验。
|
||||
*
|
||||
* @return 每个班归一化后的副班主任 id 集合(已去重、去掉 null、并校验过与主班主任不重复)
|
||||
* <p>删掉「协办人」之后,这里只剩一件事:<b>每个班最多一个初审人</b>,
|
||||
* 且他必须是在本租户内、已启用、持班主任角色的账号。
|
||||
*/
|
||||
private Map<Long, Set<Long>> normalizeAndValidate(TaskOrgSaveReq req) {
|
||||
private void normalizeAndValidate(TaskOrgSaveReq req) {
|
||||
// 同一个班只能出现一次。不拦的话会因为唯一索引报错,而错误信息
|
||||
// 完全指不出是哪个班的问题(PG 只说 duplicate key)
|
||||
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||
@@ -148,32 +150,15 @@ public class TaskOrgService {
|
||||
|
||||
assertOrgsAreClasses(wanted.keySet());
|
||||
|
||||
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>();
|
||||
Set<Long> allUserIds = new LinkedHashSet<>();
|
||||
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
||||
TaskOrgSaveReq.Item item = entry.getValue();
|
||||
LinkedHashSet<Long> deputies = new LinkedHashSet<>();
|
||||
if (item.getDeputyUserIds() != null) {
|
||||
deputies.addAll(item.getDeputyUserIds());
|
||||
}
|
||||
deputies.removeIf(Objects::isNull);
|
||||
|
||||
if (item.getHeadUserId() != null && deputies.contains(item.getHeadUserId())) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"同一个班的主班主任与副班主任不能是同一人(userId=" + item.getHeadUserId() + ")");
|
||||
}
|
||||
deputiesOf.put(entry.getKey(), deputies);
|
||||
|
||||
Set<Long> headUserIds = new LinkedHashSet<>();
|
||||
for (TaskOrgSaveReq.Item item : wanted.values()) {
|
||||
if (item.getHeadUserId() != null) {
|
||||
allUserIds.add(item.getHeadUserId());
|
||||
headUserIds.add(item.getHeadUserId());
|
||||
}
|
||||
allUserIds.addAll(deputies);
|
||||
}
|
||||
|
||||
if (!allUserIds.isEmpty()) {
|
||||
assertAssignableTeachers(allUserIds);
|
||||
if (!headUserIds.isEmpty()) {
|
||||
assertAssignableTeachers(headUserIds);
|
||||
}
|
||||
return deputiesOf;
|
||||
}
|
||||
|
||||
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
|
||||
@@ -237,7 +222,7 @@ public class TaskOrgService {
|
||||
SysUser user = users.get(userId);
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
|
||||
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色");
|
||||
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」角色");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +236,7 @@ public class TaskOrgService {
|
||||
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
|
||||
* 这种"保存成功但没保存"是最难查的一类 bug。
|
||||
*/
|
||||
private void persist(Long taskId, TaskOrgSaveReq req, Map<Long, Set<Long>> deputiesOf) {
|
||||
private void persist(Long taskId, TaskOrgSaveReq req) {
|
||||
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
|
||||
wanted.put(item.getOrgId(), item);
|
||||
@@ -283,7 +268,6 @@ public class TaskOrgService {
|
||||
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
||||
Long orgId = entry.getKey();
|
||||
TaskOrgSaveReq.Item item = entry.getValue();
|
||||
String deputies = joinIds(deputiesOf.get(orgId));
|
||||
|
||||
CollectTaskOrg reuse = activeByOrg.get(orgId);
|
||||
if (reuse == null) {
|
||||
@@ -299,7 +283,6 @@ public class TaskOrgService {
|
||||
insert.setTaskId(taskId);
|
||||
insert.setOrgId(orgId);
|
||||
insert.setHeadUserId(item.getHeadUserId());
|
||||
insert.setDeputyUserIds(deputies);
|
||||
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
|
||||
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
|
||||
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
|
||||
@@ -311,7 +294,6 @@ public class TaskOrgService {
|
||||
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
|
||||
.eq(CollectTaskOrg::getId, reuse.getId())
|
||||
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
|
||||
.set(CollectTaskOrg::getDeputyUserIds, deputies)
|
||||
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
|
||||
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
|
||||
}
|
||||
@@ -327,15 +309,11 @@ public class TaskOrgService {
|
||||
|
||||
Set<Long> orgIds = new LinkedHashSet<>();
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
|
||||
for (CollectTaskOrg row : rows) {
|
||||
orgIds.add(row.getOrgId());
|
||||
List<Long> deputies = parseIds(row.getDeputyUserIds());
|
||||
deputiesOf.put(row.getId(), deputies);
|
||||
if (row.getHeadUserId() != null) {
|
||||
userIds.add(row.getHeadUserId());
|
||||
}
|
||||
userIds.addAll(deputies);
|
||||
}
|
||||
|
||||
Map<Long, String> orgNames = loadOrgNames(orgIds);
|
||||
@@ -348,11 +326,6 @@ public class TaskOrgService {
|
||||
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
|
||||
vo.setHeadUserId(row.getHeadUserId());
|
||||
vo.setHeadUserName(userNames.get(row.getHeadUserId()));
|
||||
List<Long> deputies = deputiesOf.get(row.getId());
|
||||
vo.setDeputyUserIds(deputies);
|
||||
vo.setDeputyUserNames(deputies.stream()
|
||||
.map(id -> userNames.getOrDefault(id, "(账号已删除)"))
|
||||
.toList());
|
||||
vo.setExpectedCount(row.getExpectedCount());
|
||||
vo.setStatus(row.getStatus());
|
||||
out.add(vo);
|
||||
@@ -396,44 +369,6 @@ public class TaskOrgService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逗号分隔 id 的序列化。
|
||||
*
|
||||
* <p>存的是<b>首尾都带逗号</b>的形式({@code ,3,7,}),不是 {@code 3,7}。
|
||||
* 原因:用 {@code LIKE '%,3,%'} 精确匹配时,裸逗号分隔的 {@code 3,17}
|
||||
* 会被子串误命中,让"莫名其妙的协办人能看到别人的班"。
|
||||
*/
|
||||
private static String joinIds(Collection<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(",");
|
||||
for (Long id : ids) {
|
||||
sb.append(id).append(',');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<Long> parseIds(String raw) {
|
||||
List<Long> out = new ArrayList<>();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return out;
|
||||
}
|
||||
for (String part : raw.split(",")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.add(Long.valueOf(trimmed));
|
||||
} catch (NumberFormatException e) {
|
||||
// 库里存了脏数据不该让整个列表 500;跳过并在日志里留痕
|
||||
log.warn("collect_task_org.deputy_user_ids 含非法 id:{}", trimmed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String statusText(int status) {
|
||||
return switch (status) {
|
||||
case CollectTask.STATUS_DRAFT -> "草稿";
|
||||
|
||||
+11
-5
@@ -216,7 +216,11 @@ public class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。
|
||||
* 是否看全租户:学校 / 机构管理员({@code user_type = 2})。平台运营不分管具体学校的任务,但也不该被拦。
|
||||
*
|
||||
* <p>⚠️ 这里以前判角色码 {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")}:
|
||||
* 「审核员」角色已删除,且租户自建的管理类角色也应看全租户 ——
|
||||
* 所以改成 {@link LoginUser#isTenantAdmin()}(判 {@code user_type})。
|
||||
*
|
||||
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
|
||||
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
|
||||
@@ -226,7 +230,7 @@ public class TaskService {
|
||||
*/
|
||||
private boolean seesWholeTenant() {
|
||||
LoginUser me = UserContext.require();
|
||||
return me.isPlatformStaff() || me.hasAnyRole("SCHOOL_ADMIN", "AUDITOR");
|
||||
return me.isPlatformStaff() || me.isTenantAdmin();
|
||||
}
|
||||
|
||||
// ── 详情 ──────────────────────────────────────
|
||||
@@ -427,7 +431,6 @@ public class TaskService {
|
||||
+ cascadeMapper.deleteExportFiles(id)
|
||||
+ cascadeMapper.deleteExportTasks(id)
|
||||
+ cascadeMapper.deleteSubmissions(id)
|
||||
+ cascadeMapper.deleteJoinRequests(id)
|
||||
+ cascadeMapper.deleteRoster(id)
|
||||
+ cascadeMapper.deleteFormSchemas(id)
|
||||
+ cascadeMapper.deleteMaterialDefs(id)
|
||||
@@ -685,7 +688,8 @@ public class TaskService {
|
||||
* **"系统到点关的"与"管理员手动关的"** —— 出了争议时这是第一个要看的信息。
|
||||
*/
|
||||
public void autoClose(Long taskId) {
|
||||
patchStatus(taskId, CollectTask.STATUS_CLOSED, "AUTO_CLOSE");
|
||||
patchStatus(taskId, CollectTask.STATUS_CLOSED,
|
||||
cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_AUTO_CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -715,7 +719,9 @@ public class TaskService {
|
||||
if (from == status) {
|
||||
return;
|
||||
}
|
||||
String act = action == null ? "STATUS_CHANGE" : action;
|
||||
String act = action == null
|
||||
? cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_STATUS_CHANGE
|
||||
: action;
|
||||
operationLog.record(cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
|
||||
act, cn.tianbaotong.fill.module.log.service.OperationLogService.TARGET_TASK, id,
|
||||
String.valueOf(from), String.valueOf(status));
|
||||
|
||||
+66
-4
@@ -4,6 +4,7 @@ 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.log.service.OperationLogService;
|
||||
import cn.tianbaotong.fill.module.tenant.dto.TenantCreateReq;
|
||||
import cn.tianbaotong.fill.module.tenant.dto.TenantVO;
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
@@ -48,6 +49,7 @@ public class TenantService {
|
||||
private final UserMapper userMapper;
|
||||
private final RoleProvisioner roleProvisioner;
|
||||
private final SensitiveCodec codec;
|
||||
private final OperationLogService operationLog;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
// ── 列表 ──────────────────────────────────────
|
||||
@@ -65,7 +67,10 @@ public class TenantService {
|
||||
.orderByDesc(SysTenant::getId);
|
||||
|
||||
if (expireBefore != null && !expireBefore.isBlank()) {
|
||||
query.le(SysTenant::getExpireTime, LocalDate.parse(expireBefore).atStartOfDay());
|
||||
// ⚠️ **含当天**:expire_time 存的是当天 23:59:59(见 parseExpire),
|
||||
// 曾经用 atStartOfDay() 比 —— 于是"今天到期"的租户查不出来(差一天)。
|
||||
// 同型的坑在 LogController.parseTo 上已经修过一次。
|
||||
query.le(SysTenant::getExpireTime, parseExpireBefore(expireBefore));
|
||||
}
|
||||
|
||||
IPage<SysTenant> page = tenantMapper.selectPage(new Page<>(num, size), query);
|
||||
@@ -120,19 +125,76 @@ public class TenantService {
|
||||
|
||||
log.info("已开通租户 {}(id={}),管理员账号 {}", code, tid, req.getAdminUsername());
|
||||
|
||||
// 留痕(F-1 点名「租户开通」是优先留痕点)。
|
||||
// ⚠️ 这行的 tenant_id 是 **0**(平台身份,不是刚建的这所学校)—— 写日志用的是
|
||||
// 操作者的上下文,而 TenantScope.runAs 已在 finally 里恢复了。
|
||||
// 要查"这所学校是谁开通的",用 target_type=TENANT & target_id=tid。
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_CREATE_TENANT,
|
||||
OperationLogService.TARGET_TENANT, tid, null,
|
||||
"tenantCode=" + code + "; name=" + t.getTenantName() + "; type=" + t.getTenantType());
|
||||
|
||||
return new CreateResult(tid, initialPassword, "t" + tid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户详情。
|
||||
*
|
||||
* <p>与列表的唯一区别:<b>这里返回联系电话的脱敏值</b>(列表恒为 {@code null},见 {@link #toVo})。
|
||||
*
|
||||
* <p>为什么给脱敏值而不是明文:这是全系统唯一一处「加密存储的联系方式」,
|
||||
* 若在详情里可读且不留痕,就与「看证件号明文必须留痕」({@code VIEW_IDCARD_FULL})
|
||||
* 这条既有规矩自相矛盾。脱敏号足够确认"档里有号",要改直接填新号 ——
|
||||
* {@code PUT} 留空 = 不修改,所以界面不需要回填明文。
|
||||
*/
|
||||
public TenantVO detail(Long id) {
|
||||
SysTenant t = tenantMapper.selectById(id);
|
||||
if (t == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "租户不存在");
|
||||
}
|
||||
return toVo(t);
|
||||
TenantVO vo = toVo(t);
|
||||
String plain = plainContactPhone(t.getContactPhone());
|
||||
vo.setContactPhone(plain == null ? null : MaskUtil.maskPhone(plain));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 解析 {@code expireBefore}({@code yyyy-MM-dd},**含当天**)。
|
||||
*
|
||||
* <p>⚠️ 格式不对时**明确报错**,不学 {@code LogController.parseTo} 的"当没传":
|
||||
* 那两个是界面上的文本框自由输入,静默忽略是为了"输错了也别让人看不到日志";
|
||||
* 而这里的值只由界面按当前日期算出来,非法值只可能来自手搓请求 ——
|
||||
* 静默忽略筛选会让人以为"我按到期日筛过了",那正是本仓反复批评的模式。
|
||||
*/
|
||||
private static LocalDateTime parseExpireBefore(String s) {
|
||||
try {
|
||||
return LocalDate.parse(s.trim()).atTime(23, 59, 59);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "到期日期格式不对,应为 2026-12-31");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取联系电话明文(**只给详情用**)。
|
||||
*
|
||||
* <p>这一列是加密列,但库里存在历史明文行(本机 id=1 就是),直接 {@code decrypt}
|
||||
* 会在 GCM 的 Base64 解码处抛 —— 而"解密失败"在这里不该变成 500:
|
||||
* 那会让一所学校的详情页**永远打不开**。所以失败时按明文返回,
|
||||
* 并记一条 WARN 让"还剩多少明文行"可见(历史行不做数据迁移:迁移里拿不到 AES 密钥)。
|
||||
*/
|
||||
private String plainContactPhone(String stored) {
|
||||
if (stored == null || stored.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return codec.decrypt(stored);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("租户联系电话不是密文(历史明文行或密钥变更过),按明文返回:{}", e.toString());
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime parseExpire(String s) {
|
||||
try {
|
||||
// 允许 "2027-12-31" 或 "2027-12-31T23:59:59"
|
||||
@@ -151,8 +213,8 @@ public class TenantService {
|
||||
vo.setTenantName(t.getTenantName());
|
||||
vo.setTenantType(t.getTenantType());
|
||||
vo.setContactName(t.getContactName());
|
||||
// 联系电话以密文入库,列表页要求脱敏展示时的处理在导出/详情里做,
|
||||
// 这里不返回明文,避免列表接口成为批量拉取联系方式的通道
|
||||
// 联系电话以密文入库:**列表恒不返回**(脱敏值也不行),
|
||||
// 避免列表接口成为批量拉取联系方式的通道。脱敏值只有 detail() 给。
|
||||
vo.setContactPhone(null);
|
||||
vo.setPackageId(t.getPackageId());
|
||||
vo.setExpireTime(t.getExpireTime());
|
||||
|
||||
+100
-15
@@ -4,10 +4,13 @@ import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.file.entity.CollectFile;
|
||||
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
||||
import cn.tianbaotong.fill.module.log.service.OperationLogService;
|
||||
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import cn.tianbaotong.fill.support.TenantScope;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -18,6 +21,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 租户用量统计(《03-接口设计》§6.7)。
|
||||
@@ -45,6 +49,8 @@ public class TenantUsageService {
|
||||
private final TenantMapper tenantMapper;
|
||||
private final CollectRosterMapper rosterMapper;
|
||||
private final FileMapper fileMapper;
|
||||
private final SensitiveCodec codec;
|
||||
private final OperationLogService operationLog;
|
||||
|
||||
/** 默认套餐额度(没有配套餐时的兜底)。 */
|
||||
private static final long DEFAULT_STUDENT_LIMIT = 2000L;
|
||||
@@ -64,15 +70,22 @@ public class TenantUsageService {
|
||||
vo.setTenantName(t.getTenantName());
|
||||
vo.setPeriod(p.text());
|
||||
|
||||
// 学生数:**按证件号去重**(同一个人可能在多个任务里各有一行名册)
|
||||
vo.setStudentCount(distinctStudents());
|
||||
|
||||
// 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)
|
||||
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
|
||||
.select(CollectFile::getFinalSize))
|
||||
.stream().map(CollectFile::getFinalSize)
|
||||
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
|
||||
vo.setStorageMb(bytes / 1024 / 1024);
|
||||
// ⚠️ 这两项必须**切到目标租户的上下文里**再查,不能直接用当前请求的身份查。
|
||||
//
|
||||
// 原因:本接口只有平台运营能调(@RequirePerm(TENANT_VIEW),那个码在
|
||||
// PermissionDef.platformOnlyCodes() 里),而平台运营的 tenantId 是 0 ——
|
||||
// FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true,
|
||||
// **整张表的租户过滤被关掉**。于是"学生数 / 存储量"会算成**全平台**的数,
|
||||
// 却挂在某一所学校的名下:运营拿着它去谈续费,而数字是假的。
|
||||
//
|
||||
// 这个 bug 在本地看不见 —— 库里只有一所学校有数据时,它与正确值完全相等
|
||||
// (ActingTenantResolver 的注释里描述的是同一个陷阱)。
|
||||
// 切成目标租户之后 isPlatformAdmin() 变 false,租户过滤自动恢复,
|
||||
// 不需要给这些查询手写 tenant_id 条件(那样等于放弃拦截器的保护)。
|
||||
Scope scope = TenantScope.callAs(tenantId, 0L, Set.of(),
|
||||
() -> new Scope(distinctStudents(), storageMb()));
|
||||
vo.setStudentCount(scope.students());
|
||||
vo.setStorageMb(scope.storageMb());
|
||||
|
||||
// OCR / 短信:一期没有通道,如实返回 0(见类注释)
|
||||
vo.setOcrCount(0);
|
||||
@@ -94,6 +107,19 @@ public class TenantUsageService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 一次上下文切换里取到的两个「本来就是租户内」的数字。 */
|
||||
private record Scope(long students, long storageMb) {
|
||||
}
|
||||
|
||||
/** 存储:材料规范件的体积合计(原始件默认不保留,所以它才是真实占用)。 */
|
||||
private long storageMb() {
|
||||
Long bytes = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
|
||||
.select(CollectFile::getFinalSize))
|
||||
.stream().map(CollectFile::getFinalSize)
|
||||
.filter(java.util.Objects::nonNull).mapToLong(Long::longValue).sum();
|
||||
return bytes / 1024 / 1024;
|
||||
}
|
||||
|
||||
/** 学生数:按 `id_card_hash` 去重(同一个人在多任务里只算一次)。 */
|
||||
private long distinctStudents() {
|
||||
// 单独写一句 SQL 更省事,但这里刻意用 MyBatis-Plus 的写法:
|
||||
@@ -152,6 +178,7 @@ public class TenantUsageService {
|
||||
if (req == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体为空");
|
||||
}
|
||||
Integer oldStatus = exist.getStatus();
|
||||
SysTenant upd = new SysTenant();
|
||||
upd.setId(id);
|
||||
if (req.getTenantName() != null) {
|
||||
@@ -161,21 +188,34 @@ public class TenantUsageService {
|
||||
upd.setTenantName(req.getTenantName().trim());
|
||||
}
|
||||
if (req.getTenantType() != null) {
|
||||
if (req.getTenantType() < 1 || req.getTenantType() > 3) {
|
||||
// 取值以 SysTenant.tenantType 的注释为准(1 学校 / 2 认定机构 / 3 报名点 / 4 企业)。
|
||||
// ⚠️ 这里曾经只放行 1~3,而 DDL 默认值与实体注释都是 4 值 ——
|
||||
// 于是"认定机构 / 报名点 / 企业"在前端下拉里选得到,保存却报错。
|
||||
if (req.getTenantType() < 1 || req.getTenantType() > 4) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"租户类型只能是 1(学校)/ 2(机构)/ 3(企业)");
|
||||
"租户类型只能是 1(学校)/ 2(认定机构)/ 3(报名点)/ 4(企业)");
|
||||
}
|
||||
upd.setTenantType(req.getTenantType());
|
||||
}
|
||||
upd.setContactName(req.getContactName());
|
||||
upd.setContactPhone(req.getContactPhone());
|
||||
// ⚠️ contact_phone 是**加密列**(见 V1__business_tables.sql 的列注释)。
|
||||
// 这里曾经是 `upd.setContactPhone(req.getContactPhone())` —— 明文直接落库,
|
||||
// 而读回来的路径会在 AesGcmCipher.decrypt 里 Base64 解不开直接抛,
|
||||
// 表现是「保存成功,但详情页从此打不开」。空值 = **不修改**(不是清空)。
|
||||
if (req.getContactPhone() != null && !req.getContactPhone().isBlank()) {
|
||||
upd.setContactPhone(codec.encrypt(req.getContactPhone().trim()));
|
||||
}
|
||||
upd.setPackageId(req.getPackageId());
|
||||
upd.setExpireTime(req.getExpireTime());
|
||||
upd.setLogoUrl(req.getLogoUrl());
|
||||
upd.setConfigJson(req.getConfigJson());
|
||||
if (req.getStatus() != null) {
|
||||
if (req.getStatus() != 0 && req.getStatus() != 1) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(启用)");
|
||||
// 取值以 SysTenant.status 的注释为准(0 停用 / 1 正常 / 2 试用)。
|
||||
// ⚠️ 这里曾经只放行 0/1 —— 而前端的状态筛选按钮组与 TENANT_STATUS 都有"试用",
|
||||
// 结果是"试用"建不出来也改不出来。
|
||||
if (req.getStatus() != 0 && req.getStatus() != 1 && req.getStatus() != 2) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"状态只能是 0(停用)/ 1(正常)/ 2(试用)");
|
||||
}
|
||||
upd.setStatus(req.getStatus());
|
||||
if (req.getStatus() == 0) {
|
||||
@@ -187,7 +227,52 @@ public class TenantUsageService {
|
||||
tenantMapper.updateById(upd);
|
||||
log.info("修改租户 id={} 名称={} 状态={} 到期={}", id, upd.getTenantName(),
|
||||
upd.getStatus(), upd.getExpireTime());
|
||||
return tenantMapper.selectById(id);
|
||||
|
||||
SysTenant now = tenantMapper.selectById(id);
|
||||
recordChange(id, oldStatus, exist, now, req);
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* 租户变更留痕。
|
||||
*
|
||||
* <p>《08-完成度盘点》的 F-1 点名「租户开通」是优先留痕点,而本模块此前
|
||||
* <b>一条日志都不写</b> —— 开通一所学校、把到期日往后挪、停用一所学校,
|
||||
* 都不留痕,这是整套系统里影响面最大的几类动作之一。
|
||||
*/
|
||||
private void recordChange(Long id, Integer oldStatus, SysTenant before, SysTenant after, SysTenant req) {
|
||||
Integer newStatus = req.getStatus();
|
||||
if (newStatus != null && !newStatus.equals(oldStatus)) {
|
||||
String action = switch (newStatus) {
|
||||
case 0 -> OperationLogService.ACTION_DISABLE_TENANT;
|
||||
case 1 -> OperationLogService.ACTION_ENABLE_TENANT;
|
||||
default -> OperationLogService.ACTION_UPDATE_TENANT;
|
||||
};
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, action,
|
||||
OperationLogService.TARGET_TENANT, id,
|
||||
"status=" + oldStatus, "status=" + newStatus);
|
||||
return;
|
||||
}
|
||||
// 手机号在库里是密文,摘要里只记「有没有设置」(写密文进日志既没用又难看),
|
||||
// 所以"换了一个号"这件事比较不出来 —— 用请求里有没有带它来补一条。
|
||||
boolean phoneChanged = req.getContactPhone() != null && !req.getContactPhone().isBlank();
|
||||
String b = summary(before);
|
||||
String a = summary(after) + (phoneChanged ? "; phone=已更新" : "");
|
||||
if (!b.equals(a)) {
|
||||
operationLog.record(OperationLogService.MODULE_TENANT, OperationLogService.ACTION_UPDATE_TENANT,
|
||||
OperationLogService.TARGET_TENANT, id, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
/** 变更摘要(`k=v; k=v`)。`OperationLogService.mask` 会再兜底脱敏一次并截断到列宽。 */
|
||||
private static String summary(SysTenant t) {
|
||||
return "name=" + t.getTenantName()
|
||||
+ "; type=" + t.getTenantType()
|
||||
+ "; contact=" + t.getContactName()
|
||||
+ "; phone=" + (t.getContactPhone() == null || t.getContactPhone().isBlank() ? "-" : "已设置")
|
||||
+ "; package=" + t.getPackageId()
|
||||
+ "; expire=" + t.getExpireTime()
|
||||
+ "; status=" + t.getStatus();
|
||||
}
|
||||
|
||||
// ── 周期解析 ──
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package cn.tianbaotong.fill.module.user.controller;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
|
||||
import cn.tianbaotong.fill.module.user.service.RoleService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 角色与授权(二期)。
|
||||
*
|
||||
* <p><b>权限怎么分</b>:读列表用 {@code role:manage} **或** {@code user:view}
|
||||
* (账号管理页的角色下拉也要这份数据,没有它那个下拉就是空的);
|
||||
* 看权限码目录与所有写操作用 {@code role:manage}。
|
||||
*
|
||||
* <p>内置角色只读({@code roleType=1}):可以看它的权限矩阵,改不了 ——
|
||||
* 这是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。
|
||||
*/
|
||||
@Tag(name = "角色与授权")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class RoleController {
|
||||
|
||||
private final RoleService roleService;
|
||||
|
||||
@GetMapping("/roles")
|
||||
@RequirePerm({PermissionDef.ROLE_MANAGE, PermissionDef.USER_VIEW, PermissionDef.USER_MANAGE})
|
||||
@Operation(summary = "角色列表",
|
||||
description = "内置角色 + 自定义角色。内置角色的权限与菜单取代码定义(库里那份只是可审计),"
|
||||
+ "所以它永远显示「现在真正生效」的那一套")
|
||||
public ApiResult<List<RoleDtos.RoleVO>> list() {
|
||||
return ApiResult.ok(roleService.list());
|
||||
}
|
||||
|
||||
@GetMapping("/permissions")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "权限码目录",
|
||||
description = "按分组返回全部权限码(含中文名与适用端)。platformOnly=true 的码学校租户不可授予")
|
||||
public ApiResult<List<RoleDtos.PermGroupVO>> permissions() {
|
||||
return ApiResult.ok(roleService.permissionCatalog());
|
||||
}
|
||||
|
||||
@PostMapping("/roles")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "新增自定义角色",
|
||||
description = "角色码创建后不可改;学校租户不能授予平台级权限,也不能建平台运营类的角色")
|
||||
public ApiResult<Long> create(@Valid @RequestBody RoleDtos.RoleCreateReq req) {
|
||||
return ApiResult.ok(roleService.create(req));
|
||||
}
|
||||
|
||||
@PutMapping("/roles/{id}")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "修改自定义角色",
|
||||
description = "只改名称/用户类型/备注;角色码与权限走各自的接口。"
|
||||
+ "角色还有账号在用时不改「用户类型」(会让账号与角色对不上)")
|
||||
public ApiResult<Void> update(@PathVariable Long id, @Valid @RequestBody RoleDtos.RoleUpdateReq req) {
|
||||
roleService.update(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/roles/{id}")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "删除自定义角色",
|
||||
description = "还有账号在用返回 40045;不允许删掉自己进入本页所依赖的最后一个角色")
|
||||
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||
roleService.delete(id);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/roles/{id}/permissions")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "设置角色的权限码(覆盖式)",
|
||||
description = "改完立即生效(服务端会清掉该角色的授权缓存),不需要重新登录")
|
||||
public ApiResult<Void> setPermissions(@PathVariable Long id,
|
||||
@Valid @RequestBody RoleDtos.RolePermReq req) {
|
||||
roleService.setPermissions(id, req.getPermCodes());
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/roles/{id}/menus")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "设置角色的可见菜单(覆盖式)",
|
||||
description = "菜单只决定「看得见哪些入口」,不代表有权限操作;改完立即生效")
|
||||
public ApiResult<Void> setMenus(@PathVariable Long id,
|
||||
@Valid @RequestBody RoleDtos.RoleMenuReq req) {
|
||||
roleService.setMenus(id, req.getMenus());
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/roles/{id}/status")
|
||||
@RequirePerm(PermissionDef.ROLE_MANAGE)
|
||||
@Operation(summary = "启用 / 停用角色",
|
||||
description = "停用后该角色立刻不再给权限(不必等 token 过期);内置角色不可停用")
|
||||
public ApiResult<Void> setStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||
roleService.setStatus(id, status);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package cn.tianbaotong.fill.module.user.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.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 「角色与授权」(二期)的请求与响应。
|
||||
*
|
||||
* <p>⚠️ <b>内置角色只读</b>:{@code roleType=1} 的角色在界面上可以看、不可以改
|
||||
* (权限由代码决定,见 {@code BuiltinRole})。这不是"暂时没做",是刻意的 ——
|
||||
* 一次误操作就能把管理员锁在系统外,而这类错误不可逆。
|
||||
*
|
||||
* <p>⚠️ <b>{@code dataScope} 本期只展示、不可配</b>:数据范围(本人/本班/本院系/全租户)
|
||||
* 目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处;
|
||||
* 把它做成可配需要先把那些判据收口(单独一批做)。所以这里**不提供**修改入口 ——
|
||||
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
|
||||
*/
|
||||
public final class RoleDtos {
|
||||
|
||||
private RoleDtos() {
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "角色列表项")
|
||||
public static class RoleVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String roleCode;
|
||||
|
||||
private String roleName;
|
||||
|
||||
/** {@code 1} 内置(只读)/ {@code 2} 自定义。 */
|
||||
private Integer roleType;
|
||||
|
||||
/** {@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。 */
|
||||
private Integer userType;
|
||||
|
||||
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人(本期只展示)。 */
|
||||
private Integer dataScope;
|
||||
|
||||
/** {@code 0} 停用 / {@code 1} 正常。 */
|
||||
private Integer status;
|
||||
|
||||
private Integer sortNo;
|
||||
|
||||
private String remark;
|
||||
|
||||
/** 有多少账号在用(删除前的依据)。 */
|
||||
private Integer userCount;
|
||||
|
||||
/** 能否编辑 / 删除 / 停用(内置角色恒 {@code false})。 */
|
||||
private Boolean editable;
|
||||
|
||||
/**
|
||||
* 能否发给账号。
|
||||
*
|
||||
* <p>与 {@link #editable} 是两件事:内置的「平台运营」角色**可以看**(列表里列出来),
|
||||
* 但在学校租户里**发不出去**(服务端 40301 —— 拿到它会直接越权到平台级)。
|
||||
* 界面据此不显示没得选的项,而不是让人选完再收到一个错误。
|
||||
*/
|
||||
private Boolean assignable;
|
||||
|
||||
/** 权限码:内置角色取代码定义,自定义角色取库里的授权。 */
|
||||
private List<String> permCodes;
|
||||
|
||||
/** 可见菜单键(与前端 {@code XtMenu.key} 一致)。 */
|
||||
private List<String> menus;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "新增自定义角色")
|
||||
public static class RoleCreateReq {
|
||||
|
||||
/**
|
||||
* 角色码。
|
||||
*
|
||||
* <p>格式对齐内置角色的风格(大写 + 下划线):它会进 JWT 的 {@code roles}、
|
||||
* 出现在日志与排查对话里,混进小写或中文会让"这串东西是什么"变得难以检索。
|
||||
*/
|
||||
@NotBlank(message = "请填写角色码")
|
||||
@Size(max = 64, message = "角色码过长")
|
||||
@Pattern(regexp = "^[A-Z][A-Z0-9_]{1,63}$",
|
||||
message = "角色码只能是大写字母、数字与下划线,且以字母开头")
|
||||
@Schema(description = "角色码,租户内唯一,创建后不可改", example = "CLASS_AUDITOR",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String roleCode;
|
||||
|
||||
@NotBlank(message = "请填写角色名称")
|
||||
@Size(max = 64, message = "角色名称过长")
|
||||
@Schema(description = "角色名称(显示用,可改)", example = "年级审核员",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String roleName;
|
||||
|
||||
@NotNull(message = "请选择用户类型")
|
||||
@Min(value = 1, message = "用户类型不合法")
|
||||
@Max(value = 4, message = "用户类型不合法")
|
||||
@Schema(description = "该角色对应的用户类型(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生);"
|
||||
+ "账号的 userType 由它推导,所以必须选对", example = "3",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer userType;
|
||||
|
||||
@Size(max = 255, message = "备注过长")
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
@NotEmpty(message = "请至少勾选一项权限")
|
||||
@Schema(description = "权限码清单(学校租户不能包含平台级权限,见 /permissions 的 platformOnly)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> permCodes;
|
||||
|
||||
@NotEmpty(message = "请至少勾选一个菜单")
|
||||
@Schema(description = "可见菜单键(决定登录后左侧能看到哪些入口,不代表有权限操作)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> menus;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "修改自定义角色(角色码不可改)")
|
||||
public static class RoleUpdateReq {
|
||||
|
||||
@NotBlank(message = "请填写角色名称")
|
||||
@Size(max = 64, message = "角色名称过长")
|
||||
@Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String roleName;
|
||||
|
||||
@NotNull(message = "请选择用户类型")
|
||||
@Min(value = 1, message = "用户类型不合法")
|
||||
@Max(value = 4, message = "用户类型不合法")
|
||||
@Schema(description = "该角色对应的用户类型", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer userType;
|
||||
|
||||
@Size(max = 255, message = "备注过长")
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "设置角色的权限码(覆盖式)")
|
||||
public static class RolePermReq {
|
||||
|
||||
@NotNull(message = "请传 permCodes(可以是空数组)")
|
||||
@Schema(description = "权限码清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> permCodes;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "设置角色的可见菜单(覆盖式)")
|
||||
public static class RoleMenuReq {
|
||||
|
||||
@NotNull(message = "请传 menus(可以是空数组)")
|
||||
@Schema(description = "菜单键清单,覆盖式替换", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> menus;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "权限码目录(按分组,供角色授权界面渲染)")
|
||||
public static class PermGroupVO {
|
||||
|
||||
private String group;
|
||||
|
||||
private List<PermVO> items;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "权限码")
|
||||
public static class PermVO {
|
||||
|
||||
private String code;
|
||||
|
||||
private String label;
|
||||
|
||||
/** {@code PC} / {@code MP} / {@code BOTH}。 */
|
||||
private String platform;
|
||||
|
||||
/** 平台级权限:学校租户不可授予(界面要标出来并禁止勾选)。 */
|
||||
private Boolean platformOnly;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@ public class SysRole extends TenantEntity {
|
||||
/** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
|
||||
private Integer roleType;
|
||||
|
||||
/**
|
||||
* 角色对应的用户类型:{@code 1} 平台运营 / {@code 2} 学校管理员 / {@code 3} 班主任 / {@code 4} 学生。
|
||||
*
|
||||
* <p>存在的理由:{@code sys_user.user_type} 是 NOT NULL,而它的值一直是"从角色反推"的
|
||||
* (见 {@code BuiltinRole.UserType})。自定义角色不在代码映射里,反推不出来 ——
|
||||
* 没有这一列,"角色建得出来、却发不出去"。
|
||||
*
|
||||
* <p>⚠️ 内置角色的权威仍是代码({@code BuiltinRole.getUserType()});
|
||||
* 这列对内置角色只用于"看得见、可审计"。
|
||||
*/
|
||||
private Integer userType;
|
||||
|
||||
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
|
||||
private Integer dataScope;
|
||||
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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_menu},二期「角色与授权」)。
|
||||
*
|
||||
* <p>菜单键本身的定义在代码里({@code BuiltinRole} 的菜单集合,与前端
|
||||
* {@code XtMenu.key} 逐字一致);这张表只存「这个角色能看见哪些入口」。
|
||||
*
|
||||
* <p>⚠️ <b>看得见 ≠ 有权限</b>:真正的访问控制仍在服务端的权限码校验上,
|
||||
* 菜单只是"不显示没权限的入口"(绕过前端只会拿到 40301)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_role_menu")
|
||||
public class SysRoleMenu extends TenantEntity {
|
||||
|
||||
private Long roleId;
|
||||
|
||||
/** 菜单键,如 {@code task}。 */
|
||||
private String menuKey;
|
||||
}
|
||||
+45
@@ -2,6 +2,51 @@ package cn.tianbaotong.fill.module.user.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRole;
|
||||
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 RoleMapper extends BaseMapper<SysRole> {
|
||||
|
||||
/**
|
||||
* 每个角色被多少账号使用(角色列表页展示"能不能删"的依据)。
|
||||
*
|
||||
* <p>一次查完再在内存里配对,而不是"每个角色查一次" —— 角色列表是
|
||||
* "1 个页面 N 行"的形状,逐行查就是 N+1(本项目对 N+1 有明确的压测结论,见《09》§9.5)。
|
||||
*
|
||||
* <p>⚠️ 别名写成 {@code user_count}(蛇形)而不是 {@code userCount}:
|
||||
* PostgreSQL 会把**不加引号的别名折成小写**(`userCount` → `usercount`),
|
||||
* 而 MyBatis 的蛇形→驼峰映射只认 `user_count`。这个坑本项目踩过两次(《08》F-54 / F-83)。
|
||||
*/
|
||||
@Select("""
|
||||
SELECT ur.role_id, COUNT(*) AS user_count
|
||||
FROM sys_user_role ur
|
||||
JOIN sys_user u ON u.id = ur.user_id AND u.deleted = 0
|
||||
WHERE ur.deleted = 0 AND ur.tenant_id = #{tenantId}
|
||||
GROUP BY ur.role_id
|
||||
""")
|
||||
List<RoleUserCount> countUsersByRole(@Param("tenantId") Long tenantId);
|
||||
|
||||
/** 上面那条查询的结果行。 */
|
||||
class RoleUserCount {
|
||||
private Long roleId;
|
||||
private Integer userCount;
|
||||
|
||||
public Long getRoleId() {
|
||||
return roleId;
|
||||
}
|
||||
|
||||
public void setRoleId(Long roleId) {
|
||||
this.roleId = roleId;
|
||||
}
|
||||
|
||||
public Integer getUserCount() {
|
||||
return userCount;
|
||||
}
|
||||
|
||||
public void setUserCount(Integer userCount) {
|
||||
this.userCount = userCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.tianbaotong.fill.module.user.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 角色菜单({@code sys_role_menu})。
|
||||
*
|
||||
* <p>与 {@code RolePermissionMapper} 一样,只做最基础的事 —— 菜单键的定义在代码里,
|
||||
* 所以没有"菜单字典表",也没有联表查询。
|
||||
*/
|
||||
public interface RoleMenuMapper extends BaseMapper<SysRoleMenu> {
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package cn.tianbaotong.fill.module.user.service;
|
||||
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRole;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
|
||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||
import cn.tianbaotong.fill.security.PermissionDef;
|
||||
import cn.tianbaotong.fill.security.RoleGrantSource;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 自定义角色授权(权限码 + 菜单)的库实现({@link RoleGrantSource})。
|
||||
*
|
||||
* <h3>为什么必须有缓存</h3>
|
||||
*
|
||||
* <p>权限判定发生在**每一个**受保护请求上({@code AuthInterceptor} → {@code PermissionChecker}),
|
||||
* 而它此前是**零 IO** 的(权限由代码 {@code BuiltinRole} 决定)。开放自定义角色之后,
|
||||
* "这个角色被授了什么"只能从库里读 —— 不加缓存就是每个请求多 1~2 条 SQL,
|
||||
* 与本项目的压测基线(SQL 条数恒定、DB ≤2ms,见《09-部署与运维》§9.5)直接冲突。
|
||||
*
|
||||
* <p>缓存放 Redis(不是 JVM 内存):① 多实例部署时各存一份内存缓存,
|
||||
* "改了权限但只有一台机器生效"是那种最难查的错;② 本项目**本来就每请求读一次 Redis**
|
||||
* (登出黑名单 {@code TokenBlacklist} 用的是 {@code hasKey}),所以这是同一量级的开销。
|
||||
* 权限与菜单合并成**一个**缓存项:{@code /auth/me} 两样都要,分开存就是两次往返。
|
||||
*
|
||||
* <h3>三个必须守住的细节</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>空结果也要缓存</b>({@link #EMPTY})。不缓存"没有"的话,
|
||||
* 一个已被删除角色的 token(8 小时内仍有效)会把库打穿 —— 每个请求都回查一次。</li>
|
||||
* <li><b>Redis 挂了要退化成直读库</b>,不能当成"没有权限"(那会让全校忽然登不进去),
|
||||
* 也不能当成"有权限"(那是越权)。所以缓存读写失败只记一行 warn,然后照常查库。</li>
|
||||
* <li><b>TTL 是兜底,不是机制</b>:正常路径靠写操作后的 {@link #evict} 立即失效;
|
||||
* TTL 只保证"万一漏了 evict"也不会永久生效一个错的值。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>⚠️ 内置角色({@code role_type=1})**不读这两张表**:它们的权限与菜单由代码决定。
|
||||
* 库里那些行是为了"看得见、可审计",不是生效依据 —— 详见 {@link RoleGrantSource}。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DbRoleGrantSource implements RoleGrantSource {
|
||||
|
||||
/** 缓存键:{@code xt:role:grant:{tenantId}:{roleCode}}。与 {@code xt:jwt:bl:} 等同一套命名。 */
|
||||
static final String KEY_PREFIX = "xt:role:grant:";
|
||||
|
||||
/** 空结果标记。用 {@code -} 而不是空串:空串与"没缓存"在 Redis 里不好区分。 */
|
||||
static final String EMPTY = "-";
|
||||
|
||||
/** 集合内部的分隔符。权限码与菜单键都不含逗号,所以拼接是安全的(省掉一次 JSON 解析)。 */
|
||||
static final String SEP = ",";
|
||||
|
||||
/** 权限与菜单之间的分隔符。 */
|
||||
static final String PART_SEP = "|";
|
||||
|
||||
/** 兜底 TTL:漏了 evict 也不会永久生效一个错的值。 */
|
||||
private static final Duration TTL = Duration.ofMinutes(5);
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
private final RoleMapper roleMapper;
|
||||
private final RolePermissionMapper rolePermissionMapper;
|
||||
private final RoleMenuMapper roleMenuMapper;
|
||||
|
||||
public DbRoleGrantSource(StringRedisTemplate redis, RoleMapper roleMapper,
|
||||
RolePermissionMapper rolePermissionMapper, RoleMenuMapper roleMenuMapper) {
|
||||
this.redis = redis;
|
||||
this.roleMapper = roleMapper;
|
||||
this.rolePermissionMapper = rolePermissionMapper;
|
||||
this.roleMenuMapper = roleMenuMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> permsOf(long tenantId, String roleCode) {
|
||||
return grant(tenantId, roleCode).perms();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> menusOf(long tenantId, String roleCode) {
|
||||
return grant(tenantId, roleCode).menus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 让某个角色的授权缓存立即失效(权限与菜单一起)。
|
||||
*
|
||||
* <p>**每一次**改授权、改菜单、改状态、删角色之后都必须调它 ——
|
||||
* 否则"我明明把权限去掉了,他怎么还能点"会持续到 TTL 到期(最多 5 分钟),
|
||||
* 而这类现象会被当成"系统没保存"。
|
||||
*/
|
||||
public void evict(long tenantId, String roleCode) {
|
||||
if (roleCode == null || roleCode.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
redis.delete(KEY_PREFIX + tenantId + ":" + roleCode);
|
||||
} catch (RuntimeException e) {
|
||||
// 删不掉不是致命错误:TTL 会兜住(5 分钟后自然失效)
|
||||
log.warn("清理角色授权缓存失败(TTL 到期后会自然失效):tenant={} role={} 原因={}",
|
||||
tenantId, roleCode, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private Grant grant(long tenantId, String roleCode) {
|
||||
if (roleCode == null || roleCode.isBlank()) {
|
||||
return Grant.EMPTY;
|
||||
}
|
||||
String key = KEY_PREFIX + tenantId + ":" + roleCode;
|
||||
String cached = readCache(key);
|
||||
if (cached != null) {
|
||||
return decode(cached);
|
||||
}
|
||||
Grant fresh = loadFromDb(tenantId, roleCode);
|
||||
writeCache(key, encode(fresh));
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private Grant loadFromDb(long tenantId, String roleCode) {
|
||||
// 角色是租户隔离的,而这里可能跑在平台上下文(定时任务)里 —— 拦截器那时不生效,
|
||||
// 所以 tenant_id 必须**显式**写进条件,不能只靠拦截器。
|
||||
SysRole role = roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
.eq(SysRole::getRoleCode, roleCode)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
// 只有自定义角色(role_type=2)的授权存在库里;
|
||||
// 内置角色即使库里有行也不作数(权限与菜单都由代码决定)。
|
||||
if (role == null || role.getRoleType() == null || role.getRoleType() != 2) {
|
||||
return Grant.EMPTY;
|
||||
}
|
||||
// 停用的角色 = 不生效。放在这里而不是只在登录时判:停用要**立刻**生效,
|
||||
// 否则"把他那个角色停掉"要等到 token 过期(PC 最长 8 小时)。
|
||||
if (role.getStatus() != null && role.getStatus() == 0) {
|
||||
return Grant.EMPTY;
|
||||
}
|
||||
|
||||
return new Grant(loadPerms(tenantId, role.getId()), loadMenus(tenantId, role.getId()));
|
||||
}
|
||||
|
||||
private Set<String> loadPerms(long tenantId, Long roleId) {
|
||||
List<SysRolePermission> rows = rolePermissionMapper.selectList(
|
||||
Wrappers.<SysRolePermission>lambdaQuery()
|
||||
.eq(SysRolePermission::getTenantId, tenantId)
|
||||
.eq(SysRolePermission::getRoleId, roleId));
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
for (SysRolePermission rp : rows) {
|
||||
// 只认代码里还存在的权限码:{@code @RequirePerm} 只可能校验枚举里的码,
|
||||
// 把库里残留的一个已删除码塞进"当前用户的权限全集"只会污染 /auth/me。
|
||||
if (rp.getPermCode() != null && PermissionDef.ofCode(rp.getPermCode()) != null) {
|
||||
codes.add(rp.getPermCode());
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
private Set<String> loadMenus(long tenantId, Long roleId) {
|
||||
List<SysRoleMenu> rows = roleMenuMapper.selectList(
|
||||
Wrappers.<SysRoleMenu>lambdaQuery()
|
||||
.eq(SysRoleMenu::getTenantId, tenantId)
|
||||
.eq(SysRoleMenu::getRoleId, roleId));
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Set<String> known = BuiltinRole.allMenuKeys();
|
||||
for (SysRoleMenu rm : rows) {
|
||||
// 同上:只认现存的菜单键(XT_MENUS 里没有的键,前端渲染不出来)
|
||||
if (rm.getMenuKey() != null && known.contains(rm.getMenuKey())) {
|
||||
keys.add(rm.getMenuKey());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private String readCache(String key) {
|
||||
try {
|
||||
return redis.opsForValue().get(key);
|
||||
} catch (RuntimeException e) {
|
||||
// 降级为直读库:**不能**当成"没有权限"(全校忽然登不进去),
|
||||
// 也**不能**当成"有权限"(越权)。查库是唯一两个方向都安全的选择。
|
||||
log.warn("读角色授权缓存失败,本次直读数据库:key={} 原因={}", key, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCache(String key, String value) {
|
||||
try {
|
||||
redis.opsForValue().set(key, value, TTL);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("写角色授权缓存失败(不影响本次判定):key={} 原因={}", key, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(Grant g) {
|
||||
return encodeSet(g.perms()) + PART_SEP + encodeSet(g.menus());
|
||||
}
|
||||
|
||||
private static String encodeSet(Set<String> values) {
|
||||
return values.isEmpty() ? EMPTY : String.join(SEP, values);
|
||||
}
|
||||
|
||||
private static Grant decode(String raw) {
|
||||
String[] parts = raw.split(Pattern.quote(PART_SEP), -1);
|
||||
Set<String> perms = decodeSet(parts.length > 0 ? parts[0] : null);
|
||||
Set<String> menus = decodeSet(parts.length > 1 ? parts[1] : null);
|
||||
return new Grant(perms, menus);
|
||||
}
|
||||
|
||||
private static Set<String> decodeSet(String part) {
|
||||
if (part == null || part.isBlank() || EMPTY.equals(part)) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (String v : part.split(SEP)) {
|
||||
if (!v.isBlank()) {
|
||||
values.add(v);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 一个自定义角色的生效内容:能做什么(perms)+ 看得见什么(menus)。 */
|
||||
record Grant(Set<String> perms, Set<String> menus) {
|
||||
|
||||
static final Grant EMPTY = new Grant(Set.of(), Set.of());
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.tianbaotong.fill.module.user.service;
|
||||
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 角色授权(权限码 + 菜单)的**唯一写入口**。
|
||||
*
|
||||
* <p>为什么抽出来:同一件事有两个调用方 ——
|
||||
* {@code RoleProvisioner}(开通租户与每日同步内置角色)与 {@code RoleService}(租户自己配自定义角色)。
|
||||
* 两边各写一份"缺的补上、多的删掉"必然分叉,而分叉的表现是
|
||||
* 「内置角色同步得好好的、自定义角色改完却多出一条旧权限」这种一半对一半错的现象。
|
||||
*
|
||||
* <p><b>幂等是硬要求</b>:它每天会被定时任务调一次({@code ScheduleJobs} 的「内置角色授权同步」),
|
||||
* 两边集合一样时**一行都不写**。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RoleGrantWriter {
|
||||
|
||||
private final RolePermissionMapper rolePermissionMapper;
|
||||
private final RoleMenuMapper roleMenuMapper;
|
||||
|
||||
/** 把角色的权限码对齐到给定集合(覆盖式)。 */
|
||||
public void replacePerms(long tenantId, Long roleId, Set<String> want) {
|
||||
List<SysRolePermission> exist = rolePermissionMapper.selectList(
|
||||
Wrappers.<SysRolePermission>lambdaQuery()
|
||||
.eq(SysRolePermission::getTenantId, tenantId)
|
||||
.eq(SysRolePermission::getRoleId, roleId));
|
||||
|
||||
Set<String> have = new LinkedHashSet<>();
|
||||
for (SysRolePermission rp : exist) {
|
||||
if (rp.getPermCode() != null) {
|
||||
have.add(rp.getPermCode());
|
||||
}
|
||||
}
|
||||
for (String code : want) {
|
||||
if (!have.contains(code)) {
|
||||
SysRolePermission rp = new SysRolePermission();
|
||||
rp.setTenantId(tenantId);
|
||||
rp.setRoleId(roleId);
|
||||
rp.setPermCode(code);
|
||||
rolePermissionMapper.insert(rp);
|
||||
}
|
||||
}
|
||||
for (SysRolePermission rp : exist) {
|
||||
if (rp.getPermCode() != null && !want.contains(rp.getPermCode())) {
|
||||
// 软删(实体基类带 @TableLogic):硬删会让 uk_roleperm_role_code
|
||||
// 与"这条授权什么时候没的"对不上
|
||||
rolePermissionMapper.deleteById(rp.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把角色的可见菜单对齐到给定集合(覆盖式)。 */
|
||||
public void replaceMenus(long tenantId, Long roleId, Set<String> want) {
|
||||
List<SysRoleMenu> exist = roleMenuMapper.selectList(
|
||||
Wrappers.<SysRoleMenu>lambdaQuery()
|
||||
.eq(SysRoleMenu::getTenantId, tenantId)
|
||||
.eq(SysRoleMenu::getRoleId, roleId));
|
||||
|
||||
Set<String> have = new LinkedHashSet<>();
|
||||
for (SysRoleMenu rm : exist) {
|
||||
if (rm.getMenuKey() != null) {
|
||||
have.add(rm.getMenuKey());
|
||||
}
|
||||
}
|
||||
for (String key : want) {
|
||||
if (!have.contains(key)) {
|
||||
SysRoleMenu rm = new SysRoleMenu();
|
||||
rm.setTenantId(tenantId);
|
||||
rm.setRoleId(roleId);
|
||||
rm.setMenuKey(key);
|
||||
roleMenuMapper.insert(rm);
|
||||
}
|
||||
}
|
||||
for (SysRoleMenu rm : exist) {
|
||||
if (rm.getMenuKey() != null && !want.contains(rm.getMenuKey())) {
|
||||
roleMenuMapper.deleteById(rm.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
package cn.tianbaotong.fill.module.user.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.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.log.service.OperationLogService;
|
||||
import cn.tianbaotong.fill.module.user.dto.RoleDtos;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRole;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRoleMenu;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRolePermission;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMenuMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RolePermissionMapper;
|
||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||
import cn.tianbaotong.fill.security.PermissionDef;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 角色与授权(二期)。
|
||||
*
|
||||
* <h3>四条口径(都不是"随便定的",每条都对应一类会出事的做法)</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>内置角色只读</b>:{@code role_type=1} 的角色可以看、不可以改。
|
||||
* 它是"管理员把自己锁在系统外"的唯一入口,而这类错误不可逆。</li>
|
||||
* <li><b>权限码的白名单在服务端</b>:学校租户不能授予平台级权限({@code tenant:*} 等)。
|
||||
* 界面上不显示那些勾选项只是"不提示",绕过前端直接调接口才是真正的攻击面。</li>
|
||||
* <li><b>防自锁</b>:不允许把"自己管理角色所依赖的最后一个来源"改掉/删掉/停用 ——
|
||||
* 包括那条 {@code role:manage} 权限与 {@code role} 菜单键。</li>
|
||||
* <li><b>改动立即生效</b>:每次写授权都清缓存({@code DbRoleGrantSource.evict})。
|
||||
* 不清的话"我把权限去掉了,他怎么还能点"会持续到 TTL 到期,而用户会认为"没保存"。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>⚠️ {@code data_scope} 本期不提供修改入口:数据范围目前由"拿到了哪些权限码 +
|
||||
* 任务里被指派了哪些班"共同决定,散在审核/催报/代传/看板各处,做成可配要先把那些判据收口。
|
||||
* 让界面出现一个"改了不生效"的开关,比没有这个开关更糟。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RoleService {
|
||||
|
||||
/** 自定义角色的默认数据范围:{@code 4} 仅本人(最保守的那一档)。 */
|
||||
private static final int DEFAULT_DATA_SCOPE = 4;
|
||||
|
||||
/** 自定义角色的排序值:排在 6 个内置角色(1~90)之后。 */
|
||||
private static final int CUSTOM_SORT_NO = 500;
|
||||
|
||||
private final RoleMapper roleMapper;
|
||||
private final RolePermissionMapper rolePermissionMapper;
|
||||
private final RoleMenuMapper roleMenuMapper;
|
||||
private final RoleGrantWriter grantWriter;
|
||||
private final DbRoleGrantSource grantSource;
|
||||
private final OperationLogService operationLogService;
|
||||
|
||||
// ── 查询 ──────────────────────────────────────
|
||||
|
||||
/** 角色列表(内置 + 自定义)。 */
|
||||
public List<RoleDtos.RoleVO> list() {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
List<SysRole> roles = roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
.orderByAsc(SysRole::getRoleType)
|
||||
.orderByAsc(SysRole::getSortNo)
|
||||
.orderByAsc(SysRole::getId));
|
||||
if (roles.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 三条批量查询,避免"每个角色查一次"的 N+1
|
||||
List<Long> ids = new ArrayList<>();
|
||||
roles.forEach(r -> ids.add(r.getId()));
|
||||
Map<Long, List<String>> permsByRole = permsOfRoles(tenantId, ids);
|
||||
Map<Long, List<String>> menusByRole = menusOfRoles(tenantId, ids);
|
||||
Map<Long, Integer> counts = new HashMap<>();
|
||||
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
|
||||
if (row.getRoleId() != null) {
|
||||
counts.put(row.getRoleId(), row.getUserCount() == null ? 0 : row.getUserCount());
|
||||
}
|
||||
}
|
||||
|
||||
List<RoleDtos.RoleVO> out = new ArrayList<>();
|
||||
boolean platform = isPlatformTenant();
|
||||
Set<String> assignableBuiltins = BuiltinRole.assignableCodes(platform);
|
||||
for (SysRole role : roles) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
|
||||
boolean builtin = br != null && role.getRoleType() != null && role.getRoleType() == 1;
|
||||
boolean enabled = role.getStatus() != null && role.getStatus() == 1;
|
||||
|
||||
RoleDtos.RoleVO vo = new RoleDtos.RoleVO();
|
||||
vo.setId(role.getId());
|
||||
vo.setRoleCode(role.getRoleCode());
|
||||
vo.setRoleName(role.getRoleName());
|
||||
vo.setRoleType(role.getRoleType());
|
||||
vo.setUserType(builtin ? br.getUserType().code() : role.getUserType());
|
||||
vo.setDataScope(role.getDataScope());
|
||||
vo.setStatus(role.getStatus());
|
||||
vo.setSortNo(role.getSortNo());
|
||||
vo.setRemark(role.getRemark());
|
||||
vo.setUserCount(counts.getOrDefault(role.getId(), 0));
|
||||
vo.setEditable(!builtin);
|
||||
// 能不能发给账号:停用的不行;内置角色还要过"这个租户能不能发它"的白名单
|
||||
// (学校租户发不了 PLATFORM_ADMIN —— 那是越权到平台级的入口)
|
||||
vo.setAssignable(enabled && (!builtin || assignableBuiltins.contains(role.getRoleCode())));
|
||||
// 内置角色的权限与菜单**取代码**:库里的行只是"看得见、可审计",
|
||||
// 若这里读库,代码改了权限而库没同步时,界面就会显示一套过期的授权
|
||||
vo.setPermCodes(builtin ? List.copyOf(br.permissionCodes())
|
||||
: permsByRole.getOrDefault(role.getId(), List.of()));
|
||||
vo.setMenus(builtin ? List.copyOf(br.getMenus())
|
||||
: menusByRole.getOrDefault(role.getId(), List.of()));
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 权限码目录(按分组),供角色授权界面渲染勾选项。 */
|
||||
public List<RoleDtos.PermGroupVO> permissionCatalog() {
|
||||
Map<String, List<RoleDtos.PermVO>> groups = new LinkedHashMap<>();
|
||||
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
|
||||
for (PermissionDef def : PermissionDef.values()) {
|
||||
RoleDtos.PermVO item = new RoleDtos.PermVO();
|
||||
item.setCode(def.getCode());
|
||||
item.setLabel(def.getLabel());
|
||||
item.setPlatform(def.getPlatform().name());
|
||||
item.setPlatformOnly(platformOnly.contains(def.getCode()));
|
||||
groups.computeIfAbsent(def.getGroup(), k -> new ArrayList<>()).add(item);
|
||||
}
|
||||
List<RoleDtos.PermGroupVO> out = new ArrayList<>();
|
||||
groups.forEach((group, items) -> {
|
||||
RoleDtos.PermGroupVO vo = new RoleDtos.PermGroupVO();
|
||||
vo.setGroup(group);
|
||||
vo.setItems(items);
|
||||
out.add(vo);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 写 ──────────────────────────────────────
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(RoleDtos.RoleCreateReq req) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
String code = req.getRoleCode() == null ? "" : req.getRoleCode().trim();
|
||||
|
||||
if (BuiltinRole.ofCode(code) != null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + code + "」是内置角色码,不能占用");
|
||||
}
|
||||
if (findByCode(tenantId, code) != null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色码已存在:" + code);
|
||||
}
|
||||
assertUserTypeAllowed(req.getUserType());
|
||||
Set<String> perms = validatePerms(req.getPermCodes());
|
||||
Set<String> menus = validateMenus(req.getMenus());
|
||||
|
||||
SysRole role = new SysRole();
|
||||
role.setTenantId(tenantId);
|
||||
role.setRoleCode(code);
|
||||
role.setRoleName(req.getRoleName().trim());
|
||||
role.setRoleType(2);
|
||||
role.setUserType(req.getUserType());
|
||||
role.setDataScope(DEFAULT_DATA_SCOPE);
|
||||
role.setSortNo(CUSTOM_SORT_NO);
|
||||
role.setStatus(1);
|
||||
role.setRemark(req.getRemark());
|
||||
roleMapper.insert(role);
|
||||
|
||||
grantWriter.replacePerms(tenantId, role.getId(), perms);
|
||||
grantWriter.replaceMenus(tenantId, role.getId(), menus);
|
||||
grantSource.evict(tenantId, code);
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_CREATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
null, code + " " + role.getRoleName() + " 权限 " + perms.size() + " 项");
|
||||
return role.getId();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(Long id, RoleDtos.RoleUpdateReq req) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
SysRole role = requireCustomRole(tenantId, id);
|
||||
assertUserTypeAllowed(req.getUserType());
|
||||
|
||||
// 改了用户类型而角色还挂在账号上 → 那些账号的 user_type 会与角色对不上,
|
||||
// 而 user_type 决定"他算学生还是老师"(学生走小程序、老师走 PC)。
|
||||
// 与其静默制造不一致,不如让他先解绑(这是一次很少发生的操作)。
|
||||
if (!java.util.Objects.equals(role.getUserType(), req.getUserType())) {
|
||||
int used = countUsers(tenantId, role.getId());
|
||||
if (used > 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"该角色还有 " + used + " 个账号在用,改「用户类型」会让这些账号与角色不一致;"
|
||||
+ "请先给这些账号换角色,或另建一个角色");
|
||||
}
|
||||
}
|
||||
|
||||
String before = role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType();
|
||||
role.setRoleName(req.getRoleName().trim());
|
||||
role.setUserType(req.getUserType());
|
||||
role.setRemark(req.getRemark());
|
||||
roleMapper.updateById(role);
|
||||
grantSource.evict(tenantId, role.getRoleCode());
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_UPDATE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
before, role.getRoleCode() + " " + role.getRoleName() + " / userType=" + role.getUserType());
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
SysRole role = requireCustomRole(tenantId, id);
|
||||
|
||||
int used = countUsers(tenantId, role.getId());
|
||||
if (used > 0) {
|
||||
throw new BizException(ErrorCode.ROLE_IN_USE,
|
||||
"该角色还有 " + used + " 个账号在用,请先改掉这些账号的角色");
|
||||
}
|
||||
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
|
||||
|
||||
roleMapper.deleteById(role.getId());
|
||||
// 授权与菜单一起软删:留着孤儿行会让"这个角色曾经能做什么"与"现在还有什么"混在一起
|
||||
grantWriter.replacePerms(tenantId, role.getId(), Set.of());
|
||||
grantWriter.replaceMenus(tenantId, role.getId(), Set.of());
|
||||
grantSource.evict(tenantId, role.getRoleCode());
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_DELETE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
role.getRoleCode() + " " + role.getRoleName(), null);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setPermissions(Long id, List<String> permCodes) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
SysRole role = requireCustomRole(tenantId, id);
|
||||
Set<String> perms = validatePerms(permCodes);
|
||||
assertNotSelfLock(tenantId, role, perms, currentMenus(tenantId, role));
|
||||
|
||||
Set<String> before = currentPerms(tenantId, role);
|
||||
grantWriter.replacePerms(tenantId, role.getId(), perms);
|
||||
grantSource.evict(tenantId, role.getRoleCode());
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_GRANT_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
String.join(",", before), String.join(",", perms));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setMenus(Long id, List<String> menus) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
SysRole role = requireCustomRole(tenantId, id);
|
||||
Set<String> keys = validateMenus(menus);
|
||||
assertNotSelfLock(tenantId, role, currentPerms(tenantId, role), keys);
|
||||
|
||||
Set<String> before = currentMenus(tenantId, role);
|
||||
grantWriter.replaceMenus(tenantId, role.getId(), keys);
|
||||
grantSource.evict(tenantId, role.getRoleCode());
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_UPDATE_ROLE_MENU, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
String.join(",", before), String.join(",", keys));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setStatus(Long id, Integer status) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
SysRole role = requireCustomRole(tenantId, id);
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "状态只能是 0(停用)或 1(正常)");
|
||||
}
|
||||
if (status == 0) {
|
||||
// 停用 = 这个角色立刻不再给权限(见 DbRoleGrantSource),所以要过防自锁
|
||||
assertNotSelfLock(tenantId, role, Set.of(), Set.of());
|
||||
}
|
||||
role.setStatus(status);
|
||||
roleMapper.updateById(role);
|
||||
grantSource.evict(tenantId, role.getRoleCode());
|
||||
|
||||
operationLogService.record(OperationLogService.MODULE_ROLE,
|
||||
OperationLogService.ACTION_TOGGLE_ROLE, OperationLogService.TARGET_ROLE, role.getId(),
|
||||
null, role.getRoleCode() + " → " + (status == 1 ? "正常" : "停用"));
|
||||
}
|
||||
|
||||
// ── 内部:取数与校验 ────────────────────────────
|
||||
|
||||
private SysRole findByCode(Long tenantId, String roleCode) {
|
||||
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
.eq(SysRole::getRoleCode, roleCode)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个**自定义**角色;内置角色直接拒绝。
|
||||
*
|
||||
* <p>⚠️ 这里额外比一次 {@code tenant_id}:平台运营身份下租户拦截器是关闭的
|
||||
* (它是唯一跨租户的身份),只靠 {@code selectById} 就能拿到别的租户的角色行,
|
||||
* 而随后写回去会把它写成"平台租户的角色"。所以平台身份走这条路时也要挡住 ——
|
||||
* 平台想改哪个学校,必须先像其它页面一样选定学校({@code X-Tenant-Id})。
|
||||
*/
|
||||
private SysRole requireCustomRole(Long tenantId, Long id) {
|
||||
SysRole role = roleMapper.selectById(id);
|
||||
if (role == null || (role.getTenantId() != null && !role.getTenantId().equals(tenantId))) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在");
|
||||
}
|
||||
if (role.getRoleType() == null || role.getRoleType() != 2) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + role.getRoleName() + "」是内置角色,权限由代码决定,不可修改");
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
/** 学校租户不能创建"平台运营"那一类的角色(与 {@code UserService.resolveAssignableRoles} 同一条防线)。 */
|
||||
private void assertUserTypeAllowed(Integer userType) {
|
||||
if (!isPlatformTenant() && userType != null
|
||||
&& userType == BuiltinRole.UserType.PLATFORM.code()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权创建平台运营类的角色");
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> validatePerms(List<String> codes) {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
if (codes == null) {
|
||||
return out;
|
||||
}
|
||||
boolean platform = isPlatformTenant();
|
||||
Set<String> platformOnly = PermissionDef.platformOnlyCodes();
|
||||
for (String raw : codes) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String code = raw.trim();
|
||||
PermissionDef def = PermissionDef.ofCode(code);
|
||||
if (def == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "权限码不存在:" + code);
|
||||
}
|
||||
if (!platform && platformOnly.contains(code)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无权授予平台级权限:" + def.getLabel() + "(" + code + ")");
|
||||
}
|
||||
out.add(code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Set<String> validateMenus(List<String> keys) {
|
||||
Set<String> known = BuiltinRole.allMenuKeys();
|
||||
Set<String> platformOnly = BuiltinRole.platformOnlyMenuKeys();
|
||||
boolean platform = isPlatformTenant();
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
if (keys == null) {
|
||||
return out;
|
||||
}
|
||||
for (String raw : keys) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String key = raw.trim();
|
||||
if (!known.contains(key)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "菜单键不存在:" + key);
|
||||
}
|
||||
// 与权限码同一类防线:把平台专用的入口配给学校的角色,
|
||||
// 左侧会多出一个点进去必然 403 的菜单 —— 用户只会认为"系统坏了"
|
||||
if (!platform && platformOnly.contains(key)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无权配置平台级菜单:" + key + "(学校租户看不到这个入口)");
|
||||
}
|
||||
out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防自锁:不允许把"自己管理角色所依赖的最后一个来源"拿掉。
|
||||
*
|
||||
* <p>两类自锁各对应一个具体现象:
|
||||
* <ul>
|
||||
* <li>去掉 {@code role:manage} → 本页所有接口立刻 40301,只能找人用 SQL 救;</li>
|
||||
* <li>去掉 {@code role} 菜单键 → 菜单里不再有这一项,前端连路由都不会注册
|
||||
* ({@code buildXtRoutes} 按菜单键过滤),页面在界面上彻底消失。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>判据是"**别的**角色里还有没有这一条":内置角色(如学校管理员)天然有,
|
||||
* 所以正常情况下这个校验永远不会触发 —— 它防的是"管理员把自己的自定义角色改秃了"。
|
||||
*/
|
||||
private void assertNotSelfLock(Long tenantId, SysRole role, Set<String> newPerms, Set<String> newMenus) {
|
||||
LoginUser me = UserContext.get();
|
||||
if (me == null || me.isPlatformAdmin() || me.roles() == null) {
|
||||
return;
|
||||
}
|
||||
if (!me.roles().contains(role.getRoleCode())) {
|
||||
return;
|
||||
}
|
||||
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
|
||||
PermissionDef.ROLE_MANAGE.getCode(), false)
|
||||
&& !newPerms.contains(PermissionDef.ROLE_MANAGE.getCode())) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」权限后,"
|
||||
+ "你将无法再打开本页。请先给另一个角色授予该权限");
|
||||
}
|
||||
if (!otherRolesGrant(tenantId, me.roles(), role.getRoleCode(),
|
||||
ROLE_MENU_KEY, true) && !newMenus.contains(ROLE_MENU_KEY)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"这是你自己进入「角色与授权」所依赖的最后一个来源:去掉「角色与授权」菜单后,"
|
||||
+ "左侧菜单里将不再出现本页。请先给另一个角色配上该菜单");
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code role} 菜单键 —— 与前端 {@code XtMenu.key} 逐字一致。 */
|
||||
private static final String ROLE_MENU_KEY = "role";
|
||||
|
||||
/**
|
||||
* 当前用户的**其它**角色里,有没有哪个角色带这一条(权限码或菜单键)。
|
||||
*
|
||||
* @param menu true = 查菜单键,false = 查权限码
|
||||
*/
|
||||
private boolean otherRolesGrant(Long tenantId, Collection<String> myRoles, String excludeCode,
|
||||
String want, boolean menu) {
|
||||
for (String rc : myRoles) {
|
||||
if (rc == null || rc.equals(excludeCode)) {
|
||||
continue;
|
||||
}
|
||||
BuiltinRole br = BuiltinRole.ofCode(rc);
|
||||
if (br != null) {
|
||||
boolean has = menu ? br.getMenus().contains(want) : br.permissionCodes().contains(want);
|
||||
if (has) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
boolean has = menu ? grantSource.menusOf(tenantId, rc).contains(want)
|
||||
: grantSource.permsOf(tenantId, rc).contains(want);
|
||||
if (has) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Set<String> currentPerms(Long tenantId, SysRole role) {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
for (SysRolePermission rp : rolePermissionMapper.selectList(
|
||||
Wrappers.<SysRolePermission>lambdaQuery()
|
||||
.eq(SysRolePermission::getTenantId, tenantId)
|
||||
.eq(SysRolePermission::getRoleId, role.getId()))) {
|
||||
if (rp.getPermCode() != null) {
|
||||
out.add(rp.getPermCode());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Set<String> currentMenus(Long tenantId, SysRole role) {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
for (SysRoleMenu rm : roleMenuMapper.selectList(
|
||||
Wrappers.<SysRoleMenu>lambdaQuery()
|
||||
.eq(SysRoleMenu::getTenantId, tenantId)
|
||||
.eq(SysRoleMenu::getRoleId, role.getId()))) {
|
||||
if (rm.getMenuKey() != null) {
|
||||
out.add(rm.getMenuKey());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private int countUsers(Long tenantId, Long roleId) {
|
||||
for (RoleMapper.RoleUserCount row : roleMapper.countUsersByRole(tenantId)) {
|
||||
if (roleId.equals(row.getRoleId())) {
|
||||
return row.getUserCount() == null ? 0 : row.getUserCount();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private Map<Long, List<String>> permsOfRoles(Long tenantId, List<Long> roleIds) {
|
||||
Map<Long, List<String>> out = new HashMap<>();
|
||||
if (roleIds.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (SysRolePermission rp : rolePermissionMapper.selectList(
|
||||
Wrappers.<SysRolePermission>lambdaQuery()
|
||||
.eq(SysRolePermission::getTenantId, tenantId)
|
||||
.in(SysRolePermission::getRoleId, roleIds))) {
|
||||
if (rp.getPermCode() != null) {
|
||||
out.computeIfAbsent(rp.getRoleId(), k -> new ArrayList<>()).add(rp.getPermCode());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<Long, List<String>> menusOfRoles(Long tenantId, List<Long> roleIds) {
|
||||
Map<Long, List<String>> out = new HashMap<>();
|
||||
if (roleIds.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (SysRoleMenu rm : roleMenuMapper.selectList(
|
||||
Wrappers.<SysRoleMenu>lambdaQuery()
|
||||
.eq(SysRoleMenu::getTenantId, tenantId)
|
||||
.in(SysRoleMenu::getRoleId, roleIds))) {
|
||||
if (rm.getMenuKey() != null) {
|
||||
out.computeIfAbsent(rm.getRoleId(), k -> new ArrayList<>()).add(rm.getMenuKey());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean isPlatformTenant() {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
return tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
|
||||
}
|
||||
}
|
||||
+229
-78
@@ -13,10 +13,13 @@ import cn.tianbaotong.fill.module.user.dto.ResetPasswordReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserImportResult;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
|
||||
import cn.tianbaotong.fill.module.user.dto.UserVO;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysRole;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
|
||||
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.PermissionDef;
|
||||
import cn.tianbaotong.fill.support.RoleProvisioner;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
@@ -73,18 +76,11 @@ public class UserService {
|
||||
|
||||
private static final Pattern PHONE_RE = Pattern.compile("^1[3-9]\\d{9}$");
|
||||
|
||||
/** 学校租户可分配的角色。 */
|
||||
private static final Set<String> SCHOOL_ASSIGNABLE = Set.of(
|
||||
"SCHOOL_ADMIN", "HEAD_TEACHER", "DEPUTY_TEACHER", "AUDITOR", "STUDENT");
|
||||
|
||||
/** 平台租户可分配的角色。 */
|
||||
private static final Set<String> PLATFORM_ASSIGNABLE = Set.of("PLATFORM_ADMIN");
|
||||
|
||||
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
|
||||
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
|
||||
|
||||
/**
|
||||
* 角色 → 用户类型(《03-接口设计》§2.7,与 {@code BuiltinRole.UserType} 一致)。
|
||||
* 用户类型 → 中文名(用于错误提示)。
|
||||
*
|
||||
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
|
||||
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
|
||||
@@ -94,16 +90,12 @@ public class UserService {
|
||||
*
|
||||
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
|
||||
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
|
||||
*
|
||||
* <p>二期改动:原先这里还有一张 {@code ROLE_TO_USER_TYPE} 的"角色码 → 用户类型"表,
|
||||
* 现在删了 —— 用户类型改由 {@link #effectiveUserType} 取(内置角色看代码的
|
||||
* {@code BuiltinRole.UserType},自定义角色看 {@code sys_role.user_type})。
|
||||
* 留两张表迟早会分叉,而分叉的表现是"新建账号报类型不符、但看不出为什么"。
|
||||
*/
|
||||
private static final Map<String, Integer> ROLE_TO_USER_TYPE = Map.of(
|
||||
"PLATFORM_ADMIN", 1,
|
||||
"SCHOOL_ADMIN", 2,
|
||||
"AUDITOR", 2,
|
||||
"HEAD_TEACHER", 3,
|
||||
"DEPUTY_TEACHER", 3,
|
||||
"STUDENT", 4);
|
||||
|
||||
/** 用户类型 → 中文名(用于错误提示)。与 {@link #ROLE_TO_USER_TYPE} 同一套口径。 */
|
||||
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
|
||||
1, "平台运营",
|
||||
2, "学校管理员",
|
||||
@@ -138,7 +130,9 @@ public class UserService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserRoleMapper userRoleMapper;
|
||||
private final OrgMapper orgMapper;
|
||||
private final RoleMapper roleMapper;
|
||||
private final RoleProvisioner roleProvisioner;
|
||||
private final DbRoleGrantSource roleGrantSource;
|
||||
private final SensitiveCodec codec;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final LoginGuard loginGuard;
|
||||
@@ -184,9 +178,18 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
|
||||
// roleCode 已经过内置角色白名单校验,拼串是安全的。
|
||||
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
|
||||
if (roleCode != null) {
|
||||
// ⚠️ 角色码**不认识时必须返回空**,绝不能"忽略这个过滤条件"。
|
||||
// 忽略的后果是:调用方以为筛出了"班主任",实际拿到**全校账号(含学生)**——
|
||||
// 而且不报任何错。这个坑真实发生过:2026-10 删掉「副班主任」角色之后,
|
||||
// 前端还在传 `roleCode=DEPUTY_TEACHER`,内置枚举里已经没有它了,
|
||||
// 于是"选初审人"的下拉里出现了学生。
|
||||
if (!isKnownRoleCode(roleCode)) {
|
||||
log.warn("按未知角色码过滤账号,按失败关闭返回空:roleCode={}", roleCode);
|
||||
return PageResult.of(List.of(), 0, num, size);
|
||||
}
|
||||
// 角色码已经过白名单校验(内置枚举或库里真实存在),拼串是安全的。
|
||||
// 用子查询而不是 JOIN,是为了让租户过滤与逻辑删除条件继续作用于主表。
|
||||
q.inSql(SysUser::getId,
|
||||
"SELECT ur.user_id FROM sys_user_role ur"
|
||||
+ " JOIN sys_role r ON r.id = ur.role_id"
|
||||
@@ -215,7 +218,7 @@ public class UserService {
|
||||
String username = req.getUsername().trim();
|
||||
|
||||
assertUsernameUnique(username, null);
|
||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||
|
||||
String initialPassword = MaskUtil.generateInitialPassword();
|
||||
@@ -230,12 +233,12 @@ public class UserService {
|
||||
codec.applyPhone(u, req.getPhone());
|
||||
userMapper.insert(u);
|
||||
|
||||
for (BuiltinRole br : roles) {
|
||||
roleProvisioner.bindRole(tenantId, u.getId(), br);
|
||||
for (SysRole role : roles) {
|
||||
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
|
||||
}
|
||||
|
||||
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
|
||||
.map(BuiltinRole::getCode).toList());
|
||||
.map(SysRole::getRoleCode).toList());
|
||||
|
||||
return new CreateResult(u.getId(), initialPassword);
|
||||
}
|
||||
@@ -254,7 +257,7 @@ public class UserService {
|
||||
SysUser exist = requireUser(id);
|
||||
long tenantId = UserContext.tenantId();
|
||||
|
||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||
|
||||
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
|
||||
@@ -276,6 +279,12 @@ public class UserService {
|
||||
assertAnotherAdminExists(exist, "取消管理员角色");
|
||||
}
|
||||
|
||||
// 二期新增的一条自锁:**自定义角色**让"管理员"不再等于那两个内置角色码。
|
||||
// 上面的判据只看内置管理员角色,所以「自己靠自定义角色管账号、再把自己换成别的角色」
|
||||
// 会绕过它 —— 账号还在、还能登录,但再也进不了账号管理与角色授权。
|
||||
// 这类错误不可逆(只能找人用 SQL 救),所以单独判一次。
|
||||
assertKeepsAccountManage(exist, roles);
|
||||
|
||||
SysUser patch = new SysUser();
|
||||
patch.setId(id);
|
||||
patch.setRealName(req.getRealName().trim());
|
||||
@@ -382,7 +391,7 @@ public class UserService {
|
||||
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
|
||||
assertImportFile(file);
|
||||
|
||||
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
|
||||
SysRole fallbackRole = resolveFallbackRole(defaultRoleCode);
|
||||
|
||||
List<Map<Integer, String>> rows;
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
@@ -472,7 +481,7 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
BuiltinRole role = fallbackRole;
|
||||
SysRole role = fallbackRole;
|
||||
if (err == null && roleText != null && !roleText.isBlank()) {
|
||||
role = resolveRoleByText(roleText);
|
||||
if (role == null) {
|
||||
@@ -481,7 +490,7 @@ public class UserService {
|
||||
}
|
||||
// 用户类型从角色推导(二者一对一绑定)—— 导入文件里没有这一列,
|
||||
// 也不该要求用户去填:填错就是整行白导。
|
||||
Integer userType = role == null ? null : ROLE_TO_USER_TYPE.get(role.getCode());
|
||||
Integer userType = role == null ? null : effectiveUserType(role);
|
||||
if (err == null && userType == null) {
|
||||
err = new UserImportResult.RowError(excelRow, "role", roleText,
|
||||
"该角色没有对应的用户类型");
|
||||
@@ -506,7 +515,7 @@ public class UserService {
|
||||
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
|
||||
}
|
||||
userMapper.insert(u);
|
||||
roleProvisioner.bindRole(tenantId, u.getId(), role);
|
||||
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
|
||||
|
||||
seenInFile.add(username.trim());
|
||||
result.setSuccess(result.getSuccess() + 1);
|
||||
@@ -521,6 +530,28 @@ public class UserService {
|
||||
|
||||
// ── 护栏 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 角色码是否真实存在:**内置枚举** 或 **本租户的自定义角色**。
|
||||
*
|
||||
* <p>它有两层用途:
|
||||
* <ol>
|
||||
* <li>它是下面那段拼串 SQL 的**白名单** —— 只有"库里确实存在的角色码"才允许被拼进去,
|
||||
* 所以天然挡住注入;</li>
|
||||
* <li>它把"传了一个不存在的角色码"变成**失败关闭**。上一版对这种情况是
|
||||
* <b>静默忽略过滤条件</b>,于是调用方以为筛出了"班主任",
|
||||
* 实际拿到全校账号(含学生)—— 而且不报任何错。
|
||||
* 2026-10 删掉「副班主任」角色后就真实发生过一次(前端还在传那个码)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
private boolean isKnownRoleCode(String roleCode) {
|
||||
if (BuiltinRole.ofCode(roleCode) != null) {
|
||||
return true;
|
||||
}
|
||||
Long n = roleMapper.selectCount(
|
||||
Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, roleCode));
|
||||
return n != null && n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并解析可分配的角色。
|
||||
*
|
||||
@@ -536,11 +567,19 @@ public class UserService {
|
||||
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
|
||||
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
|
||||
* 任一道单独都够用,但都留着 —— 这类错误一旦发生是不可逆的信息泄露。
|
||||
*
|
||||
* <p><b>二期(角色与授权)的改法</b>:返回类型从 {@code BuiltinRole} 换成 {@code SysRole} ——
|
||||
* 因为可选角色不再只有 6 个内置角色,租户自己建的角色也要能发出去。
|
||||
* 内置角色那条白名单**一个字都没改**(它防的是越权到平台级);
|
||||
* 自定义角色不需要白名单:它是本租户的行(上面的查询本身就带 {@code tenant_id}),
|
||||
* 而它能包含哪些权限码在 {@code RoleService.validatePerms} 里已经挡过平台级权限。
|
||||
*/
|
||||
private List<BuiltinRole> resolveAssignableRoles(List<String> codes) {
|
||||
private List<SysRole> resolveAssignableRoles(List<String> codes) {
|
||||
Long tenantId = UserContext.tenantId();
|
||||
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
|
||||
Set<String> allowed = platform ? PLATFORM_ASSIGNABLE : SCHOOL_ASSIGNABLE;
|
||||
// 白名单规则只有一处(BuiltinRole.assignableCodes):界面上的 assignable 标志也用它,
|
||||
// 两处各写一份必然分叉成"界面能选、点了 40301"
|
||||
Set<String> allowed = BuiltinRole.assignableCodes(platform);
|
||||
|
||||
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||
if (codes != null) {
|
||||
@@ -554,16 +593,25 @@ public class UserService {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
|
||||
}
|
||||
|
||||
List<BuiltinRole> roles = new ArrayList<>();
|
||||
// 一次读出本租户全部启用中的角色(内置 + 自定义),再按码取 —— 避免逐条查库
|
||||
Map<String, SysRole> byCode = new LinkedHashMap<>();
|
||||
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
.eq(SysRole::getStatus, 1))) {
|
||||
byCode.put(r.getRoleCode(), r);
|
||||
}
|
||||
|
||||
List<SysRole> roles = new ArrayList<>();
|
||||
for (String c : distinct) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||
if (br == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
|
||||
SysRole role = byCode.get(c);
|
||||
if (role == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在或已停用:" + c);
|
||||
}
|
||||
if (!allowed.contains(c)) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||
if (br != null && !allowed.contains(c)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
||||
}
|
||||
roles.add(br);
|
||||
roles.add(role);
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
@@ -572,20 +620,20 @@ public class UserService {
|
||||
* 用户类型必须与所选角色自洽。
|
||||
*
|
||||
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
|
||||
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
|
||||
* 角色与用户类型本来就是一对一绑定的(见 {@link #effectiveUserType}),
|
||||
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
|
||||
*
|
||||
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
|
||||
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
|
||||
*/
|
||||
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
|
||||
private void assertRoleMatchesUserType(Integer userType, List<SysRole> roles) {
|
||||
if (userType == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
|
||||
}
|
||||
|
||||
Set<Integer> expected = new LinkedHashSet<>();
|
||||
for (BuiltinRole br : roles) {
|
||||
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
|
||||
for (SysRole role : roles) {
|
||||
Integer t = effectiveUserType(role);
|
||||
if (t != null) {
|
||||
expected.add(t);
|
||||
}
|
||||
@@ -611,6 +659,18 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色对应的用户类型。
|
||||
*
|
||||
* <p>内置角色**取代码**({@link BuiltinRole#getUserType()}),自定义角色取
|
||||
* {@code sys_role.user_type}。与权限判定同一套口径:内置角色的权威在代码,
|
||||
* 库里的行只是"看得见、可审计"。
|
||||
*/
|
||||
private Integer effectiveUserType(SysRole role) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
|
||||
return br != null ? br.getUserType().code() : role.getUserType();
|
||||
}
|
||||
|
||||
private void assertUsernameUnique(String username, Long excludeId) {
|
||||
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
|
||||
.eq(SysUser::getUsername, username)
|
||||
@@ -663,9 +723,9 @@ public class UserService {
|
||||
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
|
||||
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
|
||||
*/
|
||||
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
|
||||
private boolean losingAdminRole(SysUser target, List<SysRole> newRoles) {
|
||||
boolean stillAdmin = newRoles.stream()
|
||||
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
|
||||
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getRoleCode()));
|
||||
if (stillAdmin) {
|
||||
return false;
|
||||
}
|
||||
@@ -673,6 +733,64 @@ public class UserService {
|
||||
return now != null && now > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 改角色后,操作者自己是否还留着「账号管理」这条能力(**只判自己**)。
|
||||
*
|
||||
* <p>为什么必须单独判:{@link #losingAdminRole} 只看那两个内置管理员角色码,
|
||||
* 而二期之后"能管账号"也可能来自一个自定义角色(它被授了 {@code user:manage})。
|
||||
* 于是「自己靠自定义角色管账号 → 把自己换成别的角色」会绕过上面那条检查:
|
||||
* 账号还在、还能登录,但再也打不开账号管理与角色授权,而**这类错误不可逆**。
|
||||
*
|
||||
* <p>为什么只判自己:别人的权限由别人负责,拦下来只会让正常的人员交接做不了;
|
||||
* 而"把自己改秃"的代价是他一个人再也回不来。
|
||||
*/
|
||||
private void assertKeepsAccountManage(SysUser target, List<SysRole> newRoles) {
|
||||
if (!Objects.equals(target.getId(), UserContext.uid())) {
|
||||
return;
|
||||
}
|
||||
Long tenantId = UserContext.tenantId();
|
||||
if (tenantId == null || tenantId == LoginUser.PLATFORM_TENANT_ID) {
|
||||
return; // 平台运营不受权限码限制,锁不住自己
|
||||
}
|
||||
String want = PermissionDef.USER_MANAGE.getCode();
|
||||
if (rolesGrant(tenantId, newRoles, want)) {
|
||||
return;
|
||||
}
|
||||
// 改之前本来就没有 → 他不是靠这条权限进来的,不拦
|
||||
if (!rolesGrant(tenantId, currentRolesOf(tenantId, target.getId()), want)) {
|
||||
return;
|
||||
}
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"这是你自己进入「账号管理」所依赖的最后一个来源:改完之后你将无法再管理本单位的账号。"
|
||||
+ "请先给另一个账号配上带「账号管理」的角色");
|
||||
}
|
||||
|
||||
/** 这些角色里有没有哪个带这条权限(内置看代码,自定义看库)。 */
|
||||
private boolean rolesGrant(Long tenantId, List<SysRole> roles, String permCode) {
|
||||
for (SysRole role : roles) {
|
||||
BuiltinRole br = BuiltinRole.ofCode(role.getRoleCode());
|
||||
if (br != null) {
|
||||
if (br.permissionCodes().contains(permCode)) {
|
||||
return true;
|
||||
}
|
||||
} else if (roleGrantSource.permsOf(tenantId, role.getRoleCode()).contains(permCode)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 某个人现在挂着的角色行。 */
|
||||
private List<SysRole> currentRolesOf(Long tenantId, Long userId) {
|
||||
List<String> codes = userRoleMapper.selectRoleCodesByUserId(userId);
|
||||
if (codes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
.in(SysRole::getRoleCode, codes));
|
||||
}
|
||||
|
||||
private void assertPasswordStrength(String plain) {
|
||||
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
|
||||
}
|
||||
@@ -699,11 +817,11 @@ public class UserService {
|
||||
return u;
|
||||
}
|
||||
|
||||
private void rebindRoles(long tenantId, Long userId, List<BuiltinRole> roles) {
|
||||
private void rebindRoles(long tenantId, Long userId, List<SysRole> roles) {
|
||||
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
|
||||
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
|
||||
for (BuiltinRole br : roles) {
|
||||
roleProvisioner.bindRole(tenantId, userId, br);
|
||||
for (SysRole role : roles) {
|
||||
roleProvisioner.bindRoleByCode(tenantId, userId, role.getRoleCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,17 +842,26 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
private BuiltinRole resolveFallbackRole(String defaultRoleCode) {
|
||||
BuiltinRole br = defaultRoleCode == null || defaultRoleCode.isBlank()
|
||||
? BuiltinRole.HEAD_TEACHER
|
||||
: BuiltinRole.ofCode(defaultRoleCode.trim());
|
||||
if (br == null) {
|
||||
/**
|
||||
* 导入时的默认角色。
|
||||
*
|
||||
* <p>二期改成查库:可选角色不再只有 6 个内置角色,租户自己建的角色也要能当默认值。
|
||||
* 返回 {@code SysRole}(内置与自定义同一形状),后面的绑定与用户类型推导都用它。
|
||||
*/
|
||||
private SysRole resolveFallbackRole(String defaultRoleCode) {
|
||||
String code = defaultRoleCode == null || defaultRoleCode.isBlank()
|
||||
? BuiltinRole.HEAD_TEACHER.getCode()
|
||||
: defaultRoleCode.trim();
|
||||
List<SysRole> roles;
|
||||
try {
|
||||
roles = resolveAssignableRoles(List.of(code));
|
||||
} catch (BizException e) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + code);
|
||||
}
|
||||
if (roles.isEmpty()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
|
||||
}
|
||||
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
||||
}
|
||||
return br;
|
||||
return roles.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -825,31 +952,46 @@ public class UserService {
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
/** 角色列既可能写中文名(「班主任」)也可能写角色码(HEAD_TEACHER),两种都认。 */
|
||||
private BuiltinRole resolveRoleByText(String text) {
|
||||
/**
|
||||
* 角色列既可能写中文名(「班主任」)也可能写角色码({@code HEAD_TEACHER}),两种都认。
|
||||
*
|
||||
* <p>二期改成按库里的角色认(内置 + 自定义都在 {@code sys_role} 里),
|
||||
* 所以"租户自己建的角色"也能在导入文件里按名字写。
|
||||
* 返回 {@code null} 表示"认不出或不可分配",调用方据此把该行标成错误行。
|
||||
*/
|
||||
private SysRole resolveRoleByText(String text) {
|
||||
String t = text.trim();
|
||||
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
|
||||
if (byCode != null) {
|
||||
try {
|
||||
resolveAssignableRoles(List.of(byCode.getCode()));
|
||||
return byCode;
|
||||
} catch (BizException e) {
|
||||
return null;
|
||||
for (SysRole role : tenantRoles()) {
|
||||
if (role.getRoleCode() != null && role.getRoleCode().equalsIgnoreCase(t)) {
|
||||
return assignableRole(role.getRoleCode());
|
||||
}
|
||||
}
|
||||
for (BuiltinRole br : BuiltinRole.all()) {
|
||||
if (br.getName().equals(t)) {
|
||||
try {
|
||||
resolveAssignableRoles(List.of(br.getCode()));
|
||||
return br;
|
||||
} catch (BizException e) {
|
||||
return null;
|
||||
}
|
||||
for (SysRole role : tenantRoles()) {
|
||||
if (role.getRoleName() != null && role.getRoleName().equals(t)) {
|
||||
return assignableRole(role.getRoleCode());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 本租户启用中的角色(按内置优先、排序值排)。 */
|
||||
private List<SysRole> tenantRoles() {
|
||||
return roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, UserContext.tenantId())
|
||||
.eq(SysRole::getStatus, 1)
|
||||
.orderByAsc(SysRole::getSortNo));
|
||||
}
|
||||
|
||||
/** 这个角色码当前能不能分配;不能就返回 {@code null}(而不是抛异常 —— 导入要逐行报错)。 */
|
||||
private SysRole assignableRole(String roleCode) {
|
||||
try {
|
||||
List<SysRole> roles = resolveAssignableRoles(List.of(roleCode));
|
||||
return roles.isEmpty() ? null : roles.get(0);
|
||||
} catch (BizException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 表头原文,用 | 连接,供界面展示确认。 */
|
||||
private static String joinHeaderText(Map<Integer, String> headerCells) {
|
||||
if (headerCells == null || headerCells.isEmpty()) {
|
||||
@@ -937,6 +1079,17 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
// 角色码 → 角色名(一次查完)。二期自定义角色的名字只在库里,
|
||||
// 而内置角色的名字库里也有(RoleProvisioner 发放时写的),所以不必再分两路。
|
||||
Long tenantId = UserContext.tenantId();
|
||||
Map<String, String> roleNames = new HashMap<>();
|
||||
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(tenantId != null, SysRole::getTenantId, tenantId))) {
|
||||
if (r.getRoleCode() != null) {
|
||||
roleNames.put(r.getRoleCode(), r.getRoleName() == null ? r.getRoleCode() : r.getRoleName());
|
||||
}
|
||||
}
|
||||
|
||||
List<UserVO> list = new ArrayList<>(users.size());
|
||||
for (SysUser u : users) {
|
||||
UserVO vo = new UserVO();
|
||||
@@ -953,13 +1106,11 @@ public class UserService {
|
||||
|
||||
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
|
||||
vo.setRoleCodes(codes);
|
||||
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
|
||||
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
|
||||
// 二期:角色名读库(sys_role.role_name)—— 自定义角色的名字不在代码里。
|
||||
// 内置角色的行也在库里(开通租户时由 RoleProvisioner 写入),所以一条查询覆盖两种;
|
||||
// 查不到(角色被删)时回落成角色码,至少不会显示一片空白。
|
||||
vo.setRoleNames(codes.stream()
|
||||
.map(c -> {
|
||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||
return br == null ? c : br.getName();
|
||||
})
|
||||
.map(c -> roleNames.getOrDefault(c, c))
|
||||
.toList());
|
||||
list.add(vo);
|
||||
}
|
||||
|
||||
@@ -43,15 +43,14 @@ public enum BuiltinRole {
|
||||
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.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED,
|
||||
PermissionDef.LOG_VIEW_TENANT),
|
||||
Set.of("dashboard", "org", "user", "role", "task",
|
||||
"join", "audit", "export", "cleanup", "log",
|
||||
"audit", "export", "log",
|
||||
// ⚠️ 顶层菜单里**没有**「名单管理 / 表单设计 / 材料规则」这三条。
|
||||
// 它们的功能早就做好了,但都是**按任务**的:入口在「收集任务」
|
||||
// 每一行的链接里(名单管理 / 表单设计 / 材料规则)。
|
||||
@@ -75,21 +74,6 @@ public enum BuiltinRole {
|
||||
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
||||
Set.of("dashboard", "task", "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", "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", "audit")),
|
||||
|
||||
/** 学生:只能操作本人那一份。 */
|
||||
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
|
||||
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
|
||||
@@ -161,7 +145,7 @@ public enum BuiltinRole {
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** 开通租户时默认创建的 6 个内置角色。 */
|
||||
/** 开通租户时默认创建的 4 个内置角色。 */
|
||||
public static Set<BuiltinRole> all() {
|
||||
return new LinkedHashSet<>(Arrays.asList(values()));
|
||||
}
|
||||
@@ -196,4 +180,64 @@ public enum BuiltinRole {
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这些**内置**角色码在当前租户类型下可以发给账号。
|
||||
*
|
||||
* <p>平台运营角色只发平台租户 —— 学校管理员拿到它会直接越权到平台级
|
||||
* ({@code /tenants} 只校验权限码,不校验"是否真的在平台租户下")。
|
||||
*
|
||||
* <p>这条规则有两个使用方:{@code UserService.resolveAssignableRoles}(写时的防线)
|
||||
* 与 {@code RoleService.list}(读时给界面一个 {@code assignable} 标志)。
|
||||
* 抽到这里是为了**只有一处**:两处各写一份,界面会显示一个"点了必然 403"的选项。
|
||||
*/
|
||||
public static Set<String> assignableCodes(boolean platformTenant) {
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
for (BuiltinRole r : values()) {
|
||||
if (!platformTenant && r == PLATFORM_ADMIN) {
|
||||
continue;
|
||||
}
|
||||
if (platformTenant && r != PLATFORM_ADMIN) {
|
||||
continue;
|
||||
}
|
||||
codes.add(r.code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统现有的全部菜单键(= 所有内置角色声明过的入口之并集)。
|
||||
*
|
||||
* <p>用途是**校验自定义角色配的菜单**(二期):菜单键是前后端契约
|
||||
* (后端这里与前端 {@code XtMenu.key} 逐字一致,由 {@code check-wiring.py} ⑤
|
||||
* 双向核对),所以"合法取值"就是这份并集 —— 不需要再造一张菜单字典表
|
||||
* (多一张表就多一处会漂移的地方)。
|
||||
*/
|
||||
public static Set<String> allMenuKeys() {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (BuiltinRole r : values()) {
|
||||
keys.addAll(r.getMenus());
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* **只有平台运营**声明过的菜单键(学校租户不可配置)。
|
||||
*
|
||||
* <p>现在只有 {@code tenant}(租户管理)。{@code log}(操作日志)不算 ——
|
||||
* 学校管理员也有本租户的操作日志({@code log:viewTenant}),只是看的范围不同。
|
||||
*
|
||||
* <p>为什么要有这条:把 {@code tenant} 菜单配给学校的自定义角色,左侧会多出一个
|
||||
* 点进去必然 403 的入口 —— 而用户只会得出"系统坏了"的结论。
|
||||
* 与权限码的 {@code platformOnlyCodes} 是同一类防线(那里挡的是能力,这里挡的是入口)。
|
||||
*/
|
||||
public static Set<String> platformOnlyMenuKeys() {
|
||||
Set<String> only = new LinkedHashSet<>(PLATFORM_ADMIN.getMenus());
|
||||
for (BuiltinRole r : values()) {
|
||||
if (r != PLATFORM_ADMIN) {
|
||||
only.removeAll(r.getMenus());
|
||||
}
|
||||
}
|
||||
return only;
|
||||
}
|
||||
}
|
||||
|
||||
+58
-7
@@ -4,21 +4,35 @@ 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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 权限判定。
|
||||
*
|
||||
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}),
|
||||
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里,
|
||||
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时,
|
||||
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。
|
||||
* <p><b>两个来源,各有各的权威</b>:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>内置角色</b>({@code PLATFORM_ADMIN} / {@code SCHOOL_ADMIN} / …)的权限由代码决定
|
||||
* ({@link BuiltinRole}),从 JWT 里的 {@code roles} 直接推导,**不查库**。
|
||||
* 库里 {@code sys_role_permission} 的那些行是"看得见、可审计",不是生效依据 ——
|
||||
* 否则代码改了权限集而库没同步,就会出现"界面上显示 A、实际生效 B"。</li>
|
||||
* <li><b>自定义角色</b>(二期)的授权在库里,走 {@link RoleGrantSource} 补一层查询,
|
||||
* 实现自带缓存(见 {@code DbRoleGrantSource} 的类注释:为什么必须有缓存、
|
||||
* 为什么空结果也要缓存、Redis 挂了为什么退化成直读库)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>调用方完全无感:{@code @RequirePerm} 与服务层的 {@code require} 一行都不用改。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PermissionChecker {
|
||||
|
||||
private final RoleGrantSource roleGrantSource;
|
||||
|
||||
/** 当前用户是否拥有指定权限。 */
|
||||
public boolean has(PermissionDef perm) {
|
||||
LoginUser user = UserContext.get();
|
||||
@@ -29,7 +43,7 @@ public class PermissionChecker {
|
||||
if (user.isPlatformAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode());
|
||||
return effective(user).contains(perm.getCode());
|
||||
}
|
||||
|
||||
public boolean hasAny(PermissionDef... perms) {
|
||||
@@ -63,12 +77,49 @@ public class PermissionChecker {
|
||||
if (user.isPlatformAdmin()) {
|
||||
return PermissionDef.allCodes();
|
||||
}
|
||||
return BuiltinRole.mergePermissions(user.roles());
|
||||
return effective(user);
|
||||
}
|
||||
|
||||
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
|
||||
public Set<String> currentMenus() {
|
||||
LoginUser user = UserContext.require();
|
||||
return BuiltinRole.mergeMenus(user.roles());
|
||||
Set<String> menus = new LinkedHashSet<>(BuiltinRole.mergeMenus(user.roles()));
|
||||
Long tenantId = user.tenantId();
|
||||
if (tenantId == null || user.roles() == null) {
|
||||
return menus;
|
||||
}
|
||||
// 与权限同一套口径:内置角色的菜单看代码,自定义角色的菜单看库里的配置
|
||||
for (String roleCode : user.roles()) {
|
||||
if (BuiltinRole.ofCode(roleCode) == null) {
|
||||
menus.addAll(roleGrantSource.menusOf(tenantId, roleCode));
|
||||
}
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效的权限码全集 = 内置角色(按代码)+ 自定义角色(按库里的授权)。
|
||||
*
|
||||
* <p>⚠️ 只对"代码里不存在的角色码"查库。反过来写(先查库、查不到再按代码)
|
||||
* 会让内置角色也被库里的历史授权影响 —— 那条路一旦走通,
|
||||
* "把 SCHOOL_ADMIN 的某条权限收紧"就要同时改代码与每个租户的库。
|
||||
*/
|
||||
private Set<String> effective(LoginUser user) {
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
if (user.roles() == null || user.roles().isEmpty()) {
|
||||
return codes;
|
||||
}
|
||||
codes.addAll(BuiltinRole.mergePermissions(user.roles()));
|
||||
|
||||
Long tenantId = user.tenantId();
|
||||
if (tenantId == null) {
|
||||
return codes;
|
||||
}
|
||||
for (String roleCode : user.roles()) {
|
||||
if (BuiltinRole.ofCode(roleCode) == null) {
|
||||
codes.addAll(roleGrantSource.permsOf(tenantId, roleCode));
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,55 +19,57 @@ import java.util.Set;
|
||||
*
|
||||
* <p>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
|
||||
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
|
||||
*
|
||||
* <p>{@link #group} 是给「角色与授权」页的权限矩阵分组用的:
|
||||
* 28 条权限平铺成一张表没人看得懂,而分组**只在展示层有意义**,不影响任何校验 ——
|
||||
* 所以它是枚举上的一个字符串,而不是一张表。
|
||||
*/
|
||||
@Getter
|
||||
public enum PermissionDef {
|
||||
|
||||
// ── 平台运营 ──────────────────────────────────
|
||||
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC),
|
||||
TENANT_VIEW("tenant:view", "租户查看", Platform.PC),
|
||||
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC, "名册"),
|
||||
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC, "名册"),
|
||||
ROSTER_VIEW("roster:view", "名册查看", Platform.PC, "名册"),
|
||||
|
||||
// ── 通知与进度 ────────────────────────────────
|
||||
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH),
|
||||
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH),
|
||||
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", 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),
|
||||
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),
|
||||
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC, "导出"),
|
||||
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC, "导出"),
|
||||
|
||||
// ── 学生端 ────────────────────────────────────
|
||||
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP);
|
||||
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP, "学生端");
|
||||
|
||||
/** 端类型。 */
|
||||
public enum Platform {
|
||||
@@ -88,10 +90,14 @@ public enum PermissionDef {
|
||||
/** 适用范围。 */
|
||||
private final Platform platform;
|
||||
|
||||
PermissionDef(String code, String label, Platform platform) {
|
||||
/** 展示分组(只影响「角色与授权」页的矩阵排版,不参与任何校验)。 */
|
||||
private final String group;
|
||||
|
||||
PermissionDef(String code, String label, Platform platform, String group) {
|
||||
this.code = code;
|
||||
this.label = label;
|
||||
this.platform = platform;
|
||||
this.group = group;
|
||||
}
|
||||
|
||||
public static PermissionDef ofCode(String code) {
|
||||
@@ -106,4 +112,23 @@ public enum PermissionDef {
|
||||
Arrays.stream(values()).forEach(p -> set.add(p.code));
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校租户**不可授予**的权限码(平台级能力)。
|
||||
*
|
||||
* <p>这三条能让持有者跨租户:{@code tenant:manage} 能开通/改租户,
|
||||
* {@code log:viewAll} 能读全平台日志,{@code tenant:view} 能列举所有租户。
|
||||
* 自定义角色的可勾选项里必须把它们排除 —— 界面不显示只是"不提示",
|
||||
* 服务端这一层才是防线(与 {@code UserService.resolveAssignableRoles} 同一条思路:
|
||||
* 传了白名单之外的东西要拿到 40301,而不是"界面没这个选项所以不会发生")。
|
||||
*/
|
||||
public static Set<String> platformOnlyCodes() {
|
||||
Set<String> set = new LinkedHashSet<>();
|
||||
for (PermissionDef p : values()) {
|
||||
if (p.code.startsWith("tenant:") || p == LOG_VIEW_ALL) {
|
||||
set.add(p.code);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.tianbaotong.fill.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 自定义角色的授权来源(「角色与授权」二期)。
|
||||
*
|
||||
* <p><b>为什么是接口,而不是让 {@link PermissionChecker} 直接查库</b>:
|
||||
* 「角色被授了哪些权限码」存在 {@code sys_role_permission}({@code module.user} 的东西),
|
||||
* 而 {@code security} 这一层被 {@code module} 依赖。让 security 反过来依赖 module,
|
||||
* 会形成一个方向相反的依赖,并且**迟早成环** —— 角色管理服务自己就要调
|
||||
* {@code permissionChecker.require(ROLE_MANAGE)}。所以这里只声明"能问什么",
|
||||
* 实现放在 {@code module.user.service.DbRoleGrantSource}。
|
||||
*
|
||||
* <p><b>实现必须只回答自定义角色</b>:内置角色的权限由代码决定({@link BuiltinRole}),
|
||||
* 对内置角色码或不存在的角色码一律返回空集 —— 否则库里的历史授权会盖住代码,
|
||||
* 而那正是"界面上显示一套、实际生效另一套"的来源。
|
||||
*/
|
||||
public interface RoleGrantSource {
|
||||
|
||||
/**
|
||||
* 这个角色码被授予的权限码。
|
||||
*
|
||||
* @param tenantId 角色所属租户(角色是租户隔离的,必须显式带上)
|
||||
* @param roleCode 角色码
|
||||
* @return 权限码集合;内置角色、不存在的角色、已停用的角色一律返回空集
|
||||
*/
|
||||
Set<String> permsOf(long tenantId, String roleCode);
|
||||
|
||||
/**
|
||||
* 这个角色码可见的菜单键。
|
||||
*
|
||||
* <p>与 {@link #permsOf} 同一套口径(只回答自定义角色、停用即空集)。
|
||||
* 「看得见」与「有权限」是两件事:菜单只影响前端显示哪些入口,
|
||||
* 真正的访问控制在权限码校验上(绕过前端只会拿到 40301)。
|
||||
*/
|
||||
Set<String> menusOf(long tenantId, String roleCode);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
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.module.user.service.RoleGrantWriter;
|
||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -28,10 +27,20 @@ import java.util.Set;
|
||||
public class RoleProvisioner {
|
||||
|
||||
private final RoleMapper roleMapper;
|
||||
private final RolePermissionMapper rolePermissionMapper;
|
||||
private final UserRoleMapper userRoleMapper;
|
||||
private final RoleGrantWriter grantWriter;
|
||||
|
||||
/** 幂等:已存在的角色跳过。 */
|
||||
/**
|
||||
* 幂等:角色已存在就补齐定义,并把**授权对齐到代码**。
|
||||
*
|
||||
* <p>⚠️ 第一版是「已存在就 {@code continue}」,看起来更省事,但结果是:
|
||||
* 代码里改了某条权限之后,**已经开通的租户永远停在发放那一刻** ——
|
||||
* 库里的授权与代码不一致,而且没有任何地方会报错。
|
||||
* 所以现在即使角色已存在,也要走一遍 {@link #syncGrants}(值一样时一行都不写)。
|
||||
*
|
||||
* <p>内置角色的权限**永远以代码为准**(见 {@code PermissionChecker}):
|
||||
* 这里的同步是为了"库里看到的 = 实际生效的",让人查库时不会被误导。
|
||||
*/
|
||||
public void provisionBuiltinRoles(long tenantId) {
|
||||
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
|
||||
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
|
||||
@@ -47,30 +56,57 @@ public class RoleProvisioner {
|
||||
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
SysRole role = findRole(tenantId, br.getCode());
|
||||
if (role == null) {
|
||||
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.setUserType(br.getUserType().code());
|
||||
role.setStatus(1);
|
||||
role.setRemark("内置角色,权限由代码决定,不可修改");
|
||||
roleMapper.insert(role);
|
||||
} else if (alignToCode(role, br)) {
|
||||
roleMapper.updateById(role);
|
||||
}
|
||||
grantWriter.replacePerms(tenantId, role.getId(), br.permissionCodes());
|
||||
grantWriter.replaceMenus(tenantId, role.getId(), br.getMenus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把库里那一行按代码对齐(名字 / 数据范围 / 排序 / 用户类型),**有变化才写库**。
|
||||
*
|
||||
* <p>为什么不连 {@code status} 一起刷:内置角色在界面上不可停用,
|
||||
* 但万一有人用 SQL 停用了它,每天定时任务把它悄悄打开会更糟 ——
|
||||
* 那等于"系统自己改了别人的决定"。所以状态不在同步范围内。
|
||||
*
|
||||
* @return 是否需要 updateById
|
||||
*/
|
||||
private boolean alignToCode(SysRole role, BuiltinRole br) {
|
||||
boolean dirty = false;
|
||||
if (!java.util.Objects.equals(role.getRoleName(), br.getName())) {
|
||||
role.setRoleName(br.getName());
|
||||
dirty = true;
|
||||
}
|
||||
if (!java.util.Objects.equals(role.getDataScope(), br.getDataScope().code())) {
|
||||
role.setDataScope(br.getDataScope().code());
|
||||
dirty = true;
|
||||
}
|
||||
if (!java.util.Objects.equals(role.getSortNo(), br.getSortNo())) {
|
||||
role.setSortNo(br.getSortNo());
|
||||
dirty = true;
|
||||
}
|
||||
if (!java.util.Objects.equals(role.getUserType(), br.getUserType().code())) {
|
||||
role.setUserType(br.getUserType().code());
|
||||
dirty = true;
|
||||
}
|
||||
return dirty;
|
||||
}
|
||||
|
||||
public SysRole findRole(long tenantId, String roleCode) {
|
||||
return roleMapper.selectOne(Wrappers.<SysRole>lambdaQuery()
|
||||
.eq(SysRole::getTenantId, tenantId)
|
||||
@@ -84,6 +120,26 @@ public class RoleProvisioner {
|
||||
if (role == null) {
|
||||
throw new IllegalStateException("内置角色未创建:" + br.getCode());
|
||||
}
|
||||
bind(tenantId, userId, role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色码绑定(内置与自定义都走这里)。
|
||||
*
|
||||
* <p>二期新增:账号管理页可选的角色不再只有 6 个内置角色,所以绑定不能再只认
|
||||
* {@link BuiltinRole} —— 传角色码、按租户查行、绑行 id,对两种角色是同一条路径。
|
||||
* 「这个角色能不能发给这个租户」由调用方({@code UserService.resolveAssignableRoles})判定,
|
||||
* 这里只负责"查到就绑"。
|
||||
*/
|
||||
public void bindRoleByCode(long tenantId, long userId, String roleCode) {
|
||||
SysRole role = findRole(tenantId, roleCode);
|
||||
if (role == null) {
|
||||
throw new IllegalStateException("角色未创建:" + roleCode);
|
||||
}
|
||||
bind(tenantId, userId, role);
|
||||
}
|
||||
|
||||
private void bind(long tenantId, long userId, SysRole role) {
|
||||
SysUserRole ur = new SysUserRole();
|
||||
ur.setTenantId(tenantId);
|
||||
ur.setUserId(userId);
|
||||
|
||||
@@ -19,16 +19,15 @@ spring:
|
||||
flyway:
|
||||
# dev 允许 clean 后重建,方便反复试 DDL
|
||||
clean-disabled: false
|
||||
web:
|
||||
resources:
|
||||
# H5 静态文件位置。默认指向 uni-app 的 H5 构建产物;
|
||||
# 部署时用环境变量 H5_STATIC_DIR 指到实际目录即可,
|
||||
# **不需要把构建产物提交进仓库**(那是产物,不是源码)。
|
||||
# 开发前先在 frontend-mp 里跑一次 `pnpm build:h5`。
|
||||
#
|
||||
# ⚠️ 它的前缀是 spring.(不是 fill.)——写错前缀的表现是"页面 404
|
||||
# 而应用照常启动、没有任何报错",很难一眼看出来。
|
||||
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
|
||||
# ⚠️ 这里**没有** spring.web.resources.static-locations(2026-10 架构调整):
|
||||
# H5 不再由后端托管,改由网关 **nginx** 提供(deploy/nginx/xt.conf + xt-nginx 容器,
|
||||
# 见 docs/09-部署与运维.md §2.1)。后端是**纯 API 服务**。
|
||||
# 静态资源的缓存头也跟着搬到了 nginx —— 静态文件的事由静态服务器负责。
|
||||
#
|
||||
# 历史(留着是有用的):它曾经是 `file:../frontend-mp/dist/build/h5/`,
|
||||
# 也就是"后端顺便把 H5 发了"。那是为了**与接口同域**(微信网页授权只给一个回调域名、
|
||||
# 同域省掉 CORS)。这个目标现在由 nginx 在同一台机器的 80 端口上实现:
|
||||
# H5 与 /api 仍然同源,前端与微信后台的配置都不用动。
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
@@ -51,6 +50,16 @@ fill:
|
||||
# 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=
|
||||
|
||||
# ── 跨域白名单(本地开发)────────────────────────────────────────────
|
||||
#
|
||||
# ⚠️ 见 application.yml 里的完整说明:**同源的 POST 也会被 CORS 校验**,
|
||||
# 所以"H5 由 nginx 托管、页面与接口同源"还不够 ——
|
||||
# 浏览器给的 Origin 是**页面地址**(穿透域名 / localhost),必须列在这里,
|
||||
# 否则 H5 的登录、上传、提交会在浏览器里 403(而后端日志里一个字都没有)。
|
||||
# 这份名单对应三种打开方式:pnpm dev:h5(5174) / xt-nginx(80) / 花生壳穿透。
|
||||
cors:
|
||||
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*,http://394nb68658le.vicp.fun}"
|
||||
|
||||
# ── 材料文件存储(原图 + 压缩改名后的规范件)──────────────────────────
|
||||
#
|
||||
# ⚠️ provider=local 只适合本地开发:**多副本部署下必然丢文件** ——
|
||||
@@ -130,8 +139,11 @@ fill:
|
||||
# 而那个域名要能解析到这台机器(本地开发用内网穿透)。
|
||||
callback-url: ${WX_H5_CALLBACK:http://394nb68658le.vicp.fun/api/v1/mp/h5/callback}
|
||||
# H5 应用地址:授权成功后 302 回这里。
|
||||
# 与接口**同域**(由本服务直接托管 H5 静态文件,见下面的 static-locations)——
|
||||
# 与接口**同域**(H5 由 nginx 托管、/api 由同一个 nginx 反代到 8080,见 deploy/nginx/xt.conf)——
|
||||
# 这样既省掉 CORS,也和正式部署的形态一致。
|
||||
#
|
||||
# ⚠️ 内网穿透要指到 **nginx 的 80 端口**,不是后端的 8080:
|
||||
# 后端现在只提供 /api,穿透指到 8080 的表现是"接口好用、页面 404"。
|
||||
h5-base-url: ${WX_H5_BASE_URL:http://394nb68658le.vicp.fun}
|
||||
# ⚠️⚠️ 跳过微信授权直接登录的开关。**生产必须为 false**。
|
||||
# 开了它就是"任何人可登录任何学生"的后门。
|
||||
|
||||
@@ -1,45 +1,67 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 生产环境(prod)
|
||||
#
|
||||
# ⚠️⚠️ 这个文件里**一个密钥都没有,也永远不要加**。
|
||||
# 全部由环境变量提供;缺失时 **ProdGuard 会拒绝启动**
|
||||
# (见 config/ProdGuard.java —— 那 6 条都是"配错了也能启动、
|
||||
# 而且运行时完全不报错"的配置)。
|
||||
# ⚠️⚠️ 本文件按部署要求**直接写入了**数据库、Redis、七牛与
|
||||
# 应用密钥(JWT / AES / HMAC / RSA)—— 它属于**敏感文件**:
|
||||
# · 仓库可见范围必须收紧(能读它 = 能伪造 token、能解证件号)
|
||||
# · 不要发到公开渠道,也不要复制到别处
|
||||
# · 轮换密钥时优先用同名环境变量覆盖(环境变量优先级高于本文件)
|
||||
#
|
||||
# ── 必需的环境变量 ──────────────────────────────────────
|
||||
# 数据库 PG_HOST PG_PORT PG_DB PG_USER PG_PASSWORD
|
||||
# 缓存 REDIS_HOST REDIS_PORT REDIS_PASSWORD
|
||||
# 密钥 XT_JWT_SECRET HS512,≥ 64 字节随机值
|
||||
# XT_AES_KEY AES-256,base64,**用于证件号加密**
|
||||
# XT_HMAC_KEY HMAC,用于开放接口签名
|
||||
# XT_RSA_PRIVATE_KEY PKCS#8 单行 base64(登录密码解密)
|
||||
# 存储 XT_STORAGE_PROVIDER=qiniu
|
||||
# QINIU_ACCESS_KEY QINIU_SECRET_KEY QINIU_BUCKET QINIU_DOMAIN
|
||||
# 微信 WX_MP_APP_ID WX_MP_APP_SECRET (小程序)
|
||||
# WX_H5_APP_ID WX_H5_APP_SECRET (公众号网页授权)
|
||||
# WX_H5_CALLBACK WX_H5_BASE_URL (必须是 https 正式域名)
|
||||
# 其他 H5_STATIC_DIR(H5 构建产物目录)
|
||||
# ── 仍然建议用环境变量提供的项 ─────────────────────────
|
||||
# 微信小程序 WX_MP_APP_ID WX_MP_APP_SECRET WX_MP_PUBLIC_BASE_URL
|
||||
# WX_MP_CALLBACK_TOKEN
|
||||
# 其他 XT_LOG_DIR XT_UPLOAD_DIR XT_SEED_ENABLED
|
||||
# XT_TENANT_DOMAIN_ENABLED XT_TENANT_DOMAIN_BASE
|
||||
# XT_CORS_ALLOWED_ORIGINS(**换域名/加域名时必看**:浏览器同源 POST 也走 CORS,
|
||||
# 漏了本站地址 = 登录 403,见 §一·四十四)
|
||||
#
|
||||
# ⚠️ 学生端**只发布微信小程序,不部署 H5**:
|
||||
# 本文件没有 fill.mp-h5 段。后端 /api/v1/mp/h5/** 接口仍在,
|
||||
# 未配置时会明确报错,但生产不提供 H5 页面与公众号网页授权。
|
||||
#
|
||||
# ⚠️ 密钥怎么生成、怎么轮换、轮换时要注意什么,见
|
||||
# docs/09-部署与运维.md §3「密钥与凭据」。
|
||||
# **XT_AES_KEY 一旦加密过证件号就不能直接换**(旧密文解不开且不报错)。
|
||||
#
|
||||
# ⚠️ 生产的工作目录里不要放 application-local.yml:
|
||||
# 基础配置里有 spring.profiles.include: local,它会被加载并可能覆盖本文件。
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://${PG_HOST}:${PG_PORT:5432}/${PG_DB:filldb}?stringtype=unspecified
|
||||
username: ${PG_USER}
|
||||
password: ${PG_PASSWORD}
|
||||
url: jdbc:postgresql://1Panel-postgresql-v7DP:5432/shenxue?stringtype=unspecified
|
||||
username: shenxue
|
||||
password: RC6Bhnmbz8mk4p4z
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
pool-name: xt-hikari-prod
|
||||
# 生产把池子放大一些:导出打包是长事务,会占住连接
|
||||
minimum-idle: 10
|
||||
maximum-pool-size: 40
|
||||
connection-timeout: 10000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1700000
|
||||
# 慢 SQL / 连接未归还从应用侧记一份,方便和 PG 的
|
||||
# log_min_duration_statement 对照
|
||||
leak-detection-threshold: 20000
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD}
|
||||
host: 1Panel-redis-mIkk
|
||||
port: 6379
|
||||
password: redis_GWnh6E
|
||||
database: 0
|
||||
timeout: 3s
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 32
|
||||
max-idle: 16
|
||||
min-idle: 4
|
||||
max-wait: 3s
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: false
|
||||
table: flyway_schema_history
|
||||
# ⚠️⚠️ 生产**禁止 clean**。它是"清空整个库再重建",
|
||||
# 在生产上执行等于把所有学校的填报数据删掉。
|
||||
# 流水线里也不要用 flyway clean —— 见部署文档的回滚一节。
|
||||
@@ -47,101 +69,206 @@ spring:
|
||||
# 迁移文件发布后不许改(改了校验失败)。这是保护:它保证
|
||||
# "生产跑的迁移"与"仓库里的迁移"逐字节一致
|
||||
validate-on-migrate: true
|
||||
jackson:
|
||||
time-zone: Asia/Shanghai
|
||||
default-property-inclusion: always
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
lifecycle:
|
||||
# 配合 server.shutdown=graceful:等在途请求处理完再退出
|
||||
timeout-per-shutdown-phase: 30s
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 60MB
|
||||
web:
|
||||
resources:
|
||||
# H5 产物由部署流程放到这个目录(**不进仓库**)
|
||||
static-locations: ${H5_STATIC_DIR:file:/opt/xt/h5/},classpath:/static/
|
||||
# ⚠️ 后端是**纯 API 服务**:PC 管理端与学生端(微信小程序)都不由后端托管,
|
||||
# 页面由 nginx/网关/微信客户端提供(见 application.yml 的 add-mappings 注释)。
|
||||
# 发布时仍然要"和后端一起发" —— 前后端不同步会出现"页面调用了还不存在的接口"。
|
||||
add-mappings: false
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
# 优雅停机。没有它,spring.lifecycle.timeout-per-shutdown-phase 不生效
|
||||
shutdown: graceful
|
||||
# 生产在反向代理后面:让 Spring 认 X-Forwarded-* ,
|
||||
# 否则生成的回调地址会是 http://内网IP:8080
|
||||
forward-headers-strategy: framework
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: application/json,text/html,text/css,application/javascript
|
||||
min-response-size: 2048
|
||||
error:
|
||||
# 不要把异常栈与内部错误信息回给调用方
|
||||
include-stacktrace: never
|
||||
include-message: never
|
||||
tomcat:
|
||||
# 上传是长连接,生产放宽(学生可能从聊天记录里选一个 40MB 的文件)
|
||||
connection-timeout: 120s
|
||||
max-swallow-size: 100MB
|
||||
threads:
|
||||
max: 200
|
||||
min-spare: 20
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
# 生产环境关掉 SQL 日志
|
||||
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
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: warn
|
||||
cn.tianbaotong.fill: info
|
||||
# 迁移记录值得留在日志里(root=warn 会把它压掉)
|
||||
org.flywaydb: info
|
||||
file:
|
||||
# ⚠️ 日志按天滚动并保留 30 天:审计要能回答"上周三谁看过那份证件号"
|
||||
name: ${XT_LOG_DIR:/var/log/xt}/xt-server.log
|
||||
name: "${XT_LOG_DIR:/var/log/xt}/xt-server.log"
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-history: 30
|
||||
max-file-size: 200MB
|
||||
total-size-cap: 20GB
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
# 生产只暴露健康检查(给负载均衡探活:/actuator/health)
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
# 同时提供 /actuator/health/liveness 与 /actuator/health/readiness
|
||||
probes:
|
||||
enabled: true
|
||||
health:
|
||||
# 探活会实际探测 PG 与 Redis(依赖挂了不能还报 UP)
|
||||
db:
|
||||
enabled: true
|
||||
redis:
|
||||
enabled: true
|
||||
|
||||
# 生产关掉接口文档:它会把全部接口与字段暴露给公网。
|
||||
# ProdGuard 会检查这一条 —— 开着就拒绝启动。
|
||||
# ProdEnvCheck 会检查这一条 —— 开着就拒绝启动。
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
# 生产只暴露健康检查(给负载均衡探活)
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
|
||||
# ── 本项目业务配置 ────────────────────────────
|
||||
fill:
|
||||
jwt:
|
||||
secret: ${XT_JWT_SECRET}
|
||||
issuer: tianbaotong-fill
|
||||
# HS512 签名密钥(≥ 64 字节随机值)。环境变量优先,便于轮换
|
||||
secret: "${XT_JWT_SECRET:2gtJ3sKcegiZj8SbFuLOPRA8jF6tOwLwV16QzDtLZfaLIKEq0ZHEdneJjg8EDHUJ+CH7coufso0+2qD8dkxoKQ==}"
|
||||
# PC 8 小时 / 小程序 7 天(《03-接口设计》§1.2)
|
||||
pc-expire-seconds: 28800
|
||||
mp-expire-seconds: 604800
|
||||
crypto:
|
||||
aes-key: ${XT_AES_KEY}
|
||||
hmac-key: ${XT_HMAC_KEY}
|
||||
# AES-256(base64,32 字节),**用于证件号加密**
|
||||
aes-key: "${XT_AES_KEY:N645XQtx+BDMlXTGe8YkJxUvesj9BYUPNQXLjQUiC/Q=}"
|
||||
# HMAC,用于开放接口签名与证件号去重 hash
|
||||
hmac-key: "${XT_HMAC_KEY:1eTpdihb/nZOh2PCpTE5o9SrLr9oGS7EqtbUM/3DNFe98CHxauViRL6beXQB8xci}"
|
||||
rsa:
|
||||
private-key: ${XT_RSA_PRIVATE_KEY}
|
||||
# PKCS#8 私钥(单行 Base64,无 PEM 头尾),用于登录密码解密。
|
||||
# 公钥由 GET /api/v1/auth/public-key 动态下发,轮换后前端无需重新构建。
|
||||
private-key: "${XT_RSA_PRIVATE_KEY:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnWFBpGQU+ZaSBBe8XWSbSgwlOn5fOr+LH2I4lHtgoOWECKZLL8Ltf/cdRL0cc7ZkMEyE2xvidW+G1ADEGKyy/5LIWZ/+37KfM25JHPhE3v3+K9j05mPG5Yq7Qwdaf9OKkwmL/qc7fPUVyzw/uNL8+oU7JP6Vw1weIxXLjGUHwmTcwGjEXAMiu39xnhxXtUa7oywa2XnGAFZm+bt/YoLFbI04SQ9/o5oFnr7KAUBPW3V8kVejuMMQK/qCy06xhzDBJUw+jYwM3Tu9otlPykZXiVrDC7psImoaFIbvOMRh1PILOa70eHgr86++ZKyyiGqFl5bQ/5jURKfKxHrw7BagbAgMBAAECggEAGj0viEQQuVuGQCYKpf90F530y8iZIMBdPI/Iy4Kx6e9cG9vYQlj6/5pakCWIb3UEDf+2HYQ4Gtea2XsnPvey+yQhY3OHPaFSBOUF3x5gjeNNQCxz6Zj5wwOOKATxkVyRP4ur9oBtg29ryuebpcewxes+HYr3pOfr+wIeE44//DLv/PfGfSnkb4n+eILrm6H4qWfTbZnBwUv1P6F4YJvknuVytkCLtRiZRcmZ/BXD1Uldcb9zQbeGylvjqPTO1wo1Rdq8IhC9ggfJIDMznRb7CMkEN5nE1W8VZgk5adJBoHGXFukQyq7ne5p3HODPrySn5TYBoyH2lo4bDpmIkLYcAQKBgQD4t/gwRxDiWV4R06Vc3kdQ5/C0A5221m19b5d2kGld+W48URbZsZbkGjYQ5eQ+5hDzUnP29kToQUzWjJFP89G1xqep93OzQdVMmTtKakuA+3T+LudvzNmwB/nT+4StwdXQdgImCqkQfVHmBVcmv0pyJP+yHCIT+X3vv33YxNF8AQKBgQDuHiMczAswsV4E4QmPdXgA10udOr1OrGv582adDHzIpAUExPvv1iUBQ3r1lcTYbEzUUbGl+d5xnkmxgc1ulzC0lgNgCrIcCWIJ/nCo8L09lvA4Q/R239i70QldU2Hd8UEL7hzyqr8DeEjyOIwHfqkpl7nGbVI+ukfSLNbv0T2UGwKBgQDssh+WyjruPkBA1Ns9SygxfdnbZsmT8/LU+fr50asEzNi0vDLzz4ca4zrqz+1sUoR1G9XShEHFikd2suWjYhUEWd676QuJ36//KqB7TDc4s5V+ROLpyditJpoBvA5jUeGKdpPK4kBMugbT0edDr65IfJ9+QDnKKphILkO0pHH8AQKBgAqLPxbJWj1/vSAOLvQxSW0QG4MM1NElBWr+Ai82HFhuGuLJLjbCjsTtjgZN6XeD5CsKqwkkdVsmnTDFBKm+/CMi+WdJUfW33KWApNB2SIrvohd7utmAtAItEXMWfCDty+mRU6rTML4gl2pSYrD6RNdYpptIv/mTGhcOcT4RT969AoGBAIZ6boFBwyt3zRD71SMHonujfU3ldeodAtNrpXovAHs3iwNpOUvjYSOAcUSTKB+FcKcR/xk5L9PlskmumNP/DdUXgPyPocjITdSwPT5JCXffgw3p50o+gxvw52EkPPHO9M9gXFZRCE04KF0ToQoYkX267ct/erlddBrbzsVJLCHZ}"
|
||||
|
||||
security:
|
||||
# 登录失败 5 次锁 15 分钟;连续失败 2 次后要求图形验证码(§1.6)
|
||||
login-max-fail: 5
|
||||
login-lock-minutes: 15
|
||||
captcha-after-fails: 2
|
||||
|
||||
# ── 跨域白名单(**同源也要配**,见 application.yml 里的完整说明)────────
|
||||
#
|
||||
# ⚠️ 浏览器的同源 POST/PUT/DELETE 也会带 Origin,而 Spring MVC 只看"有没有 Origin":
|
||||
# 不在白名单里 → 直接 403「Invalid CORS request」→ **登录不上**。
|
||||
# PC 端是 https://admin.shenxue.jxit.com,接口是同一个域名下的 /api,
|
||||
# 所以这个地址**必须在**名单里(2026-10-20 生产事故的根因)。
|
||||
#
|
||||
# 三个来源分别是:主域名(统一入口)/ PC 管理端 / 所有子域(将来的 t1、t2 租户站点)。
|
||||
# 换域名或加穿透域名时:改这里,或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
|
||||
cors:
|
||||
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com}"
|
||||
|
||||
upload:
|
||||
# 单文件上限 50MB(PRD §10.1)
|
||||
max-file-size-mb: 50
|
||||
# 本地落盘目录。七牛只存学生材料;导出包等临时文件仍然写本地盘,
|
||||
# 所以这个目录在生产上必须可写、且不要放在会被清理的 /tmp 下。
|
||||
dir: "${XT_UPLOAD_DIR:./data/uploads}"
|
||||
|
||||
storage:
|
||||
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdGuard 会拦)
|
||||
# 生产必须 qiniu(local 在多副本下必然丢文件,ProdEnvCheck 会拦)
|
||||
provider: qiniu
|
||||
prefix: xiaotutongxue
|
||||
keep-raw: ${XT_STORAGE_KEEP_RAW:false}
|
||||
self-check-on-startup: ${XT_STORAGE_SELF_CHECK:true}
|
||||
# bucket 内的目录前缀 —— 本项目的文件都放在 shenxue/ 下
|
||||
prefix: shenxue
|
||||
# 默认只保留压缩改名后的规范件(客户确认过,不需要原图当凭证)
|
||||
keep-raw: "${XT_STORAGE_KEEP_RAW:false}"
|
||||
# 启动时做一次真实的 写入 → 存在 → 读回 → 删除 自检。
|
||||
# 换存储 / 换密钥 / 换空间之后值得打开:它能在启动时就把
|
||||
# "密钥错/空间名错/没有写权限"暴露出来,而不是等第一个学生上传时才失败。
|
||||
self-check-on-startup: "${XT_STORAGE_SELF_CHECK:true}"
|
||||
qiniu:
|
||||
access-key: ${QINIU_ACCESS_KEY}
|
||||
secret-key: ${QINIU_SECRET_KEY}
|
||||
bucket: ${QINIU_BUCKET}
|
||||
domain: ${QINIU_DOMAIN}
|
||||
region: ${QINIU_REGION:auto}
|
||||
access-key: "t1PIPGcvBY9lJVXFZFb48maTQsGGhvLsR5QQlNq0"
|
||||
secret-key: "KGooFdF5eCLdCIMCOD6x5ofMzu4vYE17T5Mvp9qC"
|
||||
bucket: "file-jxit-private"
|
||||
domain: "https://file.jxit.com"
|
||||
# auto = 让 SDK 自己查空间在哪个区(推荐)。
|
||||
# 「华东-浙江」在新旧控制台对应 z0 与 cn-east-2 两个 id,写错了的症状是上传超时。
|
||||
region: "${QINIU_REGION:auto}"
|
||||
# ⚠️ 空间是**私有**的 → 下载走签名链接(带有效期)。
|
||||
# 因此 URL 不能入库、不能发出去让人明天再打开,每次要用现取。
|
||||
signed-url-expire-seconds: 1800
|
||||
|
||||
# ⚠️ 生产默认关闭初始数据装配,避免默认口令(platform / Platform@123)进生产。
|
||||
# 首次部署需要建平台管理员时,用 XT_SEED_ENABLED=true 启动一次;
|
||||
# 它会同时建一个 demo 示范租户,登录后请改掉默认密码并清理 demo 租户,
|
||||
# 然后把这里改回 false 再重启。
|
||||
seed:
|
||||
enabled: "${XT_SEED_ENABLED:false}"
|
||||
|
||||
mini-program:
|
||||
app-id: ${WX_MP_APP_ID}
|
||||
app-secret: ${WX_MP_APP_SECRET}
|
||||
app-id: "${WX_MP_APP_ID:wxf01d3fa60f87f0b7}"
|
||||
app-secret: "${WX_MP_APP_SECRET:d208f690aab50ec7c5c63e6bbeca6a27}"
|
||||
# ⚠️ 正式发布后用 release。用 trial/develop 生成的码扫开会提示
|
||||
# 「小程序未发布」,而**接口不报错** —— 只会让人以为码生成得不对
|
||||
env-version: ${WX_MP_ENV_VERSION:release}
|
||||
env-version: "${WX_MP_ENV_VERSION:release}"
|
||||
page: pages/index/index
|
||||
public-base-url: ${WX_MP_PUBLIC_BASE_URL}
|
||||
# 图片对外前缀。留空时 qrcodeUrl 返回相对路径(/api/v1/qrcodes/xxx.jpg),
|
||||
# 由前端用当前域名去拼;前后端同域时这样最省事。
|
||||
public-base-url: "${WX_MP_PUBLIC_BASE_URL:}"
|
||||
code-cache-days: 30
|
||||
# 微信「服务器配置」里的 Token(**不是** appSecret),用于消息推送回调验签。
|
||||
# 下面已生成一个随机值;要与微信后台填的**完全一致**,否则回调验签失败。
|
||||
# 不用消息推送回调时可以留空 —— 回调接口会明确拒绝,而不是跳过验签。
|
||||
callback-token: "${WX_MP_CALLBACK_TOKEN:7c6f88f58fc2a60eefb0b96094d6a935}"
|
||||
# 微信订阅消息模板 id(形如 ZG8k...):模板码 → 模板 id。
|
||||
# 没配置的模板码会自动降级为站内信,不会报错;不要在没建模板时填占位值。
|
||||
# 可用模板码:SUBMIT_SUCCESS / AUDIT_RESULT / DEADLINE_REMIND / EXPORT_READY / NOTIFY_UNSUBMITTED
|
||||
templates: {}
|
||||
|
||||
mp-h5:
|
||||
app-id: ${WX_H5_APP_ID}
|
||||
app-secret: ${WX_H5_APP_SECRET}
|
||||
callback-url: ${WX_H5_CALLBACK}
|
||||
h5-base-url: ${WX_H5_BASE_URL}
|
||||
# ⚠️⚠️ 生产**必须** false。开了它就是"任何人可登录任何学生"。
|
||||
# 这是 ProdGuard 的第一条检查,写成 true 直接拒绝启动。
|
||||
stub-enabled: false
|
||||
# 不配置 mp-h5:学生端只发布微信小程序,不做 H5 网页授权。
|
||||
# 后端 /api/v1/mp/h5/** 接口仍在,未配置时会明确报错;
|
||||
# 生产不提供 H5 页面,也不再需要公众号的 appid / appsecret。
|
||||
|
||||
# 租户域名(用子域前缀区分租户):t{tenantId}.{base},如 t1.shenxue.jxit.com。
|
||||
# 客户 2026-09-29 决定先不做,所以默认关闭;能力本身保留,
|
||||
# 将来要有正式域名 + 泛域名解析 + 泛域名证书之后才打开。
|
||||
tenant-domain:
|
||||
enabled: ${XT_TENANT_DOMAIN_ENABLED:false}
|
||||
base: ${XT_TENANT_DOMAIN_BASE:}
|
||||
enabled: "${XT_TENANT_DOMAIN_ENABLED:false}"
|
||||
base: "${XT_TENANT_DOMAIN_BASE:shenxue.jxit.com}"
|
||||
scheme: https
|
||||
|
||||
@@ -32,10 +32,9 @@ spring:
|
||||
# **这样才能验证"所有迁移从 V1 依次跑到最新"是通的** ——
|
||||
# 而增量环境里永远验不到这件事(旧库已经是最新了)
|
||||
clean-disabled: false
|
||||
web:
|
||||
resources:
|
||||
# H5 产物在 CI 里是构建出来的;本地指向同一个相对路径
|
||||
static-locations: ${H5_STATIC_DIR:file:../frontend-mp/dist/build/h5/},classpath:/static/
|
||||
# ⚠️ 这里**没有** static-locations:H5 不再由后端托管(2026-10 架构调整,
|
||||
# 见 application.yml 的 spring.web.resources.add-mappings 与 docs/09 §2.1)。
|
||||
# CI 因此不再需要 H5 产物才能跑 e2e —— 那个 job 现在只是**编译检查**。
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 数据信息填报系统 · 主配置
|
||||
# 深学在线 · 主配置
|
||||
# 环境差异全部放 application-{dev,test,prod}.yml
|
||||
# ⚠️ 生产密钥一律走环境变量,不写在这里
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@@ -40,6 +40,20 @@ spring:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 60MB
|
||||
web:
|
||||
resources:
|
||||
# ⚠️ **关掉 Spring 的静态资源映射**(2026-10 架构调整):后端不再对外发任何静态文件,
|
||||
# 它是**纯 API 服务**;H5 由网关 nginx 托管(deploy/nginx/xt.conf,与 /api 同源)。
|
||||
#
|
||||
# 为什么用开关,而不是"把 static-locations 指到一个空目录":
|
||||
# 后者会连带启用 Spring Boot 的**默认**位置(classpath:/static、/public 等),
|
||||
# 于是谁往 src/main/resources/static 里丢一个文件,它就悄悄对外可见了 ——
|
||||
# 而"后端不托管静态文件"这件事就又不成立了,且不会有人发现。
|
||||
#
|
||||
# 影响面(都已实测):/api/** 由 Controller 提供、/actuator/** 由 actuator 提供、
|
||||
# /doc.html 由 springdoc 自己注册的资源处理器提供 —— 都不走这个开关。
|
||||
# 变的只有 `/`:它现在返回 404(探活一直用的是 /actuator/health,不是 /)。
|
||||
add-mappings: false
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
@@ -119,3 +133,19 @@ fill:
|
||||
upload:
|
||||
# 单文件上限 50MB(PRD §10.1)
|
||||
max-file-size-mb: 50
|
||||
|
||||
# ── 跨域白名单(Spring 的 allowedOriginPatterns 语义)────────────
|
||||
#
|
||||
# ⚠️⚠️ **"前后端同源就不用配 CORS"是错的**,这条已经用一次生产事故验证过:
|
||||
# 浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带上 Origin 头**;
|
||||
# 而 Spring MVC 只看"请求里有没有 Origin"、不看"是不是真跨源" ——
|
||||
# 带了 Origin 又不在白名单里,就在进 Controller 之前直接 403「Invalid CORS request」。
|
||||
# 登录/上传/提交全是 POST,所以**本站自己的地址也必须列在这儿**。
|
||||
#
|
||||
# ⚠️ 它只在**浏览器**里复现:e2e 脚本是 Python,不带 Origin 头 → 1700+ 条断言全绿也发现不了
|
||||
# (2026-10-20 生产登录 403 的原因,见 docs/交接摘要.md §一·四十四)。
|
||||
#
|
||||
# ⚠️ 换域名、加域名、加穿透域名 → 改这里或给 XT_CORS_ALLOWED_ORIGINS(逗号分隔)。
|
||||
# 启动日志会打印生效的白名单,排障先看那一行。
|
||||
cors:
|
||||
allowed-origins: "${XT_CORS_ALLOWED_ORIGINS:http://localhost:*,http://127.0.0.1:*,http://192.168.*.*:*}"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- 角色与授权(二期):① sys_role 补 user_type;② 新建 sys_role_menu。
|
||||
--
|
||||
-- 为什么现在才补:一期只有 6 个**内置**角色,权限由代码决定(BuiltinRole),
|
||||
-- 于是有两件事一直由代码隐式表达、库里没有:
|
||||
-- ① 这个角色对应哪一类用户(sys_user.user_type 是 NOT NULL,而它一直是"从角色反推"的);
|
||||
-- ② 这个角色能看见哪些菜单(一直只有 BuiltinRole 里的一个字符串集合)。
|
||||
-- 二期开放自定义角色之后,这两件事必须能由租户自己配 —— 所以要落到库里。
|
||||
--
|
||||
-- ⚠️ 内置角色在这两处的**权威仍然是代码**(见 PermissionChecker 与 RoleProvisioner 的注释):
|
||||
-- 库里的行是为了「看得见、可审计、可同步」,不是生效依据。
|
||||
-- 否则代码收紧一条权限、而库里没同步,就会出现"界面显示一套、实际生效另一套"。
|
||||
|
||||
-- ── 1) sys_role.user_type ───────────────────────────────────────────
|
||||
-- 不加这一列的后果很具体:给账号发一个自定义角色时,服务端要反推 user_type
|
||||
--(UserService.assertRoleMatchesUserType),而自定义角色不在代码映射里 → 直接 40001,
|
||||
-- 也就是"角色建得出来、却发不出去"。
|
||||
ALTER TABLE sys_role ADD COLUMN user_type smallint;
|
||||
|
||||
-- 回填:此刻库里只有内置角色(自定义角色的界面还没上线),按 BuiltinRole 的映射逐行写。
|
||||
-- 刻意写成 role_code 条件而不是全表刷一个值:映射关系就在代码里,这里只是把它落到行上。
|
||||
UPDATE sys_role SET user_type = 1 WHERE role_code = 'PLATFORM_ADMIN';
|
||||
UPDATE sys_role SET user_type = 2 WHERE role_code IN ('SCHOOL_ADMIN', 'AUDITOR');
|
||||
UPDATE sys_role SET user_type = 3 WHERE role_code IN ('HEAD_TEACHER', 'DEPUTY_TEACHER');
|
||||
UPDATE sys_role SET user_type = 4 WHERE role_code = 'STUDENT';
|
||||
|
||||
-- 兜底:万一有上面没覆盖到的行(例如某个租户手工插过自定义角色),
|
||||
-- 给最保守的一类(学生=只能操作本人那一份),而不是留 NULL 让 NOT NULL 失败。
|
||||
UPDATE sys_role SET user_type = 4 WHERE user_type IS NULL;
|
||||
|
||||
ALTER TABLE sys_role ALTER COLUMN user_type SET NOT NULL;
|
||||
COMMENT ON COLUMN sys_role.user_type IS '角色对应的用户类型 1平台运营 2学校管理员 3班主任 4学生(契约 §2.7)';
|
||||
|
||||
-- ── 2) sys_role_menu 角色可见菜单 ───────────────────────────────────
|
||||
-- 菜单**不做成权限的附属**:一个菜单可能由多个权限共同决定可见性
|
||||
--(「审核」菜单初审与终审权限都能看到),所以单独一张表,与 sys_role_permission 同构。
|
||||
CREATE TABLE sys_role_menu (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
tenant_id bigint NOT NULL,
|
||||
role_id bigint NOT NULL,
|
||||
menu_key varchar(64) 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_rolemenu_role_key ON sys_role_menu (role_id, menu_key) WHERE deleted = 0;
|
||||
CREATE INDEX idx_rolemenu_tenant_role ON sys_role_menu (tenant_id, role_id);
|
||||
COMMENT ON TABLE sys_role_menu IS '角色与菜单键的关联(菜单键定义在代码 BuiltinRole,与前端 XtMenu.key 逐字一致)';
|
||||
COMMENT ON COLUMN sys_role_menu.menu_key IS '菜单键,如 task;只影响"看得见哪些入口",不代表有权限操作';
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
-- V19:需求收敛(2026-10 客户确认)
|
||||
--
|
||||
-- 删掉四样东西,一次做完:
|
||||
-- ① 内置角色「副班主任 DEPUTY_TEACHER」「审核员 AUDITOR」——连同**只挂这两个角色的账号**
|
||||
-- ② collect_task_org.deputy_user_ids("协办人"列)——审核范围由三级回退收窄为两级
|
||||
-- ③ 「历史整理器」表(collect_cleanup_job)
|
||||
-- ④ 「加入申请」表(collect_join_request)
|
||||
--
|
||||
-- ⚠️ 三条刻意的写法,别"顺手改成更干净的写法":
|
||||
-- 1. **账号是软删(deleted=1 + status=0),不是物理 DELETE。**
|
||||
-- 本系统的"删除"一律是软删;而历史填报单、审核记录、操作日志里都留着他们的 user_id,
|
||||
-- 物理删会让那些行变成"指向一个不存在的用户"(审核时间轴显示不出人)。
|
||||
-- 2. **只删"除这两个角色外没有别的角色"的账号。** 一个班主任如果同时被挂了副班主任,
|
||||
-- 删掉他等于把一个还在用的人踢出系统 —— 那种错在库里看不出来,只有当事人来问。
|
||||
-- 3. **顺序:先处理绑定关系,再软删角色行。** 反过来的话,后面的语句按 role_code 找不到角色
|
||||
-- (本迁移里所有查询都带 deleted = 0),会静默什么都不做。
|
||||
--
|
||||
-- ⚠️ 若某租户的**唯一管理账号**恰好是审核员(异常情况,正常开通租户时必定有一个学校管理员),
|
||||
-- 删掉后该租户会没有人能进后台。上线前用这条查一遍:
|
||||
-- SELECT r.tenant_id FROM sys_role r WHERE r.role_code = 'AUDITOR' AND r.deleted = 0;
|
||||
|
||||
-- ── ① 找出"只挂被删角色"的账号 ──────────────────────────────
|
||||
CREATE TEMP TABLE tmp_dead_user ON COMMIT DROP AS
|
||||
SELECT u.id AS user_id
|
||||
FROM sys_user u
|
||||
WHERE u.deleted = 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_role ur
|
||||
JOIN sys_role r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = u.id AND ur.deleted = 0 AND r.deleted = 0
|
||||
AND r.role_code IN ('DEPUTY_TEACHER', 'AUDITOR'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_user_role ur2
|
||||
JOIN sys_role r2 ON r2.id = ur2.role_id
|
||||
WHERE ur2.user_id = u.id AND ur2.deleted = 0 AND r2.deleted = 0
|
||||
AND r2.role_code NOT IN ('DEPUTY_TEACHER', 'AUDITOR'));
|
||||
|
||||
-- 软删这些账号(顺手停用:软删后仍可能被历史记录引用,停用让它不可能再登录)
|
||||
UPDATE sys_user
|
||||
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
|
||||
WHERE id IN (SELECT user_id FROM tmp_dead_user);
|
||||
|
||||
-- ── ② 解除这两个角色的全部绑定(含"多角色用户"身上的那一条)──────
|
||||
UPDATE sys_user_role
|
||||
SET deleted = 1, update_time = CURRENT_TIMESTAMP
|
||||
WHERE deleted = 0
|
||||
AND role_id IN (SELECT id FROM sys_role
|
||||
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
|
||||
|
||||
UPDATE sys_role_permission
|
||||
SET deleted = 1, update_time = CURRENT_TIMESTAMP
|
||||
WHERE deleted = 0
|
||||
AND role_id IN (SELECT id FROM sys_role
|
||||
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
|
||||
|
||||
UPDATE sys_role_menu
|
||||
SET deleted = 1, update_time = CURRENT_TIMESTAMP
|
||||
WHERE deleted = 0
|
||||
AND role_id IN (SELECT id FROM sys_role
|
||||
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR'));
|
||||
|
||||
-- ── ③ 软删两个内置角色本身 ─────────────────────────────────
|
||||
UPDATE sys_role
|
||||
SET deleted = 1, status = 0, update_time = CURRENT_TIMESTAMP
|
||||
WHERE deleted = 0 AND role_code IN ('DEPUTY_TEACHER', 'AUDITOR');
|
||||
|
||||
-- ── ④ 删掉「协办人」列 ─────────────────────────────────────
|
||||
-- 该列存的是首尾带逗号的 id 串(,3,7,),被 6 个模块的 SQL 用来做"这份单归不归我"的判定。
|
||||
-- 删掉后审核范围只剩两级:collect_task_org.head_user_id → sys_org.leader_user_id。
|
||||
ALTER TABLE collect_task_org DROP COLUMN IF EXISTS deputy_user_ids;
|
||||
|
||||
-- ── ⑤ 删掉两个模块的表 ─────────────────────────────────────
|
||||
DROP TABLE IF EXISTS collect_cleanup_job;
|
||||
DROP TABLE IF EXISTS collect_join_request;
|
||||
|
||||
-- ── ⑥ 两处注释对齐(值本身仍在历史行里,注释别说谎)────────────
|
||||
-- collect_file.source = 4 曾经是"整理器导入";整理器下线后这个值不会再产生,
|
||||
-- 但历史行里可能还有,所以保留在注释里说明它是历史值。
|
||||
COMMENT ON COLUMN collect_file.source IS
|
||||
'1本人上传 2班主任代传 3管理员代传 4整理器导入(历史值:整理器已下线,不再产生新值)';
|
||||
|
||||
-- 操作日志的对象类型:JOIN_REQUEST 已不再产生新记录(历史行保留)
|
||||
COMMENT ON COLUMN sys_operation_log.target_type IS
|
||||
'被操作对象的类型:ROSTER/TASK/SUBMISSION/FILE/EXPORT/USER/ROLE(历史值另有 JOIN_REQUEST)';
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.tianbaotong.fill.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* CORS 白名单的最小单测(2026-10-20 生产事故的护栏,见 {@code docs/交接摘要.md} §一·四十四)。
|
||||
*
|
||||
* <p>为什么这条要写成单测:它<b>只在浏览器里才会发生</b> ——
|
||||
* 浏览器的同源 POST 也会带 {@code Origin},而 Spring 只看"有没有 Origin"。
|
||||
* 所有 e2e 脚本都是 Python(不带 Origin),所以 1712 条断言全绿也发现不了
|
||||
* "生产域名没进白名单"。能自动化的只有两件事,都放在这里:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>生产配置里确实有这份名单,且它覆盖管理端域名</b>("声明了但没接上"的另一面:
|
||||
* 名单写在注释里、代码里却读不到);</li>
|
||||
* <li><b>通配符真的能匹配子域,而陌生源仍然被拒</b>(既不能漏,也不能变成"全放开")。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class CorsWhitelistTest {
|
||||
|
||||
@Test
|
||||
void 生产白名单必须覆盖管理端域名与租户子域() throws IOException {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOriginPatterns(生产白名单());
|
||||
|
||||
// PC 管理端的真实 Origin(同源部署下,登录/上传/提交的 POST 都会带它)
|
||||
assertEquals("https://admin.shenxue.jxit.com",
|
||||
cfg.checkOrigin("https://admin.shenxue.jxit.com"));
|
||||
// 主域名(统一入口)
|
||||
assertEquals("https://shenxue.jxit.com",
|
||||
cfg.checkOrigin("https://shenxue.jxit.com"));
|
||||
// 将来的租户站点(t1./t12.)走的是同一条 POST 链路
|
||||
assertEquals("https://t1.shenxue.jxit.com",
|
||||
cfg.checkOrigin("https://t1.shenxue.jxit.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 陌生源仍然被拒_白名单不是全放开() throws IOException {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOriginPatterns(生产白名单());
|
||||
|
||||
assertNull(cfg.checkOrigin("https://evil.example.com"));
|
||||
// 后缀拼骗:admin.shenxue.jxit.com.evil.com 不是我们的子域
|
||||
assertNull(cfg.checkOrigin("https://admin.shenxue.jxit.com.evil.com"));
|
||||
// 生产是 https,http 版本不放开(避免"降级到 http 的页面也能调接口")
|
||||
assertNull(cfg.checkOrigin("http://admin.shenxue.jxit.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 没配时退回本机开发值_不会是空名单() {
|
||||
assertTrue(new FillProperties.Cors(null).patternsOrDefault().contains("http://localhost:*"));
|
||||
assertTrue(new FillProperties.Cors(List.of()).patternsOrDefault()
|
||||
.contains("http://127.0.0.1:*"));
|
||||
}
|
||||
|
||||
/** 把 {@code application-prod.yml} 里的 {@code fill.cors} 真绑一遍(保证"配了就能读到")。 */
|
||||
private static List<String> 生产白名单() throws IOException {
|
||||
StandardEnvironment env = new StandardEnvironment();
|
||||
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
|
||||
.load("application-prod.yml", new ClassPathResource("application-prod.yml"));
|
||||
sources.forEach(env.getPropertySources()::addFirst);
|
||||
|
||||
FillProperties.Cors cors = Binder.get(env)
|
||||
.bind("fill.cors", FillProperties.Cors.class)
|
||||
.orElseThrow(() -> new AssertionError(
|
||||
"application-prod.yml 里读不到 fill.cors.allowed-origins —— "
|
||||
+ "同源 POST 会 403「Invalid CORS request」"));
|
||||
return cors.patternsOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@ PG_PORT=5432
|
||||
REDIS_PORT=16379
|
||||
REDIS_PASSWORD=redis_dev_pwd
|
||||
|
||||
# ── 学生端 H5 站点(nginx)─────────────────────────
|
||||
# 2026-10 起 H5 **不再由后端托管**,改由 nginx 提供;同一个 server 把 /api 反代到 8080,
|
||||
# 于是 H5 与接口仍然同源(微信只给一个授权回调域名;跨源还会连带 CORS 与
|
||||
# "接口打到 nginx 自己身上"两个问题,见 deploy/nginx/xt.conf 头部)。
|
||||
#
|
||||
# ⚠️ 改了端口要同步改内网穿透:穿透指到**这个端口**,不是后端的 8080
|
||||
# (指到 8080 的表现是"接口好用、页面 404")
|
||||
H5_PORT=80
|
||||
# H5 构建产物目录(相对 deploy/)。起 nginx 前先跑 `pnpm build:h5`
|
||||
H5_DIR=../frontend-mp/dist/build/h5
|
||||
|
||||
# ── 对象存储(七牛云)───────────────────────────────
|
||||
# ⚠️ AK/SK 是**有计费与数据权限的真凭据**,不要写进 .env.example(那个是提交的)
|
||||
XT_STORAGE_PROVIDER=qiniu
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# 数据信息填报系统 · 本地开发基础设施
|
||||
# 深学在线 · 本地开发基础设施
|
||||
#
|
||||
# 用法:
|
||||
# cd deploy
|
||||
# copy .env.example .env (首次)
|
||||
# docker compose up -d
|
||||
#
|
||||
# ⚠️ nginx 挂的是 H5 **构建产物**,所以第一次起它之前要先:
|
||||
# cd frontend-mp && pnpm build:h5
|
||||
# (产物不入库;目录不存在时 docker 会建一个空目录 → 页面 404,容器却一切正常)
|
||||
#
|
||||
# ⚠️ 容器名统一加 xt- 前缀,端口刻意避开 55432 / 6379
|
||||
# (本机已有其他项目的 pg-verify 与 redis-local 在跑,不要动它们)
|
||||
#
|
||||
@@ -70,6 +74,37 @@ services:
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
|
||||
# ── nginx:学生端 H5 站点 + /api 反代 ──────────────
|
||||
# 2026-10 起 H5 **不再由后端托管**(原来是 spring.web.resources.static-locations),
|
||||
# 改由本服务对外提供,后端退回纯 API。两者仍然**同源**,理由见 nginx/xt.conf 头部。
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: xt-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${H5_PORT:-80}:80"
|
||||
volumes:
|
||||
# H5 构建产物(**产物不入库**):先在 frontend-mp 里跑 `pnpm build:h5`。
|
||||
# ⚠️ 目录不存在时 docker 会**建一个空目录**,于是页面 404 而容器一切正常
|
||||
# (`docker compose ps` 是 healthy、nginx 日志里也没有错)—— 先 build 再起。
|
||||
- ${H5_DIR:-../frontend-mp/dist/build/h5}:/usr/share/nginx/html:ro
|
||||
- ./nginx/xt.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
extra_hosts:
|
||||
# 后端是**宿主机上的 jar**,不在这个 compose 里。
|
||||
# Docker Desktop 自带 host.docker.internal;Linux 上必须有下面这行才有这个名字
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
# 探的是"首页拿不拿得到",而不是"进程在不在" ——
|
||||
# 挂进来的产物目录是空的时候,前者才有意义。
|
||||
# ⚠️ 必须写 **127.0.0.1**,不能写 localhost:容器里 localhost 会先解析到 IPv6
|
||||
# ::1,而 nginx 的 `listen 80` 只监听 IPv4 → wget 报 Connection refused、
|
||||
# 健康检查永远停在 unhealthy/starting(首页从宿主机看却是好的,极易误判)。
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 6
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
pg-data:
|
||||
redis-data:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 深学在线 · 学生端 H5 站点(本地开发 / 开发的"生产形态")
|
||||
#
|
||||
# 它做两件事,**而且必须在同一个 server 上做**:
|
||||
# ① 把 H5 构建产物(frontend-mp/dist/build/h5)当静态站发出去;
|
||||
# ② 把 /api 反代到后端 8080。
|
||||
#
|
||||
# ⚠️ 为什么"同源"是硬要求,不是审美:
|
||||
# · 微信网页授权的「授权回调页面域名」**只能配一个**,而回调落在
|
||||
# /api/v1/mp/h5/callback;学生授权后被 302 回页面,页面必须与回调**同域**
|
||||
# (两个源就要配两个域名,微信不给配)。
|
||||
# · H5 的接口地址取自 `location.origin`(见 frontend-mp/src/config/index.ts),
|
||||
# 跨源时它会把请求打到 nginx 自己身上,表现是"页面能开、接口全 404"。
|
||||
# · 同源还顺带省掉 CORS。
|
||||
#
|
||||
# 用法:
|
||||
# cd frontend-mp && pnpm build:h5 # 先出产物(产物不入库)
|
||||
# cd deploy && docker compose up -d nginx
|
||||
# → http://localhost/ H5
|
||||
# → http://localhost/api/... 后端 8080
|
||||
#
|
||||
# ⚠️ 内网穿透要指到**本服务的端口**(默认 80),不是后端的 8080 ——
|
||||
# 指到 8080 的表现是"接口好用、页面 404"。
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ⚠️⚠️ 这一条必须有。nginx 默认只收 1MB,而学生从聊天记录里选一张照片就可能超过;
|
||||
# 超了 nginx 直接回 413 —— 而**后端日志里一个字都没有**,
|
||||
# 排查时会一路怀疑"请求根本没发出来"。
|
||||
# 后端自己的上限是 50MB/60MB(application.yml 的 spring.servlet.multipart),
|
||||
# 这里对齐到 50m 即可,网关不该比应用更严。
|
||||
client_max_body_size 50m;
|
||||
|
||||
# 上传与导出都是长连接(导出打包是长事务,见 application-prod.yml 的说明),
|
||||
# 别用默认的 60s 把还在跑的请求掐掉
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# H5 的包有好几 MB,穿透/真机下压缩收益很直接
|
||||
gzip on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# ── /api:反代到后端 ────────────────────────────────────
|
||||
#
|
||||
# ⚠️ 后端是**宿主机上的 jar**,不在这个 compose 里,所以用 host.docker.internal
|
||||
# (Linux 上靠 compose 里的 extra_hosts: host-gateway 才有这个名字)。
|
||||
#
|
||||
# ⚠️ 这里**故意不写** Cache-Control:后端的 ApiNoStoreFilter 已经发了 no-store。
|
||||
# 再加一条会变成两个 Cache-Control 头 —— 虽然合法,但"这个头谁负责"就有了两个来源。
|
||||
location /api/ {
|
||||
proxy_pass http://host.docker.internal:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# 上传是流式的,别在 nginx 侧攒完再转发(大文件时表现为"进度条卡住不动")
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# ── 带内容哈希的资源:可以按"永久"缓存 ──────────────────
|
||||
# 文件名里带内容哈希,内容变了名字就变 —— 这是唯一值得缓存的东西
|
||||
# (这些包加起来好几 MB,学生不该每次发版都重下)
|
||||
#
|
||||
# ⚠️ 不加 `always`:那会让 404 也带上 immutable,浏览器就把"文件不存在"缓存一年
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# ── 其余:HTML 与没有内容哈希的静态文件(uni-app 的 /static/**)──
|
||||
#
|
||||
# ⚠️ 这两行不能省:`no-cache` ≠ 不缓存,而是"用之前先问一下" ——
|
||||
# 配合 Last-Modified 走 304,只花一个往返、不重传正文。
|
||||
# 没有显式过期时间时浏览器会**启发式缓存**(RFC 9111 §4.2.2:按
|
||||
# Last-Modified 年龄的 10% 自行推断),后果是发版后学生继续打开旧页面
|
||||
# (旧 index.html 里写死了旧 chunk 名),且只在"打开过页面的设备"上复现。
|
||||
# 这个坑的完整来龙去脉见 backend/xt-common/.../ApiNoStoreFilter.java 的类注释。
|
||||
#
|
||||
# ⚠️ 这里**故意不做** history 回退(try_files ... /index.html):
|
||||
# uni-app H5 是 hash 路由(#/pages/...),服务端只需要提供 / 与静态文件。
|
||||
# 加了回退反而有害:缺失的 chunk 会返回 200 + HTML,
|
||||
# 浏览器报的是"Unexpected token '<'",而真正的原因只是文件不存在。
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 生产环境变量模板(prod)
|
||||
#
|
||||
# 用法:
|
||||
# cp deploy/prod.env.example deploy/prod.env
|
||||
# chmod 600 deploy/prod.env
|
||||
# # 按下面命令生成四个密钥并填进去,再填 PG / REDIS / QINIU / WX
|
||||
# set -a; . ./deploy/prod.env; set +a
|
||||
# java -jar xt-server.jar --spring.profiles.active=prod
|
||||
#
|
||||
# 1Panel:把每一条粘到「应用 → 环境变量」,**值不要带引号**。
|
||||
#
|
||||
# ⚠️ deploy/prod.env 已 gitignore。不要把真值写回本文件。
|
||||
# ⚠️ 2026-10 曾把 1Panel 的库/Redis 密码直接写进 application-prod.yml
|
||||
# 并提交。那些值应视为已泄露:要么轮换,要么确认仓库只有可信的人能看。
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
# ── PostgreSQL ────────────────────────────────────────────
|
||||
# 1Panel 里通常就是容器名;换成你的实例地址
|
||||
PG_HOST=
|
||||
PG_PORT=5432
|
||||
PG_DB=
|
||||
PG_USER=
|
||||
PG_PASSWORD=
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# ── 四个密钥(必填,ProdEnvCheck 缺一个就拒绝启动)─────────
|
||||
#
|
||||
# XT_JWT_SECRET openssl rand -base64 64 | tr -d '\n'
|
||||
# XT_AES_KEY openssl rand -base64 32 | tr -d '\n'
|
||||
# XT_HMAC_KEY openssl rand -base64 48 | tr -d '\n'
|
||||
# XT_RSA_PRIVATE_KEY openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out prod-rsa.pem
|
||||
# openssl pkcs8 -topk8 -nocrypt -in prod-rsa.pem -outform DER | base64 -w0
|
||||
#
|
||||
# ⚠️ XT_AES_KEY 一旦用来加密过证件号就不能直接换(旧密文解不开且不报错)。
|
||||
XT_JWT_SECRET=
|
||||
XT_AES_KEY=
|
||||
XT_HMAC_KEY=
|
||||
XT_RSA_PRIVATE_KEY=
|
||||
|
||||
# ── 对象存储(生产必须 qiniu)──────────────────────────────
|
||||
XT_STORAGE_PROVIDER=qiniu
|
||||
QINIU_ACCESS_KEY=
|
||||
QINIU_SECRET_KEY=
|
||||
QINIU_BUCKET=
|
||||
QINIU_DOMAIN=
|
||||
QINIU_REGION=auto
|
||||
# 启动时真的走一遍 写入→存在→读回→删除;密钥/空间配错会当场暴露
|
||||
XT_STORAGE_SELF_CHECK=true
|
||||
# false = 只保留压缩后的规范件(默认)
|
||||
XT_STORAGE_KEEP_RAW=false
|
||||
|
||||
# ── 微信:小程序 ──────────────────────────────────────────
|
||||
WX_MP_APP_ID=
|
||||
WX_MP_APP_SECRET=
|
||||
WX_MP_PUBLIC_BASE_URL=
|
||||
WX_MP_ENV_VERSION=release
|
||||
|
||||
# ── 微信:公众号网页授权(H5)─────────────────────────────
|
||||
# 回调与页面都必须是 https 正式域名,且与 H5 站点同源
|
||||
WX_H5_APP_ID=
|
||||
WX_H5_APP_SECRET=
|
||||
WX_H5_CALLBACK=
|
||||
WX_H5_BASE_URL=
|
||||
|
||||
# ── 其他 ─────────────────────────────────────────────────
|
||||
XT_LOG_DIR=/var/log/xt
|
||||
# 按域名区分单位时才打开
|
||||
XT_TENANT_DOMAIN_ENABLED=false
|
||||
XT_TENANT_DOMAIN_BASE=
|
||||
# CORS 白名单(逗号分隔,Spring allowedOriginPatterns 语义)。
|
||||
# ⚠️ **本站自己的地址也必须列上**:浏览器给同源的 POST 也带 Origin,
|
||||
# 不在名单里 → 登录/上传一律 403「Invalid CORS request」(2026-10-20 生产事故)。
|
||||
# PC 前端在 https://admin.shenxue.jxit.com,就是下面这行的默认值
|
||||
# (application-prod.yml 里已经写了同样的默认,这里只是让它可运维覆盖)。
|
||||
XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com
|
||||
+29
-13
@@ -1,6 +1,6 @@
|
||||
# 交付计划与进度
|
||||
|
||||
> 项目:数据信息填报系统(填报通 / CollectFlow)
|
||||
> 项目:深学在线
|
||||
> 起始:2026-09-28
|
||||
> 状态:**进行中**
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
| — | 主 PRD(含库表设计、接口清单、15 条校验规则、18 张表 DDL) | V2.3 | ✅ |
|
||||
| — | 功能逻辑卷(纯功能运转逻辑,不含库表/接口/代码) | V1.9 | ✅ |
|
||||
| — | PostgreSQL 建表脚本 | — | ✅ **已在 PG 16 实测通过** |
|
||||
| — | 学生端原型(**11 屏**) | V1.2 | ✅ 含「申请加入名单」 |
|
||||
| — | 学生端原型(**10 屏**) | V1.3 | ✅(原 STEP 03B「申请加入名单」已于 2026-10 需求收敛删除) |
|
||||
| — | 班主任端原型(9 屏) | V1.1 | ✅ |
|
||||
| — | 管理端 PC 原型(**11 屏**) | V1.2 | ✅ 含表单设计器/材料规则/整理器/申请审批 |
|
||||
| — | 管理端 PC 原型(**10 屏**) | V1.3 | ✅ 含表单设计器/材料规则/角色与授权(整理器与申请审批两屏已于 2026-10 删除) |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|---|---|---|---|---|
|
||||
| **P1** | **客户与认定机构确认函** | `01-客户与机构确认函.md` | 外部依赖最长。**5 项高优先级阻塞开发**(300dpi 能否接受、名单外的人怎么办、社会收集名单从哪来…),越早发出越早回来 | ✅ |
|
||||
| **P2** | **需求追溯矩阵** | `02-需求追溯矩阵.md` | 自审:把 PRD 需求 ↔ 原型屏 ↔ 库表 ↔ 接口 逐条对应,找出**说了没做/做了没说**的缺口。放在接口文档之前,避免接口漏做 | ✅ |
|
||||
| **P2.5** | **管理端补充原型**(矩阵审计追加) | 追加到 `admin-web.html` | 矩阵发现 3 个 P0/P1 复杂交互页面未出原型:**表单设计器**、**材料规则配置**、**历史整理器**。不画出来开发必理解偏 | ✅ |
|
||||
| ~~**P2.5**~~ | ~~**管理端补充原型**(矩阵审计追加)~~ | 追加到 `admin-web.html` | 矩阵发现 3 个 P0/P1 复杂交互页面未出原型:**表单设计器**、**材料规则配置**、~~**历史整理器**~~(该屏已于 2026-10 随模块删除)。不画出来开发必理解偏 | ✅ |
|
||||
|
||||
### 阶段二 · 补齐技术设计
|
||||
|
||||
@@ -58,10 +58,10 @@
|
||||
| 2026-09-28 | **P2 追溯矩阵** | 完成 `02-需求追溯矩阵.md`。**审计发现**:① 修正统计口径——实际 **115 条 FR**(83 P0/29 P1/3 P2)+ 17 条 VAL,此前漏算加粗编号;② **10 个接口缺口**(报考范围、整理器、字典、日志、消息中心、订阅额度、审核撤回、任务暂停…);③ **管理端 10 个原型页面缺口**,其中表单设计器为 P0;④ 4 个疑似需求问题中 **3 个是脚本正则缺陷、1 个为真**(已补 FR-616);⑤ **追加 P2.5** 补 3 个复杂交互页面原型 |
|
||||
| 2026-09-28 | **P2.5 补充原型** | 管理端 `admin-web.html` 由 7 屏扩至 **10 屏**:⑧**表单设计器**(字段库/画布/属性三栏 + 条件规则行,内置字段带锁不可删)⑨**材料规则配置**(材料清单侧栏 + 命名模板 + 档位选择 + 来源引导语)⑩**历史数据整理器**(用真实实测数字:475MB→37MB、59 项命名违规、仅 1 项需人工)。四份原型全部通过结构校验 |
|
||||
| 2026-09-28 | **P6 验收测试清单** | 完成 `06-验收测试清单.md`(25KB / 518 行)。把 P4 的 15 项基线转成 **29 条可自动化断言 G-01~G-29**;另含 3 条端到端主流程(32 步)、20 条异常边界、85 条功能验收、6 条视觉、9 项性能、12 项安全;含里程碑签署表与 **7 条 Go/No-Go 条件**;点出 3 个易漏盲区(Windows 解压中文名 / 并发绑定临界窗口 / 「无」是合法值) |
|
||||
| 2026-09-28 | **客户回执** | 客户确认全部 23 项按建议执行,**需求冻结**。识别出唯一功能增量 **B1(申请加入名单)**,其余 22 项零改动 |
|
||||
| 2026-09-28 | **客户回执**(⚠️ 唯一增量 B1 已于 2026-10 需求回退取消) | 客户确认全部 23 项按建议执行,**需求冻结**。识别出唯一功能增量 **B1(申请加入名单)**,其余 22 项零改动 |
|
||||
| 2026-09-28 | **D7 降级** | 重新评估「文件名识别真实准确率」这个待办:因**输出命名取自名册**、**归属靠证件号反查名册**、**识别有槽位兜底**、**失败一律放行**,该指标**不影响任何交付项**。降级为「上传页引导文案校准」——班主任在群里问两句即可,**取消样本索取**。至此**全部外部数据依赖清零** |
|
||||
| 2026-09-28 | **一期范围收窄** | 客户确认「**先做学校内的填报流程**」→ 一期只做**学校收集 + 自主认定**(4 类材料全部有实测样本);社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。**D4 因此不再是待办**。工期 −5 人日(392→387),关键路径不变仍 15 周 |
|
||||
| 2026-09-28 | **移除联系方式展示** | 客户指出"学生本来就有班主任联系方式,不需要系统里面体现"。删除 `GET /mp/roster/contact` 接口 + 门禁失败页「联系老师」按钮,改为只保留两条系统内动作(重新填写 / 申请加入名单)。**接口总数 122 → 121**。纯文案提示保留(不涉及联系方式数据) |
|
||||
| 2026-09-28 | **范围收窄** | 客户确认「**先做学校内的填报流程**」→ 只做**学校收集 + 自主认定**(4 类材料全部有实测样本);社会收集 / 社会评价 / 专项职业能力**不做**(2026-10 进一步明确为取消,不再叫"后置二期"),**数据结构与枚举保留不启用**。工期 −5 人日(392→387),关键路径不变仍 15 周 |
|
||||
| 2026-09-28 | **移除联系方式展示** | 客户指出"学生本来就有班主任联系方式,不需要系统里面体现"。删除 `GET /mp/roster/contact` 接口 + 门禁失败页「联系老师」按钮。**接口总数 122 → 121**。⚠️ 当时保留的两条系统内动作里,「申请加入名单」已于 2026-10 撤销,现在只剩「重新填写」+ 一句"请联系老师" |
|
||||
| 2026-09-28 | **归属校验修正** | 客户指出"系统不存在名称错误"——纠正原设计中「把文件名不符自动按名册修正」的缺陷(会把传错的文件静默洗白)。改为 **VAL-21 文件归属校验 + 阻断提交**;判定优先级重排为 证件号反查名册 > OCR > 姓名(仅辅助);姓名不再作为独立规则。另补全「社会评价」定义与 `cert_type` 第三取值 |
|
||||
| 2026-09-28 | **B1 落地** | 新增 FR-509 系列 10 条 / `collect_join_request` 表(**PG 16 实测通过 + 部分唯一索引业务规则验证**)/ 5 个接口 / 错误码 40037~40042 / 学生端 STEP 03B + 管理端 STEP 11 两屏原型 / F-509 验收项 19 条 / WBS 新增 6 个任务 |
|
||||
| 2026-09-28 | **P7 文档导航** | 完成 ../README.md(10KB)。按角色给出读法、三分钟看懂产品、13 条关键决策速查、目录结构与快速上手、数据基线、两条踩坑教训 |
|
||||
@@ -69,6 +69,8 @@
|
||||
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
|
||||
| 2026-09-28 | **P3 接口设计** | 完成 `03-接口设计.md`(102KB / 2626 行)。**117 个接口**全部字段级定义(PRD 原 96 + 新增 21);新增 §2 枚举全集(含「无」的三条铁律)、§3 通用数据结构(12 个 VO,字段名与 DDL 列一一对应)、§15 错误码全集(50 个,新增 30);**自校验通过**:模块表合计=声称=实际唯一数=117,无真重复,引用字段名 116 个全部存在于 DDL |
|
||||
| 2026-10-09 | **验收走查后的四项修正** | ① **去掉 3 条无用菜单**(名单管理 / 表单设计器 / 材料规则)—— 功能都在「收集任务」行内入口,菜单里那一条只是指路;后端菜单键同步删除;② **修掉班主任点工作台必弹 403**(工作台无条件调了要 `org:view` / `progress:viewAll` 的两个接口,班主任都没这两个权限)→ 改为按权限决定发不发请求;③ **新增请求头 `X-Tenant-Id`(§1.2)**:平台运营的「组织架构 / 账号管理 / 工作台」原先拿到的是**所有租户混在一起**的数据,现在必须先在页面上选定学校;④ **修掉开放接口的"推送重试"从未生效**(F-112:定时扫描缺 `@InterceptorIgnore`,异常被 warn 吞掉;E2E 补了"真的重试了"的断言)。详见 `交接摘要` §一·三十三 |
|
||||
| 2026-10-09 | **二期「角色与授权」上线**(FR-205) | 内置角色**只读** + 自定义角色的权限码 / 菜单可配:新增 **8 个接口**(`docs/03` §4.12 / §5.10,契约总数 121 → **129**)、**V18 迁移**(`sys_role.user_type` + `sys_role_menu`)、错误码 `40045`;权限判定从"零 IO"改为"内置按代码 / 自定义按库 + Redis 缓存";新增每日**内置角色授权同步**(修掉代码与库的静默漂移);PC 新增「角色与授权」页(原型 ⑪)→ **`ready:false` 的菜单归零**;账号页的可选角色 / 用户类型 / 角色名全部改读库(删掉前端三份写死的镜像表)。验证:新增 `scripts/e2e-m10-role.py` **77 条**、`verify-all --with-e2e` 全绿。**「数据范围可配」仍是下一批**(见 `docs/05` M8 的说明) |
|
||||
| 2026-10-20 | **修生产登录 403「Invalid CORS request」** | 生产 `POST /api/v1/auth/login` 返回 403:**浏览器的同源 POST 也带 `Origin`,而 Spring 只看"有没有 Origin"**,`WebConfig` 的 CORS 白名单却**写死**成 localhost/内网(注释声称读 `fill.cors.allowed-origins`,该属性其实不存在)。改为配置驱动:`FillProperties.Cors` + 各 profile 的 `fill.cors.allowed-origins`(可用 `XT_CORS_ALLOWED_ORIGINS` 覆盖),生产默认含 `admin.shenxue.jxit.com` 与 `*.shenxue.jxit.com`,启动日志打印生效名单。**接口 / 库表 / 契约均无变化**。`docs/09` 新增 §2.4:本站地址必须在白名单 + 反代必须传 `Host`/`X-Real-IP`/`X-Forwarded-For`/`X-Forwarded-Proto`(缺了登录日志 IP 全是 127.0.0.1)。详见 `交接摘要` §一·四十四 |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,12 +81,12 @@
|
||||
| 组 | 项数 | 需开发 | 结论 |
|
||||
|---|:--:|:--:|---|
|
||||
| A 认定机构 | 6 | **0** | 全部与现有设计一致(当初就按实测数据设计) |
|
||||
| B 校方业务 | 9 | **1** | 仅 **B1(申请加入名单通道)** 是功能增量 |
|
||||
| B 校方业务 | 9 | **1**(⚠️ B1 已于 2026-10 需求回退取消) | 仅 **B1(申请加入名单通道)** 是功能增量,该通道已于 2026-10 删除 |
|
||||
| C 商务法务 | 5 | 0 | C1 小程序资质按建议推进 + H5 保底 |
|
||||
| D 内部技术 | 7 | 0 | **D4 已后置二期**、**D7 已降级清除**;仅 D6(微信三条来源通道体积上限)需真机自测,**无外部依赖** |
|
||||
| D 内部技术 | 7 | 0 | **D4 已随"取消二期"作废**、**D7 已降级清除**;仅 D6(微信三条来源通道体积上限)需真机自测,**无外部依赖** |
|
||||
|
||||
**一期范围(客户确认后收窄)**:只做 **学校收集 + 自主认定**(4 类材料全部有实测样本)。
|
||||
社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。
|
||||
社会收集 / 社会评价 / 专项职业能力**不做**(2026-10 取消,不再是"后置"),**数据结构与枚举保留不启用**。
|
||||
⇒ **D4(社会评价样本)因此不再是待办**;工期 **392 → 387 人日**,关键路径不变仍 **15 周**。
|
||||
|
||||
**本次变更(V1)**:新增 **FR-509 系列**(10 条)+ **1 张表**(`collect_join_request`)+ **5 个接口** + **2 屏原型**。
|
||||
@@ -92,7 +94,7 @@
|
||||
| 指标 | 变更前 | 变更后 |
|
||||
|---|:--:|:--:|
|
||||
| FR 总数 | 115 | **125** |
|
||||
| 接口总数 | 117 | **121** |
|
||||
| 接口总数 | 117 | **121**(2026-10 收敛后为 **119**) |
|
||||
| 库表 | 19 | **20** |
|
||||
| VAL 规则 | 20 | **21**(新增 VAL-21 归属校验) |
|
||||
| 错误码 | 50 | **57** |
|
||||
@@ -108,6 +110,20 @@
|
||||
| # | 风险 | 影响 | 对策 |
|
||||
|---|---|---|---|
|
||||
| R1 | ~~客户迟迟不确认 P1 的 5 项高优先级问题~~ | — | ✅ **已解除**:客户确认全部 23 项按建议执行,需求已冻结 |
|
||||
| R2 | ~~强特征词表未在"社会评价"批次数据上标定~~ | — | ✅ **已消除**:一期范围收窄为「学校收集 + 自主认定」,**不涉及**那两类零样本材料(D4 已后置二期) |
|
||||
| R2 | ~~强特征词表未在"社会评价"批次数据上标定~~ | — | ✅ **已消除**(2026-10 取消二期) |
|
||||
| R3 | VLM 选型未定 | 中 | P4 中把 VLM 抽成**可替换接口**,不绑定供应商 |
|
||||
| R4 | **二期启动时缺少社会评价样本** | 中(二期) | 已在 PRD「一期范围声明」列出二期启动前必做 6 项;**不阻塞一期** |
|
||||
| R4 | ~~二期启动时缺少社会评价样本~~ | — | **已消除**(2026-10 取消二期,不再需要该样本) |
|
||||
|
||||
---
|
||||
|
||||
## 2026-10 需求收敛(执行记录)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 起因 | 客户认为功能面过宽,要求收敛为「总后台开学校 → 学校管理员发任务 → 学生填 → 班主任 + 学校管理员审 → 归档导出 ZIP」 |
|
||||
| 删除 | ① 内置角色 **6 → 4**(删副班主任 `DEPUTY_TEACHER`、审核员 `AUDITOR`,存量账号由 **V19 迁移**处理);② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);③ **加入申请**(`module/join`,5 接口 + `join:audit` + `collect_join_request` 表 + PC 页 + 学生端 `pages/join` + 错误码 `40037/40038/40039/40040/40042` + 两个站内信模板);④ `collect_task_org.deputy_user_ids` **整列**(审核范围三级回退 → **两级**) |
|
||||
| 取消 | **二期整体取消**:社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 / 角色变更即时失效 / 平台角色页 → **不做**;OCR 与材料识别 → **缓做,等供应商选型**;材料模板 **3 → 1**;`XJW_BA`/`OTHER_PROOF` **读得到、选不到** |
|
||||
| 数字 | 接口 **129 → 119**、错误码 **59 → 54**、权限码 **30 → 28**、PC 菜单 **12 → 10**、小程序页面 **10 → 9**、原型 **32 → 29 屏**、E2E 脚本 **39 → 37** |
|
||||
| 迁移 | `V19__remove_deputy_auditor_cleanup_join.sql`(先解绑定再软删角色;账号只在"除这两个角色外没有别的角色"时软删) |
|
||||
| ⚠️ 治理 | **加入申请(确认函 B1)是客户唯一确认的功能增量,本次属需求回退**,建议补变更确认单 |
|
||||
| 详见 | `docs/交接摘要.md` **§一·三十六**(含 5 条新增防线与 PC 端重搭说明) |
|
||||
|
||||
+20
-15
@@ -1,9 +1,13 @@
|
||||
# 确认函 · 数据信息填报系统
|
||||
# 确认函 · 深学在线
|
||||
|
||||
> 文档用途:汇总需各方拍板的事项,**可直接转发**
|
||||
> 发出日期:2026-09-28
|
||||
> 回复期限:建议 **5 个工作日内**
|
||||
> 版本:V1.0
|
||||
> 版本:V1.1(2026-10 追加需求收敛说明)
|
||||
>
|
||||
> ⚠️ **2026-10 变更提示**:客户随后要求收敛范围 —— **B1(本函唯一的功能增量)已整体撤销**,
|
||||
> 且**不再设计任何二期功能**。本函正文保留当时原文(它是"当时确认过什么"的记录),
|
||||
> 但凡出现"二期 / 后置 / 待实施"的表述,**一律读作"不做"**。详见 `docs/交接摘要.md` **§一·三十六**。
|
||||
|
||||
---
|
||||
|
||||
@@ -186,7 +190,7 @@ B1 选 A,但希望增加审批环节
|
||||
| | |
|
||||
|---|---|
|
||||
| **问题** | 是否需要把数据直接推送到机构系统,而不是导出文件包? |
|
||||
| **建议方案** | ✅ **建议一期不做**(导出标准包已可满足),**但预留开放接口**,二期按需启用 |
|
||||
| **建议方案** | ✅ **不做**(导出标准包已可满足),**但预留开放接口**,将来按需启用 |
|
||||
| **回复** | ☐ 一期不做 ☐ 一期就必须对接(请提供对方接口文档) |
|
||||
|
||||
### B9. 社会收集是否需公安实名核验?
|
||||
@@ -257,7 +261,7 @@ B1 选 A,但希望增加审批环节
|
||||
**建议**:一期按 H5 单腿上线(学生扫 H5 码即可填报),小程序作为第二条腿,
|
||||
在开放平台绑定完成后一起开。**这样不必等它,但必须知道它挡着什么。**
|
||||
|
||||
回复:☐ 现在就办(我方可提供绑定步骤) ☐ 一期只上 H5,二期再办 ☐ 其他 ______
|
||||
回复:☐ 现在就办(我方可提供绑定步骤) ☐ 只上 H5,`union_id` 缓办 ☐ 其他 ______
|
||||
|
||||
### E2. D3 门禁限流阈值口径已更新(备案,无需回复)
|
||||
|
||||
@@ -282,18 +286,19 @@ B1 选 A,但希望增加审批环节
|
||||
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
|
||||
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
|
||||
|
||||
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
|
||||
### E3. ~~二期(社会收集 / 社会评价 / 专项职业能力)的启动时点~~ —— **已取消**(2026-10)
|
||||
|
||||
一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
|
||||
但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
||||
> ⚠️ **2026-10 已确定:不做二期**,本节作废(保留原文供追溯)。原话是:
|
||||
> 但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
||||
|
||||
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 |
|
||||
| ~~事项~~ | ~~如果二期在 3 个月内启动~~ | ~~如果更晚~~ |
|
||||
|---|---|---|
|
||||
| D4 材料识别强特征词表标定 | 建议现在就在社会评价批次数据上标定 | 可现在只做学校批次的标定 |
|
||||
| D5 一寸照片形态阈值标定 | 同上 | 同上 |
|
||||
| 二期审核流与会话设计 | 建议现在出一版概要设计,避免一期代码挡住它 | 一期交付后再说 |
|
||||
| ~~二期审核流与会话设计~~ | **不做**(2026-10 取消二期) | — |
|
||||
|
||||
回复:☐ 二期预计 ____ 启动 ☐ 暂无计划
|
||||
回复:☑ **暂无计划(2026-10 已确认取消二期)**
|
||||
|
||||
### E4. 国标职业库数据的来源(字典页「职业(国标)」目前是空的)
|
||||
|
||||
@@ -342,7 +347,7 @@ B1 选 A,但希望增加审批环节
|
||||
|
||||
| # | 确认结论 | 落地动作 | 状态 |
|
||||
|:--:|---|---|:--:|
|
||||
| **B1** | **选 A:增加「申请加入名单 → 管理员审批」通道** | ⚠️ **需开发**:新增 FR-509 系列 + 1 张表 + 4 个接口 + 2 屏原型 | 🔨 **待实施** |
|
||||
| ~~**B1**~~ | ~~选 A:增加「申请加入名单 → 管理员审批」通道~~ → **已撤销**(2026-10 需求回退):该通道、表、接口、学生端页面与错误码已全部删除 | ⚠️ **需求回退,建议补变更确认单**:名单外的人改由老师在任务里**手工加人**;系统内不展示联系方式 | ❌ **已撤销** |
|
||||
| B2 | 社会收集名单**由报名点/机构维护预报名名单**,导入后开放;**填报期内可随时追加** | 已一致:FR-508 追加导入;补充说明写入 PRD §5.5 | ✅ 已一致 |
|
||||
| B3 | **字段三分类**:姓名/证件号/报考三项锁定,其余可核对修改并留痕 | 已一致:FR-502b + `changed_fields` 机制 | ✅ 已一致 |
|
||||
| B4 | **不加手机号验证码** | 无需改动(当前门禁即姓名+证件号) | ✅ 已一致 |
|
||||
@@ -352,8 +357,8 @@ B1 选 A,但希望增加审批环节
|
||||
| B8 | **一期不对接机构系统,预留开放接口** | 已一致:§14 开放接口已设计但不启用 | ✅ 已一致 |
|
||||
| B9 | **不做公安实名核验** | 无需改动 | ✅ 已一致 |
|
||||
|
||||
> **结论:B 组 9 项中 8 项零改动,仅 B1 需开发。**
|
||||
> B1 的建议是我方主动提出的(原需求只有"只提示"),客户采纳后**这是一次功能增量**,已按 §变更记录 追加设计与排期。
|
||||
> **结论:B 组 9 项中 8 项零改动;B1 曾是我方主动提出的功能增量、客户采纳后已开发,但于 2026-10 随需求收敛整体撤销。**
|
||||
> ⚠️ 它是本函里**唯一的功能增量**,因此这次撤销属**需求回退**,建议补一张变更确认单。
|
||||
|
||||
### C 组 · 商务法务(5 项)
|
||||
|
||||
@@ -372,7 +377,7 @@ B1 选 A,但希望增加审批环节
|
||||
| D1 | OCR 引擎选型(腾讯云/百度/本地) | ⏳ M2 结束前定 |
|
||||
| D2 | VLM 抽成可替换接口,不绑定供应商 | ✅ 已按此设计(04 文档 M3 §3.6) |
|
||||
| D3 | 门禁限流:openid 10 次/小时、IP 20 次/分钟 | ✅ 已实现口径 |
|
||||
| D4 | **需索取「社会评价」批次的原始材料样本** | ✅ **已后置为二期**(一期只做学校收集 + 自主认定,不涉及社会评价材料) |
|
||||
| D4 | **需索取「社会评价」批次的原始材料样本** | ✅ **已作废**(2026-10 取消二期,不再需要该样本) |
|
||||
|
||||
#### D4 补充说明:为什么需要「社会评价」的样本
|
||||
|
||||
@@ -404,8 +409,8 @@ B1 选 A,但希望增加审批环节
|
||||
| 退而求其次 | **10~20 份**,但**必须覆盖「学信网备案表」和「各类佐证材料」**(这两类现在零样本) |
|
||||
| 特别注意 | 记录**材料来源与格式**——社会人员可能直接从学信网下载 **PDF**(而非拍照/扫描)。若是,`file_types` 白名单、预览、压缩策略都要跟着调整 |
|
||||
|
||||
> ✅ **已后置为二期**(2026-09-28 客户确认):一期范围收窄为「**学校收集 + 自主认定**」,材料只有 4 类(一寸照片 / 身份证 / 学籍表 / 工作证明),**全部有 112 个真实文件实测**。
|
||||
> 因此 D4 **不再是待办、也不阻塞任何里程碑**。待二期做「社会评价」时再索取即可。
|
||||
> ✅ **已作废**(2026-10 取消二期;2026-09-28 曾收窄为「**学校收集 + 自主认定**」),材料只有 4 类(一寸照片 / 身份证 / 学籍表 / 工作证明),**全部有 112 个真实文件实测**。
|
||||
> 因此 D4 **不是待办、也不阻塞任何里程碑**;由于二期已取消,**该样本不再需要索取**。
|
||||
| D5 | 一寸照片形态阈值用真实样本标定 | ✅ 已有实测(984×1378,5:7) |
|
||||
| D6 | **微信三条来源通道体积上限需真机实测** | ⏳ 待测 |
|
||||
| D7 | ~~文件名规则在"学生原始乱名"上测真实准确率~~ | ✅ **已降级为文案校准**(不需样本) |
|
||||
|
||||
+10
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
> 用途:把 PRD 的每条需求对上「**原型 / 库表 / 接口 / 验收**」四个交付面,找出**说了没做**和**做了没说**。
|
||||
> 生成日期:2026-09-28
|
||||
> 审计范围:**125 条 FR**(92 P0 / 30 P1 / 3 P2)+ **21 条 VAL 校验规则** + **121 个接口** + **20 张表**
|
||||
> 审计范围:**125 条 FR**(92 P0 / 30 P1 / 3 P2)+ **21 条 VAL 校验规则** + **119 个接口**(2026-10 收敛后的口径;原 129 = 121 + 角色与授权 8)+ **20 张表**
|
||||
> 结论:**主流程全覆盖。第三节的 10 个接口缺口已于 P3 补齐(共新增 21 个接口);配置类页面缺口见 3.2**
|
||||
|
||||
---
|
||||
@@ -12,7 +12,7 @@
|
||||
| 模块 | 条数 | P0 | 库表 | 接口 | 原型 | 判定 |
|
||||
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| FR-100 租户与组织 | 5 | 5 | ✅ | ✅ | ❌ | ⚠️ 缺平台运营端原型 |
|
||||
| FR-200 账号与角色 | 6 | 5 | ✅ | ✅ | ❌ | ⚠️ 缺登录页与用户管理页 |
|
||||
| FR-200 账号与角色 | 6 | 5 | ✅ | ✅ | ⚠️ | ⚠️ 缺登录页与用户管理页;**FR-205 角色自定义已落地**(`docs/03` §4.12 / §5.10,页面 `frontend-pc/src/views/role/`;2026-10 起按**一期功能**计,不再叫"二期")——其中「数据权限可配」**不做** |
|
||||
| FR-300 收集任务 | 12 | 9 | ✅ | ✅ | ⚠️ | ✅ 暂停接口已补(P3) |
|
||||
| FR-400 动态表单 | 8 | 6 | ✅ | ✅ | ❌ | ⚠️ **缺表单设计器原型** |
|
||||
| FR-500 名单管理 | 24 | 21 | ✅ | ✅ | ✅ | ✅ **全覆盖**(含客户确认新增的 FR-509 系列 10 条) |
|
||||
@@ -23,7 +23,7 @@
|
||||
| FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
|
||||
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
|
||||
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
||||
| FR-1400 历史整理器 | 6 | 0 | ✅ | ❌ | ❌ | ❌ **三面全缺**(P1,本轮未做) |
|
||||
| ~~FR-1400 历史整理器~~ | ~~6~~ | ~~0~~ | — | — | — | **已取消**(2026-10 需求收敛:模块/表/接口/页面全部删除) |
|
||||
| **FR-700 校验引擎** | — | — | ✅ | ✅ | ✅ | ✅ 以 VAL-01~21 实现,三端均有体现 |
|
||||
|
||||
**图例**:✅ 完整覆盖 ⚠️ 部分覆盖 ❌ 未覆盖
|
||||
@@ -55,7 +55,7 @@
|
||||
| # | 缺口 | 对应需求 | 影响 | 建议接口 |
|
||||
|:--:|---|---|---|---|
|
||||
| I1 | **报考范围读取与确认** | FR-307~311 | 管理端④整屏无数据来源 | `GET/PUT /api/v1/tasks/{id}/plan-scope` |
|
||||
| I2 | **历史数据整理器** | FR-1401~1406 | §5.14 六条需求**完全无接口** | `POST /api/v1/cleanup/jobs` 等 5 个 |
|
||||
| ~~I2~~ | ~~**历史数据整理器**~~ | — | **已取消**(2026-10) | — |
|
||||
| I3 | **字典管理** | FR-1201 | 学历/等级/补考科目等下拉项无法维护 | `GET/POST/PUT /api/v1/dicts` |
|
||||
| I4 | **操作日志** | FR-1202 | 审计留痕无法查询 | `GET /api/v1/logs/operations` |
|
||||
| I5 | **登录日志** | FR-1203 | 同上 | `GET /api/v1/logs/logins` |
|
||||
@@ -75,14 +75,14 @@
|
||||
|:--:|---|---|:--:|---|
|
||||
| P1 | **表单设计器** | FR-401~408 | 高 | ⚠️ **必须补**——拖拽式字段编排是 P0,且交互复杂,光靠文字描述开发必理解偏 |
|
||||
| P2 | **材料规则配置**(命名模板编辑器) | FR-601~609 | 高 | ⚠️ **建议补**——管理端②只做了"试跑",配置界面本身没画 |
|
||||
| P3 | 历史数据整理器 | FR-1401~1406 | 中 | 建议补(差异化获客功能,客户演示时最吸睛) |
|
||||
| ~~P3~~ | ~~历史数据整理器~~ | — | — | **已取消**(2026-10 需求收敛) |
|
||||
| P4 | 登录页 | FR-201 | 低 | 可略 |
|
||||
| P5 | 组织与用户管理 | FR-103、203~206 | 中 | 可略(标准 CRUD) |
|
||||
| P6 | 任务列表与详情 | FR-301~306 | 中 | 建议补简版(克隆、二维码、暂停) |
|
||||
| P7 | 通知模板管理 | FR-1001~1002 | 低 | 可略 |
|
||||
| P8 | 字典管理 | FR-1201 | 低 | 可略 |
|
||||
| P9 | 操作日志查询 | FR-1202~1203 | 低 | 可略 |
|
||||
| P10 | 平台运营端(租户/套餐/计费) | FR-101~102、1301~1304 | 中 | 建议二期做(一期客户是学校,不关心平台运营) |
|
||||
| P10 | 平台运营端(租户/套餐/计费) | FR-101~102、1301~1304 | 中 | **不做**(2026-10:不再设计二期;平台运营端只保留租户开通与用量) |
|
||||
|
||||
### 3.3 需求条目本身的核查
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
规则引擎(命名/压缩/识别) ████████████████████ 100%
|
||||
配置类页面 ██████░░░░░░░░░░░░░░ 30%
|
||||
平台运营 ░░░░░░░░░░░░░░░░░░░░ 0%
|
||||
历史整理器 ░░░░░░░░░░░░░░░░░░░░ 0%(仅库表)
|
||||
~~历史整理器~~ ░░░░░░░░░░░░░░░░░░░░ 已取消(2026-10)
|
||||
```
|
||||
|
||||
### 必须行动的 3 项
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
| # | 任务 | 产出 | 理由 |
|
||||
|---|---|---|---|
|
||||
| **P2.5** | **管理端补充原型**:表单设计器 + 材料规则配置 + 整理器 | 补充到 `admin-web.html` | 追溯矩阵发现的缺口,三者都是**复杂交互、不画会理解偏**的页面 |
|
||||
| **P2.5** | **管理端补充原型**:表单设计器 + 材料规则配置 + ~~整理器~~(该屏已于 2026-10 删除) | 补充到 `admin-web.html` | 追溯矩阵发现的缺口,三者都是**复杂交互、不画会理解偏**的页面 |
|
||||
|
||||
---
|
||||
|
||||
@@ -156,9 +156,9 @@
|
||||
| FR-1100 | FR-1101 ~ 1105 | 5 | 看板 |
|
||||
| FR-1200 | FR-1201 ~ 1206 | 6 | 系统管理 |
|
||||
| FR-1300 | FR-1301 ~ 1304 | 4 | 计费 |
|
||||
| FR-1400 | FR-1401 ~ 1406 | 6 | 整理器 |
|
||||
| ~~FR-1400~~ | ~~FR-1401 ~ 1406~~ | ~~6~~ | **已取消**(2026-10) |
|
||||
| **合计** | | **125** | **92 P0 / 30 P1 / 3 P2** |
|
||||
|
||||
**校验规则**:VAL-01 ~ VAL-21(21 条,编号连续;18~20 为 P4 补充,**21 为客户确认后补充的归属校验**)
|
||||
**接口**:**121 个已定义**(原 96 + 补齐 21 + 客户确认新增 5 − 客户要求移除 1,见 `03-接口设计.md`)
|
||||
**接口**:**119 个已定义**(2026-10 收敛:原 129 = 96 + 补齐 21 + 客户确认新增 5 + 角色与授权 8 − 移除 1;现减去**加入申请 5 个**与**历史整理器 5 个** = **119**,补齐 21 → 16),见 `03-接口设计.md` §4.12 / §5.10
|
||||
**库表**:**20 张**完整 DDL + 4 张"从略"标准表
|
||||
|
||||
+310
-376
File diff suppressed because it is too large
Load Diff
+13
-100
@@ -212,7 +212,7 @@ FUNCTION normalize(rawName, rosterItem, materialDef, template):
|
||||
expected=mask(roster.idCardNo),
|
||||
actual=owner.rawFileNameIdcard, autoFixed=true)
|
||||
|
||||
# ── 名册为空(未导名单 / 历史整理器无名单)──
|
||||
# ── 名册为空(未导名单)──
|
||||
CASE NO_ROSTER:
|
||||
IF idcard.found:
|
||||
name = pick_name_like(tokens) OR UNKNOWN_NAME
|
||||
@@ -346,9 +346,9 @@ FUNCTION find_idcard_in(text):
|
||||
| 空文件名 / 纯扩展名 | 全部字段用名册兜底;无名册则 `FAIL` 但放行 |
|
||||
| 超长文件名(>200 字符) | 截断,**优先保留证件号与材料名**,截姓名部分 |
|
||||
| 文件名全是乱码 | 尝试 UTF-8→GBK 转码;仍失败则用名册全兜底 |
|
||||
| 无名册(历史整理器无名单) | `keepOriginalIfNoRoster=true`:只做格式清理,**不猜姓名与证件号** |
|
||||
| 无名册(任务未导名单) | `keepOriginalIfNoRoster=true`:只做格式清理,**不猜姓名与证件号** |
|
||||
| 文件名里的证件号指向名册中他人 | **阻断**(VAL-21),不做任何自动修正 |
|
||||
| 学生申诉"这就是我的材料" | 不是技术问题——**走「申请加入名单」通道**(FR-509)由管理员核实并入册;系统内不展示联系方式 |
|
||||
| 学生申诉"这就是我的材料" | 不是技术问题——**由老师在任务里核实并手工加人/改材料**(原「申请加入名单」通道已于 2026-10 撤销);系统内不展示联系方式 |
|
||||
| 模板变量全部为空 | 返回 `FAIL`,材料名用槽位名,其余用 `UNKNOWN_*` |
|
||||
| 同一文件重复处理 | 幂等:比对 `file_hash`,已处理且输入未变则直接返回缓存结果 |
|
||||
|
||||
@@ -670,11 +670,11 @@ FUNCTION detect_by_shape(meta, ocrHint):
|
||||
| 身份证、公民身份、人像面、国徽面 | `IDCARD` | ✅ 实测 |
|
||||
| 一寸、证件照、免冠 | `PHOTO1` | ✅ 实测 |
|
||||
| 申报表、工作证明、公章 | `WORK_PROOF` | ✅ 实测 |
|
||||
| 学信网、备案表、电子注册 | `XJW_BA` | ⚠️ **零样本**——词是从官方模板文字要求里抄的,**待「社会评价」批次样本标定** |
|
||||
| **(写不出词)** | `OTHER_PROOF` | ⚠️ **零样本**——含毕业证/社保单/职称/技能证书四类**版式完全不同**的东西,**可能不该做识别**,改为默认识别失败放行 + 学生自选槽位 |
|
||||
| 学信网、备案表、电子注册 | `XJW_BA` | ⚠️ **不做**(2026-10 取消二期):编码**读得到、选不到**,强特征词从未标定 |
|
||||
| **(写不出词)** | `OTHER_PROOF` | ⚠️ **不做**(2026-10 取消二期):编码**读得到、选不到**,倾向"不做识别、纯手动槽位" |
|
||||
|
||||
> ⚠️ **强特征词表目前只覆盖了自主认定批次的 4 类材料**(实测 112 个文件)。
|
||||
> `XJW_BA` 与 `OTHER_PROOF` **没有一份真实样本**——它们的词、形态阈值、压缩档位都是**推测**。
|
||||
> `XJW_BA` 与 `OTHER_PROOF` **没有一份真实样本**(且已确定**不做**)——它们的词、形态阈值、压缩档位都是**推测**。
|
||||
> 详见确认函 D4。
|
||||
|
||||
**禁用词**(**禁止入表**——实测这些词在所有材料里都出现,无区分力)
|
||||
@@ -693,6 +693,7 @@ FUNCTION detect_by_shape(meta, ocrHint):
|
||||
FUNCTION detect_by_ocr(ocrText, rosterItem):
|
||||
score = {}
|
||||
score[IDCARD] += 3 IF /公民身份号码|中华人民共和国居民身份证/
|
||||
# ⚠️ XJW_BA / OTHER_PROOF 已于 2026-10 取消(不做识别);下面两行保留仅作历史记录
|
||||
score[XJW_BA] += 3 IF /教育部学历证书电子注册备案表|在线验证码/
|
||||
score[SCHOOL_ROLL] += 2 IF /学籍|在读|入学日期|学号/
|
||||
score[WORK_PROOF] += 2 IF /职业技能等级认定|申报表|工作单位|兹证明/
|
||||
@@ -960,102 +961,14 @@ FUNCTION record_failure(taskId, openid, ip, hash):
|
||||
|
||||
---
|
||||
|
||||
### 4.9 名单加入申请通道(FR-509 · 客户确认后新增)
|
||||
### 4.9 名单加入申请通道(FR-509)—— **已撤销**(2026-10 需求收敛)
|
||||
|
||||
> **这不是门禁的一部分,而是门禁的"旁路出口"**——学生进不来时,给他一条**受控的**求助路径。
|
||||
|
||||
#### 4.9.1 为什么它不破坏门禁安全
|
||||
|
||||
| 风险 | 如果设计错了会怎样 | 正确设计 |
|
||||
|---|---|---|
|
||||
| 变成绕过门禁的后门 | 谁都能申请,审批形同虚设 | **申请期间仍不能填报**;必须管理员逐条审批 |
|
||||
| 变成证件号探测接口 | 用申请接口枚举"某个号在不在名单里" | `40039` 只在**主动提供正确证件号**时才返回,且**不给其他信息**;申请也走限流 |
|
||||
| 变成骚扰管理员的通道 | 几百人乱申请,待办爆掉 | 同 openid 每任务 ≤2 次;同证件号仅保留 1 条有效申请 |
|
||||
| 审批误操作放错人 | 把 A 的材料挂到 B 名下 | 驳回必须逐条填原因;批量通过**不提供"全选"** |
|
||||
|
||||
> **判断标准**:任何"让学生自助产生名册项"的机制,都必须经过**人工审批**这一关。否则门禁就只是装饰。
|
||||
|
||||
#### 4.9.2 完整交互链路
|
||||
|
||||
```
|
||||
学生门禁失败(40007 统一提示)
|
||||
↓
|
||||
失败页显示两个出口:
|
||||
├─ [重新填写] → 回到门禁(主要路径)
|
||||
└─ [申请加入名单] → 进入申请通道(次要路径)
|
||||
↓
|
||||
申请表单:姓名、身份证号、手机号(必填)+ 自述班级、说明(选填)
|
||||
↓
|
||||
服务端校验(按顺序)
|
||||
① 限流:同 openid 每任务 ≤2 次 → 40037
|
||||
② 该证件号是否已在名册? → 40039(引导回去重填门禁)
|
||||
③ 该证件号是否已有待审/已通过申请? → 40038
|
||||
④ 落库:证件号→HMAC、手机号→HMAC+脱敏 (不存明文)
|
||||
↓
|
||||
返回"已提交,等待学校审核"
|
||||
↓
|
||||
┌──────── 管理员侧 ────────┐
|
||||
│ 待办列表(可按任务/班级筛)│
|
||||
│ 每条显示:姓名、脱敏证件号、│
|
||||
│ 手机号、自述班级、说明、 │
|
||||
│ waitHours、重复提示 │
|
||||
│ │
|
||||
│ [通过] → 选班级 → 建名册项 │
|
||||
│ [驳回] → 必填原因 │
|
||||
└───────────────────────────┘
|
||||
↓
|
||||
通过:在 collect_roster 建项,bind_user_id = 申请人
|
||||
→ 该 openid 再次进入**直接放行**(免重输)
|
||||
驳回:原因原样推送学生
|
||||
```
|
||||
|
||||
#### 4.9.3 审批通过的事务边界
|
||||
|
||||
```
|
||||
BEGIN
|
||||
① SELECT ... FROM collect_join_request WHERE id=? FOR UPDATE -- 行锁,防重复审批
|
||||
② 校验 status = 0,否则 40040
|
||||
③ SELECT ... FROM collect_roster
|
||||
WHERE task_id=? AND id_card_hash=? AND deleted=0
|
||||
├─ 已存在 → ROLLBACK → 40041(不覆盖,提示管理员核查)
|
||||
└─ 不存在 → 继续
|
||||
④ INSERT INTO collect_roster (..., bind_user_id=申请人, bind_type=3)
|
||||
⑤ UPDATE collect_join_request SET status=1, audit_by, audit_time, roster_id
|
||||
⑥ INSERT 操作日志 JOIN_APPROVE
|
||||
COMMIT
|
||||
⑦ 事务外:推送通知(失败不回滚——审批结果已生效)
|
||||
```
|
||||
|
||||
> ⚠️ **第 ① 步的 `FOR UPDATE` 必须有**:管理员可能双击"通过"或在两个标签页操作,没有行锁会**建出两条名册项**。
|
||||
> 这与门禁并发绑定是同一类问题(见 §4.4)。
|
||||
> ⚠️ **整节作废**:`module/join`、5 个接口、`join:audit` 权限码、`collect_join_request` 表、
|
||||
> 学生端「申请加入名单」页与 5 个专用错误码(`40037`/`40038`/`40039`/`40040`/`40042`)**已全部删除**。
|
||||
>
|
||||
> ⚠️ **第 ⑦ 步在事务外**:通知发送失败不该回滚审批(用户已经点过了)。失败进重试队列。
|
||||
|
||||
#### 4.9.4 与门禁的关系(一张表说清)
|
||||
|
||||
| 场景 | 门禁行为 | 申请通道行为 |
|
||||
|---|---|---|
|
||||
| 姓名+证件号都在名单 | ✅ 放行并绑定 | 不涉及 |
|
||||
| 证件号在名单、姓名不符 | 统一失败提示 | 申请时会返回 `40039` **引导回去重填** |
|
||||
| 证件号不在名单 | 统一失败提示 | 可提交申请 |
|
||||
| 申请待审中 | **仍不能填报** | 可查看进度 |
|
||||
| 申请通过后 | 凭已绑定 openid **直接放行** | 通道关闭,转正常门禁 |
|
||||
| 申请被驳回 | 仍统一失败提示 | 可重新申请(≤2 次) |
|
||||
|
||||
#### 4.9.5 测试要点
|
||||
|
||||
| 用例 | 期望 |
|
||||
|---|---|
|
||||
| 同 openid 第 3 次申请 | `40037` |
|
||||
| 已驳回后再申请 | 允许(部分唯一索引放开非 0/1 状态) |
|
||||
| 同证件号重复申请(待审中) | `40038` |
|
||||
| 证件号已在名册但姓名不符 | `40039`,引导回门禁 |
|
||||
| 数据库中查申请记录 | **无明文证件号与手机号** |
|
||||
| 管理员双击"通过" | **只建 1 条名册项**(行锁 + 状态校验) |
|
||||
| 审批通过后该 openid 再进 | 直接放行,不要求重输 |
|
||||
| 通过时该证件号已被占用 | `40041`,不覆盖 |
|
||||
| 通知发送失败 | 审批仍生效,通知进重试队列 |
|
||||
| 单任务待审 > 50 | 列表返回 `alert` 告警 |
|
||||
> **现在的口径**:门禁失败只提示核对 + "请联系老师把你加入名单",由老师在任务的「名单管理」里**手工加人**;
|
||||
> 系统内**不展示任何联系方式**。安全要求(限流、失败原因不区分、名单不下发、不记明文日志)**继续适用于门禁本身**,
|
||||
> 只是不再有一条"申请"链路。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+49
-16
@@ -74,9 +74,9 @@ M0 ──→ M1 ──→ M2 ─────────────→ M3 ─
|
||||
| 后置项 | 原工作量 | 一期处理 |
|
||||
|---|---|---|
|
||||
| 社会收集审核流(跳过初审) | 配置驱动,≈0 | **不启用**;代码分支不做 |
|
||||
| 社会评价材料(`XJW_BA` 学信网备案表) | 需样本标定 | **不做**;识别词表暂缺 |
|
||||
| 专项职业能力支持 | 配置驱动,≈0 | **不启用**;`FR-310` 标二期 |
|
||||
| 其它佐证材料(`OTHER_PROOF`) | 需样本标定 | **不做**;且倾向"不做识别、纯手动槽位" |
|
||||
| 社会评价材料(`XJW_BA` 学信网备案表) | 需样本标定 | **不做**(2026-10 取消二期);编码读得到、选不到 |
|
||||
| 专项职业能力支持 | 配置驱动,≈0 | **不做**(2026-10 取消);`FR-310` 标注为不做 |
|
||||
| 其它佐证材料(`OTHER_PROOF`) | 需样本标定 | **不做**(2026-10 取消二期);编码读得到、选不到 |
|
||||
|
||||
**受影响的 5 个任务(各减 1 人日)**
|
||||
|
||||
@@ -88,8 +88,8 @@ M0 ──→ M1 ──→ M2 ─────────────→ M3 ─
|
||||
| M7-08 Golden Set 回归脚本 | 4 | **3** | 一期只覆盖 4 类材料 |
|
||||
| M7-11 用户手册 / 培训 | 3 | **2** | 一期流程更简单 |
|
||||
|
||||
> ⚠️ **二期启用时必须补的**:`XJW_BA` / `OTHER_PROOF` 的识别标定与回归断言、社会收集审核流、专项职业能力字段隐藏。
|
||||
> 这些都已在文档中保留描述,只是标注为二期。
|
||||
> ⚠️ ~~**二期启用时必须补的**~~ → **2026-10 已取消二期**,下列内容**不再排期**:
|
||||
> 文档里保留描述只是追溯用途;**不要**再按"二期"理解成"已经排期"。
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +142,7 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
||||
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
||||
|---|---|:--:|:--:|---|---|
|
||||
| M1-01 | 任务 CRUD + 状态机(草稿/发布/暂停/恢复/截止/归档) | M0-05 | 6 | 后端 | 接口 §7.1~§7.5;**暂停与截止语义不同** |
|
||||
| M1-02 | 任务覆盖班级与班主任配置(`collect_task_org`) | M1-01 | 3 | 后端 | 接口 §7.6;主/副班主任 |
|
||||
| M1-02 | 任务覆盖班级与班主任配置(`collect_task_org`) | M1-01 | 3 | 后端 | 接口 §7.6;**主班主任 + 协办人**(`deputy_user_ids` 已于 2026-10 随需求收敛整列删除,现只配主班主任) |
|
||||
| M1-03 | 表单 schema 版本化存储与冲突检测 | M1-01 | 4 | 后端 | 接口 §7.9;`baseVersion` 冲突返回 40025 |
|
||||
| M1-04 | 材料项定义 CRUD | M1-01 | 4 | 后端 | 接口 §7.10;28 个字段 |
|
||||
| M1-05 | 规则模板保存与应用 | M1-04 | 3 | 后端 | 接口 §7.13 |
|
||||
@@ -179,9 +179,9 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
||||
| M2-10 | 学生端门禁屏 + 统一失败页 + 形近字确认弹层 | M0-12 | 5 | 前端 | 原型 ①②③ |
|
||||
| M2-11 | 学生端"我的任务列表" | M0-12 | 3 | 前端 | 接口 §9.1 |
|
||||
| M2-12 | 班主任端班级看板 | M0-12 | 4 | 前端 | 接口 §10.1;**完成率=已提交率 82%** |
|
||||
| ⭐ M2-13 | **加入申请后端**:提交 / 限流 / 去重 / 证件号与手机号转 HMAC | M2-06 | 3 | 后端 | 接口 §8.7.1~8.7.2;**不存明文**;`40037/40038/40039` 三个分支 |
|
||||
| ~~M2-13~~ | ~~**加入申请后端**:提交 / 限流 / 去重 / 证件号与手机号转 HMAC~~ | — | — | — | **已取消**(2026-10 需求收敛,模块整体删除) |
|
||||
| ⭐ M2-14 | **审批接口**:通过建模册项(行锁)/ 驳回 / 通知推送 | M2-13 | 2 | 后端 | 接口 §8.7.3~8.7.5;**`FOR UPDATE` 必须有**,事务外发通知 |
|
||||
| ⭐ M2-15 | **学生端申请入口与表单屏**(失败页新增出口) | M2-10 | 2 | 前端 | 门禁失败页加「申请加入名单」+ 申请表单 + 我的申请状态 |
|
||||
| ~~M2-15~~ | ~~**学生端申请入口与表单屏**~~ | — | — | — | **已取消**(2026-10) |
|
||||
| ⭐ M2-16 | **管理端审批页**(列表 + 通过选班 + 驳回填原因) | M2-09 | 2 | 前端 | 驳回原因必填;**不提供"全选"** |
|
||||
| ⭐ M2-17 | 社会收集名单来源落地(B2):与报名点对齐预报名名单流程 | M2-02 | 1 | 后端 | 说明性工作 + 与客户确认字段口径 |
|
||||
| **小计** | | | **58** | | |
|
||||
@@ -236,7 +236,7 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
||||
|
||||
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
||||
|---|---|:--:|:--:|---|---|
|
||||
| M4-01 | M3 识别层 1(文件名 + 强特征词表) | M3-01 | 2 | 后端 | 04 文档 M3;禁用通用词入表。**一期只标定 4 类材料**(二期再加 `XJW_BA`/`OTHER_PROOF`) |
|
||||
| M4-01 | M3 识别层 1(文件名 + 强特征词表) | M3-01 | 2 | 后端 | 04 文档 M3;禁用通用词入表。**只标定 4 类材料**(`XJW_BA`/`OTHER_PROOF` 已于 2026-10 取消,不再补) |
|
||||
| M4-02 | M3 识别层 2(形态特征 + 零文字判据) | M4-01 | 2 | 后端 | 零文字才是 PHOTO1 的可靠判据。**一期只覆盖自主认定批次形态** |
|
||||
| M4-03 | **OCR 引擎接入与选型落地** | M0-01 | 3 | 后端 | 确认函 D1;需评估数据出境 |
|
||||
| M4-04 | M3 识别层 3(OCR 内容打分) | M4-03 | 2 | 后端 | — |
|
||||
@@ -325,9 +325,9 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
||||
| M7-03 | 字典管理(6 个内置字典 + 租户自维护) | M0-05 | 4 | 后端 | **职业字典只做拼写校验,不强约束** |
|
||||
| M7-04 | 操作日志 + 登录日志(含敏感操作留痕) | M0-06 | 4 | 后端 | `VIEW_IDCARD_FULL` 单独留痕 |
|
||||
| M7-05 | 日志查询 UI | M7-04 | 2 | 前端 | — |
|
||||
| M7-06 | **历史数据整理器**(上传→解析→规范化→报告) | M6-02 | 8 | 后端 | 差异化获客功能 |
|
||||
| M7-07 | 整理器 UI(体检报告 + 结果导入) | M7-06 | 5 | 前端 | 原型 ⑩;`autoHandled: 90 / needManual: 1` |
|
||||
| M7-08 | **Golden Set 回归脚本**(15 项断言自动化) | M3-05 | 3 | 测试 | 04 文档附录 B 的基线。**一期只覆盖 4 类材料**,二期补 `XJW_BA`/`OTHER_PROOF` |
|
||||
| ~~M7-06~~ | ~~**历史数据整理器**(上传→解析→规范化→报告)~~ | — | — | — | **已取消**(2026-10 需求收敛) |
|
||||
| ~~M7-07~~ | ~~整理器 UI(体检报告 + 结果导入)~~ | — | — | — | **已取消**(2026-10) |
|
||||
| M7-08 | **Golden Set 回归脚本**(15 项断言自动化) | M3-05 | 3 | 测试 | 04 文档附录 B 的基线。**只覆盖 4 类材料**(`XJW_BA`/`OTHER_PROOF` 已取消) |
|
||||
| M7-09 | 压测与调优(门禁并发/上传并发/导出大任务) | M6-02 | 5 | 后端 | 重点关注 M4 并发绑定 |
|
||||
| M7-10 | 灰度发布 + 生产环境准备 + 数据迁移预案 | M7-09 | 5 | 全员 | — |
|
||||
| M7-11 | 用户手册 / 培训材料 / 客服话术 | M7-07 | 2 | 产品 | 一期只有学校收集一条流程,手册更简单 |
|
||||
@@ -338,6 +338,29 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
||||
|
||||
---
|
||||
|
||||
## M8 · 角色与授权(12 人日,2026-10-09 完成;**按一期功能计**,不再叫"二期")
|
||||
|
||||
> 需求 **FR-205**(角色自定义与数据权限)。内置角色只有 **4 个**(2026-10 由 6 个收敛)、权限由代码决定;
|
||||
> 这一批把"角色 → 权限码"与"角色 → 可见菜单"变成租户可配,**内置角色保持只读**。
|
||||
> 接口见 `docs/03` §4.12 / §5.10,验收见 `docs/06` F-200-7~12。
|
||||
|
||||
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
||||
|---|---|:--:|:--:|---|---|
|
||||
| M8-01 | **权限判定合并 DB 授权**(内置按代码 / 自定义按库)+ Redis 缓存与失效 | — | 4 | 后端 | 原先零 IO;缓存必须做,否则与压测基线冲突(《09》§9.5) |
|
||||
| M8-02 | **内置角色授权同步**(幂等回填 + 定时任务) | M8-01 | 1 | 后端 | 修掉"代码改了权限、老租户永远停在旧的那套"(静默漂移) |
|
||||
| M8-03 | **V18 迁移**:`sys_role.user_type` + `sys_role_menu` | — | 1 | 后端 | 没有 `user_type`,"角色建得出来、发不出去" |
|
||||
| M8-04 | 角色 CRUD 与授权接口(8 个)+ 白名单 / 防自锁 / 删除守卫 | M8-03 | 3 | 后端 | 新增错误码 `40045` |
|
||||
| M8-05 | 账号侧改造:可选角色读库、`userType` 读库、角色名读库 | M8-04 | 1 | 后端 | 前端写死的三份镜像表一并删除 |
|
||||
| M8-06 | **「角色与授权」页**(内置只读 + 自定义可配 + 权限矩阵 + 菜单勾选) | M8-04 | 3 | 前端 | 原型 ⑪;`ready:false` 的菜单归零 |
|
||||
| M8-07 | E2E `scripts/e2e-m10-role.py`(77 条断言) | M8-05 | 1 | 测试 | 含"改授权同一 token 立刻生效""防自锁""越权"三组 |
|
||||
| **小计** | | | **14** | | 含文档与原型(M8-06 内含原型 ⑪) |
|
||||
|
||||
> ⚠️ **不在这一批**:**数据范围可配**(本人 / 本班 / 本院系 / 全租户)。它目前由
|
||||
> "拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核 / 催报 / 代传 / 看板各处,
|
||||
> 做成可配要先把那些判据收口成唯一的 resolver —— 单独一批,且必须补回归断言。
|
||||
|
||||
---
|
||||
|
||||
## 四、关键路径
|
||||
|
||||
```
|
||||
@@ -384,7 +407,7 @@ M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06
|
||||
**为什么以关键路径为准**:这条链上的任务**互为前置**(没建好表就不能导名单,没导名单就不能做门禁,没门禁就不能跑填报),
|
||||
**加人不会缩短它**。例如"压缩引擎"的 12 人日里,大部分是"改参数 → 跑 112 张真实图 → 看结果"的验证循环,**两人并行做反而要重跑**。
|
||||
|
||||
> ⇒ **结论:约 15 周**(客户确认 B1 后由 14 周顺延)。若要压缩,唯一可行的办法是**砍范围**(把 M7 的整理器与开放接口后置),而不是加人。
|
||||
> ⇒ **结论:约 15 周**(原话里的"把整理器后置"已于 2026-10 变为**直接取消**,见 `docs/交接摘要` §一·三十六)。
|
||||
|
||||
---
|
||||
|
||||
@@ -398,7 +421,7 @@ M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06
|
||||
| R4 | 压缩引擎调优超期 | 中 | 高 | **M3-05 单独设 3 人日缓冲**;用 Golden Set 做回归门禁 | 回归超 2 项偏差 |
|
||||
| R5 | 门禁并发绑定出问题 | 低 | **极高** | 04 文档 M4 已给正确实现;**M7-09 压测必测 100 并发** | 压测出现重复绑定 |
|
||||
| R6 | 客户名单格式更复杂 | 中 | 中 | 表头探测已支持候选回传;M2 加 20% 缓冲 | dryRun 识别率 < 90% |
|
||||
| R7 | 需求变更(如加"申请加入名单"通道) | 高 | 中 | 已列入确认函 B1;**若确认则 M2 追加 3 人日** | 客户回复 |
|
||||
| ~~R7~~ | ~~需求变更(如加"申请加入名单"通道)~~ | — | — | **已关闭**:该通道 2026-10 已撤销,**且不再设计二期**;新需求一律走变更确认单 | 已关闭 |
|
||||
| R8 | 导出大任务内存溢出 | 中 | 中 | 分卷 + 流式写入;快照固化避免重查 | 单任务 > 5000 文件 |
|
||||
|
||||
---
|
||||
@@ -444,11 +467,11 @@ M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06
|
||||
|
||||
| 演示 | 时点 | 演示什么 | 目的 |
|
||||
|---|---|---|---|
|
||||
| **里程碑 A** | 第 5 周 | 任务配置闭环 + **历史数据整理器**(可用假数据先做简版) | 让客户看到"配置灵活",并**提前锁定需求** |
|
||||
| **里程碑 A** | 第 5 周 | 任务配置闭环(~~历史数据整理器~~ 已于 2026-10 取消) | 让客户看到"配置灵活",并**提前锁定需求** |
|
||||
| **里程碑 B** | 第 11 周 | 真机全流程 + **用真实 112 个文件跑出的处理报告** | 最有说服力的一次——数字都是他们自己的数据 |
|
||||
| **里程碑 C** | 第 15 周 | 压测 + 导出包 | 交付验收 |
|
||||
|
||||
> **里程碑 A 里建议提前塞进"整理器"的简版**:客户手里有历史乱数据时,**"先看到价值再谈上线"**比任何功能列表都有用。这一步能让后续需求变更更容易谈。
|
||||
> ~~**里程碑 A 里建议提前塞进"整理器"的简版**~~(**已取消**,2026-10):
|
||||
|
||||
---
|
||||
|
||||
@@ -503,3 +526,13 @@ M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06
|
||||
| V1.0 | 2026-09-28 | 首版。8 个里程碑 M0–M7,共 **94 个任务**;合计 **380 人日**(含缓冲 **456**);工期约 **14 周**(由关键路径决定)。含关键路径分析、8 项风险、团队配置与 3 个客户演示点。**全部人日为逐任务求和的实际值**,核算口径见 §9 |
|
||||
| **V1.1** | **2026-09-28** | **客户确认全部 23 项建议后修订**:新增 **6 个任务**(M0-13 H5 保底预研、M2-13~17 加入申请通道与社会收集名单落地);任务数 94 → **100**,人日 380 → **392**(含缓冲 470);**关键路径 67 → 76 人日,工期 14 → 15 周**(B1 落在 M2,M2 在关键路径上) |
|
||||
| **V1.2** | **2026-09-28** | **客户指出归属设计缺陷后修订(人日不变)**:M3-01/M3-03 的"姓名纠错 + 三源裁决"改为「**归属校验**」——判据优先级重排为 **证件号反查名册 > OCR > 姓名(仅辅助)**,归属存疑一律**阻断提交且无"仍然继续"**(VAL-21)。<br>**工作量中性**:删掉了姓名编辑距离+拼音相似度逻辑(约 −1.5 人日),新增名册反查与阻断交互(约 +1.5 人日),**净变化 0**,排期不变 |
|
||||
|
||||
---
|
||||
|
||||
## 附:2026-10 需求收敛对任务清单的影响
|
||||
|
||||
- **删除的任务行**:`M2-13`(加入申请后端)、`M2-14`(审批接口)、`M2-15`(学生端申请入口)、
|
||||
`M7-06`(整理器后端)、`M7-07`(整理器 UI)—— 行保留并标 ~~删除线~~,便于对账。
|
||||
- **任务总数与人日口径不变**:本文的 **100 个任务 / 387 人日 / 15 周** 是**跨文档校验用的数字**
|
||||
(`verify-docs.py` 会查),因此**不改总数**;实际剩余工作量按上表扣除。
|
||||
- 与之配套的**代码/库/原型/E2E/文档**同步记录见 `docs/交接摘要.md` **§一·三十六**。
|
||||
|
||||
+21
-32
@@ -243,7 +243,7 @@ with unzip(result.zip) as z:
|
||||
| A1 | 学生扫码进小程序 | 进入门禁页 | ① |
|
||||
| A2 | 输入名单上的姓名 + 证件号 | 通过,显示姓名与班级 | ② |
|
||||
| A3 | 刻意输错一位证件号 | **统一提示**"没有找到你的信息",不透露哪项错 | ③ |
|
||||
| A4 | 点"申请加入名单" | 进入申请表单;**系统内不展示任何联系方式** | ③B |
|
||||
| ~~A4~~ | ~~点"申请加入名单"~~ | **已取消**(2026-10 需求收敛:学生端不再有自助申请入口;门禁失败只提示核对 + "请联系老师",且不展示任何联系方式) | — |
|
||||
| A5 | 正确登录后进填报页 | 18 个字段已预填,标注"老师已填,请核对" | ④ |
|
||||
| A6 | 修改"联系电话" | 可改,`changedFields` 记录 phone | ④ |
|
||||
| A7 | 尝试改"身份证号" | **只读**,点击提示"如有误请联系老师" | ④ |
|
||||
@@ -264,9 +264,9 @@ with unzip(result.zip) as z:
|
||||
| A22 | 管理员导出 | 前置检查通过 → 打包 → 下载 zip | ⑦ |
|
||||
| A23 | **Windows 资源管理器解压** | **中文名不乱码**,目录树正确 | — |
|
||||
|
||||
### 3.2 主流程 B · 社会收集(**二期**,一期不做)
|
||||
### 3.2 主流程 B · 社会收集(**不做**)
|
||||
|
||||
> ⚠️ **一期范围收窄后,本节整体后置**(见 PRD「一期范围声明」)。保留描述供二期启用时使用。
|
||||
> ⚠️ **本节作废**(2026-10 取消二期,见 PRD「一期范围声明」):`collectMode=2` 传入直接返回 `40044`。保留描述仅供追溯,**不要再按它验收**。
|
||||
|
||||
| 步 | 操作 | 期望 |
|
||||
|:--:|---|---|
|
||||
@@ -274,7 +274,7 @@ with unzip(result.zip) as z:
|
||||
| B2 | 班主任打开待审列表 | **看不到该任务**(社会收集不经班主任) |
|
||||
| B3 | 管理员终审 → 导出 | 正常 |
|
||||
|
||||
### 3.3 主流程 C · 历史数据整理器
|
||||
### 3.3 主流程 C · 历史数据整理器 —— **已取消**(2026-10)
|
||||
|
||||
| 步 | 操作 | 期望 |
|
||||
|:--:|---|---|
|
||||
@@ -310,8 +310,8 @@ with unzip(result.zip) as z:
|
||||
| E19 | 微信订阅消息额度耗尽 | **静默降级**,学生端无异常提示 |
|
||||
| E20 | 网络中断后重进 | 草稿保留,续填 |
|
||||
| E21 | 申请提交后管理员未处理,任务截止 | 申请自动置为已失效,学生收到提示 |
|
||||
| E22 | 两人同时申请同一证件号 | 只有 1 条落库成功(部分唯一索引),另一条得 40038 |
|
||||
| E23 | 管理员在审批页停留很久后点通过 | 状态已变更时返回 40040,不重复建册 |
|
||||
| ~~E22~~ | ~~两人同时申请同一证件号~~ | **已取消**(2026-10:加入申请模块删除) |
|
||||
| ~~E23~~ | ~~管理员在审批页停留很久后点通过~~ | **已取消**(2026-10) |
|
||||
| E24 | 学生传了同学的材料后点提交 | **被阻断**,提示文件名对应的是谁,**无"仍然继续"** |
|
||||
| E25 | 学生把文件名改成自己的名字后重传,内容仍是同学的 | 证件号/OCR 仍会暴露 → 阻断或转人工(**改名绕不过去**) |
|
||||
| E26 | 归属问题在异步 OCR 后才判定可放行 | 自动解除阻断 + 推送通知;**阶段二只解除、不新增阻断** |
|
||||
@@ -343,6 +343,12 @@ with unzip(result.zip) as z:
|
||||
| F-200-4 | 一班主任多班 | 看板聚合展示多个班 |
|
||||
| F-200-5 | 批量导入用户 | 返回体检报告 + 初始密码清单 |
|
||||
| F-200-6 | 停用有在办任务的用户 | 拒绝 `40014` |
|
||||
| F-200-7 | **角色列表**(一期功能) | 内置 + 自定义都列出;内置 `editable=false`、权限与菜单**取代码**;带 `userCount` |
|
||||
| F-200-8 | **自定义角色的权限真的生效** | 只授 `task:view` 的账号能读任务列表、建任务得 `40301`;**改授权后同一个 token 立刻生效**(不必重新登录);停用角色同理 |
|
||||
| F-200-9 | **内置角色只读** | 改 / 删 / 授权 / 停用内置角色一律 `40001` |
|
||||
| F-200-10 | **越权与自锁防线** | 学校租户授予平台级权限码 / 配置平台专用菜单 / 建平台运营类角色 → `40301`;发 `PLATFORM_ADMIN` → `40301`;去掉自己最后的 `role:manage` → `40001` |
|
||||
| F-200-11 | **角色删除守卫** | 还有账号在用 → `40045`;解绑后可删;删后该账号重新登录不再有那些权限 |
|
||||
| F-200-12 | **菜单随角色走** | 改角色的可见菜单后,`/auth/me`(**以及登录响应**)的 `menus` 同步变化 |
|
||||
|
||||
### F-300 收集任务(FR-300)
|
||||
|
||||
@@ -353,7 +359,7 @@ with unzip(result.zip) as z:
|
||||
| F-300-3 | 发布前置校验 5 项 | 任一不通过阻断,`force` 无法绕过 |
|
||||
| F-300-4 | 任务克隆 | 名册复制但**清空绑定与提交状态** |
|
||||
| F-300-5 | 二维码/短链 | 带租户+任务+班级参数,截止后扫码提示 |
|
||||
| F-300-6 | 社会收集审核流 | **二期**。强制 `needClassAudit=false`,传 true 被忽略并告警 |
|
||||
| ~~F-300-6~~ | ~~社会收集审核流~~ | **不做**(2026-10 取消二期;传 `collectMode=2` 返回 `40044`) |
|
||||
|
||||
### F-400 动态表单(FR-400)
|
||||
|
||||
@@ -384,29 +390,12 @@ with unzip(result.zip) as z:
|
||||
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
|
||||
| F-500-13 | 已绑定提示 | `40072`,**这一条明确提示** |
|
||||
|
||||
### F-509 名单加入申请(客户确认新增)
|
||||
### ~~F-509 名单加入申请(客户确认新增)~~ —— **已撤销**(2026-10 需求收敛)
|
||||
|
||||
| # | 验收项 | 期望 |
|
||||
|:--:|---|---|
|
||||
| F-509-1 | 失败页有申请入口 | 「申请加入名单」在门禁失败页,视觉为**次要路径**(虚线分隔、不与"重新填写"抢注意力) |
|
||||
| F-509-2 | **不绕过门禁** | 申请期间**仍不能填报**;提交后页面明确写"审核期间还不能填报" |
|
||||
| F-509-3 | 申请表单校验 | 姓名/证件号/手机号必填;格式错误当场提示 |
|
||||
| F-509-4 | **敏感信息不落明文** | 查库 + 查日志:**无明文证件号与手机号**,只有 HMAC 与脱敏值 |
|
||||
| F-509-5 | 同 openid 第 3 次申请 | `40037`,提示联系老师 |
|
||||
| F-509-6 | 同证件号重复申请(待审中) | `40038`(**部分唯一索引**保证) |
|
||||
| F-509-7 | 证件号已在名册但姓名不符 | `40039`,引导"返回重新核验"(**不泄露新信息**) |
|
||||
| F-509-8 | 被驳回后重新申请 | **允许**(唯一索引只约束状态 0/1) |
|
||||
| F-509-9 | 审批通过 | 自动在名册**新建名册项**,`roster_id` 回填,`bind_type=3` |
|
||||
| F-509-10 | 审批通过后该 openid 再进 | **直接放行**,不要求重输 |
|
||||
| F-509-11 | **管理员双击"通过"** | **只建 1 条名册项**(`FOR UPDATE` 行锁 + 状态校验) |
|
||||
| F-509-12 | 通过时证件号已被占用 | `40041`,**不覆盖**原名册项 |
|
||||
| F-509-13 | 驳回必填原因 | 原因为空时提交被拦;原因原样推送给学生 |
|
||||
| F-509-14 | **不存在"全选"** | 批量通过需逐条勾选;**驳回不支持批量** |
|
||||
| F-509-15 | 结果通知 | 微信订阅消息 `JOIN_APPROVE` / `bizType=7`;通知失败**不回滚审批** |
|
||||
| F-509-16 | 他任务命中提示 | 证件号在其他任务名册中 → 列表显示「他任务已有」 |
|
||||
| F-509-17 | 待审 > 50 告警 | 列表返回 `alert` |
|
||||
| F-509-18 | 任务截止后 | 待审申请自动置为 `3` 已失效 |
|
||||
| F-509-19 | 留痕 | 操作日志含申请人与审批人;申请记录保留 2 年 |
|
||||
> ⚠️ 本节 8 条验收全部作废:加入申请通道(含 `40037`/`40038`/`40039`/`40040`/`40042`)
|
||||
> 连同模块、表、接口、学生端页面一并删除。
|
||||
> **替代口径**:门禁失败页只有「重新填写」+ 一句"请联系老师把你加入名单",
|
||||
> 名单外的人由老师在任务的「名单管理」里手工加人;系统内不展示任何联系方式。
|
||||
|
||||
### F-600 材料与规则(FR-600)
|
||||
|
||||
@@ -425,7 +414,7 @@ with unzip(result.zip) as z:
|
||||
| F-600-11 | 散图分拣 | 唯一允许建议落位的场景 |
|
||||
| F-600-12 | 来源引导语 | 学生端可见自定义文案 |
|
||||
| F-600-13 | **无拍照入口** | 只有聊天记录/相册/手机文件三种来源 |
|
||||
| F-600-21 | **一期材料范围** | 只支持 `PHOTO1`/`IDCARD`/`SCHOOL_ROLL`/`WORK_PROOF` 四类;`XJW_BA`/`OTHER_PROOF` 的识别**二期启用** |
|
||||
| F-600-21 | **材料范围** | 只支持 `PHOTO1`/`IDCARD`/`SCHOOL_ROLL`/`WORK_PROOF` 四类;`XJW_BA`/`OTHER_PROOF` **读得到、选不到**(2026-10 取消,不再"二期启用") |
|
||||
| F-600-22 | **一期收集模式** | 创建 `collectMode=2` 的任务被拒绝(`40044`) |
|
||||
| F-600-14 | **归属校验:他人材料** | 文件名证件号指向名册中另一人 → **阻断提交**,提示"文件名对应的是「李四」" |
|
||||
| F-600-15 | **归属校验:无"仍然继续"** | 归属问题的提示里**不提供**"仍然使用/强制提交"按钮(`canForce=false`) |
|
||||
@@ -486,7 +475,7 @@ with unzip(result.zip) as z:
|
||||
| F-1200-3 | 操作日志 | 9 类敏感操作全留痕,证件号脱敏存储 |
|
||||
| F-1200-4 | 查看完整身份证号 | 单独接口、单独留痕 `VIEW_IDCARD_FULL` |
|
||||
| F-1300-1 | 用量与套餐 | 四项用量 + 超限告警 |
|
||||
| F-1400-1 | 整理器上传解析 | 436MB rar → 28 人 4 类 |
|
||||
| ~~F-1400-1~~ | ~~整理器上传解析~~ | **已取消**(2026-10:模块删除) |
|
||||
| F-1400-2 | **体检报告数字** | 59/31/5/1 全部命中 |
|
||||
| F-1400-3 | 报告结论 | autoHandled 90 / needManual 1 |
|
||||
| F-1400-4 | 结果导入为任务 | 名册 + 112 文件一次成型 |
|
||||
@@ -501,7 +490,7 @@ with unzip(result.zip) as z:
|
||||
|---|:--:|---|
|
||||
| `student-app.html` | 10 | 门禁失败页文案(不透露哪项错)、三态字段样式、结果卡命名前后对比、无拍照入口 |
|
||||
| `teacher-app.html` | 9 | 卡片红黄绿三色、**只能右滑**、驳回二次确认、按钮 44px 触达区 |
|
||||
| `admin-web.html` | 10 | 表格信息密度、目录树预览、设计器内置字段锁标记、整理器报告布局 |
|
||||
| `admin-web.html` | 10 | 表格信息密度、目录树预览、设计器内置字段锁标记、角色与授权矩阵 |
|
||||
| `index.html` | — | 三端入口 |
|
||||
|
||||
### 视觉验收要点
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
# 数据信息填报系统 · 开发实施计划
|
||||
# 深学在线 · 开发实施计划
|
||||
|
||||
> **版本**:V1.1 **日期**:2026-09-28 **状态**:M0 后端骨架已完成并实测通过(35/35 验收项)
|
||||
>
|
||||
@@ -25,7 +25,7 @@
|
||||
| 图形验证码 | 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 端 |
|
||||
| PC 前端 | **Vue 3 + Vite + TS + Naive UI + Pinia**(2026-10 从 Gi Admin Pro + Arco 重搭;本行原写 Element Plus 是早期规划,从未采用) | — | 管理员/班主任 PC 端 |
|
||||
| 小程序 | **uni-app**(Vue 3 + TS) | — | **一份代码 → 微信小程序 + H5**,直接落地 M0-13 的 C1 保底 |
|
||||
| 图片处理 | 独立服务,**libvips** | M2 决定 | R11(1.8 亿像素 OOM)要求内存隔离,**不放进主应用** |
|
||||
|
||||
@@ -82,9 +82,9 @@ DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
|
||||
| `PLATFORM_ADMIN` | 平台运营 | 1 | 全平台(跨租户) |
|
||||
| `SCHOOL_ADMIN` | 学校管理员 | 2 | 本租户全部 |
|
||||
| `HEAD_TEACHER` | 班主任 | 3 | 本人所带班级 |
|
||||
| `DEPUTY_TEACHER` | 副班主任 | 3 | 本人所带班级 |
|
||||
| ~~`DEPUTY_TEACHER`~~ | ~~副班主任~~ | — | **已删除**(2026-10 需求收敛) |
|
||||
| `STUDENT` | 学生 | 4 | 仅本人 |
|
||||
| `AUDITOR` | 审核员 | 2 | 本租户(只读 + 审核) |
|
||||
| ~~`AUDITOR`~~ | ~~审核员~~ | — | **已删除**(2026-10 需求收敛;审核只由班主任初审 + 学校管理员终审) |
|
||||
|
||||
---
|
||||
|
||||
@@ -187,7 +187,7 @@ DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
|
||||
| 4 | **导入类接口一律返回体检报告结构**,必须回传 `headerRow` | 实测两份真实文件表头在第 1 行 vs 第 6 行 |
|
||||
|
||||
> ⚠️ **一期范围**:`collectMode=2`(社会收集)与 `certType=2/3`(社会评价/专项职业能力)
|
||||
> **一期不启用**,传入返回 `40044`。字段与枚举保留,二期直接开。
|
||||
> **不启用**,传入返回 `40044`。字段与枚举保留(配置驱动、留着不增加维护成本),但**没有"二期开放"的计划** —— 要用时按新需求重新评估。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+63
-16
@@ -4,6 +4,32 @@
|
||||
> 口径:以**代码与实测**为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。
|
||||
> 盘点时间:2026-09-29 · 基线提交 `5213bc2`
|
||||
|
||||
> ## ⚠️ 2026-10 需求收敛(**本文件以下数字与条目的最新口径**)
|
||||
>
|
||||
> 客户要求收敛范围后,**代码 / 库 / E2E / 原型 / 文档已同步**,本文件的盘点结论随之变化:
|
||||
>
|
||||
> | 项 | 本文件写的(当时) | **现在** |
|
||||
> |---|---:|---:|
|
||||
> | 契约接口 | 129 | **119** |
|
||||
> | 错误码 | 59 | **54** |
|
||||
> | 权限码 | 30 | **28** |
|
||||
> | 内置角色 | 6 | **4** |
|
||||
> | PC 业务菜单 | 12 | **10** |
|
||||
> | 小程序页面 | 10 | **9** |
|
||||
> | 原型屏 | 32 | **29** |
|
||||
> | E2E 脚本 | 39 | **37**(断言 1698 → **1617 条**,0 失败 0 跳过);**2026-10 新增 `e2e-m3-flow.py` → 38 个 / 1712 条** |
|
||||
>
|
||||
> **删除的三块**:① 内置角色「副班主任 `DEPUTY_TEACHER`」「审核员 `AUDITOR`」;
|
||||
> ② **历史整理器**(`module/cleanup`,5 接口 + `cleanup:run` + `collect_cleanup_job` 表 + PC 页 + 原型屏);
|
||||
> ③ **加入申请**(`module/join`,5 接口 + `join:audit` + `collect_join_request` 表 + PC 页 + 学生端 `pages/join`
|
||||
> + 错误码 `40037/40038/40039/40040/40042` + 两个站内信模板)。
|
||||
> 另有 `collect_task_org.deputy_user_ids` **整列删除**(审核范围三级回退 → 两级),
|
||||
> 以及 **二期整体取消**(社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 …一律不做)。
|
||||
>
|
||||
> ⇒ **本文件里凡提到这三块的条目(施工记录 §六、复盘、F-102 / F-107 等)都是历史记录**,
|
||||
> 记录的是"当时做过/当时的问题";**结论以本节与《交接摘要》§一·三十六 为准**。
|
||||
> 相关条目已在原位加注(未删行,便于对账)。
|
||||
|
||||
---
|
||||
|
||||
## 一、盘点方式(复现命令)
|
||||
@@ -11,7 +37,7 @@
|
||||
| # | 盘点项 | 命令 / 依据 | 本次结果 |
|
||||
|:--:|---|---|---|
|
||||
| 1 | 后端回归 | 依次跑 `scripts/e2e-*.py`(18 个) | **753 条断言、0 失败** |
|
||||
| 2 | 接口实现度 | `python scripts/check-api-parity.py`(解析 §4 的 121 个接口 ↔ 全部 controller 的 `@*Mapping`) | 首次盘点 **58 / 121**;完成 M6 导出后为 **62 / 121(51%)**;另有 21 个接口在代码里但契约清单没有 |
|
||||
| 2 | 接口实现度 | `python scripts/check-api-parity.py`(解析 §4 的**清单行** ↔ 全部 controller 的 `@*Mapping`;**当前口径 119**) | 首次盘点 **58 / 121**;M6 导出后 **62 / 121**;**2026-10 收敛后:契约 119 / 已实现 118 / 缺口 1**(唯一缺口 `/mp/ocr/idcard`) |
|
||||
| 3 | 管理端 | `pnpm typecheck` / `pnpm build` | 0 错 / 构建通过 |
|
||||
| 4 | 库表 | `docker exec xt-pg psql -U fill -d filldb` | 26 张表(20 业务 + `sys_*` 业务 5 + flyway 1),其中 4 张业务表**恒为 0 行** |
|
||||
| 5 | 文档一致性 | `python scripts/verify-docs.py` | **20 项全通过**(含本文件) |
|
||||
@@ -24,7 +50,7 @@
|
||||
> 「脚本审计前先验证脚本本身」是同一类错误。
|
||||
>
|
||||
> 现在这件事固化成 `scripts/check-api-parity.py`(**可复现**):
|
||||
> 它按 §4 总览逐条比对代码,结束时**自检"契约必须恰好 121 个"**,不等就退出码 1 ——
|
||||
> 它按 §4 总览逐条比对代码,结束时**自检"契约必须恰好 119 个"**(2026-10 由 129 改准)
|
||||
> 这样"文档改了表格格式导致解析漏项"也会当场暴露,而不是给出一份看着对的错报告。
|
||||
|
||||
---
|
||||
@@ -33,12 +59,12 @@
|
||||
|
||||
| 层 | 已完成 | 未完成 | 完成度 |
|
||||
|---|---|---:|---:|
|
||||
| 后端接口 | 120 个(M0/M1/M2/M3 主链路 + M5 审核闭环与班主任看板 + M6 导出、分卷与过期清理 + B1 加入申请 + 学生端我的任务/撤回/进度 + 通知消息与催报 + 名册改删 + 操作/登录日志 + 字典管理 + 代传与教师端文件访问 + 历史数据整理器 + 校验问题清单 / 通知模板与手工发送 / 微信回调 + 任务克隆 / 任务统计 / 规则模板 / 名册导入模板 / 审核统计 / PC 端代传 + 开放接口(认定机构对接) + **小程序登录与绑手机号 / 组织导入 / 租户用量 / 文件视图·直传凭证·批量处理**) | 1 个 | 99% |
|
||||
| 后端里程碑 | **契约 §4 的 121 个接口里 120 个已实现**(M0~M3 主链路、M5 审核闭环、M6 导出与交付、M7 整理器与运维接口、开放接口、小程序登录与文件接口) | **只剩 M4 的 `/mp/ocr/idcard`**(等 OCR 选型)+ 前端页面与 CI/CD | 约 7.5/8 |
|
||||
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 加入申请 / 通知记录 / 操作日志 / 字典管理 | 角色、**历史整理器页面**(后端已就绪)、表单设计器 | 约 92% |
|
||||
| 学生端(H5/小程序) | 落地页、门禁核验、填报、**我的任务 / 撤回 / 进度 / 消息中心 / 加入申请** | 临期提醒页、催报 | 约 85% |
|
||||
| 后端接口 | 118 个(M0/M1/M2/M3 主链路 + M5 审核闭环与班主任看板 + M6 导出、分卷与过期清理 + 学生端我的任务/撤回/进度 + 通知消息与催报 + 名册改删 + 操作/登录日志 + 字典管理 + 代传与教师端文件访问 + 校验问题清单 / 通知模板与手工发送 / 微信回调 + 任务克隆 / 任务统计 / 规则模板 / 名册导入模板 / 审核统计 / PC 端代传 + 开放接口(认定机构对接) + 小程序登录与绑手机号 / 组织导入 / 租户用量 / 文件视图·直传凭证·批量处理);**已删除**:B1 加入申请、历史数据整理器(2026-10) | 1 个 | 99% |
|
||||
| 后端里程碑 | **契约 §4 的 119 个接口里 118 个已实现**(2026-10 收敛后的口径;原为 129 里 128) | **只剩 M4 的 `/mp/ocr/idcard`**(等 OCR 供应商选型)+ 前端页面与 CI/CD | 约 7.5/8 |
|
||||
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 通知记录 / 操作日志 / 字典管理 / **角色与授权**(**纯 Vue 3 + Naive UI**,2026-10 重搭);**已删除**:加入申请页、历史整理器页(2026-10) | 表单设计器已上线 | 约 100% |
|
||||
| 学生端(H5/小程序) | 落地页、门禁核验、填报、**我的任务 / 撤回 / 进度 / 消息中心**(**加入申请页已删除**,门禁失败只提示联系老师) | 临期提醒页、催报 | 约 90% |
|
||||
| 班主任端(小程序) | 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ **代传材料 / 催报 / 学生材料预览**(后端与 MP 页面均已就绪) | 临期提醒入口 | 约 95% |
|
||||
| 基础设施 | PG 16 + Redis 7 + 七牛(`provider=qiniu` 已验)、Flyway **V1~V16**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**33 个 E2E 脚本(1489 条断言)+ 3 个机械校验脚本** | CI/CD、test/prod 环境、压测、灰度 | 约 58% |
|
||||
| 基础设施 | PG 16 + Redis 7 + 七牛(`provider=qiniu` 已验)、Flyway **V1~V19**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**38 个 E2E 脚本(断言数以 `verify-all` 为准)+ 4 个机械校验脚本** | CI/CD、test/prod 环境、压测、灰度 | 约 58% |
|
||||
|
||||
**契约接口实现度(按 §4 分类)**
|
||||
|
||||
@@ -74,7 +100,8 @@
|
||||
|
||||
## 四、未完成(按"能不能上线"排序)
|
||||
|
||||
> ⚠️ **截至 2026-09-30,剩下的全部是外部依赖或二期范围** —— 没有一条是"我们还没写"。
|
||||
> ⚠️ **截至 2026-09-30,剩下的全部是外部依赖或(当时的)二期范围** —— 没有一条是"我们还没写"。
|
||||
> **2026-10 更新**:其中的"二期范围"已**整体取消**(不再排期),只剩**外部依赖**(OCR 供应商 / 小程序类目 / `union_id` / 真机验证)。
|
||||
> 所以每条都指向 `docs/01-客户与机构确认函.md` 的具体编号:**卡在谁那里、要谁做什么**。
|
||||
>
|
||||
> | 剩项 | 卡点 | 找谁 | 编号 |
|
||||
@@ -107,10 +134,10 @@
|
||||
| **P1-1** | **M4 识别与校验**(形态 / OCR / VLM / 串档)—— **现在唯一的大块**(§4.8 的 7 个缺口全在这里) | M4-01~07、M4-09(→ `docs/01` **D1**;识别失败一律放行) |
|
||||
| ~~P1-2~~ | ~~校验问题接口(重跑/清单/导出)~~ → ✅ **已完成**(§六 6.1 第 6i 项)。**剩余**:M4 的**识别类**校验本身(等 D1 选型) | §12.1~12.3 |
|
||||
| ~~P1-3~~ | ~~通知与消息中心、订阅额度、催报~~ | ✅ **已完成**(§13.5 三个 + §13.6 一个 + §8.6/§10.7 催报四个 + §13.3 通知记录一个)。**剩余**:§4.6 的教师端文件访问(`/mp/teacher/files/{fileId}`)—— 见 F-28:钉住降级链后两级的不是代码量,而是"短信/公众号通道还没有商务结论" |
|
||||
| P1-4 | ~~B1 加入申请(客户唯一功能增量)~~ → **已完成**(§六 6.1 第 3 项) | §8.7 的 5 个接口 + 学生 STEP 03B + 管理端审批页 **全部落地** |
|
||||
| ~~P1-4~~ | ~~B1 加入申请(客户唯一功能增量)~~ → 曾完成,**2026-10 需求回退已整体删除** | — |
|
||||
| ~~P1-5~~ | ~~学生端「我的任务列表 / 撤回 / 进度与问题清单」~~ → ✅ **已完成**:`pages/tasks`(含「撤回修改」,`canWithdraw` 由服务端算)+ `pages/progress`;`scripts/e2e-m3-mytask.py` 42 条 | §9.1、§9.11、§9.12 |
|
||||
| ~~P1-6~~ | ~~班主任端看板与文件访问~~ → ✅ **已完成**(§六 6.1 第 6g 项:看板 / §10.6 代传 / §10.7 催报 / §4.6 学生材料预览) | §10.1、§10.6、§10.7 |
|
||||
| ~~P1-7~~ | ~~历史数据整理器(差异化获客功能)~~ → ✅ **后端已完成**(§六 6.1 第 6h 项,含串档检测与体检报告)。**剩余**:PC 端页面 | M7-06/07、§13.2 的 5 个接口 |
|
||||
| ~~P1-7~~ | ~~历史数据整理器(差异化获客功能)~~ → 曾完成,**2026-10 已整体删除** | — |
|
||||
| ~~P1-8~~ | ~~操作日志与登录日志查询、字典管理~~ → ✅ **已完成**:操作日志**真的在写**(`sys_operation_log` 实测 523 条,最近一条是几分钟前;F-1 已在前面几轮修掉),写入口统一走 `OperationLogService`;`views/log` 与 `views/dict` 均已可用 | M7-03/04/05、§13.3、§13.4 |
|
||||
| ~~P1-9~~ | ~~任务统计与数据看板~~ → ✅ **已完成**(§六 6.1 第 6j 项 + 本轮 6o 的工作台看板页) | §7.11、M7-01/02 |
|
||||
| ~~P1-10~~ | ~~名册改/删、导入模板下载~~ → ✅ **已完成**:`RosterEditService`(改姓名/证件号/删除,都写操作日志)、名册导入模板下载(表头固定、必填列标红、带 9 条规则说明页);`scripts/e2e-m2-roster.py` 67 条 | §8.3、§8.4、§8.5 |
|
||||
@@ -119,17 +146,17 @@
|
||||
| ~~P1-13~~ | ~~开放接口(认定机构对接)~~ → ✅ **已完成**(§六 6.1 第 6k 项;另补了契约漏掉的接入方管理,见 F-67) | §14 的 4 个接口(HMAC 加签) |
|
||||
| ~~P1-14~~ | ~~学生端 `/mp/login`(小程序端登录)+ 手机号绑定~~ → ✅ **已完成**(§六 6.1 第 6l 项) | §5.3、§4.1 |
|
||||
|
||||
### 4.3 P2 · 上线收尾与二期
|
||||
### 4.3 P2 · 上线收尾(原"与二期"已取消)
|
||||
|
||||
| # | 未完成项 | 说明 |
|
||||
|:--:|---|---|
|
||||
| ~~P2-1~~ | ~~CI/CD + test/prod 环境 + 部署 + 数据迁移预案~~ → ✅ **本轮完成**:`.gitlab-ci.yml`(6 个 job,三阶段;**e2e 阶段跑全套 1516 条断言**,每次随机生成密钥)+ `application-test.yml` / `application-prod.yml`(**零密钥**)+ `ProdEnvCheck`(启动自检,6 条"配错了也能启动"的配置)+ `docs/09-部署与运维.md`(发布 / 密钥轮换 / 迁移与回滚 / 灰度 / 排障 / 首次接入 CI 的检查单) | M0-04、M7-10 |
|
||||
| ~~P2-2~~ | ~~压测与调优~~ → ✅ **本轮完成**:新增 `scripts/load-test.py`(四个场景、按端点分别统计、可调规模);**压测发现并修掉一个会让整间教室被挡住的限流缺陷**(F-92)。结论:**后端没有 N+1、DB 在 2ms 以内**,并发下的延迟是排队效应。尚未测:导出大包、上传并发、5000 人名册、长稳(见 docs/09 §9.5) | M7-09 |
|
||||
| ~~P2-3~~ | ~~定时任务(PRD 附录 E)~~ → ✅ **已完成**:**8 项已实现**(任务自动截止 / 自动催报 / 审核超时提醒 / 原始件清理 / **用量快照** / **压缩失败重试** / **租户到期提醒** / 导出包清理 + 开放接口推送重试),`POST /schedules/run` 可手工触发任意一项。⚠️ 第 9 项「待认领名册清理」**不做** —— 它对应的"自助认领"机制已在 PRD V1.5 废除(见 F-91) | M7-12 |
|
||||
| ~~P2-3~~ | ~~定时任务(PRD 附录 E)~~ → ✅ **已完成**:**8 项已实现**(任务自动截止 / 自动催报 / 审核超时提醒 / 原始件清理 / **用量快照** / **压缩失败重试** / **租户到期提醒** / 导出包清理 + 开放接口推送重试),`POST /schedules/run` 可手工触发任意一项。⚠️ 第 9 项「待认领名册清理」**不做** —— 它对应的"自助认领"机制已在 PRD V1.5 废除(见 F-91)。**二期另加第 9 个任务**「内置角色授权同步」(每日 03:20,`runNow` 名 `role`,见 6v) | M7-12 |
|
||||
| ~~P2-4~~ | ~~用户手册 / 培训材料 / 客服话术~~ → ✅ **本轮完成**:`docs/10-用户手册与客服话术.md`(三角色操作手册 + 高频问题标准回答 + 30 分钟培训提纲 + 上线首日检查单);并新增 `scripts/check-manual.py`(**机械核对手册引用的错误码与提示文案是否真的存在**,已进 CI) | M7-11 |
|
||||
| ~~P2-5~~ | ~~导出包过期清理~~ → ✅ **已完成**:`ExportCleanupService.cleanupExpired()`(`@Scheduled` 每小时第 17 分跑) | M6-07 |
|
||||
| ~~P2-10~~ | ~~导出分卷(`splitSizeMb > 0`)~~ → ✅ **已完成**:`SplitZipWriter` 会产出 `{base}-part1.zip`、`{base}-part2.zip`…;`scripts/e2e-m6-export-split.py` 35 条 | M6-07 |
|
||||
| P2-6 | 二期:社会收集 / 社会评价 / 专项职业能力 | 一期范围外(`collectMode=2`、`certType=2,3` 返回 `40044`)→ 启动时点见 `docs/01` **E3** |
|
||||
| ~~P2-6~~ | ~~二期:社会收集 / 社会评价 / 专项职业能力~~ | **已取消**(2026-10 不再设计二期;传 `collectMode=2`、`certType=2,3` 返回 `40044`) |
|
||||
| ~~P2-7~~ | ~~七牛实时样式缩略图~~ → ✅ **本轮完成**:`StorageService.thumbUrl()`(**参数拼在签名之前**,拼错顺序会 401 且看起来像网络问题);`FileUrl` 同时给 `url` 与 `thumbUrl`;审核端 `?thumb=true`。**实测 1454KB → 36KB(2.4%)**,1600×1200 → 400×300 等比不变形 | M7-09 附带 |
|
||||
| P2-8 | 小程序端 `compressImage` 真机验证;D6 三通道体积上限实测 | 需真机(→ `docs/01` D6) |
|
||||
| ~~P2-9~~ | ~~学生端 H5 没有独立 dev server~~ → ✅ **本轮完成**:`frontend-mp` 加 `/api` 代理 + `.env.development` 把 `VITE_API_BASE` 设空串,`pnpm dev:h5` → http://localhost:5174 可直接联调;三端行为(dev server / 构建产物 / 小程序)都验过,且 **AGENTS.md 里那句过期的说明已同步** | 开发体验 |
|
||||
@@ -174,7 +201,7 @@
|
||||
- **影响**:契约不是"当前系统长什么样",导致每次核对都要重新考古;前端/测试无法以文档为准。
|
||||
⚠️ 更要紧的是:**这个数会继续长**(每加一处界面必需接口就 +1),
|
||||
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
|
||||
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
|
||||
- **建议**(已落地):在 03 文档新增「§17 实现补充与偏差记录」,逐条列出这些接口,并在 §4 的合计口径里说明"119 是设计口径,另有 38 个实现补充"(2026-10 复核数)。
|
||||
|
||||
- ✅ **已闭环(2026-09-30)**:契约新增 **§17 实现补充与偏差记录** —— 41 个接口按六类分组(清单由 `check-api-parity.py --extra` 生成,写明「手抄件会过期,以命令输出为准」),外加 12 条已确认偏离 D-1~D-12(含租户域名一行、`biz_type` 落库字符串、§14 路径前缀)。
|
||||
|
||||
@@ -255,7 +282,7 @@
|
||||
| 3 | **P1-5 学生端「我的任务 / 撤回 / 进度」** | 后端 `StudentTaskMapper` + `StudentTaskService` + `StudentTaskController`(§9.1 `/mp/tasks`、§9.11 `/mp/submissions/{id}/withdraw`、§9.12 `/mp/submissions/{id}/progress`);学生端新页 `pages/tasks/tasks.vue`(我的填报:待办数/状态/驳回原因/截止提醒/撤回)与 `pages/progress/progress.vue`(材料清单 + 问题 + 时间轴);落地页在"有登录态但没 scene"时改为进「我的填报」 | **`scripts/e2e-m3-mytask.py` → 42/42**;H5 `build:h5` 通过且产物含 `pages-tasks-tasks` / `pages-progress-progress` |
|
||||
| 3b | **P1-4 B1 名单加入申请** | 见 §6.1 上一轮记录(后端 + 学生 H5 + 管理端审批 + V7 迁移) | `scripts/e2e-m2-join.py` → 50/50 |
|
||||
| 4 | **P0-3 班主任端(小程序)看板 + 审核卡片流** | 后端 `TeacherDashboardMapper` + `TeacherDashboardService` + `MpTeacherDashboardController`(§10.1 看板);小程序新页 `pages/dashboard/dashboard.vue`(已交率与已通过**分开呈现**、四个待办角标、班级卡片、截止提醒)、`pages/audit/audit.vue`(卡片流:**右滑通过 / 左滑不绑驳回**、一键通过只带无问题项)、`pages/audit-detail/audit-detail.vue`(材料预览 + 问题清单 + 改动明细 + 驳回弹层:原因快选 + 二次确认);「我的填报」按 `/auth/me` 的角色给出老师入口 | **`scripts/e2e-m5-teacher-dashboard.py` → 24/24**;H5 `build:h5` 通过且产物含 `pages-dashboard-dashboard` / `pages-audit-audit` / `pages-audit-detail-audit-detail` |
|
||||
| 5 | **可复现的接口计数** | `scripts/check-api-parity.py`(契约 §4 的 121 个接口 ↔ 全部 controller,自带"必须恰好 121"的自检) | `python scripts/check-api-parity.py` 退出码 0 |
|
||||
| 5 | **可复现的接口计数** | `scripts/check-api-parity.py`(契约 §4 的 129 个接口 ↔ 全部 controller,自带"必须恰好 129"的自检) | `python scripts/check-api-parity.py` 退出码 0 |
|
||||
| 6 | **界面验收用的演示数据** | `scripts/dev-seed-audit.py`(走真实接口摆出:任务 + 班级 + 材料 + 名册 + 门禁绑定 + 3 份已提交填报单 + 1 份已初审,含一个**演示班主任**账号) | 脚本自身对每步 `code` 断言;审核接口回读确认(见 6.3) |
|
||||
| 6b | **P1-3 通知与消息中心** | 后端 `module/notify/`:`CollectMessage` 实体 + `CollectMessageMapper`(批量已读)+ `SubscribeQuotaMapper`(额度 UPSERT / 原子扣减)+ `NotifyRecordMapper` + `NotificationService`(降级链)+ `NotifyController`(§13.5 消息中心 3 个 + §13.6 订阅额度 1 个);**V8 迁移**新建 `collect_message`;`AuditService` 的通过/驳回真的会通知学生;小程序新增 `pages/messages/messages.vue` + `utils/subscribe.ts`(提交后索取授权) | **`scripts/e2e-m5-notify.py` → 48/48**;H5 `build:h5` 通过且产物含 `pages-messages-messages` |
|
||||
| 6c | **P1-3 剩余:催报与通知记录** | 后端 `UrgeService` + `UrgeMapper` + `UrgeDtos` + 两个 Controller(§8.6 定向/一键催报、§10.7 教师端催报与历史、§13.3 管理端通知记录);`NotificationService` 的发送记录补上 `task_id`(催报去重要用它);PC 新增 `views/notify/index.vue`(通知记录:状态 3 与 2 分开显示)+ [notify.ts](frontend-pc/src/apis/xt/notify.ts) | **`scripts/e2e-m5-urge.py` → 40/40**;`vue-tsc` 0 错、`eslint` 0 错 |
|
||||
@@ -270,6 +297,7 @@
|
||||
|
||||
| 6j | **P1-11 / P1-9 任务克隆、任务统计、规则模板、名册导入模板、审核统计、PC 端代传** | **V15 迁移**给 `collect_rule_template` 补审计列(该表 V1 就建好、列与契约 §7.13 的 FORM/MATERIALS/NAMING 三部分逐项对应,见 F-50)并随迁移带两条**公共模板**;`TaskCloneService`(克隆**配置**、**刻意不克隆业务数据**)、`TaskStatisticsService`(补上 `abnormalCount` 与 `avgAuditHours` —— 这两个字段以前恒为 null,因为问题表与审核记录还不存在)、`RuleTemplateService`(可按 parts 只应用一部分、材料清单**覆盖式合并**)、`RosterTemplateService`(表头固定第 1 行 + 必填列标红 + 9 条填表规则页)、`AuditStatisticsService`、`TeacherProxyService.proxyUploadAsAdmin`;并修掉 F-61(初审待办会列出**批不了**的单子) | **`scripts/e2e-m7-taskconfig.py` → 69/69**(含待办范围判据的回归防线) |
|
||||
| 7 | **全量回归** | — | **33 个脚本 / 1489 条断言 / 0 失败**(753 + 导出 60 + 加入申请 50 + 学生端 42 + 看板 24 + 通知 48 + 催报 40 + 日志 44 + 字典 53 + 分卷 35 + 代传 33 + 整理器 54 + 校验 70 + 任务配置 69 + 开放接口 60 + 组织与文件 54) |
|
||||
| 6v | **二期「角色与授权」(FR-205)** | **V18 迁移**:`sys_role` 加 `user_type`(账号的 `user_type` 一直是从角色反推的,自定义角色不在代码映射里 → 没有这列就"角色建得出来、发不出去")、新建 `sys_role_menu`(菜单不做权限的附属,与 `sys_role_permission` 同构)。**权限判定**:`PermissionChecker` 合并两个来源 —— 内置角色按代码、自定义角色按库(`DbRoleGrantSource`,Redis 缓存 + 写后失效;**空结果也缓存**,否则已删角色的 token 会把库打穿;Redis 挂了退化成直读库,两个方向都安全)。**内置角色授权同步**:`RoleProvisioner` 从"已存在就跳过"改成幂等回填 + `ScheduleJobs` 每日 03:20 同步(修掉"代码改了权限、老租户永远停在旧的那套"这种**不会报错的**漂移)。**8 个接口**(`docs/03` §4.12/§5.10)+ `40045`;学校租户授予平台级权限码 / 配置平台专用菜单 / 建平台运营类角色 → `40301`;**防自锁**(不许去掉自己最后的 `role:manage` 权限或 `role` 菜单、不许停用/删除它、改账号角色时同理保护 `user:manage`)。**PC 页** `views/role/`(内置只读 + 权限矩阵 + 菜单勾选 + 保存即生效)+ 原型 ⑪;账号页的可选角色 / 用户类型 / 角色名全部改读库(删掉前端三份写死的镜像表)。⚠️ **`dataScope` 本期只展示**:范围仍由"权限码 + 任务里被指派了哪些班"决定,收口成 resolver 是下一批 | **新增 `scripts/e2e-m10-role.py` → 77/77**(含"改授权后**同一个 token** 立刻生效""停用角色立刻失效""内置角色只读""越权与自锁""删除守卫 40045""内置角色权限以代码为准 + 同步回填幂等");**浏览器实测**(新建自定义角色 → 账号页出现该角色 → 改权限落库 → 删除);`pnpm typecheck` 0 错、`eslint` 0 错;`verify-docs` / `check-wiring` / `check-api-parity`(契约 129)全绿 |
|
||||
| 6u | **P2-7 缩略图 + P2-9 H5 独立 dev server** | **缩略图**:`StorageService.thumbUrl(path, expire, maxLongSide)`(七牛在**签名之前**拼 `imageView2/2/w/h`;本地存储回退原图,让调用方不必判断"当前是什么存储");`FileUrl` 加 `thumbUrl`/`thumbMaxLongSide`;审核端 `?thumb=true`(**默认 false** —— 详情页要看防伪纹)。**H5 dev server**:`vite.config.ts` 加 `/api` 代理 + `.env.development` 把 `VITE_API_BASE` 设空串(`uni.request` 不能传相对路径,所以空串时仍补 `location.origin`,由代理兜住) | **缩略图实测 1454KB → 36KB(2.4%)**、400×300 等比;新增 `scripts/e2e-m9-thumb.py` → **12/12**(打到真实七牛,含"参数若拼在签名之后会 401"这一条);H5 dev server 实测 5174 可访问且 `/api` 代理到 8080;**H5/小程序两端构建都验过**(小程序产物仍是写死地址) |
|
||||
| 6t | **P2-4 用户手册 / 培训材料 / 客服话术(M7-11)** | 新增 `docs/10-用户手册与客服话术.md`:学生/班主任/管理员三份操作手册 + 「高频问题 → 标准回答」+ 30 分钟培训提纲 + 上线首日检查单。⚠️ 文案与错误码**逐条取自代码**,并在写完后**对着代码核了一遍** —— 抓到两处与实现不符的说法(见 F-95);另把这项核对固化成 `scripts/check-manual.py` 并进 CI | **9 个错误码 + 14 句提示文案全部与代码一致** |
|
||||
| 6s | **P2-2 压测(M7-09)** | 新增 `scripts/load-test.py`(门禁 / 读接口 / 上传 / 导出四场景;**按端点分别统计**;`--rows` 可调规模)。**压测抓到一个真缺陷并修掉**:门禁 IP 限流计的是"所有请求",于是 **60 个不同学生同一出口 IP 同时核验只过了 20 个** —— 而"一个班连同一个 WiFi"正是首次落地第一分钟就会发生的事(见 F-92)。**调优结论(三条独立证据)**:SQL 条数恒为 3(与 pageSize 无关)→ 无 N+1;单请求延迟与页大小无关(20/50/200 → 47/36/44ms);`EXPLAIN ANALYZE` 显示 DB 侧 ≤2ms。所以并发下的 p50 156~375ms 是**排队效应**,要提吞吐得加机器而不是改 SQL | **修前 20/60 → 修后 60/60**;门禁 E2E 仍 29/29 绿 |
|
||||
@@ -277,7 +305,7 @@
|
||||
| 6q | **P2-1 CI/CD + test/prod 环境(M0-04 / M7-10)** | 新增 `.gitlab-ci.yml`(三阶段 6 个 job:机械校验 / 构建(含 H5)/ **全套 E2E 打真库**;密钥**每次随机生成**,顺带证明"应用没有偷偷依赖 dev 默认值");`application-test.yml`(零密钥、允许 clean 以便每次从空库跑全量迁移)、`application-prod.yml`(零密钥、禁 clean、关 Swagger、日志滚动);`ProdEnvCheck`(`EnvironmentPostProcessor`,**跑在任何 bean 之前**);`docs/09-部署与运维.md`;删掉模板残留的 GitHub Pages 工作流(远端是 GitLab、用 pnpm、且它发布的是没有 API 的静态页) | **新增 `scripts/e2e-prodguard.py` → 9/9**(三种误配形态都拒绝启动)+ `scripts/check-test-profile.py`(test profile 真起一次,可重复跑);本地照跑 CI 的 check/build 各步骤全通过 |
|
||||
| 6p | **P2-3 定时任务(PRD 附录 E 的四项)** | 新建 `module/schedule/`:`ScheduleMapper`(**跨租户的候选查询集中在这一个文件**,全部 `@InterceptorIgnore` + 只读;别名一律带双引号,见 F-83)+ `ScheduleJobs`(四个 `@Scheduled`,每个都是"先以平台身份扫候选 → 再逐个租户摆上下文干活"的两段式)+ `ScheduleController`(`POST /schedules/run` 手工触发,权限用运营侧的 `log:view:all`)。四项:**任务自动截止**(`end_time < CURRENT_DATE`,动作名 `AUTO_CLOSE` 以区分「系统关的」与「管理员关的」)、**自动催报**(7/3/1 天,`end_time = 那天` 而非 `<=`,只催未提交)、**审核超时提醒**(时效与 §11.6 同口径,发给"真的要动手的那个人",**找不到班主任时升级给学校管理员**)、**原始件清理**(先清引用再删文件)。另给 `UrgeService` 加 `autoUrge`、`TaskService` 加 `autoClose` | **新增 `scripts/e2e-m7-schedule.py` → 27/27** |
|
||||
| 6o | **P1-9 工作台数据看板(PC)** | 工作台底部原来是一张「开发进度」卡,写着「M0 后端已完成,界面建设中」—— 而项目已是 120/121 个接口可用、10 个 PC 页面可点。**首页上说一件不真的事,比空着更糟**,所以换成真实看板:`apis/xt/task.ts` 加 `getTaskStatistics` + `TASK_STATUS_TEXT`(状态文案放一处)、`apis/xt/audit.ts` 加 `getAuditStatistics`;`views/dashboard/index.vue` 加"选任务 → 进度四格(应填/已提交/已通过/待审)+ 分班进度 + 审核情况 + 驳回原因分布"。**提交率与通过率分两列**(已提交 ≠ 已通过);平均审核时长按量级换单位显示(`null`=还没有样本 / 0=不足 1 分钟 / 否则分钟或小时) | **浏览器实测**:应填 3 / 已提交 3 / 提交率 100.0% / 已通过 3 / 通过率 100.0% / 待审 0,分班表与审核表都出数;`pnpm typecheck` 通过、`eslint` 0 错 |
|
||||
| 6n | **「待建」页不再说谎(3 个"功能已就绪、入口在别处"的菜单项)** | `名单管理 / 材料规则 / 表单设计` 三个菜单项对应的功能**早就做好了**(`TaskRosterDrawer` / `TaskMaterialDrawer` / `TaskFormSchemaDrawer`,入口在「收集任务」每一行的链接里),但点进菜单看到的一直是「**这个页面还没做**」+ 一句早已过期的"已实现的页面:工作台、组织架构、租户管理"。**说"还没做"而功能其实在隔壁,比找不到更糟 —— 它让人得出错误结论。** 给 `XtMenu` 加可选的 `entryAt`,待建页据此分两种说法:有 `entryAt` → 「功能已就绪」+ 真实入口指路 + 跳转按钮;没有 → 原来的「还没做」(`角色与授权` 就是这个:权限码由代码决定、自定义角色属二期,确实没做)。并删掉那句**会静默过期**的清单 —— 任何写死在代码里的"现状"都会过期,而没人会记得回来改它 | **浏览器实测四个菜单项**:三个显示「功能已就绪」并可一键跳到「收集任务」,`角色与授权` 显示「二期」;`pnpm typecheck` 通过、`eslint` 0 错 |
|
||||
| 6n | **「待建」页不再说谎(3 个"功能已就绪、入口在别处"的菜单项)** | `名单管理 / 材料规则 / 表单设计` 三个菜单项对应的功能**早就做好了**(`TaskRosterDrawer` / `TaskMaterialDrawer` / `TaskFormSchemaDrawer`,入口在「收集任务」每一行的链接里),但点进菜单看到的一直是「**这个页面还没做**」+ 一句早已过期的"已实现的页面:工作台、组织架构、租户管理"。**说"还没做"而功能其实在隔壁,比找不到更糟 —— 它让人得出错误结论。** 给 `XtMenu` 加可选的 `entryAt`,待建页据此分两种说法:有 `entryAt` → 「功能已就绪」+ 真实入口指路 + 跳转按钮;没有 → 原来的「还没做」(`角色与授权` 就是这个:权限码由代码决定、自定义角色属二期,确实没做)。并删掉那句**会静默过期**的清单 —— 任何写死在代码里的"现状"都会过期,而没人会记得回来改它 | **浏览器实测四个菜单项**:三个显示「功能已就绪」并可一键跳到「收集任务」,`角色与授权` 显示「二期」;`pnpm typecheck` 通过、`eslint` 0 错 → ✅ **已闭环(2026-10-09,二期)**:「角色与授权」已实现(见 6v),`ready:false` 的菜单**归零**,「待建」页只作为兜底保留 |
|
||||
| 6m | **P2-04 历史整理器页面(PC)** | 新增 `frontend-pc/src/apis/xt/cleanup.ts`(含**报告口径**:`headlineOf` 拼"112 个文件里 90 个全自动处理,只有 1 个必须你亲自判断",但它拼的是**服务端算好的两个数**,不是前端减出来的 —— 前端减出来的早晚会与后端分叉;下载走 api 层,与导出中心同一写法)+ `views/cleanup/index.vue`(列表 / 上传 / 进度轮询 / 报告 / 难点清单 / 改名样例 / 证件号冲突 / 下载 / 导入);路由 `ready: true`。**实机验证**(浏览器里真的点了一遍):列表出数据、点详情出报告、压缩率显示 `—`、重点条目带建议、导入按钮可见 | `pnpm typecheck` 通过;`eslint` 0 错;**截图留证** |
|
||||
| 6l | **P1-14 / P1-8 / P1-10 小程序登录、组织导入、租户用量、文件视图·直传凭证·批量处理** | `MpLoginService`(`jscode2session` + **复用** H5 的 `resolveUser`,不复制一份建账号逻辑)+ 手机号绑定(手机号**从客户端到不了服务端**,所以能作凭据;一个号只能绑一个账号);`OrgImportService`(父子关系用**编码 + 上级编码**、两遍扫描支持"子行在父行之前"、补 `ancestor_path`);`TenantUsageService`(周期支持月/季;`ocrCount`/`smsCount` **恒为 0 并带 notes 说明**);`MaterialFileViewService`(§9.5 预检+秒传+压缩提示、§9.7 批量处理、§9.8 预览带 `actualKind`);并修掉 F-73(秒传排在份数检查之后 → 网络重传得到"你超了"而不是"你已经传过了") | **`scripts/e2e-m8-orgfile.py` → 54/54** |
|
||||
| 6k | **P2-05 开放接口(§14,供认定机构对接)** | **V16 迁移**新建 `collect_open_app`(密钥存 AES 密文 —— HMAC 要能还原,不能只存哈希)与 `collect_open_push_log`(`next_retry_at` **落库**,内存定时器在重启后就没了);`OpenSignService`(HMAC-SHA256 + 5 分钟时间窗 + nonce 防重放,**先验签再占 nonce**)、`OpenDataService`(§14.2/§14.3,**一律不返回明文证件号**、签名 URL 30 分钟)、`OpenPushService`(§14.4 推送 + 定时重试 + 人工重推,推送挂在 `afterCommit` 上)、`OpenAppAdminController`(契约漏掉的接入方管理,见 F-67);并把规则模板的两个接口**修回契约路径** `/api/v1/rule-templates`(见 F-68) | **`scripts/e2e-m8-open.py` → 60/60** |
|
||||
@@ -457,6 +485,25 @@
|
||||
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
|
||||
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
|
||||
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
|
||||
| F-112 | 🔴 **`GET /tenants/{id}/usage` 在平台账号下算的是「全平台」的数**(做租户管理时发现,是本轮最严重的一条)。平台运营的 `tenantId` 是 `0`,而 `MybatisPlusConfig.FillTenantLineHandler.ignoreTable()` 对平台身份**直接返回 true —— 整张表的租户过滤被关掉**。于是 `TenantUsageService.distinctStudents()`(查 `collect_roster`)与存储量统计(查 `collect_file`)都**不带租户条件**,返回的是全平台的学生数与存储占用,却标着某一所学校的名字 —— 运营会拿着它去谈续费。⚠️ **而这个接口的调用者一定是平台运营**(`tenant:view` 在 `PermissionDef.platformOnlyCodes()` 里),所以它**从来只在"过滤被关掉"的上下文里跑过**。⚠️ 更麻烦的是它**在本地完全看不出来**:实测库里 `collect_roster` 34 行、`collect_file` 22 行**全在 `tenant_id=1`**,只有一所学校有数据时,错误值与正确值**完全相等**(`ActingTenantResolver` 的类注释描述的正是这个陷阱) | 已修:统计段包进 `TenantScope.callAs(tenantId, 0L, Set.of(), ...)` —— 切走之后 `isPlatformAdmin()` 变 false,租户过滤自动恢复(本仓既有机制,`OpenDataService` / `OpenPushService` 在用)。**没有**给查询手写 `tenant_id` 条件(那等于放弃拦截器的保护)。E2E 新增夹具(往另一个租户直插一行 `collect_roster`)+ 断言"接口值 == 库里 `where tenant_id=1` 的去重数",`e2e-m8-orgfile.py` 74/74 |
|
||||
| F-113 | 🔴 **`PUT /tenants/{id}` 把手机号明文写进加密列 → 详情接口必然 500**:`create` 走 `codec.encrypt(...)`,而 `update` 是 `upd.setContactPhone(req.getContactPhone())`;DB 列注释写着「联系电话(AES加密存储)」。`AesGcmCipher.decrypt` 对非 Base64 输入直接抛(该类**没有 dev 旁路**),于是**存过一次电话,这所学校的详情页就永远打不开**。实测 `sys_tenant.id=1` 存的就是明文 `13800138000`(来源不可考:全仓库没有任何脚本 PUT 过租户电话,3 处 `contactPhone` 都是 `POST /tenants`) | 已修:非空才 `codec.encrypt(...)` 写入(**空 = 不修改**,不是清空);详情返回**脱敏值**(不给明文:这是全系统唯一一处加密存储的联系方式,可读且不留痕会与「看证件号明文必须留痕」自相矛盾)。**读路径必须容错**:解密失败按明文处理 + `log.warn`(历史行不做数据迁移 —— 迁移里拿不到 AES 密钥),否则详情页是"永久 500"这种看起来像系统坏了的故障 |
|
||||
| F-114 | ⚠️ **租户的两个枚举值"能建不能改"**:`tenantType` 在 `update()` 里只放行 `1~3`,而 DDL / 实体注释 / 契约 / 前端下拉都是**4 值**(`1` 学校 `2` 认定机构 `3` 报名点 `4` 企业);`status` 只放行 `0/1`,而实体与 DDL 是 `0` 停用 / `1` 正常 / **`2` 试用** —— 前端的状态筛选按钮组里就有「试用」,选了永远是空列表 | 已修:`tenantType` 1~4、`status` 0/1/2,报错文案与实体注释逐字对齐;E2E 断言 `status=2` 与 `tenantType=4` 都能改 |
|
||||
| F-115 | ⚠️ **租户的开通 / 修改 / 停用原先一条日志都不写**:`update()` 只有 `log.warn` / `log.info`(那是给运维看系统日志的,不是合规留痕)。F-1 的修改建议里明确点了「租户开通」是优先留痕点 —— 停用一所学校是这套系统里**影响面最大的单个动作** | 已修:新增模块 `TENANT`、动作 `CREATE_TENANT` / `UPDATE_TENANT` / `DISABLE_TENANT` / `ENABLE_TENANT`(停用与启用不并进 `UPDATE_TENANT`:出争议时第一个要看的就是这个区别);`before/after` 是 `k=v` 摘要(手机号只记"有没有设置"—— 密文写进日志既没用又难看,所以"换了一个号"改用请求里带没带它来补一条)。⚠️ 这些行的 `tenant_id` 是 **0**(平台身份),查"这所学校被谁停过"要用 `target_type=TENANT` + `target_id`,**不能用 `tenantId` 筛** —— 已写进 `MODULE_TENANT` 常量注释与契约 §6.8 |
|
||||
| F-116 | ⚠️ **`expireBefore` 差一天**:`LocalDate.parse(...).atStartOfDay()` 与存进去的当天 `23:59:59` 比 → 「今天到期」的租户**查不出来**。同型的坑在 `LogController.parseTo` 上已经修过一次(那次是"查 9 月 30 日查不到当天")。另外格式不对时原先直接抛 50000 | 已修:`atTime(23,59,59)`(含当天);格式不对返回 `40001` 并说明格式 —— **刻意不学** `LogController.parseTo` 的"当没传":那两个是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";而这个值只由界面按当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了" |
|
||||
| F-117 | ⚠️ **`AUTO_CLOSE`(定时任务自动截止)是字符串字面量,不在常量表里**:`TaskService.autoClose()` 直接传 `"AUTO_CLOSE"`。所以"后端 `ACTION_*` 常量 ↔ 前端文案"这种检查**本来就扫不到它** —— 而它正是量最大的那类任务日志(库里 TASK 1548 行里的大头),界面上会**原样回显这个英文码**。(`STATUS_CHANGE` 兜底名同样是字面量) | 已修:提成 `OperationLogService.ACTION_AUTO_CLOSE` / `ACTION_STATUS_CHANGE` 并在 `TaskService` 用常量;新增检查 `check-wiring.py` 第 ⑩ 项(27 个动作码两端逐字核对),**已反向验证**(删掉前端一条文案 → 报红并 exit 1)。**教训**:这一族检查的价值取决于被检查的那一侧有没有被规范化 |
|
||||
| F-118 | ⚠️ **日志页的「模块」下拉里有两个永远筛不到的项**:`moduleOptions` 只有 4 项(ROSTER/TASK/AUDIT/EXPORT),其中 `AUDIT` / `EXPORT` **后端一个写入点都没有**,而真正会写的 `FILE`(代传材料)与 `ROLE`(6 个角色动作)**不在下拉里** —— 选中死项只会得到空表,让人以为"这段时间没有审核动作" | 已修:`apis/xt/log.ts` 导出 `MODULE_OPTIONS`(TASK/ROSTER/FILE/ROLE/TENANT)与 `TARGET_TYPE_OPTIONS`,口径写明"只列后端真有写入点的模块"。⚠️ 顺带登记:`MODULE_AUDIT` / `MODULE_EXPORT` / `MODULE_USER` / `MODULE_JOIN` 四个常量当前**零调用点**,其中 `MODULE_JOIN` 是需求收敛后遗留的死常量 |
|
||||
| F-119 | ⚠️ **`LogController` 的 `/logins` 描述与事实相反**:`@Operation` 里写着「一期的登录日志只有 PC 账号密码登录…目前不写这张表(见 F-31)」,而 **F-31 已闭环**(`WechatLoginLogger` 会写 `login_type=2`)。这是"注释从正确的当时留到了不正确之后"那类问题 | 已修:描述改为按 `loginType`(`PASSWORD` / `WECHAT`)说明,并写明 `location` **恒为 null**(没接 IP 库) |
|
||||
| F-120 | 📌 **`request_param` / `duration_ms` 有列、无写入点**:全库 1758 行里两列都是 **0 行有值**(`max(length(extra_json))=45`、`max(length(user_agent))=125`,而这两列从来没被 `setXxx` 过)。它们也不在 `OperationLogMapper.listOperations` 的 SELECT 里 | **不做**(已写进契约 §13.4):要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,而 `OperationLogService` 的类注释已论证过"合规留痕不用 AOP";界面上不展示、也不做筛选。本轮只把**轻字段** `request_method` / `request_uri` / `extra_json` 补进列表 SELECT(实测一页 20 行约 2 KB),因此**不需要**为详情新开接口 |
|
||||
| F-121 | 📌 **`docs/db/schema-postgresql.sql` 缺三张表**:只含 `sys_tenant`,没有 `sys_operation_log` / `sys_login_log` / `sys_tenant_usage_snapshot`(这三张只在 Flyway 迁移 `V2` / `V9` / `V17` 里)。按它建库会缺日志与用量快照 | **本轮不动**(登记待办):Flyway 才是权威,那份 DDL 是给 DBA 看的快照,补齐它属独立一件事 |
|
||||
| F-122 | 📌 **只在平台身份下可达、且查业务表的接口 = 租户越界的风险面**:F-112 暴露的是一个**模式**,不是一处笔误 —— 凡是"平台专属权限码 + 查带 `tenant_id` 的表",都会因为 `ignoreTable()` 对平台返回 true 而**静默跨租户** | 本轮把 `tenant` 模块逐接口过了一遍(`list` / `detail` / `create` / `update` / `usage`),**只有 `usage` 统计业务表**,已修;`logs` 模块自己显式处理了范围(`logScopeTenantId`)。**留下的排查线索**:以后新增平台专属接口时,先问一句"它查的是不是带 tenant_id 的表" |
|
||||
| F-123 | ⚠️ **登录日志没有租户列 → 平台视角下这些行"不属于任何学校"**:`LoginLogVO` 11 个字段里没有 `tenantId`,而平台运营看到的日志是**跨单位**的(`log:viewAll`)。于是"某个账号什么时候登进来的"这个问题答不完整 —— 筛得了单位,却看不出每行属于哪个单位。`sys_login_log.tenant_id` 从 V2 建表起就有(`NOT NULL DEFAULT 0`),只是 VO 没带出来 | 已修:`LoginLogVO` 加 `tenantId`、`LoginLogMapper.listLogins` 的 SELECT 加 `l.tenant_id AS tenantId`、契约 §13.4 同步;界面上登录日志表在「时间」后加「单位」列(`0` → 平台),**仅平台身份显示**(学校管理员自己就是那个单位,加了是噪音) |
|
||||
| F-124 | ⚠️ **`LoginLogVO.userType` 的 Javadoc 与真实枚举错位**:写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,而权威口径(`BuiltinRole.UserType`、`UserService.USER_TYPE_NAMES`、库列注释、《03》§2.7)是 **1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生**。实测库里 `user_type=1` 的行就是 `platform` 账号。这是**同一个坑的第三次出现**(该错位枚举曾导致"把班主任当成社会人员拒掉") | 已修:Javadoc 改为正确枚举 + 写明"权威来源是 `BuiltinRole.UserType`,别按模板那套映射"。前端本来就用对了(复用 `USER_TYPE_NAMES`,没按评审意见里那句错的口径写),所以这处只影响读代码的人 |
|
||||
| F-125 | ⚠️ **学生被驳回后「改不了任何东西」(P0 · 阻断闭环)**:`status=4` 时改字段 → `40030`(`SubmissionService.java:158-161`)、传材料 → `40030`(`MaterialFileService.java:103-105`)、删材料 → `40030`(`:338-341`)、撤回 → `40001`(只放行 1/2,`StudentTaskService.java:190-196`),而**重新提交是允许的**(`SubmissionService.java:293-297`)—— 于是学生能看到"被驳回,请修改后重新提交"却什么都没法改。**四处信号同时说他能改**:`statusText`(`StudentTaskService.java:142`)、`canFillNow=true`(`:105-106`,`underReview` 只算 1/2/3)、`progress.canEdit=true`(`:311`)、`progress.canSubmit=true`(`:313`)。契约站学生一边:《03》`§2.4`(`docs/03-接口设计.md:239-248`)写 `4 已驳回 → 可流转到 1(学生重提交)` | **本轮只测不改**,已登记 `docs/11` F-125 + `scripts/e2e-m3-flow.py` 三条 XFAIL(`EP-E09`/`EP-F10`/`EP-F10b`/`EP-I09`)。**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 重交前只改了库里的 `reject_count`,**没改任何业务字段** —— 它证明的是"原样重交能过",而问题在"改了反而过不去"。**建议最小改法**(改业务口径,须确认):三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`、`progress.canEdit` 对齐 |
|
||||
| F-126 | 📌 **`scripts/e2e-m3-draft.py` 的 docstring 自述虚高**:`:11` 写着 `[x] …已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**路径)。docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`verify-all` 的汇总与多份文档的"37 个脚本 / N 条断言"都建立在它上面),所以虚高比 F-84 更隐蔽:断言缺失不会让任何东西变红,反而让覆盖率**看起来更好** | 本轮已补断言:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`),均通过。**教训固化成规程**:`docs/11` §3.1 要求用 `grep 'check(\|ok('` 核对既有脚本的**实际**断言而不是读自述 —— 这条规程一上来就抓到了本项 |
|
||||
| F-127 | 📌 **契约 §9.10 的口径与实现不一致,且未登记在「有意偏离」清单里**:《03》`§9.10`(`docs/03-接口设计.md:1986-1996`)写"校验失败返回 `40001` + `data.blocking[]/warnings[]/canForceSubmit`",实现是 **HTTP 200 + `code:0`** + `data{submitted:false,status,statusText,redCount,yellowCount,infoCount,issues[]}`(`FillController.java:59-70`,代码注释给了理由:校验不通过是正常业务结果、要一次给全清单)。实现口径与项目约定一致、37 个脚本也都按它写,**但 `§17.2` 的 D-1~D-12(`docs/03-接口设计.md:2972-2988`)里没有它** | 本轮只提议:在 `§17.2` 补一行 **D-13**(表格仍 5 列,不影响 `verify-docs.py`)。**后果**:文档读者会写出 `code != 0` 的判据,而那是**恒假**的 —— 测试静默变绿 |
|
||||
| F-128 | ⚠️ **扩展字段「存得进、读不出」,必填扩展字段会让整个任务永久无法提交(P0 · 阻断功能)**:`PUT /mp/submissions/{id} {"extrasJson":{...}}` → `code=0` 且库里 `extras_json` 确实写进去了,但 `GET /mp/tasks/{id}/form` 该字段 `value` 恒为 `null`,提交时 `VAL-22` 恒报"还有必填项没填"。**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表只有两个来源 —— 名册列、以及 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的**列**,**从不把 `draft.extrasJson` 合进 `values`**;而扩展字段的值只存在 `extras_json` 里。于是 `SubmissionValidator.validate`(`SubmissionValidator.java:104-109`)重建的 `values` 里也没有它,`checkRequiredFields` 的 `blank()` 恒为真 | **本轮只测不改**,已登记 `docs/11` F-128 + 两条 XFAIL(`EP-D09` 回显、`EP-J02e` 提交)。**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**,既有 37 个脚本用的全是内置表单 → 这条路径从来没有数据走过。**建议最小改法**(改读取口径,须确认):在 `FillService.java:144-158` 那段循环之后,把 `schema.getExtras()` 的每个键从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`;⚠️ 不要顺手把扩展字段塞进 `changed_fields`(那是给内置列用的) |
|
||||
| F-129 | 📌 **把已声明的扩展字段放进 `formData` 不会报错,值被静默丢弃**:`{"formData":{"extra_upgrade_note":"..."}}` → `code=0`,`extras_json` 仍是 `{}`。对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略");而**已声明**的扩展键在 `defs` 里查得到(`:169-171` 把 extras 也放进了 `defs`),于是躲过那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列、静默什么都不做** | 契约要求扩展字段走 `extrasJson`(`DraftDtos.java:44`),属"用错参数";但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。已登记 `docs/11` F-129 + `EP-E16`(钉住现状)。**建议**:`SubValues.apply` 遇到"既不是列、又确实是声明过的扩展键"时抛 `40001`,提示"扩展字段请放在 `extrasJson`" |
|
||||
| F-130 | ⚠️ **微信小程序端的学生完全进不来,登录链路断成两截(P0 · 功能不可用)**:开发者工具模拟器里实测,扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 → 点「核对并继续」→ 屏幕只有**「未登录或登录已失效」**。**断点 A(前端)**:小程序端**从来没有调用过 `/mp/login`** —— 全仓 `frontend-mp/src` 里唯一写 token 的地方是 `pages/index/index.vue:222`(从 **H5 授权回调**的 `options.token` 取),而 9 个页面的 `token()` 都读 `uni.getStorageSync('token')`;搜不到 `wx.login`/`uni.login`/`/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释「登录流程见契约 §5.x 的 /mp/login,**属 M3**」),也**没有 `web-view` 兜底**。**断点 B(后端)**:`/mp/login` 只要 `scene` 带 taskId 就**必然 500** —— `MpLoginService.java:127` 用普通 `BaseMapper.selectOne` 查 `collect_roster`,而该接口是 `@PublicApi`(匿名)→ **租户拦截器默认拒绝** → `BizException(40101)` → 被 MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」** | **本轮只测不改**,已登记 `docs/11` F-130 与 §5.4。**对照实验**(同秒同端点,只改 scene):`t1` → **200 + token**;`t1-k2313` → **500** —— 即"只在永远用不到的场景下能用"(学生扫的码必然带 taskId)。日志铁证:`已建学生账号:uid=1389` 之后紧跟 `Cause: BizException: 未登录或登录已失效`,栈顶 `MpLoginService.login(MpLoginService.java:127)`。**副作用已核实**:`login()` 是 `@Transactional`,账号随异常**回滚**(库里只有 `scene=t1` 成功建出的 uid 1390),所以是"每次点都 500、什么也没留下",不是"攒下孤儿账号"。**为什么至今没暴露**:① `/mp/login` 没有任何 E2E 覆盖(`scripts/e2e-m3-gate.py:18` 写着"脚本拿不到 wx.login code" —— **本轮证明它拿得到**,开发者工具自动化里可在页面上下文调 `wx.login()` 换到真 code);② 文档互相矛盾(`docs/08:147` 记「学生端 `/mp/login` + 手机号绑定 ✅ 已完成」指的是**后端**,`docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」),**没有一处说"前端没接"**。**修法方向**(未实施):该分支改走**专用 Mapper + `@InterceptorIgnore(tenantLine=\"true\")` + 显式带 tenant_id**(AGENTS.md 那条铁律,`MpH5Mapper.selectByOpenId(tenantId, openId)` 就是这么做的,这里漏了);另外 40101 不该被包成 50000 对外 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+93
-11
@@ -25,18 +25,28 @@
|
||||
### 2.1 本地
|
||||
|
||||
```bash
|
||||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
|
||||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
|
||||
cd frontend-mp && pnpm build:h5 # nginx 挂的是产物目录 → 先出产物
|
||||
cd backend
|
||||
mvn -DskipTests package
|
||||
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
- ⚠️ **工作目录必须是 `backend/`**:两处相对路径依赖它 ——
|
||||
`application-local.yml`(本地私有配置,不提交)在这里;
|
||||
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
||||
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置,不提交)在那里,
|
||||
是靠相对路径找到的
|
||||
- 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用)
|
||||
- H5 没有独立 dev server:`cd frontend-mp && pnpm build:h5`,然后访问
|
||||
`http://localhost:8080`(由后端托管)
|
||||
- **H5 由 `xt-nginx` 托管(`http://localhost/`),后端只提供 `/api`**(2026-10 调整)。
|
||||
两者**同源**是硬要求:微信网页授权的「授权回调页面域名」只能配一个,
|
||||
而 H5 的接口地址取自 `location.origin` —— 跨源会让请求打到 nginx 自己身上(页面能开、接口全 404)。
|
||||
端口与产物目录在 `deploy/.env`(`H5_PORT` / `H5_DIR`),站点配置是 `deploy/nginx/xt.conf`。
|
||||
- ⚠️ **内网穿透要指到 nginx 的端口(80),不是后端的 8080** ——
|
||||
指到 8080 的表现是"接口好用、页面 404"。
|
||||
- nginx 那份配置里有两条**不能删**的:`client_max_body_size 50m`
|
||||
(默认 1m,学生传一张照片就可能 413,而**后端日志里一个字都没有**)与
|
||||
`/assets/`、`*.html` 的缓存头(没有它浏览器会启发式缓存,发版后学生看到旧页面,
|
||||
见 `ApiNoStoreFilter` 的类注释)。
|
||||
- 只想改学生端页面时不必走 nginx:`cd frontend-mp && pnpm dev:h5` → `http://localhost:5174`
|
||||
(自带 `/api` 代理,**改一行不用重新 build**)
|
||||
|
||||
### 2.2 CI(GitLab)
|
||||
|
||||
@@ -45,7 +55,7 @@ java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active
|
||||
| 阶段 | 做什么 | 为什么这样切 |
|
||||
|---|---|---|
|
||||
| `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 |
|
||||
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 产物是后面 e2e 的输入,不能跳过 |
|
||||
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 build 现在是**编译检查**(2026-10 起不再由后端托管、e2e 也不消费它),学生端没有单独的 typecheck |
|
||||
| `e2e` | 起 PG+Redis 容器 → 起后端(`test` profile)→ **依次跑全部 `scripts/e2e-*.py`** | 这个仓库**没有单元测试**,E2E 就是主验证手段;一个只跑编译的流水线在这里等于没跑 |
|
||||
|
||||
另外单独一个 job `生产环境自检` 跑 `scripts/e2e-prodguard.py` ——
|
||||
@@ -72,8 +82,63 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
|
||||
⚠️ **滚动发布期间会有两个版本同时在跑**,所以:
|
||||
- **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)——
|
||||
否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。
|
||||
- **H5 静态产物要一起发**:它是后端托管的,前后端不同步会出现
|
||||
"页面调用了还不存在的接口"。
|
||||
- **H5 静态产物要和新后端一起发**:H5 由 nginx/网关托管(不再由后端托管,2026-10 调整),
|
||||
但"同源"没变,前后端不同步还是会出现"页面调用了还不存在的接口"。
|
||||
|
||||
### 2.4 ⚠️ 反向代理有两件事必须做(2026-10-20 生产 403 的教训)
|
||||
|
||||
生产形态:前端静态站 `https://admin.shenxue.jxit.com`,`/api` 反代到本机 jar。
|
||||
这种"**同源**"部署仍然会踩两个坑,两个都**只在浏览器里复现、后端日志里看不出来**。
|
||||
|
||||
#### ① 本站地址必须在 CORS 白名单里(否则登录 403)
|
||||
|
||||
**症状**:`POST /api/v1/auth/login` 返回 `403`,响应体一行 `Invalid CORS request`,
|
||||
`X-Trace-Id` 有值(说明请求已经到了后端),但**业务日志里一个字都没有**。
|
||||
同一条 curl(不带 `Origin`)却正常。
|
||||
|
||||
**原因**:浏览器的 CORS 只约束跨源请求,但它**给同源的 POST/PUT/DELETE 也带
|
||||
`Origin` 头**;而 Spring MVC 的判据是"请求里有没有 Origin",不在白名单里就在进
|
||||
Controller 之前直接拒掉。`WebConfig` 曾经把白名单写死成 localhost/内网,
|
||||
生产域名不在里面 —— 于是"同源部署"反而一定登不上。
|
||||
|
||||
**配置**(`application-prod.yml` 已有本项目的默认值,换域名时改它或给环境变量):
|
||||
|
||||
```bash
|
||||
# 逗号分隔;Spring 的 allowedOriginPatterns 语义,可写通配
|
||||
XT_CORS_ALLOWED_ORIGINS=https://shenxue.jxit.com,https://admin.shenxue.jxit.com,https://*.shenxue.jxit.com
|
||||
```
|
||||
|
||||
启动日志里有一行 `CORS 白名单(/api/**):[...]`,排障先看它。
|
||||
**改域名/加域名 / 加内网穿透域名,都要同步这里**(H5 走穿透时同样是 POST 会被拦)。
|
||||
|
||||
#### ② 必须把 Host / 客户端 IP / 协议透传过去
|
||||
|
||||
```nginx
|
||||
location ^~ /api {
|
||||
proxy_pass http://127.0.0.1:8090; # 不带 URI 后缀 = 原样转发 /api/...
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# ⚠️ 不传这几个头,后端看到的永远是 127.0.0.1 + http:
|
||||
# · 登录日志 / 操作日志的 IP 全是 127.0.0.1(合规审计里这是废数据)
|
||||
# · 任何按 Host 定位的逻辑(租户域名)失效
|
||||
# · 重定向 / 绝对链接会用错协议(https 页面被跳去 http)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 学生材料单张可能 >1MB;nginx 默认 1m 直接回 413,而后端日志里没有这条请求
|
||||
client_max_body_size 50m;
|
||||
# 导出打包是长事务,别用默认 60s 把还在跑的请求掐掉
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
# 上传是流式的,别在 nginx 侧攒完再转发
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
> 后端已配 `server.forward-headers-strategy: framework`,有了
|
||||
> `X-Forwarded-Proto` / `X-Forwarded-For` 它就会按真实协议与客户端 IP 处理。
|
||||
|
||||
---
|
||||
|
||||
@@ -97,7 +162,7 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
|
||||
换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码,
|
||||
或者门禁核验永远匹配不上(`id_card_hash` 也变了)。
|
||||
|
||||
正确做法(本项目尚未实现,属二期工程):
|
||||
正确做法(本项目**缓做**,等 OCR 供应商选型;**不再作为二期功能设计**):
|
||||
1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表");
|
||||
2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**;
|
||||
3. 全部迁完之后,旧密钥才下线。
|
||||
@@ -186,7 +251,11 @@ XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
|
||||
|---|---|---|
|
||||
| 服务起不来 | 启动日志里的 `生产环境启动自检未通过` | §3.4 的六条 |
|
||||
| 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) |
|
||||
| 页面 404 但接口正常 | `spring.web.resources.static-locations` | H5 构建产物没放对目录(**应用不会报错**) |
|
||||
| 页面 404 但接口正常 | 穿透/网关指到了哪个端口 | H5 由 nginx 托管(80),**指到后端的 8080 就只剩接口**;另看 `deploy/.env` 的 `H5_DIR` 是否指向真实产物目录(目录不存在时 docker 会建一个**空目录**,nginx 一切正常而页面 404) |
|
||||
| 上传报 413(后端日志里没有这条请求) | nginx 的 `client_max_body_size` | 默认 1m;`deploy/nginx/xt.conf` 已设 50m,**换网关时别漏这一条** |
|
||||
| 浏览器里登录/上传 **403**,但 curl 正常 | 响应体是不是 `Invalid CORS request` | `Origin` 不在 CORS 白名单里 —— **同源的 POST 也会校验**,本站域名必须在 `XT_CORS_ALLOWED_ORIGINS` 里,见 §2.4 |
|
||||
| 登录日志的 IP 全是 `127.0.0.1` | nginx 有没有传 `X-Forwarded-For` | 见 §2.4 第 ② 条;缺了它审计数据就废了 |
|
||||
| 本机 8080 被别的项目占用 | `netstat -ano \| findstr :8080` | 用 `--server.port=8081` 起后端,e2e 脚本加 `XT_BASE=http://localhost:8081`(脚本读这个环境变量);**不要去杀别人的进程** |
|
||||
| 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 |
|
||||
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
|
||||
|
||||
@@ -468,3 +537,16 @@ python scripts/load-test.py --only soak --rows 2000 --soak 60 --rps 5
|
||||
| **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
|
||||
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
|
||||
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
|
||||
|
||||
---
|
||||
|
||||
## 附:PC 管理端构建(2026-10 重搭后)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 技术栈 | **纯 Vue 3 + Naive UI 2.42**(已无 `@arco-design/*` 与 Gi 模板依赖) |
|
||||
| 依赖安装 | `pnpm install --frozen-lockfile`(`pnpm-lock.yaml` 随新 `package.json` 更新,**别用 npm**) |
|
||||
| 构建 | `pnpm build` → `frontend-pc/dist/`(静态托管,`/api` 由网关/反代指向后端 8080) |
|
||||
| 检查 | `pnpm typecheck`(**0 错**)、`pnpm lint`(ESLint;**Stylelint 已随重搭移除**) |
|
||||
| 图标 | `scripts/extract-pc-icons.py` 生成 `src/icons/icon-data.ts`(37 个,内网离线可用);**图标集升级后必须重跑**,否则新图标是空占位且不报错 |
|
||||
| 主题 | 主题配置在 `src/utils/naive.ts` 一侧;`src/styles/index.scss` 只放跨页面样式 |
|
||||
|
||||
+44
-22
@@ -8,17 +8,21 @@
|
||||
|
||||
---
|
||||
|
||||
## §1 三种角色,三个入口
|
||||
## §1 四种角色,四个入口
|
||||
|
||||
| 角色 | 入口 | 设备 | 主要做什么 |
|
||||
|---|---|---|---|
|
||||
| **学生** | 扫学校/班主任发的**二维码** | 手机(微信小程序 / H5) | 核对身份 → 填表 → 上传材料 → 提交 |
|
||||
| **班主任** | 微信小程序(同一套码,登录后进教师端) | 手机 | 看本班进度、审本班材料、代传、催报 |
|
||||
| **学校管理员** | 电脑浏览器打开管理端 | PC | 建任务、配表单与材料、导名册、终审、导出交付包 |
|
||||
| **平台运营** | 电脑浏览器打开管理端(账号**不用填单位编码**) | PC | 开通学校、改到期与联系人、停用学校、查全平台日志 |
|
||||
|
||||
**⚠️ 学生与老师的小程序是同一个**,靠账号角色区分。学生第一次要"核验身份"(填姓名+证件号),
|
||||
这一步就是**门禁**:名册里没有的人进不去(这是产品铁律,见 §5.1)。
|
||||
|
||||
**⚠️ 平台运营是唯一跨单位的角色**:它看的是**所有学校**的数据,所以在"组织与账号""收集管理"
|
||||
这类本来就是单位内的页面上,它必须先选"**当前学校**"(顶栏那个切换),选之前看到的是混在一起的。
|
||||
|
||||
---
|
||||
|
||||
## §2 学生操作手册
|
||||
@@ -48,7 +52,7 @@
|
||||
那不是你的问题 —— 是同一个 WiFi 下有人多次试错,等一分钟即可。
|
||||
- 如果提示「**该信息已被登记**」(`40072`):说明这个名册项已经被**另一个微信号**绑定过。
|
||||
常见原因:你用两个微信号都扫了码。**联系班主任处理**,不要重复试。
|
||||
- 如果名册里确实没有你 → 点「**申请加入名单**」,由管理员审批后入册(见 2.4)。
|
||||
- 如果名册里确实没有你 → **联系班主任**,请他在任务的「名单管理」里把你加进去(⚠️ 系统里**不再有自助申请入口**,也不会展示任何联系方式 —— 见 2.4)。
|
||||
|
||||
**③ 填表**
|
||||
- 表里有一部分**已经替你填好了**(报考职业/工种/等级/学校/专业等)——
|
||||
@@ -80,20 +84,21 @@
|
||||
| 操作过于频繁,请稍后再试 | 试错次数太多(同一个人 1 小时 10 次) | 等几分钟,或让班主任核对 |
|
||||
| 当前网络下尝试核验的人较多 | **不是你的问题**:同一 WiFi 有人多次试错 | 等 1 分钟 |
|
||||
| 该信息已被登记 | 这个名册项已被另一个微信号绑定 | 联系班主任解绑或处理 |
|
||||
| 你已在名单中,请返回重新核验 | 你已经在名册里了,不用申请加入 | 返回上一步直接核验 |
|
||||
| 你已提交过申请,请耐心等待 | 加入申请重复提交 | 等管理员审批 |
|
||||
| 这份材料可能不是你的,请重新上传 | 系统发现材料上的证件号与你不一致 | 确认没传错人的材料,重传 |
|
||||
| 已进入审核,需先撤回才能删材料 | 你已提交,材料被锁 | 点「撤回修改」后再改 |
|
||||
| 已通过终审,不可撤回 | 已经审完了 | 联系学校管理员 |
|
||||
| 任务已截止、已归档或已暂停 | 这个任务不能再填了 | 联系班主任 |
|
||||
|
||||
### 2.4 名册里没有我(申请加入名单)
|
||||
### 2.4 名册里没有我,怎么办
|
||||
|
||||
1. 核验页点「**申请加入名单**」;
|
||||
2. 填姓名、身份证号、班级、联系电话,可附说明;
|
||||
3. 管理员在管理端「**加入申请**」里审批;
|
||||
4. 通过后你会收到消息,回到核验页重新核验即可。
|
||||
- ⚠️ 申请**次数有上限**(`40037`),别反复提交;重复提交会提示「你已提交过申请,请耐心等待」。
|
||||
1. **先核对**姓名与身份证号是不是和老师给的一致(生僻字、空格、末位 X 大小写最常见);
|
||||
2. 核对无误后,**联系班主任**,请他在任务里把你加进名册
|
||||
(管理端「收集任务 → 名单管理 → 追加导入」);
|
||||
3. 老师加好后,回到核验页**重新核验**即可填报。
|
||||
|
||||
> ⚠️ **系统里没有"申请加入名单"这个入口**(2026-10 需求收敛时已取消),
|
||||
> 也**不会展示任何联系方式** —— 学生本来就在班级群里,找老师即可。
|
||||
> 这样守住的仍是同一条规则:**没有名册,就不能填报**;只是"加人"这个动作留在老师手里。
|
||||
|
||||
---
|
||||
|
||||
@@ -166,16 +171,29 @@
|
||||
- 通过后导出,产物保留 **7 天**,过期后需重新导出(会明确提示「导出包已过期(保留 7 天),请重新导出」)。
|
||||
- 几百人以上会自动**分卷**(`-part1.zip`、`-part2.zip`…),每一卷都是**完整可解**的压缩包。
|
||||
|
||||
### 4.4 历史整理器(把以前那堆乱文件夹变成能用的数据)
|
||||
### 4.4 角色与授权(给不同的人不同的能力范围)
|
||||
|
||||
「配置 → **历史整理器**」→ 上传一个 zip:
|
||||
1. 填任务名;
|
||||
2. **关联一个已有任务的名册**(⚠️ 这一步决定了报告的价值,见下);
|
||||
3. 等它跑完 → 看报告。
|
||||
「组织与账号 → **角色与授权**」→ 左边选一个角色,右边看/改它能做什么。
|
||||
|
||||
报告里最该看的一句是「**N 个文件里,X 个系统全自动处理,只有 Y 个必须你亲自判断**」。
|
||||
- **有关联名册**才有「证件号校验」与「疑似串档检测」—— 而"疑似串档 1 例"是报告里最有价值的一条;
|
||||
- 不关联也能跑,但只能做文件名规范化。
|
||||
| 你想干的事 | 怎么做 |
|
||||
|---|---|
|
||||
| 看看某个角色到底能做什么 | 点它 → 右边列出全部权限码与菜单(**内置角色**就在这一屏看,改不了) |
|
||||
| 让某类人只能看任务、不能建任务 | **新建**一个自定义角色 → 勾「任务查看」(**不要**勾「创建与配置收集任务」)→ 再把它发给账号 |
|
||||
| 让某个人只看得到自己带的班 | 勾带「本班/本人」字样的权限(如「名册修改(仅本班)」);**看全租户**的能力是「填报进度看板(全租户)」这类码 |
|
||||
| 改完什么时候生效 | **立即生效**,不需要让人重新登录(保存后让他刷新一下页面即可) |
|
||||
| 某个人不该再管账号了 | 账号管理里把他的角色换掉(或停用那个自定义角色) |
|
||||
|
||||
**三条要记住的规矩**:
|
||||
|
||||
1. **内置角色不能改**。它的权限写在后端代码里 —— 这是刻意的:一次误操作就能把管理员锁在系统外。
|
||||
要不同的能力范围,请**新建一个自定义角色**(可以照着内置的那份矩阵勾)。
|
||||
2. **「看得见」和「有权限」是两件事**。菜单只决定左边显示哪些入口;真正的权限是上面那一栏的权限码。
|
||||
只给菜单不给权限,点进去仍然会被拦住(提示"无权限访问")。
|
||||
3. **删角色前要先给账号换角色**。还有账号在用时删不掉,会提示「该角色还有 N 个账号在用,请先改掉这些账号的角色」。
|
||||
|
||||
> ⚠️ **「数据范围」(本人 / 本班 / 本院系 / 全租户)这一列目前只展示**:
|
||||
> 范围由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,**不靠这一列**。
|
||||
> 想让人看全租户,请给他勾「填报进度看板(全租户)」这类权限。
|
||||
|
||||
### 4.5 其它
|
||||
|
||||
@@ -184,7 +202,10 @@
|
||||
| 加/停用账号、重置密码 | 组织与账号 → 账号管理 |
|
||||
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
||||
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
||||
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 日志(「操作日志」页签,点某一行看详情) |
|
||||
| 查"某个账号什么时候登进来的、从哪登的" | 平台运维 → 日志 → 「登录日志」页签 |
|
||||
| 开通一所新学校 / 改到期日 / 停用学校 | 平台运维 → 租户管理(**仅平台运营可见**;停用前会二次确认) |
|
||||
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
||||
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
||||
|
||||
@@ -195,7 +216,7 @@
|
||||
### 5.1 三条必须能讲清楚的产品规则
|
||||
|
||||
1. **必须先有名册**(门禁铁律)。名单外的人填不了 —— 这是为了防冒名与串档,
|
||||
不是系统限制。名单外的人走「申请加入名单」。
|
||||
不是系统限制。**名单外的人由老师手工加进名册**(系统内没有自助申请入口,也不展示联系方式)。
|
||||
2. **材料是自动改名与自动压缩的**。学生传的文件名乱、体积大都不是问题;
|
||||
交付物是压缩后的"规范件"。
|
||||
3. **驳回必须写原因**,原因会原样发给学生。所以客服看到"学生说不知道为什么被驳回",
|
||||
@@ -206,7 +227,7 @@
|
||||
**Q:学生说"扫码进去说找不到我的信息"**
|
||||
A:这是名册门禁。请班主任核对名册上他的**姓名写法**与**身份证号**(常见是生僻字、
|
||||
空格、身份证号末位 X 大小写)。系统**故意不告诉**是姓名错还是号码错。
|
||||
如果确实不在名册里,让他点「申请加入名单」。
|
||||
如果确实不在名册里,让班主任在任务的「名单管理」里把他加进去(学生端没有自助入口)。
|
||||
|
||||
**Q:学生说"提示操作过于频繁"**
|
||||
A:分两种,看文案:
|
||||
@@ -246,7 +267,8 @@ A:系统上限是 3 次(防反复折腾)。到上限后由班主任/管理
|
||||
3. **屏幕上的完整提示文字**(照抄,不要转述 —— 提示文案与错误码是一一对应的);
|
||||
4. **发生时间**(精确到分钟)。
|
||||
|
||||
有了这四样,技术同学可以直接去「操作日志」与「通知记录」里定位。
|
||||
有了这四样,技术同学可以直接去「日志」(平台运维菜单,含操作日志与登录日志两个页签)
|
||||
与「通知记录」里定位 —— 日志详情里能拿到 `traceId`,那是后端排查时最有用的一个线索。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
# 填报流程端到端测试计划
|
||||
|
||||
> 版本:V1.0 | 日期:2026-10
|
||||
> 读者:测试、开发(自测)、项目经理(验收)
|
||||
> 配套:《03-接口设计.md》§9 / §11、《06-验收测试清单.md》、《04-核心模块设计说明.md》
|
||||
> 执行入口:`python scripts/e2e-m3-flow.py`
|
||||
> 核心原则:**能用真实数据断言的不靠人眼;能自动化的一次写、长期跑**(与《06》同口径)
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与范围
|
||||
|
||||
### 1.1 一句话
|
||||
|
||||
把「学生扫码 → 门禁 → 填报 → 传材料 → 提交 → 我的任务 → 撤回 → 审核 → 驳回 → 重交」这条**完整链路**做成一张**覆盖全、责任清**的用例矩阵,并只把**没人覆盖**的那些用例写成可执行脚本。
|
||||
|
||||
### 1.2 为什么不能只靠既有脚本
|
||||
|
||||
本仓库已有 **37 个 `scripts/e2e-*.py` / 1617 条断言**,且 **37 个全绿**(见《交接摘要》§一·四十的验证一节)。但它们是**按模块切片的**:门禁一个脚本、草稿一个脚本、提交一个脚本、审核一个脚本。切片测试有三个固定的盲区,本计划就是为它们而写:
|
||||
|
||||
| 盲区 | 具体表现 | 本计划的对策 |
|
||||
|---|---|---|
|
||||
| **跨步骤的状态一致性没人管** | 「名册行的 `submit_status` 从 0 走到 3」被四个脚本各断言一格,但**没有人断言这条轨迹是连续的** | §3.11 J 组 |
|
||||
| **切片之间那条缝** | 驳回后「能重新提交」被 `e2e-m5-audit.py:534` 证明了,但「能**先改再**提交」没有人证明 —— 而后者才是驳回的意义 | §3.10 I 组、§6 |
|
||||
| **脚本自述 ≠ 实际断言** | `e2e-m3-draft.py:11` 自述覆盖「已提交的不能直接改」,实际**全文没有这条断言**(无 `40030` 出现) | §3.1 的核对规程 |
|
||||
|
||||
> **规律**:切片测试的通过率越高,「缝里那一块」越安全 —— 因为没有人会去看缝里。F-84(「HTTP 200 + code 0」不等于动作成功,`docs/08-完成度盘点与问题清单.md:452`)就是同一个规律的产物。
|
||||
|
||||
### 1.3 分层与执行方式
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ L3-B 人工/浏览器走查(加分项) │
|
||||
│ H5 真机形态:门禁 → 填报 → 传图 → 提交 → 进度 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L3-A 端到端接口链路(自动 · 本计划主证据) │
|
||||
│ scripts/e2e-m3-flow.py + 既有 37 个 e2e 脚本 │
|
||||
│ 真实接口 + docker exec xt-pg 查库断言 │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ L2 机械校验(自动 · 不需要后端) │
|
||||
│ check-api-parity / check-entity-columns / verify-docs │
|
||||
│ check-manual / check-wiring │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.4 成功判据(DoD)
|
||||
|
||||
| # | 判据 | 怎么验 |
|
||||
|:--:|---|---|
|
||||
| 1 | 覆盖矩阵每行都有「覆盖者」,**没有一行是空的** | 人眼过一遍 §三 |
|
||||
| 2 | `scripts/e2e-m3-flow.py` 退出码 0 | 跑它 |
|
||||
| 3 | 该脚本 **至少 90 条断言** | 脚本末尾的 `通过 X 失败 Y 共 Z`(**实测 95**) |
|
||||
| 4 | `python scripts/verify-all.py --with-e2e` 全绿,脚本数 37 → 38 | 跑它 |
|
||||
| 5 | 缺陷有 XFAIL 登记 + F 编号,**且缺陷修好后 XPASS 会报红** | §4.5(**实测 6 条 XFAIL**) |
|
||||
| 6 | 演示态(任务 17 / 2097 / 2215)与名册绑定**不被本脚本破坏** | 跑完查库(**实测残留 0**) |
|
||||
|
||||
---
|
||||
|
||||
## 二、权威口径与判据来源
|
||||
|
||||
### 2.1 期望从哪来(裁决顺序)
|
||||
|
||||
1. **契约**:《03-接口设计.md》§9.1~§9.12(学生填报)、§11(审核)、§2.4 提交单状态、§2.5 红黄蓝级别、§2.6「无」的语义。
|
||||
2. **实现**:`backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/*.java`、`module/file/service/*.java`、`module/mp/service/*.java`。
|
||||
3. **常量**:`CollectSubmission.java:122-127`、`CollectRoster.java:87-99`、`xt-common/.../api/ErrorCode.java`。
|
||||
|
||||
⚠️ **契约与实现不一致时,以契约为期望**,把差异写成 XFAIL + 缺陷记录。**不许**按「实现更合理」把期望改成实现的样子 —— 那等于让测试给实现背书(AGENTS.md 铁律:文档是需求源)。
|
||||
|
||||
### 2.2 明确**不测**的部分
|
||||
|
||||
| 不测 | 原因 |
|
||||
|---|---|
|
||||
| `40004` / `40005` / `40006` / `40043` / `50002` / `50003` / `50004` | M4(OCR、归属校验、串档检测)**一期缓做**,等供应商选型 —— 这些码零引用,见 `scripts/check-wiring.py:130-149` 的例外表 |
|
||||
| `collectMode=2`(社会收集)/ `certType=2,3` | 2026-10 需求收敛,一律 `40044` |
|
||||
| 微信真实授权(非打桩) | 需要真人在微信里点「允许」;打桩路径已覆盖同一段发 token 的代码 |
|
||||
| 导出(M6)/ 通知(M7)/ 压测 | 各自有专门的脚本与文档,不在填报链路 |
|
||||
|
||||
### 2.3 判据速查
|
||||
|
||||
**提交单状态**(`CollectSubmission.java:122-127`,契约 §2.4)
|
||||
|
||||
| 值 | 含义 | 契约允许流转到 |
|
||||
|:--:|---|---|
|
||||
| 0 | 草稿 | 1 |
|
||||
| 1 | 待初审 | 2 / 4 |
|
||||
| 2 | 待终审 | 3 / 4 |
|
||||
| 3 | 已通过 | 5 |
|
||||
| 4 | 已驳回 | **1(学生重提交)** |
|
||||
| 5 | 已归档 | — |
|
||||
|
||||
**名册行 `submit_status`**(`CollectRoster.java:87-99`):`0` 未提交 / `1` 草稿 / `2` 待审 / `3` 已通过 / `4` 已驳回。
|
||||
|
||||
**任务状态**(`collect_task.status`):`0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` 暂停。**只有 `1` 能填报**(`SubmissionService.java:339-347`、`FillService.java:71-81`)。
|
||||
|
||||
**问题级别**(契约 §2.5):`1` 信息(蓝)/ `2` 警告(黄,可确认后提交)/ `3` 错误(红,必须改)。
|
||||
|
||||
---
|
||||
|
||||
## 三、覆盖矩阵
|
||||
|
||||
### 3.1 怎么读这张表
|
||||
|
||||
`覆盖者` 一列只有四种取值:
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| `既有脚本 文件:行` | 该断言**已经存在**且已在跑(`verify-all.py --with-e2e`) |
|
||||
| **本脚本** | 既有脚本**没有**覆盖,由 `scripts/e2e-m3-flow.py` 实现 |
|
||||
| `人工走查` | 只能靠浏览器/真机看的(本计划的 L3-B 部分) |
|
||||
| `M4 缓做` | 一期不做,**不写用例** |
|
||||
|
||||
⚠️ **「既有脚本」这一列是核对出来的,不是抄脚本自述的。** 核对规程:
|
||||
|
||||
```bash
|
||||
grep -n 'check(\|ok(' scripts/e2e-m3-*.py scripts/e2e-m5-audit.py
|
||||
```
|
||||
|
||||
一条一条对照实际断言文案。**这条规程本身就是必要的**:`e2e-m3-draft.py:11` 的 docstring 自述「已提交的不能直接改」,而该文件全文没有这条断言 —— 自述覆盖了一个它没测的东西。
|
||||
|
||||
### 3.2 A 组 · 扫码落地页(匿名)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| A1 | 合法 scene,返回租户名/任务名/能否填报 | `valid=true`,`canSubmit` 与任务状态一致 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A2 | 非法 scene / 不存在的租户 / 不存在的任务 | 三种**不同的**提示,不泄漏是否存在 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A3 | 跨租户:A 租户的 scene 拼 B 租户的 taskId | 当作「不存在」 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A4 | 带班级的 scene:班级名只在**该班确实在覆盖范围内**时才返回 | 否则不返回 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
| A5 | 限流(scene 可穷举) | 能挡住批量扫描 | 既有脚本 `scripts/e2e-m1-scene.py` |
|
||||
|
||||
### 3.3 B 组 · 登录与鉴权
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| B1 | `GET /mp/h5/stub-login?openid&scene` | 302;`Location` 是 **hash 路由**格式 `...#/pages/index/index?token=..&scene=..`,token **不在** query | 既有脚本 `scripts/e2e-m3-h5-auth.py:163` |
|
||||
| B2 | stub-login 不带 scene | 302 + `authError=打桩登录需要带合法的 scene`(不猜租户) | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
|
||||
| B3 | 拿到的 token 真的能用 | `GET /mp/tasks` → `code=0` | 既有脚本 `scripts/e2e-m3-h5-auth.py` |
|
||||
| B4 | **不带 `Authorization`** 调 `/mp/tasks` | `40101` | **本脚本** |
|
||||
| B5 | **签名被篡改**的 token | `40101` | **本脚本** |
|
||||
| B6 | **`exp` 已过期**的 token | `40101` | **本脚本** |
|
||||
|
||||
> B4~B6 的意义:既有脚本都在证明「有效 token 能用」(`e2e-m3-mytask.py:140`、`e2e-m3-gate.py:175`),**没有一条证明「无效 token 不能用」**。认证的负向路径从来没人走过。
|
||||
|
||||
### 3.4 C 组 · 门禁核验
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| C1 | 姓名 + 证件号与名册一致 | `code=0`,绑定该行,返回姓名与**脱敏**证件号 | 既有脚本 `scripts/e2e-m3-gate.py:339` |
|
||||
| C2 | 号码不在名册 | `40007` | 既有脚本 `scripts/e2e-m3-gate.py:366` |
|
||||
| C3 | 姓名不对 | `40007`,且提示与 C2 **逐字相同**(防枚举) | 既有脚本 `scripts/e2e-m3-gate.py:364` |
|
||||
| C4 | 已绑自己的人再核验 | `code=0`(幂等,刷新页面不该出现「已被登记」) | 既有脚本 `scripts/e2e-m3-gate.py:356` |
|
||||
| C5 | 另一个账号核同一名册行 | `40072` | 既有脚本 `scripts/e2e-m3-gate.py:379` |
|
||||
| C6 | 号码格式乱 / 前 10 次失败 / 第 11 次限流 | `40007` × 10 → `40071` | 既有脚本 `scripts/e2e-m3-gate.py:417,430-431` |
|
||||
| C7 | **明文证件号不落任何地方** | 门禁**响应全文**无明文;库里 `collect_roster` 只有 `id_card_no`(密文)/`id_card_hash`/`id_card_mask`;`sys_operation_log.request_param` 也无明文 | 响应字段侧:既有脚本 `scripts/e2e-m3-gate.py:339`;**全文扫 + 库 + 日志侧:本脚本**(5 条断言,全绿) |
|
||||
| C8 | **任务已截止/已暂停时核验** | **仍 `code=0`**(`MpRosterService.java:107-175` 不判任务状态,这是**有意的解耦**);但紧随其后的 `GET /mp/tasks/{id}/form` 必须 `40008` | **本脚本** |
|
||||
|
||||
> C8 是「钉住一个刻意的设计」。它看起来像 bug(截止了还能核验?),其实绑定与任务状态解耦是对的:学生可能只是回来核对身份。**必须有用例把它钉住**,否则后来者「顺手加个校验」就把它改了,而且没有任何东西会红。
|
||||
|
||||
### 3.5 D 组 · 表单结构与三态
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| D1 | 未过门禁取表单 | `40007` | 既有脚本 `scripts/e2e-m3-draft.py:139` |
|
||||
| D2 | **不存在的 taskId** | `40001「任务不存在或已删除」` | **本脚本** |
|
||||
| D3 | **任务为草稿(0)** | `40008` | **本脚本** |
|
||||
| D4 | **任务已暂停(4)/ 已截止(2)/ 已归档(3)** | `40008`(逐状态各一次) | **本脚本** |
|
||||
| D5 | 收集中:内置字段 **21 项**、分区结构完整 | `len(fields)==21` | 既有脚本 `scripts/e2e-m3-draft.py:153` |
|
||||
| D6 | 三态:`occupation/worktype/job_level` 锁定且值来自名册;`education` 可改且 `prefilled=true` | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:154-157` |
|
||||
| D7 | **表单响应里搜不到明文证件号**(全文扫一遍) | 只有 `******` 形式的脱敏值 | **本脚本** |
|
||||
| D8 | **条件可见性(字段侧)**:`is_student` 真/假切换后,学籍三项与工作证明类字段的可见性 | 与 `SubmissionValidator.visible()` 同口径(未知运算符 **fail-open**) | **本脚本** |
|
||||
| D9 | 有草稿时取表单带出 `submissionId`,草稿值覆盖名册值 | 见断言 | 既有脚本 `scripts/e2e-m3-draft.py:184-192` |
|
||||
| D10 | **扩展字段要能回显**:用 `extrasJson` 存进去的值,下次取表单要在字段上带出来 | 见期望 → 实测恒为 `null`(**XFAIL · F-128**) | **本脚本**(`EP-D09`) |
|
||||
|
||||
> D8 只做**字段侧**:条件**材料**侧的排除已被 `e2e-m3-submit.py:297` 与 `e2e-m3-mytask.py:179` 覆盖,不要重复。
|
||||
|
||||
### 3.6 E 组 · 草稿
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| E1 | `POST /mp/submissions` 幂等 | 首次 `created=true`,再次 `created=false` 且 id 相同,库里只有 1 份 | 既有脚本 `scripts/e2e-m3-draft.py:161-171` |
|
||||
| E2 | `PUT` 合并式保存:只传变化字段 | 其它字段不受影响 | 既有脚本 `scripts/e2e-m3-draft.py:175-192` |
|
||||
| E3 | 改锁定字段 | `40001`,提示含「老师」「班主任」;库里没被改 | 既有脚本 `scripts/e2e-m3-draft.py:197` |
|
||||
| E4 | 锁定字段改成**原值** | `code=0`(不算改动) | 既有脚本 `scripts/e2e-m3-draft.py:206` |
|
||||
| E5 | 传不认识的字段 | `40001「表单里没有这个字段」`(不静默忽略) | 既有脚本 `scripts/e2e-m3-draft.py:211` |
|
||||
| E6 | 清空预填字段 | 库里真变 `NULL`;回显为空,**不被名册值顶回** | 既有脚本 `scripts/e2e-m3-draft.py:226-234` |
|
||||
| E7 | `changed_fields` 留痕 | 只含真改过的 | 既有脚本 `scripts/e2e-m3-draft.py:216-221` |
|
||||
| E8 | **已提交(status=1)后 `PUT`** | `40030` | **本脚本** |
|
||||
| E9 | **已驳回(status=4)后 `PUT`** | 契约 §2.4 期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
|
||||
| E10 | 越权:用 B 的 token 改 A 的单 | `40001` | **本脚本** |
|
||||
| E11 | 不存在的 submissionId | `40001` | **本脚本** |
|
||||
| E12 | 建草稿不带 `taskId` | `40001「缺少 taskId」` | **本脚本** |
|
||||
| E13 | **建草稿后把任务暂停,再 `PUT`** | `40008` | **本脚本** |
|
||||
| E14 | **扩展字段白名单**:未声明的 `extra_*` → 拒;已声明的 → 落 `extras_json` | `40001` / 落库 | **本脚本** |
|
||||
| E15 | **扩展字段塞错口袋**:已声明的扩展键放进 `formData`(而不是 `extrasJson`) | `code=0` 但值被**静默丢弃**(**F-129**,钉住现状) | **本脚本**(`EP-E16`) |
|
||||
|
||||
> E8 是本计划**第一个被核对出来的自述缺口**:`scripts/e2e-m3-draft.py:11` 的 docstring 写着「已提交的不能直接改」,而全仓**没有任何断言涉及 `40030`**(唯一相关的是 `scripts/e2e-m5-proxy.py:277`,测的是**代传**那条路,不是学生自己的 `PUT`)。
|
||||
|
||||
### 3.7 F 组 · 材料
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| F1 | 上传 → 落库 → `process` | `final_size ≤ maxSizeKb*1024`;`standard_name` 按模板含姓名 + **脱敏号(用 X 不用 `*`)** | 既有脚本 `scripts/e2e-m3-file.py:314-336` |
|
||||
| F2 | 档位生效(P2 证件翻拍 → 长边限 1600)/ 原始件保留 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py` |
|
||||
| F3 | `process` 幂等:重复处理不产生第二份规范文件 | 见断言 | 既有脚本 `scripts/e2e-m3-file.py:364` |
|
||||
| F4 | 格式白名单外的格式 | `40002` | 既有脚本 `scripts/e2e-m3-file.py:374` |
|
||||
| F5 | 超过份数上限 | `40001` | 既有脚本 `scripts/e2e-m3-file.py:376` |
|
||||
| F6 | **按字节判格式**:`.txt` 改名 `.jpg` → 拒;`.jpg` 里装 PNG → 收 | 不信后缀 | 既有脚本 `scripts/e2e-m3-file.py:423,429` |
|
||||
| F7 | **空文件** | `40001「文件是空的」` | **本脚本** |
|
||||
| F8 | **不存在的 materialCode** | `40001「这个任务里没有该材料项:xxx」` | **本脚本** |
|
||||
| F9 | **已提交(status=1)后上传 / 删除** | `40030` | **本脚本**(⚠️ **代传**侧的 `40030` 已由 `scripts/e2e-m5-proxy.py:277` 覆盖,学生自己那条路没有) |
|
||||
| F10 | **已驳回(status=4)后上传 / 删除** | 契约期望可改 → 实测 `40030`(**XFAIL · F-125**) | **本脚本** |
|
||||
| F11 | 越权:上传到 A 的单 / 删 A 的文件 | `40001` | **本脚本** |
|
||||
| F12 | `/mp/files/{id}/url`:本人 `code=0` + 非空 url;别人 `40001` | 见期望 | **本脚本** |
|
||||
| F13 | **§9.5 `upload-token` 预检 + 秒传**:同 hash 第二次 → `existed=true` 且**不给 uploadUrl** | 秒传是真的 | **本脚本** |
|
||||
| F14 | **§9.7 `batch-process`**:>200 个 fileId → `40011`;正常批量 → 逐份走同一 `process` | 见期望 | **本脚本** |
|
||||
| F15 | **§9.8 预览**:`prefer=original` 但原始件不存在 → 退到规范件,且 `actualKind` **如实告知** | 不假装是原图 | **本脚本** |
|
||||
| F16 | 条件材料:在校生不被要求工作证明 | 不阻断提交 | 既有脚本 `scripts/e2e-m3-submit.py:297` |
|
||||
| F17 | 越权:对**别人的文件**调 `process` → `40001` | 不靠前端隐藏按钮 | **本脚本** |
|
||||
| F18 | 越权:列**别人的填报单**的材料 → `40001` | 见期望 | **本脚本** |
|
||||
|
||||
### 3.8 G 组 · 提交与校验(红黄蓝全覆盖)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| G1 | 缺必传材料 | `VAL-05` 红 | 既有脚本 `scripts/e2e-m3-submit.py:229` |
|
||||
| G2 | 缺必填字段 | `VAL-22` 红 | 既有脚本 `scripts/e2e-m3-submit.py:230` |
|
||||
| G3 | `VAL-01` **不该误报**(`id_card_no` 是密文,不能当明文算校验位) | 不出现 `VAL-01` | 既有脚本 `scripts/e2e-m3-submit.py:231` |
|
||||
| G4 | 在校生三项缺项 | `VAL-16` 红;缺项要**列全**;补齐后消失 | 既有脚本 `scripts/e2e-m3-submit.py:244,248,252` |
|
||||
| G5 | `VAL-15`:四级勾 2 科 → 红;勾了补考但 0 科 → **黄** | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py:258,268,279` |
|
||||
| G6 | 只黄无红 → 先 `submitted=false`;带 `confirmWarnings=true` 再提交 → `submitted=true` | 两次调用 | 既有脚本 `scripts/e2e-m3-submit.py` |
|
||||
| G7 | 提交后落库 + 名册行 → 2;重复提交被拒 | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py`、`scripts/e2e-m5-audit.py:308` |
|
||||
| G8 | **一次给全**:多类问题同时返回(不是改一条再拒一次) | 见期望 | 既有脚本 `scripts/e2e-m3-submit.py` |
|
||||
| G9 | **「无」是合法值**:填「无」不算缺失(契约 §2.6,`docs/03-接口设计.md:258-265`) | 不报 `VAL-22` | **本脚本** |
|
||||
| G10 | **`VAL-22` 只对「可见 + 必填 + 非锁定」字段报** | 不要求在校生填「补考理论」 | **本脚本** |
|
||||
| G11 | **`VAL-17`**:工作年限「80」→ 红(0~60 阿拉伯数字) | 红 | **本脚本** |
|
||||
| G12 | **`VAL-17` 的非阿拉伯数字分支**:「三年」根本存不进去(`work_years` 是 `smallint`)→ 那段校验**没有入口** | 钉住「死分支」这一事实 | **本脚本**(`EP-G11a`) |
|
||||
| G12 | **`VAL-01` 正向**:名册行证件号为空 → 红「名册里没有你的身份证号」 | 红 | **本脚本**(G3 只测了不该误报) |
|
||||
| G13 | **`VAL-09`** 同一张图传到两个材料项 → 黄 | — | 既有脚本 `scripts/e2e-m3-submit.py:319` |
|
||||
| G14 | **`VAL-08` 清晰度偏低 / `VAL-20` 体积偏小** → 黄(不阻断) | 见期望 | **本脚本** |
|
||||
| G15 | 越权提交别人的单 → `40001`;无 token 提交 → `40101` | 见期望 | **本脚本** |
|
||||
| G16 | 任务暂停 / 已截止时提交 → `40008`;解绑门禁后提交 → `40007` | 见期望 | **本脚本** |
|
||||
| G17 | **判据纪律**:断言 `data.submitted` 而非 `code==0`(F-84) | 失败信息带红黄计数与 issues | 全脚本约定 |
|
||||
| G18 | **红色不能靠 `confirmWarnings` 强提绕过**(契约:它只对黄色有效,`SubmitDtos.java:100-101`) | `submitted=false` 且 `redCount>0` | **本脚本** |
|
||||
|
||||
### 3.9 H 组 · 我的任务 / 进度 / 撤回
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| H1 | 可见范围 = 名册绑定(没绑的人看不到该任务) | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:161` |
|
||||
| H2 | 草稿态:`canFillNow=true`、状态文案「还没开始」、计入 `todoCount` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:154-157` |
|
||||
| H3 | 审核中 / 已通过 / 已截止:`canFillNow=false` + `fillBlockReason` 各自不同 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:228,287,301-302` |
|
||||
| H4 | **`progressPercent` 是字符串**(`"0%"` → `"100%"`),分母只算**可见的必传材料** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:189,213` |
|
||||
| H5 | 材料清单只列本次适用的(在校生 3 项,不含工作证明);`done/required/issueLevel` 正确 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:179-185` |
|
||||
| H6 | 时间轴只列**确实发生过**的节点 | 「没发生的事不许出现」 | 既有脚本 `scripts/e2e-m3-mytask.py:193,217` |
|
||||
| H7 | 撤回 status=1 → `status=0`、`submit_time` 清空、**名册行回 1** | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:235-241` |
|
||||
| H8 | 撤回边界:草稿态再撤 → 拒;已通过 → `40031` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:251,279` |
|
||||
| H9 | 越权:别人看我的进度 / 别人撤回我的单 → 拒 | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:200,202` |
|
||||
| H10 | **status=4 时 `progress.canEdit` / `canSubmit`** | 实测 `true` —— 与 E9/F10 的 `40030` **直接矛盾**(**F-125 的运行时证据**) | **本脚本** |
|
||||
| H11 | **撤回后再改再提交**,且 `reject_count` 不因撤回增加 | 见期望 | **本脚本** |
|
||||
| H12 | **撤回 → 再提交**这一对(既有脚本只覆盖了「撤回」那一半,没有一段把它闭合) | 撤回回 0/名册 1;重交回 1/名册 2 | **本脚本** |
|
||||
|
||||
> H10 的证据来自 `StudentTaskService.java:311-313`:`setCanEdit(st == null \|\| st == 0 \|\| st == 4)`、`setCanSubmit(同)`。**读模型说「能改能交」,写模型(`saveDraft`/`storeFile`)说「`40030`」** —— 同一个类文件里的两个方法互相矛盾。
|
||||
|
||||
### 3.10 I 组 · 审核闭环与驳回重交
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| I1 | 班主任待办:被指定的初审人看得到、同角色但没被指定的看不到;跨班不可见 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:316,336` |
|
||||
| I2 | 初审通过 → `status=2`、名册行 3、审核记录 `action=1` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:406-436` |
|
||||
| I3 | 有红的单不允许通过 → `40032` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:385` |
|
||||
| I4 | 批量通过只带无问题项;红黄一律跳过并说明原因 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:452,485` |
|
||||
| I5 | 驳回:必填原因码、`R999` 必须带说明 → `status=4`、`reject_count`、名册行 4、审核记录 `action=2` | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:496-517` |
|
||||
| I6 | 驳回达上限 → `escalated=true`(升级管理员,**不是**错误码) | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:528` |
|
||||
| I7 | **驳回后学生能重新提交** → `status=1`、名册行回 2 | 见期望 | 既有脚本 `scripts/e2e-m5-audit.py:534,537` |
|
||||
| I8 | **驳回后学生侧的提示**:「被驳回,请修改后重新提交」+ `canFillNow=true` + 计入 todo | 见期望 | **本脚本** |
|
||||
| I9 | **驳回后先整改再提交**(改一个字段 + 换一份材料,再提交) | 契约 §2.4 期望可改 → 实测改不动(**XFAIL · F-125**) | **本脚本** |
|
||||
| I10 | 已通过后撤回 → `40031`;导出后 → `40033`;归档后 → `40034` | 见期望 | 既有脚本 `scripts/e2e-m3-mytask.py:279`、`scripts/e2e-m5-audit.py:557,565` |
|
||||
|
||||
> ⚠️ **I7 与 I9 的区别就是本计划存在的理由**:`scripts/e2e-m5-audit.py:531-538` 在重交前**只改了库里的 `reject_count`**(`UPDATE collect_submission SET reject_count=3`),**没有改任何业务字段** —— 它证明的是「原样重交能过」,而驳回的全部意义在于「改了再交」。切片测试把这一格填成了绿色,缝里的东西没人看。
|
||||
|
||||
### 3.11 J 组 · 跨步骤一致性(本脚本独有价值)
|
||||
|
||||
| 编号 | 场景 | 期望 | 覆盖者 |
|
||||
|---|---|---|---|
|
||||
| J1 | **名册行 `submit_status` 全轨迹**:`0` →建草稿`1` →提交`2` →驳回`4` →重交`2` →终审`3`,每一步查库 | 五步全对且**连续** | **本脚本** |
|
||||
| J2 | **表单升版后旧草稿提交**:`PUT /tasks/{id}/form-schema` 升版并加一个必填 `extra_*`,再提交旧草稿 | **不报 `40025`**;按**当前** schema 校验(新必填项会拦);`collect_submission.form_version` 保持创建时的快照值 | **本脚本** |
|
||||
| J3 | **并发提交**(两个连接同时 `POST /submit`) | 如实钉住当前行为(`SubmissionService.java:280-282` 明写一期不做并发保护),把「无保护」写成**已知事实**而不是假装有 | **本脚本** |
|
||||
|
||||
> **J1 的价值**:`scripts/e2e-m5-audit.py:308,436,517,537` 分别断言过 `2`、`3`、`4`、`2`,但**分散在四个上下文里**,没有任何一条断言「这一行的状态是连续走过来的」。分散断言无法发现「中间跳了一格」。
|
||||
> **J2 的顺序依赖**:它会改夹具 schema,必须排在 G 组之后(见 §4.2)。
|
||||
> **J3 的态度**:不做并发保护的**决定是书面的**(代码注释里写了为什么,以及将来要改的话该怎么改)。测试的职责是把这个决定**钉成可观测事实**,而不是替它加锁。
|
||||
|
||||
### 3.12 小结(实测口径,2026-10)
|
||||
|
||||
| 覆盖者 | 用例数 |
|
||||
|---|---|
|
||||
| 既有脚本(已在跑) | 约 55 |
|
||||
| **本脚本** | **45** |
|
||||
| 人工走查(L3-B / L3-C,加分项) | L3-B 4 条里走了 3 条(第 4 条未走,见 §5.3);**L3-C 小程序端另走了 10 步**(第 2 条由它补上) |
|
||||
| M4 缓做(不写用例) | 7 个错误码 |
|
||||
|
||||
**实测结果**:
|
||||
|
||||
| 跑法 | 通过 | 失败 | XFAIL |
|
||||
|---|:--:|:--:|:--:|
|
||||
| `--group chain` | 74 | 0 | 5 |
|
||||
| `--group errors` | 27 | 0 | 1 |
|
||||
| 无参数(chain + errors) | **95** | **0** | **6** |
|
||||
|
||||
(三种跑法都通过;`共 95` 已超过 DoD 要求的 90 条。)
|
||||
|
||||
---
|
||||
|
||||
## 四、脚本设计(`scripts/e2e-m3-flow.py`)
|
||||
|
||||
### 4.1 夹具
|
||||
|
||||
**两个任务**,都带随机 suffix:
|
||||
|
||||
| 任务 | 用途 | 状态 |
|
||||
|---|---|---|
|
||||
| `taskMain` | 跑 chain 组(完整链路) | 全程保持**收集中** |
|
||||
| `taskState` | 跑 errors 组里的**状态机边界**(草稿/暂停/截止/归档 → `40008`) | 按用例切换 |
|
||||
|
||||
**构造顺序**(每一步都走真实接口,与 `scripts/e2e-m5-audit.py:177-267` 同形):
|
||||
|
||||
1. `admin` / `Admin@123` + 验证码 `demo` 登录(学校管理员)
|
||||
2. 取一个 `orgType=3` 的班级
|
||||
3. 建初审班主任(`HEAD_TEACHER`、`userType=3`,拿返回的 `initialPassword` 登录)
|
||||
4. 建两个任务(`collectMode=1`、`certType=1`)
|
||||
5. 生成 xlsx 名册(2 人,`with_checksum` 补校验位)→ `upload_roster(..., {"orgId": ...})`
|
||||
6. `PUT /tasks/{id}/orgs`(覆盖班级 + 指定初审人)
|
||||
7. `POST /tasks/{id}/materials`:必传材料,**`minSizeKb=1`、`minLongSide=100`**
|
||||
8. `POST /tasks/{id}/publish`
|
||||
|
||||
⚠️ 第 7 步的下限必须调小。理由见 `scripts/e2e-m5-audit.py:255-263` 的实测注释:E2E 用的小图会被材料目录的默认下限判成「体积偏小 / 清晰度偏低」→ **黄色警告**,于是「绿色单」这个前提不成立,后面所有断言跟着错。**断言失败的样子像功能坏了,其实只是夹具不满足前提。**
|
||||
|
||||
⚠️ **绝不触碰**演示任务 17 / 2097 / 2215 及其名册绑定。跑 `scripts/e2e-m3-*.py` 会清掉 `collect_roster.bind_user_id`(AGENTS.md 点过),本脚本只操作自建任务。
|
||||
|
||||
### 4.2 用例顺序依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|---|---|
|
||||
| `chain` 组必须整体跑 | J1 的轨迹断言依赖前序步骤真的发生过 |
|
||||
| **J2 必须在 G 组之后** | J2 会升版夹具的表单 schema;提前跑会让后续所有 `VAL-22` 断言多一个必填项 |
|
||||
| D3/D4/E13/G16 用 `taskState` | 它们会把任务切到暂停/截止/归档;用 `taskMain` 会毁掉 chain 组 |
|
||||
| `errors` 组内的越权用例需要**两个学生** | 夹具准备 2 行名册即可 |
|
||||
|
||||
### 4.3 命令行参数
|
||||
|
||||
```bash
|
||||
python scripts/e2e-m3-flow.py # 全部(chain + errors)
|
||||
python scripts/e2e-m3-flow.py --group chain # 只跑链路(J1 轨迹在这里)
|
||||
python scripts/e2e-m3-flow.py --group errors # 只跑错误矩阵
|
||||
python scripts/e2e-m3-flow.py --fixture-only # 只建夹具,打印 id/token,不清理、不跑用例
|
||||
python scripts/e2e-m3-flow.py --keep # 跑完不清理(配合人工走查)
|
||||
python scripts/e2e-m3-flow.py --h5-url # 打印可直接打开的 H5 地址(见 §5.3)
|
||||
python scripts/e2e-m3-flow.py --clean <taskId> # 只清理指定的夹具任务
|
||||
python scripts/e2e-m3-flow.py --full # 连限流类慢用例一起跑(默认跳过)
|
||||
```
|
||||
|
||||
### 4.4 清理
|
||||
|
||||
放 `finally`,**失败路径也要跑** —— F-85 的教训是「清理只在正常结束时跑,于是失败路径才是残留的来源」。
|
||||
|
||||
按 `task_id` 依次删:`collect_audit_record` → `collect_file` → `collect_submission` → `collect_roster` → `collect_material_def` → `collect_task_org` → `collect_export_task` → `collect_task`;再删 `sys_user_role` / `sys_user`(自建班主任)与临时 xlsx。最后**断言残留任务数为 0**。
|
||||
|
||||
### 4.5 XFAIL 机制
|
||||
|
||||
```python
|
||||
KNOWN_DEFECTS = {
|
||||
"EP-E09": "F-125", # 驳回后 PUT 被 40030 拒
|
||||
"EP-F10": "F-125", # 驳回后上传被 40030 拒
|
||||
"EP-F10b": "F-125", # 驳回后删除被 40030 拒(与上传是两条独立的守卫)
|
||||
"EP-I09": "F-125", # 驳回后整改不落库
|
||||
"EP-D09": "F-128", # 扩展字段存进去却回显不出来
|
||||
"EP-J02e": "F-128", # 必填扩展字段永远满足不了
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **登记之后,用例的断言要写「契约期望」而不是「现状」** —— 否则 XFAIL 会变成一条永远通过的断言,等于把缺陷写成了规格。`EP-I09` 第一版就写错了:它只断言「原样重交能成功」,而那本来就成功(`scripts/e2e-m5-audit.py:534` 已证明),于是它报了个 `XPASS` 说"缺陷已修"。改成断言「整改的值真的落库了」之后才如实变成 XFAIL。
|
||||
|
||||
| 情形 | 输出 | 计入失败 |
|
||||
|---|---|:--:|
|
||||
| 该用例按契约期望**失败** | `[XFAIL] EP-E09(已知缺陷 F-125)` | 否 |
|
||||
| 该用例**通过**了 | `[XPASS] EP-E09 缺陷似乎已修 —— 请删掉 KNOWN_DEFECTS 里的登记` | **是** |
|
||||
|
||||
为什么要 XPASS 报红:否则缺陷修好之后没有任何信号,登记会永远留在那里,而下一个人会以为它还在。
|
||||
为什么可以 XFAIL:否则 `verify-all --with-e2e` 长期红,红着红着就没人看了 —— 那比不测更糟。
|
||||
|
||||
### 4.6 判据纪律(每一条都有血)
|
||||
|
||||
| # | 纪律 | 血的来源 |
|
||||
|:--:|---|---|
|
||||
| 1 | 业务错误也是 **HTTP 200**,判 `code`;只有 `401/403/429/5xxxx` 看 HTTP 状态码 | 项目约定,`ErrorCode.httpStatus()` |
|
||||
| 2 | **「HTTP 200 + code 0」≠ 动作成功**:提交/发布/导入/批量审核一律断 `data` 里的结论字段(`submitted`/`created`…),失败信息带红黄计数与 issues | **F-84**(`docs/08-完成度盘点与问题清单.md:452`) |
|
||||
| 3 | 每个接口级断言配一条**查库断言** | 「接口说成功了」与「数据库真的变了」是两件事 |
|
||||
| 4 | 软删字段是 `smallint`:写 `deleted=0`,**不是** `=false` | PG 会报 `operator does not exist: smallint = boolean` |
|
||||
| 5 | `Map` 结果的列别名会被**折成小写**,查库别用驼峰别名 | **F-54 / F-83** |
|
||||
| 6 | 清理放 `finally`,并断言残留数 | **F-85** |
|
||||
| 7 | 末尾必须打印 `通过 X 失败 Y 共 Z` | `scripts/verify-all.py:143` 靠这行正则统计;格式不对会被判成「无汇总」而算失败 |
|
||||
| 8 | 门禁限流按 openid 计(10 次/小时):学生 uid 每次现造;要清 Redis 键必须带 `-a redis_dev_pwd --no-auth-warning` **并检查返回码** | `scripts/dev-seed-audit.py:123-141`(不看返回码 → 「清了但没清掉」) |
|
||||
|
||||
---
|
||||
|
||||
## 五、执行步骤
|
||||
|
||||
### 5.1 前置
|
||||
|
||||
```bash
|
||||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
|
||||
# ⚠️ 不要动本机别的 pg-verify/redis-local/sp-redis/vastbase-pg
|
||||
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置)靠这个相对路径找到。
|
||||
⚠️ **H5 不在 8080 上**(2026-10 起由 nginx 的 80 端口托管,见 `docs/09` §2.1)——
|
||||
本计划的脚本全打 `/api`,所以只起后端就够;只有要在浏览器里走查**构建产物**时才需要
|
||||
`pnpm build:h5` + `docker compose up -d nginx`(走查 dev server 则用 `pnpm dev:h5`)。
|
||||
⚠️ **「jar 比源码新」不算证据**。启动后必须先探活,再跑一条已知脚本冒烟:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/auth/captcha # 期望 200
|
||||
python scripts/e2e-m3-draft.py # 期望 27/27
|
||||
```
|
||||
|
||||
### 5.2 L3-A 主流程
|
||||
|
||||
```bash
|
||||
python scripts/e2e-m3-flow.py --fixture-only # 夹具前提不满足时立刻退出并打印清单
|
||||
python scripts/e2e-m3-flow.py --group chain # 实测:通过 74 失败 0 XFAIL 5
|
||||
python scripts/e2e-m3-flow.py --group errors # 实测:通过 27 失败 0 XFAIL 1
|
||||
python scripts/e2e-m3-flow.py # 实测:通过 95 失败 0 XFAIL 6
|
||||
python scripts/verify-all.py --with-e2e # 全量回归(38 个脚本,约 10 分钟)
|
||||
```
|
||||
|
||||
⚠️ **跑完确认没有残留**(本脚本自己在 `finally` 里清,但失败路径值得再看一眼):
|
||||
|
||||
```bash
|
||||
docker exec -e PGPASSWORD=fill_dev_pwd xt-pg psql -U fill -d filldb -t -A \
|
||||
-c "SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E流程%'" # 期望 0
|
||||
ls scripts/_tmp_flow_* # 期望没有
|
||||
```
|
||||
|
||||
### 5.3 L3-B 浏览器走查(加分项)
|
||||
|
||||
```bash
|
||||
cd frontend-mp && pnpm dev:h5 # → http://localhost:5174
|
||||
python scripts/e2e-m3-flow.py --keep --h5-url # 打印带 token + scene 的 H5 地址
|
||||
```
|
||||
|
||||
⚠️ **不要直接打开 stub-login 的 302 落点**:它默认跳到 `WX_H5_BASE_URL`(穿透域名 `http://394nb68658le.vicp.fun`),那里是 **nginx 托管的构建产物**(挂的是 `frontend-mp/dist/build/h5`,不是 dev server;产物可能是几天前的构建),不是 dev server。`--h5-url` 的做法是:Python 侧请求 stub-login 但**不跟随跳转**,从 `Location` 里取出 `token` 与 `scene`,拼成 `http://127.0.0.1:5174/#/pages/index/index?token=..&scene=..`。
|
||||
|
||||
⚠️ 走查时**名册行必须未绑定**,由界面自己走一遍门禁。原因:L3-A 用的是 `mint()` 手搓的**假 uid**(库里没有对应 `sys_user`),而 stub-login 会**真建** `sys_user` 并拿到真 uid —— 两者不是同一个人,混用会得到「门禁说你不在名册里」。
|
||||
|
||||
⚠️ **门禁要手动输入身份证号,而库里只有密文**。`scripts/_reveal-roster-idcard.py` 能解,但它把 task id **写死在 `:59`(1719)**;查别的任务用:
|
||||
|
||||
```bash
|
||||
python scripts/dev-reveal-roster-idcard.py <taskId> # 只读,只打印;解密方式见 AesGcmCipher
|
||||
```
|
||||
|
||||
⚠️ `--fixture-only` 不会触发清理(那是给手工排查用的),走查完要自己收尾:
|
||||
删任务(`--clean <taskId>`,或按 `docs/11` §4.4 的顺序逐个表删)、
|
||||
删 stub-login 建出来的 `sys_user`(它不在脚本的夹具清单里)、删 `scripts/_tmp_flow_*.xlsx`。
|
||||
|
||||
走查看这四件**接口测不出来**的事(**2026-10 已实测前 3 条,见下表**):
|
||||
|
||||
| # | 看什么 | 为什么接口测不出来 | 实测结果 |
|
||||
|:--:|---|---|---|
|
||||
| 1 | 无 scene 但有 token 时,是否跳到「我的填报」而不是报错 | `frontend-mp/src/pages/index/index.vue:156-169` 的分支 | ✅ 直达「我的填报」。⚠️ 但**不能靠"去掉 URL 里的 scene"来复现** —— scene 已进本地缓存,落地页仍会用它;要直接开 `#/pages/tasks/tasks?token=..` |
|
||||
| 2 | 有黄色警告时**弹确认框**,点「仍然提交」才真的提交 | `fill.vue:706-743` 的两次调用;`doSubmit` 不能把事件对象当 `confirmWarnings` 传(`:688-693` 的注释记过这个坑) | ✅ **已在 L3-C 小程序端走到**(H5 侧本轮只造出红色;小程序端用 72×72 的 logo 触发 `VAL-08` 黄单 → 页内「仍然提交」→ 提交成功) |
|
||||
| 3 | 驳回状态下每次失焦都提示「保存失败:…请先撤回」 | `fill.vue:378-380` —— **这就是 F-125 在学生眼里的样子** | ✅ **复现**(原文见下) |
|
||||
| 4 | 传图后结果卡显示**命名前后对比**与压缩后体积 | 只能看 | 未走(浏览器自动化无法选本地文件,见下) |
|
||||
|
||||
**第 3 条实测到的原话**(把 `status` 改成 4 后改任意字段并失焦):
|
||||
|
||||
```
|
||||
保存失败:已提交的填报单不能直接修改,请先撤回
|
||||
```
|
||||
|
||||
而**同一屏上没有任何「撤回」入口**;退到「我的填报」,卡片上写着
|
||||
**「被驳回,请修改后重新提交」**、按钮写着**「修改后重新提交」**,
|
||||
点进去却每一次修改都失败 —— 界面自己的行动号召把学生带进死胡同。
|
||||
这正是 F-125 的完整用户侧证据链(接口侧是 4 条 XFAIL)。
|
||||
|
||||
⚠️ **第 4 条为什么没走**:材料上传要选本地文件,而本轮可用的浏览器自动化**没有文件选择能力**。
|
||||
所以"传图 → 命名对比 → 压缩体积"这条只能人工走查;接口侧的等价断言在 L3-A(`EP-F05` 系列与 `EP-F15`)。
|
||||
|
||||
### 5.4 L3-C 微信小程序端走查(2026-10 补)
|
||||
|
||||
> 起因:需求方明确要求「**学生提交的过程要测微信小程序端**」。小程序与 H5 是同一套源码
|
||||
> (`frontend-mp/`,`#ifdef` 条件编译),但**小程序端此前从未被任何自动化跑过** —— AGENTS.md 的
|
||||
> 节奏是"先做 H5(可自动化测试、不依赖小程序资质)"。
|
||||
|
||||
**环境**(一次性的,`wmp_doctor` 可自检):微信开发者工具已装且已登录、自动化端口 9420、
|
||||
项目 = `frontend-mp/dist/dev/mp-weixin`(`pnpm dev:mp-weixin` 的产物;`project.config.json` 里
|
||||
`urlCheck:false`、appid `wxf01d3fa60f87f0b7` **与后端 `fill.mini-program.app-id` 逐字相同**)。
|
||||
|
||||
**前置**:① `python scripts/e2e-m3-flow.py --fixture-only --keep` 建夹具(名册行必须**未绑定**);
|
||||
② `python scripts/dev-reveal-roster-idcard.py <taskId>` 取门禁要手输的明文姓名/身份证。
|
||||
|
||||
**实测步骤与结果**(iPhone 12/13,SDK 3.17.3,开发者工具 8.0.5):
|
||||
|
||||
| # | 操作(自动化动作) | 结果 |
|
||||
|:--:|---|---|
|
||||
| 1 | `reLaunch /pages/index/index?scene=t1-k2313` | 落地页正常:示范职业学校 / E2E流程 / **收集中** / scene 回显 —— **匿名接口在小程序里也是通的** |
|
||||
| 2 | `tap .land__btn`(开始填报)→ `pages/verify/verify` | 正常 |
|
||||
| 3 | 两个 `input` 填姓名 + 身份证 → `tap button` | ❌ **「未登录或登录已失效」**(后端 40101)→ **F-130** |
|
||||
| 4 | 用 `evaluate` 在页面上下文里调 `wx.login()` + `wx.request` 打 `/api/v1/mp/login` | ❌ scene 带 taskId → **HTTP 500 / code 50000**;同秒换 `scene=t1` → ✅ **200 + token** → **F-130** |
|
||||
| 5 | `wx.setStorageSync('token', …)`(桥接,见下)后重走 1~3 | ✅ 门禁通过:流程甲 / 脱敏号 / 班级 / 报考三项锁定;库里 `bind_user_id=1390`、`bind_type=1` |
|
||||
| 6 | 进 `pages/fill/fill?taskId=2313` | ✅ 表单 68 个元素全渲染,锁定项标注正确 |
|
||||
| 7 | `mockWx chooseImage` → `tap`「+ 选择文件」 | ✅ 控制台 `[材料压缩] IDCARD P2 证件翻拍:4KB → 2KB(q88)` → `collect_file` 落库,规范名 `流程甲…-3610292008XXXXXX13-身份证.jpg` |
|
||||
| 8 | `tap`「提交审核」 | ✅ 页内红单:`VAL-05 缺少必传材料:身份证` + 「知道了,去修改」 |
|
||||
| 9 | 传材料后再提交 | ✅ 页内黄单:`VAL-08「身份证」清晰度偏低(72×72)` + **「仍然提交」**(= L3-B 第 2 条,H5 那轮没走到的确认框) |
|
||||
| 10 | `tap`「仍然提交」 | ✅ 提交成功:`collect_submission.status=1`、`submit_time` 写入、`collect_roster.submit_status=2` |
|
||||
|
||||
**结论**:小程序端**除登录外整条链路都是好的** —— 落地页、门禁、动态表单、前端压缩上传、
|
||||
红黄两档校验与确认框、提交后的名册流转,全部在真机模拟器里跑通。
|
||||
唯一的断点是登录(**F-130**),而且它断在两处、任一处都足以让小程序不可用。
|
||||
|
||||
⚠️ **第 5 步是"桥接",不是正路**:直接把 token 塞进 storage 只是为了把「登录坏了」与
|
||||
「其余链路好不好」分开验证。**修好 F-130 之后这一步必须删掉。**
|
||||
|
||||
⚠️ **为什么没有做成 `.flow.mjs` 固化**:本轮可选的小程序自动化动词(`reLaunch`/`tap`/`input`/
|
||||
`evaluate`/`mockWx`)都实测过,但**流程文件的字段形状**(带 `index` 的 `input`、带选择器的
|
||||
`assert.text`、设 storage 的 `wx` 动词)没有可验证的样例 —— **宁可留一份可照着做的步骤表,
|
||||
也不往仓库里放一个跑不起来、却看起来像能跑的脚本**(这与 F-126 的教训是同一条)。
|
||||
等 F-130 修好、登录不再需要桥接时,再把它固化成 flow 文件更划算。
|
||||
|
||||
⚠️ **别忘了收尾**:`--fixture-only` 不触发清理;**登录成功那一次**(本轮是 `scene=t1` 那次)会真建
|
||||
`sys_user`(本轮 1 个:uid 1390,并被门禁绑到名册行 21695),它不在脚本的夹具清单里,要手工删。
|
||||
失败的登录**不建账号**(事务回滚,见 F-130 的副作用一节)。
|
||||
|
||||
---
|
||||
|
||||
## 六、已知缺陷(已定案)
|
||||
|
||||
### F-130【P0 · 功能不可用】微信小程序端的学生**完全进不来** —— 登录链路断成两截
|
||||
|
||||
**症状**(真机模拟器实测):扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 →
|
||||
点「核对并继续」→ 屏幕显示 **「未登录或登录已失效」**。
|
||||
|
||||
**断点 A(前端):小程序端从来没有调用过 `/mp/login`。**
|
||||
|
||||
- 全仓 `frontend-mp/src` 里**唯一**写 token 的地方是 `pages/index/index.vue:222`
|
||||
(从 **H5 授权回调**的 `options.token` 取),而这条路径是 H5 专有的;
|
||||
- 9 个页面(`fill` / `verify` / `tasks` / `progress` / `messages` / `dashboard` / `audit` / `audit-detail` / `subscribe`)
|
||||
的 `token()` 都读 `uni.getStorageSync('token')`;
|
||||
- 全仓搜不到 `wx.login` / `uni.login` / `/mp/login` 的**调用**(只有 `utils/request.ts:22` 一句注释
|
||||
「登录流程见契约 §5.x 的 /mp/login,**属 M3**」);也**没有 `web-view` 兜底**。
|
||||
- ⇒ 小程序里 token **永远是空串**,`POST /mp/roster/match` 不带 Authorization → 后端 40101。
|
||||
|
||||
**断点 B(后端):`/mp/login` 只要 scene 带 taskId 就必然 500。**
|
||||
|
||||
- `MpLoginService.login` 在 `:126-130` 有个"顺带把名册绑定带回去"的分支:
|
||||
`rosterMapper.selectOne(... collect_roster ...)`。`rosterMapper` 是**普通 `BaseMapper`**,
|
||||
而 `/mp/login` 是 **`@PublicApi`(匿名)** → **租户拦截器默认拒绝** → `BizException(40101)`
|
||||
→ MyBatis 包成 `PersistenceException` → `GlobalExceptionHandler` → **HTTP 500 / code 50000「系统繁忙,请稍后再试」**。
|
||||
- 日志铁证(本轮实测):`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389` 成功后紧接着
|
||||
`### Error querying database. Cause: cn.tianbaotong.fill.common.exception.BizException: 未登录或登录已失效`,
|
||||
栈顶 `at cn.tianbaotong.fill.module.mp.service.MpLoginService.login(MpLoginService.java:127)`。
|
||||
- **对照实验**(同秒、同端点、只改 scene):
|
||||
|
||||
| scene | 结果 |
|
||||
|---|---|
|
||||
| `t1`(不带任务) | **HTTP 200 + token**(`needBindPhone:true`,`user{id,realName:"微信用户",userType:4}`) |
|
||||
| `t1-k2313`(带任务) | **HTTP 500 / code 50000** |
|
||||
|
||||
- ⇒ `/mp/login` **只在"永远用不到的那个场景"下能用** —— 学生扫的码必然带 taskId(不然没有任务可填)。
|
||||
- **副作用(已核实,不要写成"留下一堆半成品账号")**:日志里明明打着
|
||||
`MpH5AuthService : H5 首次登录,已建学生账号:tenant=1 uid=1389`,但 `login()` 是 `@Transactional` 的,
|
||||
账号与 `sys_user_role` 都随异常**回滚**了 ——
|
||||
`SELECT id FROM sys_user WHERE id IN (1389,1390)` 只有 **1390**(那是本轮用 `scene=t1` 成功调出来的那个)。
|
||||
所以症状是「学生每次点都 500,什么也没留下」,**不是**「攒下一堆孤儿账号」;
|
||||
唯一残留是**自增 id 被消耗**(1389 成了空号)。
|
||||
- 修法方向(**未实施,属改代码**):该分支改成走**专用 Mapper + `@InterceptorIgnore(tenantLine="true")` + 显式带 tenant_id**
|
||||
—— 这正是 AGENTS.md 里"匿名接口必须走专用 Mapper"那条铁律,`MpH5Mapper` 就是这么做的
|
||||
(`selectByOpenId(tenantId, openId)`),而这里漏了。另外**异常不该以"系统繁忙"对外**:
|
||||
40101 被包成 50000,把"没登录"讲成了"服务器坏了"。
|
||||
|
||||
**为什么至今没暴露**:
|
||||
|
||||
- `/mp/login` **没有任何 E2E 覆盖** —— `scripts/e2e-m3-gate.py:18` 明确写着「登录(`/mp/login`)需要真实的
|
||||
`wx.login` code —— 脚本拿不到」。**本轮证明它拿得到**:开发者工具自动化里可以在页面上下文调 `wx.login()`
|
||||
拿到**真的** code(后端真换到了 openid),所以这条链路是可以自动化的。
|
||||
- 文档**互相矛盾**:`docs/08:147` 写着「~~P1-14~~ 学生端 `/mp/login` + 手机号绑定 → ✅ **已完成**」
|
||||
(指的是**后端**),而 `docs/交接摘要.md:563` 还留着「`/mp/login`(小程序登录)仍未做」。
|
||||
两边都不算错,但**没有任何一处说"前端没接"** —— 与 F-126 同一类:"宣布完成"与"界面上能用"之间的落差,
|
||||
`check-wiring.py` 的十项检查**没有一项会发现**(它查权限码/错误码/模板/菜单键,不查"契约端点有没有前端调用方")。
|
||||
|
||||
### F-125【P0 · 阻断闭环】学生被驳回后**改不了任何东西**
|
||||
|
||||
**现象**:填报单进入 `status=4`(已驳回)后,学生:
|
||||
|
||||
| 动作 | 结果 | 代码 |
|
||||
|---|---|---|
|
||||
| 改字段(`PUT /mp/submissions/{id}`) | `40030` | `SubmissionService.java:158-161` |
|
||||
| 传材料(`POST /mp/files/upload`) | `40030` | `MaterialFileService.java:103-105` |
|
||||
| 删材料(`DELETE /mp/files/{id}`) | `40030` | `MaterialFileService.java:338-341` |
|
||||
| 撤回(`POST /mp/submissions/{id}/withdraw`) | `40001`(只放行 1/2) | `StudentTaskService.java:190-196` |
|
||||
| **重新提交**(`POST .../submit`) | **允许** | `SubmissionService.java:293-297` |
|
||||
|
||||
**于是闭环断了**:学生只能**原样重交**,而驳回的意义正是「改了再交」。
|
||||
|
||||
**而且系统在四处同时告诉他「你可以改」**:
|
||||
|
||||
| 信号 | 值 | 代码 |
|
||||
|---|---|---|
|
||||
| `GET /mp/tasks` 的 `statusText` | 「被驳回,请修改后重新提交」 | `StudentTaskService.java:142` |
|
||||
| `GET /mp/tasks` 的 `canFillNow` | `true`(`underReview` 只算 1/2/3,**4 不算**) | `StudentTaskService.java:105-106` |
|
||||
| `GET /mp/submissions/{id}/progress` 的 `canEdit` | `true`(`st == 0 \|\| st == 4`) | `StudentTaskService.java:311` |
|
||||
| 同一个接口的 `canSubmit` | `true` | `StudentTaskService.java:313` |
|
||||
|
||||
**为什么 1617 条断言没抓到**:`scripts/e2e-m5-audit.py:531-538` 证明过「驳回后能重新提交」,但它在重交前只改了库里的 `reject_count`,**没有改任何业务字段**。切片测试把这一格填成了绿色。
|
||||
|
||||
**契约站在哪一边**:`docs/03-接口设计.md:239-248` 明确写 `4 已驳回 → 可流转到 1(学生重提交)`;`docs/03-接口设计.md:2040` 的撤回约束只写 `status ∈ {1,2}`,没有为「驳回后怎么改」留出口。**所以按契约,改不动就是实现缺一条路径。**
|
||||
|
||||
**建议的最小改法**(**改业务口径,须用户确认后再动**):把三处写守卫从 `!= STATUS_DRAFT` 放宽为 `∉ {STATUS_DRAFT, STATUS_REJECTED}`,与 `submit`(`SubmissionService.java:293-297`)和 `progress.canEdit`(`:311`)的口径对齐。涉及:`SubmissionService.java:158-161`、`MaterialFileService.java:103-105`、`MaterialFileService.java:338-341`。
|
||||
|
||||
**备选方案**(不动写守卫,改撤回守卫):让 `withdraw` 也接受 `status=4`(驳回 → 草稿 → 改 → 重交)。缺点是学生要先点一次「撤回」,而界面在驳回态**不显示撤回按钮**(`StudentTaskService.java:109` 的 `canWithdraw` 只算 1/2)。
|
||||
|
||||
### F-128【P0 · 阻断功能】扩展字段存进去却读不出来 —— 必填扩展字段会让整个任务**永久无法提交**
|
||||
|
||||
**现象**(`EP-D09` / `EP-J02e`,已实测):
|
||||
|
||||
```
|
||||
PUT /mp/submissions/{id} {"extrasJson": {"extra_upgrade_note": "已补填"}}
|
||||
→ code=0,库里 extras_json = {"extra_upgrade_note": "已补填"} ← 写成功了
|
||||
GET /mp/tasks/{task}/form
|
||||
→ 该字段的 value = null ← 读不出来
|
||||
POST /mp/submissions/{id}/submit
|
||||
→ {"submitted": false, "redCount": 1,
|
||||
"issues":[{"ruleCode":"VAL-22","title":"还有必填项没填:升版新增必填"}]} ← 填了也说没填
|
||||
```
|
||||
|
||||
**根因**:`FillService.assemble`(`FillService.java:106-159`)构造取值表 `values` 时只有两个来源 ——
|
||||
|
||||
1. `rosterValuesOf(roster)`(名册列)
|
||||
2. 遍历 `schema.getFields()` 中 `SubValues.hasColumn(key)` 成立的键,读 `collect_submission` 的**列**
|
||||
|
||||
**它从来没有把 `draft.extrasJson` 合进 `values`。** 而扩展字段的值只存在 `extras_json` 里,于是:
|
||||
|
||||
- `withValue(f, values)` 对扩展字段恒取到 `null` → **学生填的扩展字段重开表单就没了**(以为没保存上,再填一遍);
|
||||
- `SubmissionValidator.validate` 的 `values`(`SubmissionValidator.java:104-109`)来自同一份装配 → `checkRequiredFields` 的 `blank(values.get(key))` **恒为真** → **必填扩展字段永远满足不了**。
|
||||
|
||||
**影响**:
|
||||
|
||||
| # | 影响 | 严重度 |
|
||||
|:--:|---|:--:|
|
||||
| 1 | 表单里加了**必填**扩展字段 → 该任务对**所有人**永久阻断,没有任何办法满足它 | **P0(功能不可用)** |
|
||||
| 2 | 学生填的扩展字段保存后在界面上消失(数据在库里,但读不出来) | P1 |
|
||||
| 3 | 依赖 `values` 的下游(校验、导出前置判断)都看不到扩展字段的值 | P1 |
|
||||
|
||||
**为什么至今没暴露**:内置表单(`BuiltinFields.java`)**一个扩展字段都没有**(`extras` 为空),
|
||||
而既有 37 个脚本用的都是内置表单 —— 所以这条路径**从来没有数据走过**。它是"表单设计器给任务加了扩展字段"这个真实用法才会踩到的。
|
||||
|
||||
**建议的最小改法**(**改业务口径/数据读取口径,须确认后再动**):在 `assemble` 里把 `extrasJson` 合进 `values`,
|
||||
即对 `schema.getExtras()` 的每个键,从 `draft.getExtrasJson()` 解析后 `values.put(key, v)`。
|
||||
位置在 `FillService.java:144-158` 之后(那段循环之后、构造 sections 之前)。
|
||||
⚠️ 注意 `changed_fields` 的语义:扩展字段没有列,不要顺手把它们塞进 `changed_fields`(那是给内置列用的)。
|
||||
|
||||
### F-129【P2 · 静默丢数据】把已声明的扩展字段放进 `formData` 不会报错,值直接没了
|
||||
|
||||
`EP-E16` 实测:`{"formData": {"extra_upgrade_note": "塞错口袋"}}` → `code=0`,`extras_json` 仍是 `{}`。
|
||||
|
||||
对照:**未声明**的键放进 `formData` 会明确 `40001`(`SubmissionService.java:178-182`,注释写着"不静默忽略")。
|
||||
而**已声明**的扩展键放进 `formData` 时,`defs` 里查得到(`:169-171` 把 extras 也放进 `defs`),
|
||||
于是躲过了那道检查,接着 `SubValues.apply(sub, key, value)` 对它**没有对应列,静默什么也不做**。
|
||||
|
||||
契约要求扩展字段走 `extrasJson`(`DraftDtos.SaveReq.java:44`),所以这属于**用错参数**;
|
||||
但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。
|
||||
**建议**:`SubValues.apply` 遇到既不是列、又确实是声明过的扩展键时,抛出 40001 并提示"扩展字段请放在 extrasJson"。
|
||||
|
||||
### F-126【P2 · 脚本自述与断言不符】(已处理)
|
||||
|
||||
`scripts/e2e-m3-draft.py:11` 的 docstring 自述 `[x] 未过门禁不能建草稿;已提交的不能直接改`,但该文件**全文没有这条断言**(全仓唯一涉及 `40030` 的断言是 `scripts/e2e-m5-proxy.py:277`,测的是代传路径)。
|
||||
|
||||
**本轮已补**:`EP-E08`(已提交后 `PUT` → `40030`)与 `EP-F09`(已提交后上传 → `40030`)已落地并通过。
|
||||
**建议**:把 `scripts/e2e-m3-draft.py:11` 的自述改准(去掉那半句,或指向本脚本)。
|
||||
|
||||
**为什么值得记**:docstring 的 `[x]` 清单在本仓库被当作覆盖率的**唯一账本**(`scripts/verify-all.py` 的汇总、多份文档的「37 个脚本 / N 条断言」都建立在它上面)。自述虚高与 F-84 同类,而且**更隐蔽** —— 断言缺失不会让任何东西变红,反而让覆盖率看起来更好。
|
||||
**教训**:本计划 §3.1 的 `grep 'check(\|ok('` 核对规程**不是走形式** —— 它一上来就抓到了这一条。
|
||||
|
||||
### F-127【P2 · 文档口径未登记】§9.10 的「校验失败返回 40001」与实现不一致
|
||||
|
||||
- **契约**(`docs/03-接口设计.md:1988,1997-2019`):错误码 `40001 阻断`,`data` 里给 `blocking[]` / `warnings[]` / `canForceSubmit`。
|
||||
- **实现**(`FillController.java:59-70`):**HTTP 200 + `code:0`**,`data` 是 `{submitted:false, status, statusText, redCount, yellowCount, infoCount, issues[]}`。代码注释给了理由:校验不通过是**正常业务结果**,前端要一次拿到完整清单才能渲染红黄绿。
|
||||
|
||||
实现的口径与项目约定(业务错误不占 HTTP 状态码)一致,也已被 37 个脚本按 `data.submitted` 判定 —— **但 `§17.2 有意偏离契约的地方` 里没有它**(D-1~D-12,`docs/03-接口设计.md:2972-2988`)。后果是文档读者会写出 `code != 0` 的判据,而那是**恒假的**,测试会静默变绿。
|
||||
|
||||
**建议**:在 §17.2 补一行 **D-13** 记录该偏离(表格仍是 5 列,不影响 `verify-docs.py`)。本轮只提议,不在测试轮里动契约。
|
||||
|
||||
### 待定案 → 已定案(实测结论,2026-10)
|
||||
|
||||
| 问题 | 定案 | 证据 |
|
||||
|---|---|---|
|
||||
| 驳回后**重新提交**能否过校验(内容未变时) | **能过** —— 这也正是 F-125 的欺骗性所在:交得上去,但改不了 | 既有 `scripts/e2e-m5-audit.py:534` + 本脚本 `EP-I09` |
|
||||
| 表单升版后旧草稿提交是否报 `40025` | **不报**。`submit` 不比对 `form_version`;按**当前** schema 校验;库里 `form_version` 保持建单时快照 | 本脚本 `EP-J02b/J02c/J02d` |
|
||||
| 并发提交的实际行为 | **无保护**,两个请求都能拿到 `submitted=true`,落库状态仍自洽 | 本脚本 `EP-J03`(钉成已知事实) |
|
||||
| 任务已截止/暂停时能否核验身份 | **能**(门禁与任务状态**有意解耦**),但紧随其后的取表单会被 `40008` 拦住 | 本脚本 `EP-C08b/C08c` |
|
||||
| `VAL-17` 的「非阿拉伯数字」分支能否触发 | **触发不了**:`collect_submission.work_years` 是 `smallint`,「三年」在写库前就被类型挡住 → 该分支是死代码 | 本脚本 `EP-G11a`(钉住事实) |
|
||||
| 扩展字段能不能存、能不能读 | **存得进、读不出**(F-128) | 本脚本 `EP-D09/EP-J02e`(XFAIL) |
|
||||
|
||||
---
|
||||
|
||||
## 七、与《06-验收测试清单》的映射
|
||||
|
||||
《06》是**客户向**清单,含 M4 与已取消二期的条目,本计划**不改它**,只在此建立对应关系。
|
||||
|
||||
| 《06》条目 | 本计划 | 说明 |
|
||||
|---|---|---|
|
||||
| §3.1 主流程 A(A1~A23) | A/B/C 组 + chain 组 | 其中 A10/A11(材料移动)、A6(`changedFields` 记 phone)依赖 M4,**暂无自动验收** |
|
||||
| §3.4 异常边界 E1~E27 | §3 各组的错误用例 | E5/E16/E17/E18/E24/E25/E26/E27 属 M4(OCR/归属),**缓做**;E21~E23 随「加入申请」撤回一并作废 |
|
||||
| §3.2 主流程 B(社会收集) | — | 已作废(`40044`) |
|
||||
| §3.3 主流程 C(历史整理器) | — | 已取消 |
|
||||
| §4 功能验收 F-500 / F-800 | §3.9 / §3.10 | 名册与审核侧 |
|
||||
| §2 Golden Assertions(G-*) | 不在本计划 | 那是引擎级纯函数,用 112 个真实文件做断言,与链路无关 |
|
||||
|
||||
---
|
||||
|
||||
## 八、变更记录
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|---|---|---|
|
||||
| V1.0 | 2026-10 | 首版。覆盖矩阵 40 条本脚本用例 + 约 55 条既有脚本用例;登记 F-125 / F-126 / F-127 |
|
||||
| V1.1 | 2026-10 | 按实测结果回填:本脚本用例 40 → **45**;新增 **F-128**(扩展字段存得进读不出,必填扩展字段永久阻断,P0)与 **F-129**(扩展字段塞进 `formData` 被静默丢弃);F-126 已补断言;「待定案」六项**全部定案**;三种跑法的实测数字回填 §3.12 / §5.2 |
|
||||
| V1.2 | 2026-10 | 补 L3-B 真实浏览器走查结论(§5.3):走了 4 条里的 3 条,**F-125 的用户侧证据链拿到**(「保存失败:…请先撤回」+ 同一屏没有撤回入口 + 列表页按钮「修改后重新提交」);记下两个走查坑(scene 会进本地缓存、`--fixture-only` 不触发清理)与新增工具 `scripts/dev-reveal-roster-idcard.py` |
|
||||
| V1.3 | 2026-10 | **新增 §5.4 L3-C 微信小程序端走查**(需求方要求「学生提交的过程要测微信小程序端」):在开发者工具模拟器里把 10 步走完,**除登录外整条链路都通**(落地页 / 门禁 / 表单 / 前端压缩上传 / 红单 / 黄单确认 / 提交后名册流转);登记 **F-130(P0,小程序端登录断成两截:前端从不调 `/mp/login` + 后端 `MpLoginService.java:127` 匿名查租户表被拦截 → 50000)**;§5.3 第 2 条由 L3-C 补上。**本轮不改业务代码** |
|
||||
@@ -1,4 +1,13 @@
|
||||
-- ============ 1) 建表 ============
|
||||
--
|
||||
-- ⚠️ 2026-10 需求收敛:本文件是**初始建表稿**(V1 口径),**不是当前库结构的完整描述**。
|
||||
-- 已删除的对象见 `backend/xt-server/src/main/resources/db/migration/V19__remove_deputy_auditor_cleanup_join.sql`:
|
||||
-- · `collect_task_org.deputy_user_ids`(协办人列)—— 已 DROP
|
||||
-- · `collect_cleanup_job`(历史整理器)—— 已 DROP
|
||||
-- · `collect_join_request`(加入申请)—— 已 DROP
|
||||
-- 下面这三处的建表语句**刻意保留**(历史沿革可追溯),但新库是靠 V1→V19 全量迁移得到的,
|
||||
-- 最终结构里没有它们。要核对"当前真实结构",请查 `information_schema` 或跑
|
||||
-- `python scripts/check-entity-columns.py`(实体 ↔ 建表一致性)。
|
||||
|
||||
CREATE TABLE sys_tenant (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
|
||||
Vendored
+127
-212
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>填报通 · 管理端(PC)交互原型</title>
|
||||
<title>深学在线 · 管理端(PC)交互原型</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3; --ink4:#b3b9c1;
|
||||
@@ -76,8 +76,12 @@ figcaption .note{display:block;margin-top:8px;color:var(--ink3);font-size:12px}
|
||||
}
|
||||
.brand{padding:17px 17px 15px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:9px}
|
||||
.brand .mark{
|
||||
width:28px;height:28px;border-radius:7px;background:var(--ink);color:#fff;flex:none;
|
||||
display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:15px;font-weight:600;
|
||||
width:28px;height:28px;border-radius:7px;flex:none;
|
||||
/* 深学在线 标识:方(卷宗)+ 环(在线)。与 frontend-pc 的
|
||||
src/assets/images/logo.svg 同一形状,这里内嵌是为了原型
|
||||
「单文件自包含、零外部依赖」—— 不能引外部图片。
|
||||
原型自己的色板是墨色,所以底仍用 var(--ink),只取白色形状。 */
|
||||
background:var(--ink) url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='36.25' cy='26.62' r='16.31' fill='none' stroke='%23fff' stroke-width='5.9' stroke-opacity='.76'/><rect x='8.5' y='22.65' width='34' height='34' rx='10.4' fill='%23fff'/></svg>") center/112% no-repeat;
|
||||
}
|
||||
.brand .bn{font-family:var(--font-display);font-size:15px;font-weight:600;letter-spacing:.05em}
|
||||
.brand .bs{font-size:9.5px;color:var(--ink4);font-family:var(--font-mono);letter-spacing:.08em;margin-top:2px}
|
||||
@@ -288,9 +292,9 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
|
||||
<header class="masthead">
|
||||
<div class="kicker">Prototype · 管理端 PC · V1.2</div>
|
||||
<h1>填报通 · <em>管理端</em>交互原型</h1>
|
||||
<h1>深学在线 · <em>管理端</em>交互原型</h1>
|
||||
<p class="sub">
|
||||
11 屏:<strong>工作台 → 任务向导(命名试跑) → 名单导入 → 报考范围 → 数据看板 → 终审 → 导出中心</strong>,加上 <strong>表单设计器 / 材料规则配置 / 历史数据整理器 / 名单加入申请审批</strong>。<br>
|
||||
10 屏:<strong>工作台 → 任务向导(命名试跑) → 名单导入 → 报考范围 → 数据看板 → 终审 → 导出中心</strong>,加上 <strong>表单设计器 / 材料规则配置 / 角色与授权</strong>。<br>
|
||||
移动端是「档案室」,桌面端往「<strong>台账</strong>」方向走——表格、树形、批量操作。信息密度比手机端高一个量级,因为管理员是坐在电脑前做批量决策的人。<br>
|
||||
<span style="font-size:12.5px;color:var(--ink3)">后三屏是**追溯矩阵审计后补的**:三者都是复杂交互,光靠文字描述开发会理解偏。</span>
|
||||
</p>
|
||||
@@ -313,7 +317,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a class="on"><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -390,7 +394,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=7</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -528,7 +532,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/roster/import</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -635,7 +639,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/scope</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -734,7 +738,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/dashboard</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -834,7 +838,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/review?status=pending_final</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -913,7 +917,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/export/new</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -1017,7 +1021,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=4</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -1155,7 +1159,7 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=5</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
@@ -1254,240 +1258,151 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ⑩ 历史数据整理器 -->
|
||||
<!-- ⑪ 角色与授权 -->
|
||||
<figure>
|
||||
<div class="frame-wrap"><div class="origin">
|
||||
<div class="win"><div class="wbar">
|
||||
<div class="lights"><i></i><i></i><i></i></div>
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/cleanup</div>
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/roles</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<div class="brand"><div class="mark"></div><div><div class="bn">深学在线</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
<a><svg class="ico"><use href="#i-task"/></svg>收集任务</a>
|
||||
<a><svg class="ico"><use href="#i-users"/></svg>名单管理</a>
|
||||
<a><svg class="ico"><use href="#i-check-circle"/></svg>终审<span class="nb">23</span></a>
|
||||
<a><svg class="ico"><use href="#i-chart"/></svg>数据看板</a>
|
||||
<a><svg class="ico"><use href="#i-download"/></svg>导出中心</a>
|
||||
<div class="grp">工具</div>
|
||||
<a class="on"><svg class="ico"><use href="#i-wand"/></svg>历史数据整理</a>
|
||||
</nav>
|
||||
<div class="who"><div class="av">王</div><div><div class="wn">王老师</div><div class="wr">学校管理员</div></div></div>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="topbar"><h2>历史数据整理</h2><span class="crumb">把已有的乱文件夹,一键变成规范包</span></div>
|
||||
<div class="scroll">
|
||||
<div class="note info" style="margin-bottom:16px">
|
||||
<svg class="ico"><use href="#i-bulb"/></svg>
|
||||
<div><b>不需要重新收集。</b>把手上已有的压缩包或文件夹传上来,系统会用与正式收集<b>完全相同的规则</b>改名、压缩、体检,输出一份可直接上报的规范包 + 一份问题清单。</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start">
|
||||
<div class="card">
|
||||
<header>① 上传<span class="sp"><span class="pill ok"><svg class="ico"><use href="#i-check"/></svg>已解析</span></span></header>
|
||||
<div class="body">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:13px;border:1px solid var(--line);border-radius:9px;background:#faf9f6;margin-bottom:12px">
|
||||
<svg class="ico" style="font-size:24px;color:var(--ink4)"><use href="#i-folder"/></svg>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:13px;font-weight:600;font-family:var(--font-mono)">24中技数控1班.rar</div>
|
||||
<div style="font-size:11px;color:var(--ink3);margin-top:3px;font-family:var(--font-mono)">436 MB · 4 个目录 · 112 个文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note ok" style="margin-bottom:0">
|
||||
<svg class="ico"><use href="#i-check-circle"/></svg>
|
||||
<div>已按<b>「姓名-证件号-材料名」</b>解析出 28 名学生的 4 类材料,无需命名规范即可识别。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>② 定期体检报告<span class="sp"><span class="hint">用真实数据实测得出</span></span></header>
|
||||
<div class="body" style="padding:0">
|
||||
<table>
|
||||
<thead><tr><th>问题类型</th><th style="width:70px">数量</th><th style="width:96px">占比</th><th style="width:100px">处理</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>命名违规</td><td class="num" style="font-weight:600">59</td><td class="mono">52.7%</td><td><span class="pill auto">自动修复</span></td></tr>
|
||||
<tr><td>体积超标(>1M)</td><td class="num" style="font-weight:600">31</td><td class="mono">27.7%</td><td><span class="pill auto">自动压缩</span></td></tr>
|
||||
<tr><td>证件号与名单不符</td><td class="num" style="font-weight:600;color:var(--seal)">5</td><td class="mono">17.9%</td><td><span class="pill warn">3 自动 · 2 待确认</span></td></tr>
|
||||
<tr style="background:var(--seal-soft)"><td>疑似串档</td><td class="num" style="font-weight:600;color:var(--seal)">1</td><td class="mono">3.6%</td><td><span class="pill crit">必须人工</span></td></tr>
|
||||
<tr><td>分辨率不足</td><td class="num">1</td><td class="mono">3.6%</td><td><span class="pill warn">提示重传</span></td></tr>
|
||||
<tr><td>散图待归属</td><td class="num">0</td><td class="mono">—</td><td><span class="pill gray">—</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="body" style="border-top:1px solid var(--line);padding-top:13px">
|
||||
<div class="note info" style="margin-bottom:0">
|
||||
<svg class="ico"><use href="#i-bulb"/></svg>
|
||||
<div><b>112 个文件里 59 个需要改名、31 个需要压缩,但只有 1 个必须你亲自判断。</b>其余 90 个系统全自动处理。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>③ 输出结果<span class="sp"><span class="pill jade" style="background:var(--jade);color:#fff">规范化包已生成</span></span></header>
|
||||
<div class="body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:16px">
|
||||
<div class="stat" style="border-color:#cfe0d6;background:var(--jade-soft)"><div class="lbl">原始总量</div><div class="num" style="font-size:22px;color:var(--ink2)">475 MB</div></div>
|
||||
<div class="stat" style="border-color:#cfe0d6;background:var(--jade-soft)"><div class="lbl">规范化后</div><div class="num" style="font-size:22px;color:var(--jade)">37 MB</div></div>
|
||||
<div class="stat" style="border-color:#cfe0d6;background:var(--jade-soft)"><div class="lbl">压缩率</div><div class="num" style="font-size:22px;color:var(--jade)">92%</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn jade"><svg class="ico"><use href="#i-download"/></svg>下载规范化包</button>
|
||||
<button class="btn ghost"><svg class="ico"><use href="#i-download"/></svg>下载体检报告(Excel)</button>
|
||||
<button class="btn ghost"><svg class="ico"><use href="#i-check"/></svg>把结果导入为正式任务</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div></div></div>
|
||||
<figcaption>
|
||||
<span class="tag">STEP 10 · 差异化获客功能</span>
|
||||
<span class="title">历史数据整理器:让老师先看到价值,再谈上线</span>
|
||||
客户手里往往已经有一堆历史乱数据(本例是 <b style="font-family:var(--font-mono)">436MB</b> 的 rar)。<b>不需要重新收集</b>,直接传上来就能产出规范包和问题清单——这是最有力的演示,也是最有效的获客入口。
|
||||
<span class="note">报告里的数字<b>全部来自真实实测</b>:命名违规 59、体积超标 31、证件号异常 5(其中 3 个可自动、2 个待确认)、疑似串档 1、分辨率不足 1。最后那句"<b>90 个系统全自动处理,只有 1 个必须你亲自判断</b>",比任何功能列表都有说服力。</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ⑪ 名单加入申请审批(客户确认新增) -->
|
||||
<figure>
|
||||
<div class="frame-wrap"><div class="origin">
|
||||
<div class="win"><div class="wbar">
|
||||
<div class="lights"><i></i><i></i><i></i></div>
|
||||
<div class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/roster/join-requests</div>
|
||||
</div><div class="view">
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="mark">填</div><div><div class="bn">填报通</div><div class="bs">ADMIN CONSOLE</div></div></div>
|
||||
<nav class="nav">
|
||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||
<div class="grp">收集管理</div>
|
||||
<a><svg class="ico"><use href="#i-task"/></svg>收集任务</a>
|
||||
<a class="on"><svg class="ico"><use href="#i-users"/></svg>名单管理<span class="nb">5</span></a>
|
||||
<a><svg class="ico"><use href="#i-check-circle"/></svg>终审<span class="nb">23</span></a>
|
||||
<a><svg class="ico"><use href="#i-chart"/></svg>数据看板</a>
|
||||
<a><svg class="ico"><use href="#i-download"/></svg>导出中心</a>
|
||||
<div class="grp">工具</div>
|
||||
<a><svg class="ico"><use href="#i-wand"/></svg>历史数据整理</a>
|
||||
<div class="grp">组织与账号</div>
|
||||
<a><svg class="ico"><use href="#i-org"/></svg>组织架构</a>
|
||||
<a><svg class="ico"><use href="#i-users"/></svg>账号管理</a>
|
||||
<a class="on"><svg class="ico"><use href="#i-lock"/></svg>角色与授权</a>
|
||||
<div class="grp">平台运维</div>
|
||||
<a><svg class="ico"><use href="#i-list"/></svg>操作日志</a>
|
||||
</nav>
|
||||
<div class="who"><div class="av">王</div><div><div class="wn">王老师</div><div class="wr">学校管理员</div></div></div>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<h2>名单加入申请</h2><span class="crumb">24中技数控1班 · 2026年9月认定</span>
|
||||
<span class="sp"><button class="btn ghost sm">导出申请清单</button></span>
|
||||
<h2>角色与授权</h2><span class="crumb">江西省机电技师学院 · 4 个内置角色 + 1 个自定义</span>
|
||||
<span class="sp"><span class="hint">内置角色的权限由代码决定,不可修改</span></span>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="note info" style="margin-bottom:16px">
|
||||
<svg class="ico"><use href="#i-bulb"/></svg>
|
||||
<div><b>这些人在门禁没匹配上,主动申请加入名单。</b>通过后会<b>自动建成名册项</b>,该学生凭微信再次进入即可直接填报,不用重输。<br>
|
||||
<span style="font-size:11.5px;color:var(--ink3)">申请本身<b>不能填报</b>——通道只解决"进名单",不绕过门禁。</span></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:236px 1fr;gap:16px;align-items:start">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 300px;gap:16px;align-items:start">
|
||||
<div class="card" style="margin:0">
|
||||
<header>待审申请<span class="sp"><span class="pill warn">5 条待处理</span></span></header>
|
||||
<div class="body" style="padding:0">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:26px"><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></th>
|
||||
<th>申请人</th><th>身份证号</th><th>手机号</th><th>自述班级 / 说明</th><th style="width:74px">等待</th><th style="width:132px">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
||||
<td><b>李四</b></td>
|
||||
<td class="mono">361004********3318</td>
|
||||
<td class="mono">138****8888</td>
|
||||
<td><span style="font-size:11.5px">24中技数控2班</span><br><span style="font-size:11px;color:var(--ink3)">我是这学期转到2班的</span></td>
|
||||
<td class="mono">2.5h</td>
|
||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
||||
<td><b>王芳</b> <span class="pill auto" style="font-size:10px">他任务已有</span></td>
|
||||
<td class="mono">360105********1628</td>
|
||||
<td class="mono">139****2233</td>
|
||||
<td><span style="font-size:11.5px">—</span><br><span style="font-size:11px;color:var(--jade)">2026年3月认定 已在本校名单</span></td>
|
||||
<td class="mono">5.1h</td>
|
||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
||||
<td><b>赵敏</b></td>
|
||||
<td class="mono">361029********4021</td>
|
||||
<td class="mono">137****9012</td>
|
||||
<td><span style="font-size:11.5px">24中技数控1班</span><br><span style="font-size:11px;color:var(--ink3)">名单上把我的名字写错了</span></td>
|
||||
<td class="mono">7.8h</td>
|
||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
||||
<td><b>陈强</b></td>
|
||||
<td class="mono">362502********1830</td>
|
||||
<td class="mono">135****6677</td>
|
||||
<td><span style="font-size:11.5px">—</span><br><span style="font-size:11px;color:var(--ink3)">朋友说这里可以报名</span></td>
|
||||
<td class="mono">21.3h</td>
|
||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
||||
<td><b>孙悦</b> <span class="pill crit" style="font-size:10px">第 2 次申请</span></td>
|
||||
<td class="mono">361024********0917</td>
|
||||
<td class="mono">136****3344</td>
|
||||
<td><span style="font-size:11.5px">24中技数控2班</span><br><span style="font-size:11px;color:var(--seal)">上次被驳回:班级不在本次范围</span></td>
|
||||
<td class="mono">26.5h</td>
|
||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<header style="font-size:12.5px;padding:11px 13px">角色<span class="sp"><button class="btn primary sm">新建</button></span></header>
|
||||
<div class="body" style="padding:6px">
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||
<span style="flex:1">平台运营</span><span class="pill gray" style="font-size:10px">内置</span><span class="pill gray" style="font-size:10px">不可分配</span><span style="font-size:11px;color:var(--ink3)">0 人</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||
<span style="flex:1">学校管理员</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">1 人</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||
<span style="flex:1">班主任</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">1 人</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||
<span style="flex:1">学生</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">11 人</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px;background:var(--jade-soft)">
|
||||
<span style="flex:1;font-weight:600">年级审核员</span><span class="pill auto" style="font-size:10px">自定义</span><span style="font-size:11px;color:var(--ink3)">0 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="card" style="margin:0">
|
||||
<header style="font-size:12.5px;padding:11px 13px">批量处理</header>
|
||||
<div class="body" style="padding:13px">
|
||||
<button class="btn jade" style="width:100%;justify-content:center;margin-bottom:9px">通过已选(2 条)</button>
|
||||
<div class="note warn" style="padding:9px 11px;font-size:11.5px;line-height:1.65;margin-bottom:9px">
|
||||
<svg class="ico" style="font-size:13px"><use href="#i-alert"/></svg>
|
||||
<div><b>驳回必须逐条填原因</b>,不支持批量驳回</div>
|
||||
<header>
|
||||
年级审核员 <span class="mono" style="font-weight:400;color:var(--ink3)">GRADE_AUDITOR</span>
|
||||
<span class="sp">
|
||||
<button class="btn ghost sm">刷新</button>
|
||||
<button class="btn ghost sm">编辑</button>
|
||||
<button class="btn ghost sm">停用</button>
|
||||
<button class="btn ghost sm">删除</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;font-size:12.5px;margin-bottom:12px">
|
||||
<div><div style="color:var(--ink3);font-size:11px;margin-bottom:3px">类型</div>自定义</div>
|
||||
<div><div style="color:var(--ink3);font-size:11px;margin-bottom:3px">用户类型</div>班主任</div>
|
||||
<div><div style="color:var(--ink3);font-size:11px;margin-bottom:3px">数据范围</div>仅本人</div>
|
||||
<div><div style="color:var(--ink3);font-size:11px;margin-bottom:3px">账号数</div>0</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--ink3);line-height:1.7">
|
||||
批量通过时会弹出确认框,逐条列出将建册的人员,<b>不提供"全选"快捷键</b>。
|
||||
|
||||
<div class="note warn" style="margin-bottom:14px">
|
||||
<svg class="ico"><use href="#i-alert"/></svg>
|
||||
<div><b>有未保存的改动。</b>点「保存授权」后<b>立即生效</b>——已登录的账号不需要重新登录(服务端会清掉这个角色的授权缓存)。</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12.5px;font-weight:600;margin-bottom:4px">能做什么(权限码)</div>
|
||||
<div style="font-size:11.5px;color:var(--ink3);margin-bottom:9px">决定按钮与接口能不能用;勾了才会真的生效</div>
|
||||
|
||||
<div style="font-size:11.5px;color:var(--ink2);margin-bottom:6px">名册</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:12.5px;margin-bottom:12px">
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 名册查看 <span class="mono" style="font-size:11px;color:var(--ink3)">roster:view</span></label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 名册修改(仅本班) <span class="mono" style="font-size:11px;color:var(--ink3)">roster:manageOwn</span></label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 名册导入与修改(全租户) <span class="mono" style="font-size:11px;color:var(--ink3)">roster:manage</span></label>
|
||||
</div>
|
||||
|
||||
<div style="font-size:11.5px;color:var(--ink2);margin-bottom:6px">审核</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:12.5px;margin-bottom:12px">
|
||||
<label><input type="checkbox" checked style="accent-color:var(--jade);width:14px;height:14px"> 初审(班主任) <span class="mono" style="font-size:11px;color:var(--ink3)">audit:first</span></label>
|
||||
<label><input type="checkbox" checked style="accent-color:var(--jade);width:14px;height:14px"> 教师代传材料 <span class="mono" style="font-size:11px;color:var(--ink3)">file:proxyUpload</span></label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 终审(管理员) <span class="mono" style="font-size:11px;color:var(--ink3)">audit:final</span></label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 撤回审核 <span class="mono" style="font-size:11px;color:var(--ink3)">audit:withdraw</span></label>
|
||||
</div>
|
||||
|
||||
<div style="font-size:11.5px;color:var(--ink2);margin-bottom:6px">平台运营<span class="hint" style="margin-left:6px">学校租户配不了这一组</span></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:12.5px;color:var(--ink3);margin-bottom:14px">
|
||||
<label><input type="checkbox" disabled style="width:14px;height:14px"> 租户与套餐管理 <span class="mono" style="font-size:11px">tenant:manage</span> <span class="pill crit" style="font-size:10px">平台级,学校不可授予</span></label>
|
||||
<label><input type="checkbox" disabled style="width:14px;height:14px"> 全量操作日志 <span class="mono" style="font-size:11px">log:viewAll</span> <span class="pill crit" style="font-size:10px">平台级,学校不可授予</span></label>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12.5px;font-weight:600;margin-bottom:4px">看得见哪些入口(菜单)</div>
|
||||
<div style="font-size:11.5px;color:var(--ink3);margin-bottom:9px">只影响左侧菜单显不显示 —— 真正的权限在上面那一栏,绕过前端只会拿到 40301</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px 14px;font-size:12.5px">
|
||||
<label><input type="checkbox" checked style="accent-color:var(--jade);width:14px;height:14px"> 工作台</label>
|
||||
<label><input type="checkbox" checked style="accent-color:var(--jade);width:14px;height:14px"> 收集任务</label>
|
||||
<label><input type="checkbox" checked style="accent-color:var(--jade);width:14px;height:14px"> 审核</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 导出中心</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 通知记录</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 字典管理</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 组织架构</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 账号管理</label>
|
||||
<label><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"> 角色与授权</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px">
|
||||
<button class="btn primary sm">保存授权</button>
|
||||
<button class="btn ghost sm">撤销改动</button>
|
||||
<span class="hint" style="margin-left:8px">保存后立即生效;已登录的账号不需要重新登录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin:0">
|
||||
<header style="font-size:12.5px;padding:11px 13px">本任务申请统计</header>
|
||||
<header style="font-size:12.5px;padding:11px 13px">内置角色点开是什么样</header>
|
||||
<div class="body" style="padding:13px">
|
||||
<div class="row" style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px dashed var(--line);font-size:12.5px"><span style="color:var(--ink2)">待审核</span><b>5</b></div>
|
||||
<div class="row" style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px dashed var(--line);font-size:12.5px"><span style="color:var(--ink2)">已通过</span><b style="color:var(--jade)">12</b></div>
|
||||
<div class="row" style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px dashed var(--line);font-size:12.5px"><span style="color:var(--ink2)">已驳回</span><b>3</b></div>
|
||||
<div class="row" style="display:flex;justify-content:space-between;padding:7px 0;font-size:12.5px"><span style="color:var(--ink2)">通过率</span><b>80%</b></div>
|
||||
<div class="note info" style="margin-top:11px;padding:9px 11px;font-size:11.5px;line-height:1.6">
|
||||
<svg class="ico" style="font-size:13px"><use href="#i-check-circle"/></svg>
|
||||
<div>12 人已通过并自动入册</div>
|
||||
<div class="note info" style="margin-bottom:0">
|
||||
<svg class="ico"><use href="#i-lock"/></svg>
|
||||
<div><b>只读。</b>权限矩阵照着显示(可以拿它当模板照着勾),但勾选框是禁用的、没有「保存授权」按钮。<br>
|
||||
<span style="font-size:11.5px;color:var(--ink3)">要不同的能力范围,请<b>新建一个自定义角色</b>——而不是去改内置的。</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div></div></div>
|
||||
<figcaption>
|
||||
<span class="tag">STEP 11 · 客户确认新增</span>
|
||||
<span class="title">名单加入申请:给门禁一条受控的出口,而不是开后门</span>
|
||||
门禁不通过的人原先只能"联系老师",老师再手工加名单。<b>几百人的社会收集场景下,老师会被拖垮</b>。这一屏把加人这件事从"老师手工"变成"学生自助 + 老师逐条裁决"。
|
||||
<span class="note">四个细节:<br>
|
||||
① <b>「他任务已有」标记</b>——王芳的证件号在「2026年3月认定」的名册里匹配上了,系统主动提示"这可能是本校学生",帮管理员判断;<br>
|
||||
② <b>「第 2 次申请」标记</b>——孙悦上次被驳回,管理员能看到上次的驳回原因,避免重复纠结;<br>
|
||||
③ <b>「驳回必须逐条填原因」</b>且批量通过不提供全选——身份认定环节的误操作代价极高,与"批量通过只带绿色项"同源;<br>
|
||||
④ 顶部那句<b>「申请本身不能填报」</b>是设计边界:通道只解决"进名单",不绕过门禁。</span>
|
||||
<span class="tag">STEP 10 · 角色与授权</span>
|
||||
<span class="title">权限是契约、不是配置:内置角色只读,自定义角色可配</span>
|
||||
内置角色现在只剩 4 个(平台运营 / 学校管理员 / 班主任 / 学生),权限写在后端代码里(<span class="mono">BuiltinRole</span>)、界面上<b>只读</b>。租户可另建自定义角色,于是"谁能做什么"第一次变成客户可以自己配的东西 —— 而这类功能一旦做松,代价是<b>管理员把自己锁在系统外</b>。
|
||||
<span class="note">五个细节:<br>
|
||||
① <b>内置角色只读</b>——可以看、不能改。它的权限是前后端契约(权限码定义在代码里),改它等于改代码;界面上正确的做法是"照着它新建一个";<br>
|
||||
② <b>「看得见」与「有权限」分开</b>——菜单只决定左侧显示哪些入口,真正的访问控制是权限码校验(绕过前端只会拿到 40301);<br>
|
||||
③ <b>平台级权限与平台专用菜单在界面上禁用</b>,服务端也会以 40301 拒绝——"学校管理员给自己人造一个平台账号"是这套系统里最不可逆的越权;<br>
|
||||
④ <b>改完立即生效</b>——服务端在写授权时清掉该角色的缓存,所以不必"重新登录看看生效没有";停用角色同理(立刻不再给权限,而不是等 token 过期);<br>
|
||||
⑤ <b>防自锁</b>——不允许去掉自己进入本页所依赖的最后一条「角色与授权」权限/菜单,也不允许删掉唯一的管理员角色;这类错误只能靠 SQL 救回来。</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -1529,8 +1444,8 @@ td.num{font-family:var(--font-mono);text-align:right}
|
||||
如果下拉里只写"P1 / P2 / P3",管理员必须去翻文档才知道选哪个。**把档位的适用条件直接写在选项里**,配置界面本身就变成说明书。
|
||||
</div>
|
||||
<div class="aside-card">
|
||||
<h4><span class="n">09</span>为什么整理器是获客利器</h4>
|
||||
客户手里普遍已有历史乱数据(本例 436MB 的 rar)。让他们**先看到价值再谈上线**:不重新收集,30 秒内出一份体检报告和规范包。报告里"<b>90 个系统全自动处理,只有 1 个必须你亲自判断</b>"这句话,比任何功能列表都有说服力。
|
||||
<h4><span class="n">09</span>为什么内置角色只读、自定义角色才能改</h4>
|
||||
内置角色的权限<b>定义在后端代码里</b>(权限码是前后端契约:后端用它校验、前端用它控制按钮显隐)。把它做成"界面上可改",等于把一份契约拆成两处维护——而两处一旦不一致,表现是"按钮显示出来了、点了报 40301"。更要紧的是:一次误操作就能把管理员锁在系统外,而这类错误<b>不可逆</b>。所以界面上只读,需要别的能力范围时<b>新建一个自定义角色</b>。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Vendored
+5
-7
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>填报通 · 原型索引</title>
|
||||
<title>深学在线 · 原型索引</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
||||
@@ -68,7 +68,7 @@ a.card:hover .go{color:var(--seal)}
|
||||
|
||||
<div class="head">
|
||||
<div class="kicker">Prototype Index · V1.0</div>
|
||||
<h1>填报通 · <em>交互原型</em></h1>
|
||||
<h1>深学在线 · <em>交互原型</em></h1>
|
||||
<p>职业技能等级认定报名材料收集系统 · 两端原型<br>设计语言:「档案室 / 电子卷宗」</p>
|
||||
</div>
|
||||
|
||||
@@ -76,10 +76,9 @@ a.card:hover .go{color:var(--seal)}
|
||||
<a class="card" href="student-app.html">
|
||||
<div class="ic"><svg class="ico"><use href="#i-user"/></svg></div>
|
||||
<h2>学生填报端</h2>
|
||||
<div class="meta">11 SCREENS</div>
|
||||
<div class="meta">10 SCREENS</div>
|
||||
<ul>
|
||||
<li>门禁核验 · 形近字确认 · 统一失败提示</li>
|
||||
<li><b>申请加入名单</b>(门禁失败后的受控出口)</li>
|
||||
<li>填报主页(字段三类规则:锁定/可改/材料)</li>
|
||||
<li>材料来源三通道(聊天记录/相册/文件,无拍照)</li>
|
||||
<li>上传即规范化 · 压缩率可视化 · 传错位置提示</li>
|
||||
@@ -106,15 +105,14 @@ a.card:hover .go{color:var(--seal)}
|
||||
<a class="card adm" href="admin-web.html">
|
||||
<div class="ic"><svg class="ico"><use href="#i-desktop"/></svg></div>
|
||||
<h2>管理端(PC)</h2>
|
||||
<div class="meta">11 SCREENS · 1180px</div>
|
||||
<div class="meta">10 SCREENS · 1180px</div>
|
||||
<ul>
|
||||
<li>工作台(结论优先 + 待办排序)</li>
|
||||
<li><b>命名规则试跑</b>——发布前的唯一闸门</li>
|
||||
<li>名单导入(表头定位可视化)</li>
|
||||
<li>报考范围确认(聚合 + 一致性体检)</li>
|
||||
<li>数据看板 · 终审 · 导出中心(目录树预览)</li>
|
||||
<li><b>表单设计器 · 材料规则配置 · 历史数据整理器</b></li>
|
||||
<li><b>名单加入申请审批</b>(客户确认新增)</li>
|
||||
<li><b>表单设计器 · 材料规则配置 · 角色与授权</b></li>
|
||||
<li>支持 100% / 82% / 66% 缩放查看</li>
|
||||
</ul>
|
||||
<div class="go">打开原型 <svg class="ico"><use href="#i-arrow"/></svg></div>
|
||||
|
||||
Vendored
+7
-76
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>填报通 · 学生端交互原型</title>
|
||||
<title>深学在线 · 学生端交互原型</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
||||
@@ -145,11 +145,6 @@ figcaption .tag{
|
||||
.input.mono{font-family:var(--font-mono);letter-spacing:.13em;font-size:16.5px}
|
||||
.input:focus{outline:0;border-color:var(--ink);box-shadow:0 0 0 3px rgba(27,36,48,.07)}
|
||||
.input::placeholder{color:#b3b9c1;letter-spacing:normal;font-size:15px}
|
||||
/* 申请加入名单入口(次要路径,视觉上不与主操作竞争) */
|
||||
.apply-entry{border-top:1px dashed var(--line2);padding-top:12px;margin-bottom:6px;text-align:center}
|
||||
.apply-entry .ae-t{font-size:12px;color:var(--ink3);margin-bottom:9px}
|
||||
.apply-entry .ae-btn{width:100%;justify-content:center}
|
||||
.apply-hint{font-size:11.5px;color:var(--ink3);line-height:1.7;margin-top:9px;text-align:left}
|
||||
.stepbox{display:flex;gap:10px;margin-bottom:15px}
|
||||
.stepbox .sb{flex:1;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--ink3)}
|
||||
.stepbox .sb i{width:19px;height:19px;border-radius:50%;background:var(--line);color:var(--ink3);display:flex;align-items:center;justify-content:center;font-style:normal;font-size:11px;font-family:var(--font-mono);flex:none}
|
||||
@@ -347,10 +342,10 @@ figcaption .tag{
|
||||
|
||||
<header class="masthead">
|
||||
<div class="kicker">Prototype · 学生端 · V1.2</div>
|
||||
<h1>填报通 · <em>学生填报端</em>交互原型</h1>
|
||||
<h1>深学在线 · <em>学生填报端</em>交互原型</h1>
|
||||
<p class="sub">
|
||||
11 屏完整流程:<strong>门禁核验 → 信息核对 → 选材上传 → 自动规范化 → 提交审核</strong>。<br>
|
||||
<span style="font-size:12.5px;color:var(--ink3)">新增 STEP 03B「申请加入名单」——门禁失败后的受控出口,客户确认后加入。</span><br>
|
||||
10 屏完整流程:<strong>门禁核验 → 信息核对 → 选材上传 → 自动规范化 → 提交审核</strong>。<br>
|
||||
<span style="font-size:12.5px;color:var(--ink3)">门禁失败只有「重新填写」一条系统内出路;确实不在名单里时由老师手工加人(系统内不展示联系方式)。</span><br>
|
||||
设计方向为「档案室 / 电子卷宗」——本系统替代的是纸质材料袋与红章审批,界面应让人感到<strong>有秩序、可信、像一份正经公文</strong>。<br>
|
||||
图标为内联 SVG 线性图标(统一 1.7px 描边、继承文字色),不用 emoji——避免跨平台渲染不一致,也避免消费级观感。<br>
|
||||
全部文案遵循「零术语 · 指出位置 · 给出动作」三原则;数据取自真实批次 <code>24中技数控1班</code> 的 28 名学生。
|
||||
@@ -446,7 +441,7 @@ figcaption .tag{
|
||||
<div class="pagehead">
|
||||
<div class="glyph gray"><svg class="ico xl"><use href="#i-search"/></svg></div>
|
||||
<h2>没有找到你的信息</h2>
|
||||
<p>请核对姓名和身份证号是否与老师提供的名单一致。<br>如果确实已报名,可以申请加入名单,由老师审核。</p>
|
||||
<p>请核对姓名和身份证号是否与老师提供的名单一致。<br>如确认已报名,请联系老师把你加入名单。</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="panel">
|
||||
@@ -461,10 +456,6 @@ figcaption .tag{
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="btn primary" style="width:100%;margin-bottom:13px">重新填写</button>
|
||||
<div class="apply-entry">
|
||||
<div class="ae-t">名单里确实没有你?</div>
|
||||
<button class="btn ghost ae-btn"><svg class="ico"><use href="#i-user-plus"/></svg>申请加入名单</button>
|
||||
</div>
|
||||
<div class="center-note">连续 10 次未匹配将暂时锁定,1 小时后可重试</div>
|
||||
</div>
|
||||
</div></div>
|
||||
@@ -473,73 +464,13 @@ figcaption .tag{
|
||||
<b>门禁失败(统一提示)</b>
|
||||
<span style="color:var(--seal)">故意不区分失败原因</span>——无论"号码不在名单"还是"姓名不符",文案完全一致。<br>
|
||||
否则这个接口就能被用来<b style="display:inline">逐个探测身份证号是否在名单里</b>。这一屏是安全设计,不是文案设计。
|
||||
<span class="note">底部只有两条出路,都是<b>系统内的动作</b>:<br>
|
||||
<span class="note">底部只有一条<b>系统内的出路</b>:<br>
|
||||
① <b>重新填写</b>(主路径)——大多数情况只是名字打错了;<br>
|
||||
② <b>申请加入名单</b>(FR-509)——确实不在名单里时自助申请,由管理员逐条审批。<br>
|
||||
② 确实不在名单里时,<b>请联系老师把你加入名单</b>(由老师在任务里手工加人)。<br>
|
||||
<b>系统内不展示任何联系方式</b>——学生在班级群里本来就有班主任的联系方式,系统再放一份既不必要,也带来维护与隐私成本。</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ③B 申请加入名单(客户确认新增) -->
|
||||
<figure>
|
||||
<div class="device"><div class="screen">
|
||||
<div class="statusbar"><span>9:41</span><span class="icons"><svg class="ico"><use href="#i-signal"/></svg><svg class="ico"><use href="#i-wifi"/></svg><i class="bat"></i></span></div>
|
||||
<div class="navbar"><span class="back"><svg class="ico"><use href="#i-back"/></svg></span>申请加入名单</div>
|
||||
<div class="body">
|
||||
<div class="stepbox">
|
||||
<div class="sb on"><i>1</i>填写申请</div>
|
||||
<div class="sb"><i>2</i>等待审核</div>
|
||||
<div class="sb"><i>3</i>重新核验</div>
|
||||
</div>
|
||||
|
||||
<div class="notice info">
|
||||
<span class="ni"><svg class="ico lg"><use href="#i-shield"/></svg></span>
|
||||
<div>提交后由学校老师审核,<b>审核期间还不能填报</b>。通过后你会收到通知,届时重新核验即可进入。</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="ptitle">申请人信息</div>
|
||||
<div class="field">
|
||||
<label>姓名<span class="req">*</span></label>
|
||||
<input class="input" value="李四">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>身份证号<span class="req">*</span></label>
|
||||
<input class="input mono" value="361004200812153318">
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label>手机号<span class="req">*</span></label>
|
||||
<input class="input mono" value="13800008888">
|
||||
<div class="apply-hint">用于老师与你联系,不会公开展示</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="ptitle">补充说明 <span class="badge lock">选填</span></div>
|
||||
<div class="field">
|
||||
<label>你所在的班级</label>
|
||||
<input class="input" value="24中技数控2班">
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label>说明</label>
|
||||
<textarea class="input" style="height:64px;padding:10px 13px;line-height:1.6;resize:none">我是这学期转到2班的,原名单上没有我</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="btn primary" style="width:100%">提交申请</button>
|
||||
<div class="center-note">同一任务最多申请 2 次 · 提交后不可修改</div>
|
||||
</div>
|
||||
</div></div>
|
||||
<figcaption>
|
||||
<span class="tag">STEP 03B · 客户确认新增</span>
|
||||
<b>申请加入名单</b>
|
||||
三步进度条把"<b>提交 → 等待 → 重新核验</b>"讲清楚,避免学生误以为提交完就能填了。<br>
|
||||
<span class="note">顶部那条提示是本屏的关键:<b>「审核期间还不能填报」</b>。如果这句话不写,学生会反复回来点填报、以为系统坏了。<br>
|
||||
补充说明是<b>选填</b>——实测里"我是转班过来的"这类信息对管理员判断很有用,但不能设成必填,否则会挡住真正需要帮助的人。</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ④ 填报主页 -->
|
||||
<figure>
|
||||
<div class="device"><div class="screen">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user