Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df078e0ea | |||
| 1f3c485ceb | |||
| 5b877451eb |
@@ -3,22 +3,27 @@
|
|||||||
面向 AI 编码助手的项目指南。**用简体中文交流、用中文写文档。**
|
面向 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/` 放响应体/错误码/加解密) |
|
| `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` |
|
| `frontend-mp/` | 学生端 **uni-app**(H5 与微信小程序同一套代码) | `pnpm build:h5` / `pnpm dev:mp-weixin` |
|
||||||
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
|
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
|
||||||
|
|
||||||
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
|
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
|
||||||
|
|
||||||
**一期范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`,
|
**范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`。
|
||||||
别顺手把二期功能实现出来。
|
**不设二期** —— 与「二期」有关的任何设计(社会收集、社会评价、专项职业能力、数据范围可配等)都**不做**,
|
||||||
|
别顺手实现;OCR 与材料识别是「缓做,等供应商选型」。
|
||||||
|
|
||||||
## 二、常用命令(备注里是容易猜错的地方)
|
## 二、常用命令(备注里是容易猜错的地方)
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active
|
|||||||
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
|
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
|
||||||
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
||||||
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
|
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
|
||||||
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 V6;迁移文件发布后不许改)
|
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 **V19**;迁移文件发布后不许改,只能新加)
|
||||||
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
|
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
|
||||||
(模板 `backend/application-local.yml.example`)。
|
(模板 `backend/application-local.yml.example`)。
|
||||||
**不要改用 `spring.config.import`** —— 它优先级低于 profile 文件,会静默失效(见 `application.yml` 注释)
|
**不要改用 `spring.config.import`** —— 它优先级低于 profile 文件,会静默失效(见 `application.yml` 注释)
|
||||||
@@ -56,12 +61,17 @@ pnpm typecheck # vue-tsc pnpm lint # eslint
|
|||||||
|
|
||||||
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
|
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
|
||||||
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
|
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
|
||||||
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据。
|
**没有 Mock**(`mock/` 与 `vite-plugin-mock` 都已删除)→ 后端不起着就没有数据
|
||||||
⚠️ `frontend-pc/.agents/skills/project-api/SKILL.md` 仍以 Mock 为主线(Gi 模板原文),
|
- 接口模块都在 `src/apis/xt/`(按业务域分文件);**没有 auto-import**,组件与 API 全显式 `import`
|
||||||
该文件开头已加"本仓库不用 Mock"的说明 —— 看它时先看那段
|
- ⚠️ **2026-10 已从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI**,因此:
|
||||||
- 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加
|
- **`pnpm lint:style`(Stylelint)已移除**,只剩 `pnpm lint`(ESLint);`pnpm typecheck` 现在 **0 错**
|
||||||
- **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。
|
- 权限判断统一写 `userStore.has('码')`(**没有 `v-hasPerm` 指令**),字符串必须与后端 `PermissionDef` **逐字一致**
|
||||||
改那里的代码前按它的表加载对应 skill,不要另起一套写法
|
- `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)
|
### 学生端(uni-app)
|
||||||
|
|
||||||
@@ -78,7 +88,8 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
|||||||
dev server → 代理;构建产物(后端托管)→ `location.origin`;小程序 → 写死地址
|
dev server → 代理;构建产物(后端托管)→ `location.origin`;小程序 → 写死地址
|
||||||
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
|
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
|
||||||
- 构建时 Dart Sass 的 `legacy-js-api` DEPRECATION 属正常
|
- 构建时 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**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
|
- 当前节奏:**先做 H5**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
|
||||||
|
|
||||||
## 三、怎么验证(本仓库最不显然的一点)
|
## 三、怎么验证(本仓库最不显然的一点)
|
||||||
@@ -90,7 +101,8 @@ pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
|||||||
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
||||||
```
|
```
|
||||||
|
|
||||||
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**38 个脚本 / 1620 条断言**),
|
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(**37 个脚本 / 1595 条断言**,以最近一次
|
||||||
|
`verify-all --with-e2e` 的输出为准;2026-10 需求收敛删掉了 `e2e-m2-join.py` 与 `e2e-m7-cleanup.py`),
|
||||||
或一条命令 `python scripts/verify-all.py --with-e2e`
|
或一条命令 `python scripts/verify-all.py --with-e2e`
|
||||||
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||||
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
||||||
@@ -105,9 +117,17 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
|||||||
- 文档一致性:`python scripts/verify-docs.py`
|
- 文档一致性:`python scripts/verify-docs.py`
|
||||||
- 接线检查:`python scripts/check-wiring.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 换品牌标时连踩两次))。
|
||||||
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
⚠️ 这一族错**编译 / lint / review 都不会红**(类在、表在、常量在、注释还写得很清楚),
|
||||||
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)
|
只有"从入口走一遍"才暴露 —— 所以把它固化成命令;有意不接线的写在脚本上方的例外表里(带理由)。
|
||||||
|
⚠️ **检查器自己也会失效**:PC 端重搭后权限码的写法从 `permissions.includes` / `v-hasPerm`
|
||||||
|
改成了 `user.has`,第 ⑦ 项曾因此"一个码都扫不到却判为通过"。
|
||||||
|
加/改这类检查时**必须反向验证一次**(故意写错,它得红)
|
||||||
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
|
- 一条命令跑完上面这些:`python scripts/verify-all.py --with-e2e`
|
||||||
(9 项;**跳过项会如实列出来**,不是静默跳过)
|
(9 项;**跳过项会如实列出来**,不是静默跳过)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 数据信息填报系统
|
# 深学在线
|
||||||
|
|
||||||
> 职业技能等级认定**报名材料收集 SaaS**
|
> 职业技能等级认定**报名材料收集 SaaS**
|
||||||
> **一期范围**:学校收集 + 自主认定(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力后置二期
|
> **范围(2026-10 收敛)**:只做「学校收集 + 自主认定」(4 类材料,全部有实测样本)。社会收集 / 社会评价 / 专项职业能力 **不做**(传 `collectMode=2`、`certType=2,3` 返回 `40044`);**不设二期**
|
||||||
> 学生在微信小程序填报 · 班主任在手机初审 · 管理员在 PC 终审与导出
|
> 学生在微信小程序填报 · 班主任在手机初审 · 管理员在 PC 终审与导出
|
||||||
> 最后更新:2026-09-28 | **状态:需求已冻结(客户全部确认,可开工)**
|
> 最后更新:2026-09-28 | **状态:需求已冻结(客户全部确认,可开工)**
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 |
|
| **产品 / 需求** | `数据信息填报系统PRD.md` → `数据信息填报系统PRD-功能逻辑卷.md` | 前者含库表接口,后者是纯功能逻辑 |
|
||||||
| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 |
|
| **后端开发** | `03-接口设计.md` → `04-核心模块设计说明.md` → `db/schema-postgresql.sql` | 接口收发 → 引擎怎么做 → 建表 |
|
||||||
| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** |
|
| **前端开发** | `prototype/` → `03-接口设计.md` | 原型是视觉与交互的**唯一基准** |
|
||||||
| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 29 条 Golden 断言 + 85 条功能验收 |
|
| **测试** | `06-验收测试清单.md` → `04-核心模块设计说明.md` 附录 B | 36 条 Golden 断言 + 111 条功能验收 |
|
||||||
| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 |
|
| **新加入的人** | `交接摘要.md` → 本文件 | 一份文档看清全部状态 |
|
||||||
|
|
||||||
### 全部文档
|
### 全部文档
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
| 00 | `00-交付计划.md` | 8 KB | 7 项计划与进度追踪、风险跟踪 | ✅ |
|
| 00 | `00-交付计划.md` | 8 KB | 7 项计划与进度追踪、风险跟踪 | ✅ |
|
||||||
| 01 | `01-客户与机构确认函.md` | 18 KB | **23 项已全部确认** + 客户回执 + 落地动作清单 | ✅ **需求冻结** |
|
| 01 | `01-客户与机构确认函.md` | 18 KB | **23 项已全部确认** + 客户回执 + 落地动作清单 | ✅ **需求冻结** |
|
||||||
| 02 | `02-需求追溯矩阵.md` | 10 KB | 115 条 FR ↔ 原型/库表/接口 的自审报告 | ✅ |
|
| 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 项回归基线 | ✅ |
|
| 04 | `04-核心模块设计说明.md` | 56 KB | **6 个模块**伪代码级说明 + 依赖图 + 26 项回归基线 | ✅ |
|
||||||
| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ |
|
| 05 | `05-开发任务拆解.md` | 31 KB | **100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与风险 | ✅ |
|
||||||
| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ |
|
| 06 | `06-验收测试清单.md` | 29 KB | **36 条 Golden 断言** + 111 条功能验收 + 签署表 | ✅ |
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ |
|
| — | `数据信息填报系统PRD.md` | 190 KB | 主 PRD V2.3+:**125 条 FR**、**21 条 VAL**、**20 张表 DDL**、验收标准 | ✅ |
|
||||||
| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ |
|
| — | `数据信息填报系统PRD-功能逻辑卷.md` | 88 KB | 纯功能运转逻辑(**不含库表/接口/代码**) | ✅ |
|
||||||
| — | `db/schema-postgresql.sql` | 36 KB | 可执行 PG DDL(20 表),**已在 PostgreSQL 16 实测零错误** | ✅ |
|
| — | `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 +57,7 @@
|
|||||||
```
|
```
|
||||||
┌─ 学生端(小程序)─┐ ┌─ 班主任端(手机)─┐ ┌─ 管理端(PC)─┐
|
┌─ 学生端(小程序)─┐ ┌─ 班主任端(手机)─┐ ┌─ 管理端(PC)─┐
|
||||||
名单 ──→ 门禁核验 ──→ 填报 ──→ 卡片流初审 ──→ 终审 ──→ 导出规范包
|
名单 ──→ 门禁核验 ──→ 填报 ──→ 卡片流初审 ──→ 终审 ──→ 导出规范包
|
||||||
└─✗ 失败 → 申请加入名单 → 管理员审批 → 入册放行
|
└─✗ 失败 → 只提示核对 + "请联系老师加你进名单"(老师手工加人)
|
||||||
▲ ▲ ▲ ▲
|
▲ ▲ ▲ ▲
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
报考范围 识别 命名+压缩 汇总 Excel
|
报考范围 识别 命名+压缩 汇总 Excel
|
||||||
@@ -95,9 +95,9 @@
|
|||||||
| 滑动交互 | 班主任端**只做右滑通过**,驳回必须点按钮 |
|
| 滑动交互 | 班主任端**只做右滑通过**,驳回必须点按钮 |
|
||||||
| 批量通过 | **只带无问题项**,红黄一律排除 |
|
| 批量通过 | **只带无问题项**,红黄一律排除 |
|
||||||
| 动态表单 | 内置字段(固定列可统计)+ 扩展字段(`extras_json`) |
|
| 动态表单 | 内置字段(固定列可统计)+ 扩展字段(`extras_json`) |
|
||||||
| 名单外的人 | **申请加入名单 → 管理员逐条审批**;通道只解决"进名单",**不绕过门禁** |
|
| 名单外的人 | **不做自助通道**(2026-10 撤销):由老师在任务里**手工加人**(名册追加导入),**不绕过门禁** |
|
||||||
| 归属判定优先级 | **证件号反查名册 > OCR 内容 > 姓名(仅辅助,不做独立规则)** |
|
| 归属判定优先级 | **证件号反查名册 > OCR 内容 > 姓名(仅辅助,不做独立规则)** |
|
||||||
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写 / 申请加入名单」两条系统内出路 |
|
| 联系方式 | **系统内不展示任何班主任/管理员联系方式**——学生本来就有;门禁失败只给「重新填写」一条系统内出路 + 一句"请联系老师" |
|
||||||
| 校验位的作用 | **失败 = 不是真实号码 → 可自动改**;**通过 = 真人 → 改了就是改身份 → 必须阻断** |
|
| 校验位的作用 | **失败 = 不是真实号码 → 可自动改**;**通过 = 真人 → 改了就是改身份 → 必须阻断** |
|
||||||
| 审批粒度 | 批量通过**不提供"全选"**,驳回**必须逐条填原因**(身份认定误操作代价极高) |
|
| 审批粒度 | 批量通过**不提供"全选"**,驳回**必须逐条填原因**(身份认定误操作代价极高) |
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ python check2.py docs/prototype/admin-web.html
|
|||||||
## 六·二、把系统跑起来(三条命令)
|
## 六·二、把系统跑起来(三条命令)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) 基础设施:PG 16 + Redis 7 + MinIO
|
# 1) 基础设施:PG 16 + Redis 7(没有对象存储,MinIO 已删)
|
||||||
cd deploy
|
cd deploy
|
||||||
copy .env.example .env # 首次
|
copy .env.example .env # 首次
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -194,12 +194,12 @@ python scripts/e2e-m0.py
|
|||||||
|
|
||||||
```
|
```
|
||||||
backend/ Java 21 + Spring Boot 4.1.1
|
backend/ Java 21 + Spring Boot 4.1.1
|
||||||
├── xt-common/ 统一响应 / 58 个错误码 / AES-GCM / HMAC / RSA
|
├── xt-common/ 统一响应 / 54 个错误码 / AES-GCM / HMAC / RSA
|
||||||
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块
|
└── xt-server/ 配置 / 鉴权 / 多租户拦截 / 业务模块(26 张表,Flyway 已到 V19)
|
||||||
deploy/ docker-compose + RSA 开发公钥
|
deploy/ docker-compose + RSA 开发公钥
|
||||||
scripts/ DDL 同步、登录助手、M0 验收测试
|
scripts/ E2E 验收(37 个脚本)、接线检查、DDL 同步、图标抽取
|
||||||
frontend-pc/ (待建)Vue 3 + Element Plus
|
frontend-pc/ PC 管理端:**纯 Vue 3 + Naive UI**(pnpm dev → 5173)
|
||||||
frontend-mp/ (待建)uni-app
|
frontend-mp/ 学生端 / 教师端:uni-app(H5 与微信小程序同一套代码)
|
||||||
```
|
```
|
||||||
|
|
||||||
### ⚠️ 搭环境前请先读
|
### ⚠️ 搭环境前请先读
|
||||||
@@ -214,8 +214,8 @@ frontend-mp/ (待建)uni-app
|
|||||||
| 阶段 | 内容 | 状态 |
|
| 阶段 | 内容 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **需求** | 主 PRD(**125 FR** / 20 VAL / **20 张表**)+ 功能逻辑卷 | ✅ |
|
| **需求** | 主 PRD(**125 FR** / 20 VAL / **20 张表**)+ 功能逻辑卷 | ✅ |
|
||||||
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**121**)+ 模块设计(6) | ✅ |
|
| **设计** | 数据库 DDL(PG 16 实测)+ 接口设计(**119**)+ 模块设计(6) | ✅ |
|
||||||
| **原型** | **31 屏**(学生 **11** / 班主任 9 / 管理 **11**)= 31 | ✅ |
|
| **原型** | **29 屏**(学生 **10** / 班主任 9 / 管理 **10**)= 29 | ✅ |
|
||||||
| **计划** | **100 任务 / 387 人日 / 15 周**(一期范围已收窄) | ✅ |
|
| **计划** | **100 任务 / 387 人日 / 15 周**(一期范围已收窄) | ✅ |
|
||||||
| **测试** | **36** 条 Golden 断言 + **111** 条功能验收 | ✅ |
|
| **测试** | **36** 条 Golden 断言 + **111** 条功能验收 | ✅ |
|
||||||
| **待外部** | ~~客户确认函(23 项)~~ | ✅ **已确认,按建议执行** |
|
| **待外部** | ~~客户确认函(23 项)~~ | ✅ **已确认,按建议执行** |
|
||||||
@@ -225,7 +225,7 @@ frontend-mp/ (待建)uni-app
|
|||||||
| 项 | 确认结论 |
|
| 项 | 确认结论 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **A1** | ✅ 认定机构接受 300dpi 输出 |
|
| **A1** | ✅ 认定机构接受 300dpi 输出 |
|
||||||
| **B1** | ✅ 增加「申请加入名单 → 管理员审批」通道(**唯一功能增量,已落地**) |
|
| **B1** | ⚠️ **已回退**(2026-10 需求收敛):加入申请通道整体撤销(模块/表/接口/页面/错误码一并删除)。它是确认函里唯一的功能增量,建议补变更确认单 |
|
||||||
| **B3** | ✅ 字段三分类:姓名/证件号/报考三项锁定,其余可核对修改并留痕 |
|
| **B3** | ✅ 字段三分类:姓名/证件号/报考三项锁定,其余可核对修改并留痕 |
|
||||||
|
|
||||||
**其余 20 项均与现有设计一致,零改动。** 需求已冻结,可按 `05-开发任务拆解.md` 的 **M0** 开工。
|
**其余 20 项均与现有设计一致,零改动。** 需求已冻结,可按 `05-开发任务拆解.md` 的 **M0** 开工。
|
||||||
@@ -257,7 +257,7 @@ frontend-mp/ (待建)uni-app
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 数据库 | PostgreSQL 16(DDL 已实测通过) |
|
| 数据库 | PostgreSQL 16(DDL 已实测通过) |
|
||||||
| 开发顺序 | **M1 命名引擎 → M4 门禁 → M2 压缩 → M5 校验 → M3 识别 → M6 导出** |
|
| 开发顺序 | **M1 命名引擎 → M4 门禁 → M2 压缩 → M5 校验 → M3 识别 → M6 导出** |
|
||||||
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)已落地 |
|
| 客户回执 | 全部 23 项按建议执行;唯一功能增量 B1(申请加入名单)**已于 2026-10 需求回退取消** |
|
||||||
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG 图标、**无 emoji** |
|
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG 图标、**无 emoji** |
|
||||||
| 最长的两个单点 | 压缩引擎 12 人日、打包引擎 10 人日(**加人无效**) |
|
| 最长的两个单点 | 压缩引擎 12 人日、打包引擎 10 人日(**加人无效**) |
|
||||||
| 回归门禁 | 改动后必须复现 **15 项基线指标**(对应 29 条断言),偏差即回归 |
|
| 回归门禁 | 改动后必须复现 **15 项基线指标**(对应 29 条断言),偏差即回归 |
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<artifactId>fill-parent</artifactId>
|
<artifactId>fill-parent</artifactId>
|
||||||
<version>1.0.0-SNAPSHOT</version>
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
<name>数据信息填报系统</name>
|
<name>深学在线</name>
|
||||||
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
|
<description>职业技能等级认定 · 报名材料收集 SaaS</description>
|
||||||
|
|
||||||
<modules>
|
<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>
|
* <b>改这里之前先改文档,不要只改代码。</b>
|
||||||
*
|
*
|
||||||
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
|
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
|
||||||
@@ -101,22 +101,14 @@ public enum ErrorCode {
|
|||||||
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
|
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
|
||||||
/** 字典项有下级,不可停用 */
|
/** 字典项有下级,不可停用 */
|
||||||
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
|
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, "名册中该证件号已被占用"),
|
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
|
||||||
/** 申请已通过,不可再驳回 */
|
|
||||||
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
|
|
||||||
/** 文件归属存疑,阻断提交且无「仍然继续」 */
|
/** 文件归属存疑,阻断提交且无「仍然继续」 */
|
||||||
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
|
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
|
||||||
/** 一期不支持该收集模式 / 认定类型 */
|
/** 一期不支持该收集模式 / 认定类型 */
|
||||||
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
|
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
|
||||||
|
/** 角色还有账号在用,不可删除 */
|
||||||
|
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
|
||||||
|
|
||||||
// ── 认证与权限 ────────────────────────────────
|
// ── 认证与权限 ────────────────────────────────
|
||||||
/** 未登录 / token 失效(真 HTTP 401) */
|
/** 未登录 / token 失效(真 HTTP 401) */
|
||||||
@@ -146,7 +138,7 @@ public enum ErrorCode {
|
|||||||
STORAGE_FAILED(50007, "存储写入失败,请重传"),
|
STORAGE_FAILED(50007, "存储写入失败,请重传"),
|
||||||
|
|
||||||
// ── 兜底 ──────────────────────────────────────
|
// ── 兜底 ──────────────────────────────────────
|
||||||
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
|
/** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
|
||||||
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
|
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
|
||||||
|
|
||||||
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
|
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
|
||||||
|
|||||||
@@ -68,9 +68,24 @@ public record LoginUser(
|
|||||||
return false;
|
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。 */
|
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
|
||||||
public boolean coversOrg(Long orgId) {
|
public boolean coversOrg(Long orgId) {
|
||||||
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
|
if (isPlatformAdmin() || isTenantAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return orgId != null && orgIds != null && orgIds.contains(orgId);
|
return orgId != null && orgIds != null && orgIds.contains(orgId);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
|||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据信息填报系统 · 主应用。
|
* 深学在线 · 主应用。
|
||||||
*
|
*
|
||||||
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
|
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
|
||||||
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
|
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class OpenApiConfig {
|
|||||||
public OpenAPI fillOpenApi() {
|
public OpenAPI fillOpenApi() {
|
||||||
return new OpenAPI()
|
return new OpenAPI()
|
||||||
.info(new Info()
|
.info(new Info()
|
||||||
.title("数据信息填报系统 API")
|
.title("深学在线 API")
|
||||||
.version("1.0.0")
|
.version("1.0.0")
|
||||||
.description("""
|
.description("""
|
||||||
职业技能等级认定 · 报名材料收集 SaaS
|
职业技能等级认定 · 报名材料收集 SaaS
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public class StorageConfig {
|
|||||||
*/
|
*/
|
||||||
private void selfCheck(QiniuStorageService svc) {
|
private void selfCheck(QiniuStorageService svc) {
|
||||||
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
|
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
|
||||||
byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
|
byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
|
||||||
try {
|
try {
|
||||||
svc.put(path, payload, "text/plain; charset=utf-8");
|
svc.put(path, payload, "text/plain; charset=utf-8");
|
||||||
if (!svc.exists(path)) {
|
if (!svc.exists(path)) {
|
||||||
|
|||||||
+5
-8
@@ -10,18 +10,17 @@ import java.util.List;
|
|||||||
* 审核范围:<b>"这个人能审哪些班"</b>。
|
* 审核范围:<b>"这个人能审哪些班"</b>。
|
||||||
*
|
*
|
||||||
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
|
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
|
||||||
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
|
* 这里把它翻译成一次查询。两级回退的顺序是<b>刻意的</b>:
|
||||||
*
|
*
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
|
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
|
||||||
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
|
|
||||||
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
|
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
|
* <p>⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
|
||||||
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
|
* 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
|
||||||
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
|
* 需要多人协审时,正确做法是给其中一人指定为该班初审人,
|
||||||
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
|
* 而不是再引入一个"协办人"字段。
|
||||||
*
|
*
|
||||||
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
|
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
|
||||||
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
|
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
|
||||||
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
|
|||||||
</if>
|
</if>
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
)
|
)
|
||||||
ORDER BY cto.org_id
|
ORDER BY cto.org_id
|
||||||
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
|
|||||||
AND cto.status = 1
|
AND cto.status = 1
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
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.status = 1
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{auditorId}
|
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})
|
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.status = 1
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{auditorId}
|
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})
|
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),
|
Set.copyOf(roleCodes),
|
||||||
user.getUserType());
|
user.getUserType());
|
||||||
|
|
||||||
|
// ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
|
||||||
|
// 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
|
||||||
|
// 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
|
||||||
|
// 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
|
||||||
|
// (现象是"刚登录什么都没有,刷新一下又好了")。
|
||||||
|
UserContext.set(principal);
|
||||||
|
|
||||||
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
|
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
|
||||||
|
|
||||||
SysUser touch = new SysUser();
|
SysUser touch = new SysUser();
|
||||||
@@ -183,9 +190,9 @@ public class AuthService {
|
|||||||
throw new BizException(ErrorCode.UNAUTHORIZED);
|
throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
|
List<String> roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
|
||||||
LoginVO vo = buildProfile(user, roleCodes);
|
// 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
|
||||||
vo.setPermissions(permissionChecker.currentPermissions());
|
// 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
|
||||||
return vo;
|
return buildProfile(user, roleCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 修改自己的密码 ─────────────────────────────
|
// ── 修改自己的密码 ─────────────────────────────
|
||||||
@@ -358,8 +365,13 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vo.setRoleCodes(roleCodes);
|
vo.setRoleCodes(roleCodes);
|
||||||
vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
|
// ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
|
||||||
vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
|
// 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
|
||||||
|
// 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
|
||||||
|
// 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
|
||||||
|
// 所以漏在这里就等于"要刷新一次才看得见菜单"。
|
||||||
|
vo.setMenus(permissionChecker.currentMenus());
|
||||||
|
vo.setPermissions(permissionChecker.currentPermissions());
|
||||||
return vo;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-3
@@ -67,6 +67,8 @@ public class OperationLogService {
|
|||||||
public static final String MODULE_JOIN = "JOIN";
|
public static final String MODULE_JOIN = "JOIN";
|
||||||
public static final String MODULE_USER = "USER";
|
public static final String MODULE_USER = "USER";
|
||||||
public static final String MODULE_FILE = "FILE";
|
public static final String MODULE_FILE = "FILE";
|
||||||
|
/** 角色与授权(二期)。改授权等于改"谁能做什么",是这套系统里最敏感的一类动作。 */
|
||||||
|
public static final String MODULE_ROLE = "ROLE";
|
||||||
|
|
||||||
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
|
public static final String ACTION_UPDATE_IDCARD = "UPDATE_IDCARD";
|
||||||
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
|
public static final String ACTION_UPDATE_ROSTER_NAME = "UPDATE_ROSTER_NAME";
|
||||||
@@ -87,16 +89,27 @@ public class OperationLogService {
|
|||||||
public static final String ACTION_PROXY_UPLOAD = "PROXY_UPLOAD";
|
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_VIEW_IDCARD_FULL = "VIEW_IDCARD_FULL";
|
||||||
public static final String ACTION_CONFIG_COMPRESS = "CONFIG_COMPRESS";
|
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 TARGET_ROSTER = "ROSTER";
|
public static final String TARGET_ROSTER = "ROSTER";
|
||||||
public static final String TARGET_TASK = "TASK";
|
public static final String TARGET_TASK = "TASK";
|
||||||
public static final String TARGET_SUBMISSION = "SUBMISSION";
|
public static final String TARGET_SUBMISSION = "SUBMISSION";
|
||||||
public static final String TARGET_FILE = "FILE";
|
public static final String TARGET_FILE = "FILE";
|
||||||
public static final String TARGET_EXPORT = "EXPORT";
|
public static final String TARGET_EXPORT = "EXPORT";
|
||||||
public static final String TARGET_JOIN_REQUEST = "JOIN_REQUEST";
|
|
||||||
public static final String TARGET_USER = "USER";
|
public static final String TARGET_USER = "USER";
|
||||||
|
/** 角色({@code sys_role.id})。 */
|
||||||
|
public static final String TARGET_ROLE = "ROLE";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记一条操作日志(**不抛异常**)。
|
* 记一条操作日志(**不抛异常**)。
|
||||||
|
|||||||
+3
-1
@@ -104,7 +104,9 @@ public class MaterialDefController {
|
|||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
|
||||||
List<Map<String, Object>> codes = new java.util.ArrayList<>();
|
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);
|
MaterialCatalog.Kind kind = MaterialCatalog.of(code);
|
||||||
Map<String, Object> m = new LinkedHashMap<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
m.put("code", code);
|
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() {
|
public static List<String> allCodes() {
|
||||||
return KINDS.stream().map(Kind::code).toList();
|
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) {
|
public static Kind of(String code) {
|
||||||
return code == null ? null : BY_CODE.get(code);
|
return code == null ? null : BY_CODE.get(code);
|
||||||
}
|
}
|
||||||
@@ -134,10 +152,10 @@ public final class MaterialCatalog {
|
|||||||
public record TemplateItem(String code, String visibleWhen) {
|
public record TemplateItem(String code, String visibleWhen) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** §7.10 的三个内置模板。 */
|
/** 内置模板。**一期只有学校收集一种** —— 社会收集与专项职业能力已随二期一起取消。 */
|
||||||
private static final Map<String, List<TemplateItem>> TEMPLATES = new LinkedHashMap<>();
|
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_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":true}";
|
||||||
private static final String WHEN_NOT_STUDENT = "{\"field\":\"is_student\",\"op\":\"eq\",\"value\":false}";
|
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("IDCARD", null),
|
||||||
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
|
new TemplateItem("SCHOOL_ROLL", WHEN_STUDENT),
|
||||||
new TemplateItem("WORK_PROOF", WHEN_NOT_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) {
|
public static List<TemplateItem> template(String templateCode) {
|
||||||
@@ -179,8 +186,6 @@ public final class MaterialCatalog {
|
|||||||
m.put("templateCode", code);
|
m.put("templateCode", code);
|
||||||
m.put("label", switch (code) {
|
m.put("label", switch (code) {
|
||||||
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
|
case "TPL_SCHOOL_ROLL" -> "学校收集(照片+身份证+学籍表+工作证明)";
|
||||||
case "TPL_SOCIAL" -> "社会收集(照片+身份证+工作证明+学信网备案表)";
|
|
||||||
case "TPL_ZHUANXIANG" -> "专项职业能力(照片+身份证+工作证明)";
|
|
||||||
default -> code;
|
default -> code;
|
||||||
});
|
});
|
||||||
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
|
m.put("materialCodes", items.stream().map(TemplateItem::code).toList());
|
||||||
|
|||||||
-5
@@ -84,7 +84,6 @@ public interface TeacherDashboardMapper {
|
|||||||
</if>
|
</if>
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
)
|
)
|
||||||
GROUP BY o.id, o.org_name
|
GROUP BY o.id, o.org_name
|
||||||
@@ -114,7 +113,6 @@ public interface TeacherDashboardMapper {
|
|||||||
</if>
|
</if>
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -142,7 +140,6 @@ public interface TeacherDashboardMapper {
|
|||||||
</if>
|
</if>
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -177,7 +174,6 @@ public interface TeacherDashboardMapper {
|
|||||||
</if>
|
</if>
|
||||||
AND (
|
AND (
|
||||||
cto.head_user_id = #{userId}
|
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})
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -200,7 +196,6 @@ public interface TeacherDashboardMapper {
|
|||||||
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
|
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
|
WHERE cto.task_id = t.id AND cto.status = 1
|
||||||
AND (cto.head_user_id = #{userId}
|
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}))
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
|
||||||
)
|
)
|
||||||
ORDER BY t.end_time ASC
|
ORDER BY t.end_time ASC
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ public final class NotifyDtos {
|
|||||||
@Schema(description = "登记订阅消息额度")
|
@Schema(description = "登记订阅消息额度")
|
||||||
public static class SubscribeReq {
|
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;
|
private String templateCode;
|
||||||
|
|
||||||
/** 本次授权次数(微信返回,默认 1) */
|
/** 本次授权次数(微信返回,默认 1) */
|
||||||
|
|||||||
-1
@@ -30,7 +30,6 @@ public class CollectMessage extends TenantEntity {
|
|||||||
public static final int BIZ_APPROVE = 4;
|
public static final int BIZ_APPROVE = 4;
|
||||||
public static final int BIZ_URGE = 5;
|
public static final int BIZ_URGE = 5;
|
||||||
public static final int BIZ_EXPORT = 6;
|
public static final int BIZ_EXPORT = 6;
|
||||||
public static final int BIZ_JOIN_AUDIT = 7;
|
|
||||||
|
|
||||||
/** 点开消息跳哪儿。 */
|
/** 点开消息跳哪儿。 */
|
||||||
public static final String LINK_SUBMISSION = "SUBMISSION";
|
public static final String LINK_SUBMISSION = "SUBMISSION";
|
||||||
|
|||||||
-2
@@ -99,7 +99,6 @@ public interface UrgeMapper {
|
|||||||
</if>
|
</if>
|
||||||
<if test="scopeUserId != null">
|
<if test="scopeUserId != null">
|
||||||
AND (cto.head_user_id = #{scopeUserId}
|
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}))
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{scopeUserId}))
|
||||||
</if>
|
</if>
|
||||||
ORDER BY o.org_name, r.id
|
ORDER BY o.org_name, r.id
|
||||||
@@ -314,7 +313,6 @@ public interface UrgeMapper {
|
|||||||
AND r.task_id = #{taskId}
|
AND r.task_id = #{taskId}
|
||||||
</if>
|
</if>
|
||||||
AND (cto.head_user_id = #{userId}
|
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}))
|
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
|
|||||||
+3
-3
@@ -72,14 +72,14 @@ public class NotificationService {
|
|||||||
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
|
* <li>{@link NotifyTemplates} 那套({@code TASK_PUBLISH / AUDIT_REJECT / URGE …})
|
||||||
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
|
* 是**站内信的文案模板**,管理员在「通知模板」页改的就是它。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 两套里 {@code JOIN_APPROVE} 恰好**同名**,纯属巧合 ——
|
* ⚠️ 删掉「加入申请」之后,两套同名的那一个({@code JOIN_APPROVE})已经不存在了:
|
||||||
* 下一个改代码的人很容易当成同一个东西,所以在这里写清楚(见《08》F-106)。
|
* 上一版这里专门写过"同名纯属巧合",现在两套码**没有任何重名**,
|
||||||
|
* 但仍要记住它们是两个命名空间(见《08》F-106)。
|
||||||
*/
|
*/
|
||||||
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
|
public static final String TPL_SUBMIT_SUCCESS = "SUBMIT_SUCCESS";
|
||||||
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
|
public static final String TPL_AUDIT_RESULT = "AUDIT_RESULT";
|
||||||
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
|
public static final String TPL_DEADLINE_REMIND = "DEADLINE_REMIND";
|
||||||
public static final String TPL_EXPORT_READY = "EXPORT_READY";
|
public static final String TPL_EXPORT_READY = "EXPORT_READY";
|
||||||
public static final String TPL_JOIN_APPROVE = "JOIN_APPROVE";
|
|
||||||
|
|
||||||
private final CollectMessageMapper messageMapper;
|
private final CollectMessageMapper messageMapper;
|
||||||
private final SubscribeQuotaMapper quotaMapper;
|
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, "有一条填报单需要你介入",
|
new Def("AUDIT_ESCALATE", 3, "有一条填报单需要你介入",
|
||||||
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
|
"{任务名} 的 {姓名} 已被驳回达上限,最后一次原因是:{驳回原因}。请查看后决定怎么处理。",
|
||||||
"驳回次数达上限时发给学校管理员(契约 §11.4)")
|
"驳回次数达上限时发给学校管理员(契约 §11.4)")
|
||||||
|
|||||||
+5
-4
@@ -162,17 +162,18 @@ public interface ScheduleMapper {
|
|||||||
/**
|
/**
|
||||||
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
|
* 某个任务下"该审批的人"(初审=班级负责人,终审=学校管理员)。
|
||||||
*
|
*
|
||||||
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` /
|
* <p>找人的口径沿用审核台的范围查询(`collect_task_org.head_user_id` 优先,
|
||||||
* `deputy_user_ids` / 组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
|
* 为空回退到组织的 `leader_user_id`)—— 提醒要发给**真的要动手的那个人**,
|
||||||
* 发给所有人等于没人负责。
|
* 发给所有人等于没人负责。
|
||||||
*/
|
*/
|
||||||
@InterceptorIgnore(tenantLine = "true")
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
|
SELECT DISTINCT u.id AS "userId", u.real_name AS "realName"
|
||||||
FROM collect_task_org cto
|
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
|
JOIN sys_user u ON u.deleted = 0 AND u.status = 1
|
||||||
AND (u.id = cto.head_user_id
|
AND u.id = COALESCE(cto.head_user_id, o.leader_user_id)
|
||||||
OR cto.deputy_user_ids LIKE ('%,' || u.id || ',%'))
|
|
||||||
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
|
WHERE cto.task_id = #{taskId} AND cto.status = 1 AND cto.tenant_id = #{tenantId}
|
||||||
""")
|
""")
|
||||||
List<Map<String, Object>> classAuditors(@Param("tenantId") Long 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.tenant.service.TenantUsageService usageService;
|
||||||
private final cn.tianbaotong.fill.module.file.service.MaterialFileService fileService;
|
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.module.file.mapper.FileMapper fileMapper;
|
||||||
|
private final cn.tianbaotong.fill.support.RoleProvisioner roleProvisioner;
|
||||||
|
|
||||||
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
|
/** 原始件一次最多清多少行(分轮清完,别一次把磁盘 IO 打满)。 */
|
||||||
private static final int RAW_CLEAN_BATCH = 200;
|
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) {
|
private int remindExpire(Long tenantId, String tenantName, int daysLeft) {
|
||||||
var receivers = scheduleMapper.usersOfRole(tenantId,
|
var receivers = scheduleMapper.usersOfRole(tenantId,
|
||||||
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
|
cn.tianbaotong.fill.security.BuiltinRole.SCHOOL_ADMIN.getCode());
|
||||||
@@ -527,13 +572,14 @@ public class ScheduleJobs {
|
|||||||
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
|
* <p>为什么要这个入口:**定时任务的"等不起"是测试与运维的共同问题** ——
|
||||||
* 验证"截止后会怎样"总不能在测试里等到明天 09:00(F-37 已经踩过一次)。
|
* 验证"截止后会怎样"总不能在测试里等到明天 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 这一轮做了什么(给人看的说明)
|
* @return 这一轮做了什么(给人看的说明)
|
||||||
*/
|
*/
|
||||||
public String runNow(String job) {
|
public String runNow(String job) {
|
||||||
if (job == null || job.isBlank()) {
|
if (job == null || job.isBlank()) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
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()) {
|
switch (job.trim().toLowerCase()) {
|
||||||
case "close" -> autoCloseExpiredTasks();
|
case "close" -> autoCloseExpiredTasks();
|
||||||
@@ -543,8 +589,9 @@ public class ScheduleJobs {
|
|||||||
case "usage" -> snapshotUsage();
|
case "usage" -> snapshotUsage();
|
||||||
case "retry" -> retryFailedProcess();
|
case "retry" -> retryFailedProcess();
|
||||||
case "expire" -> remindTenantExpiring();
|
case "expire" -> remindTenantExpiring();
|
||||||
|
case "role" -> syncBuiltinRoleGrants();
|
||||||
default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
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;
|
return "已手动触发一轮:" + job;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -52,15 +52,11 @@ public class TaskOrgSaveReq {
|
|||||||
* 该班初审人(班主任)。
|
* 该班初审人(班主任)。
|
||||||
*
|
*
|
||||||
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
|
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
|
||||||
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。
|
* <b>持有班主任角色</b>的启用账号(见 {@code TaskOrgService})。
|
||||||
*/
|
*/
|
||||||
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
|
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
|
||||||
private Long headUserId;
|
private Long headUserId;
|
||||||
|
|
||||||
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
|
|
||||||
@Schema(description = "副班主任")
|
|
||||||
private List<Long> deputyUserIds = new ArrayList<>();
|
|
||||||
|
|
||||||
@Min(value = 0, message = "应填人数不能为负")
|
@Min(value = 0, message = "应填人数不能为负")
|
||||||
@Schema(description = "应填人数,用于与名册实际人数比对告警")
|
@Schema(description = "应填人数,用于与名册实际人数比对告警")
|
||||||
private Integer expectedCount;
|
private Integer expectedCount;
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
|
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
|
||||||
*
|
*
|
||||||
@@ -34,11 +31,6 @@ public class TaskOrgVO {
|
|||||||
|
|
||||||
private String headUserName;
|
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}。 */
|
/** 该班初审人(班主任);为空则回退到 {@code sys_org.leader_user_id}。 */
|
||||||
private Long headUserId;
|
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;
|
private Integer expectedCount;
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -18,9 +18,14 @@ import java.util.List;
|
|||||||
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
|
* review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
|
||||||
*
|
*
|
||||||
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
|
* <p><b>表清单来自事实</b>({@code information_schema.columns} 里带 {@code task_id} 列的
|
||||||
* 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
|
* 全部业务表):任务本身不在此列(由 {@code TaskService} 删);
|
||||||
* {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
|
* {@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 的动机是"库会一直堆"(演示/测试任务
|
* <p><b>为什么子表物理删、任务行却软删</b>:F-14 的动机是"库会一直堆"(演示/测试任务
|
||||||
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
|
* 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
|
||||||
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
|
* 而 {@code collect_task} 行本身必须<b>软删保留</b>,因为它要继续占住 {@code task_code}
|
||||||
@@ -75,9 +80,6 @@ public interface TaskCascadeMapper {
|
|||||||
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
|
@Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
|
||||||
int deleteSubmissions(@Param("taskId") Long 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}")
|
@Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
|
||||||
int deleteRoster(@Param("taskId") Long taskId);
|
int deleteRoster(@Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -13,7 +13,7 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
|||||||
/**
|
/**
|
||||||
* 某人在本租户下"能看到的"任务 id。
|
* 某人在本租户下"能看到的"任务 id。
|
||||||
*
|
*
|
||||||
* <p><b>谁需要这个</b>:班主任与副班主任。《03-接口设计》§7.1 明确
|
* <p><b>谁需要这个</b>:班主任。《03-接口设计》§7.1 明确
|
||||||
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
|
* 「班主任**只看自己带的班级的任务**(由 {@code collect_task_org.head_user_id} 决定)」,
|
||||||
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
|
* 不能让一个班主任从列表里看到全校的任务名与批次 —— 那本身就是信息泄露。
|
||||||
*
|
*
|
||||||
@@ -21,8 +21,8 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
|||||||
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
|
* 为空时回退到 {@code sys_org.leader_user_id}。绝大多数班主任就是班级负责人,
|
||||||
* 不该要求每条都显式指定一遍。
|
* 不该要求每条都显式指定一遍。
|
||||||
*
|
*
|
||||||
* <p><b>副班主任</b>走 {@code deputy_user_ids} 的逗号匹配。该列存的是
|
* <p>⚠️ 这里以前还有一条 {@code deputy_user_ids}(副班主任)分支,
|
||||||
* 首尾带逗号的 {@code ,3,7,} 形式,所以 {@code '%,3,%'} 不会误命中 {@code 17}。
|
* 随该字段与角色一起删掉了 —— 现在**一个班只有一个初审人**。
|
||||||
*
|
*
|
||||||
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
|
* <p><b>为什么显式传 tenantId 并关掉拦截器</b>:这是一条<b>权限过滤</b>用的查询。
|
||||||
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
|
* 漏掉租户条件会返回别校的任务 id,而调用方是拿它去 {@code IN (...)} 的 ——
|
||||||
@@ -38,7 +38,6 @@ public interface TaskMapper extends BaseMapper<CollectTask> {
|
|||||||
AND cto.tenant_id = #{tenantId}
|
AND cto.tenant_id = #{tenantId}
|
||||||
AND (
|
AND (
|
||||||
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
|
COALESCE(cto.head_user_id, o.leader_user_id) = #{userId}
|
||||||
OR cto.deputy_user_ids LIKE '%,' || #{userId} || ',%'
|
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
|
List<Long> selectVisibleTaskIds(@Param("tenantId") Long tenantId,
|
||||||
|
|||||||
-3
@@ -136,9 +136,6 @@ public class TaskCloneService {
|
|||||||
copy.setOrgId(o.getOrgId());
|
copy.setOrgId(o.getOrgId());
|
||||||
copy.setExpectedCount(o.getExpectedCount());
|
copy.setExpectedCount(o.getExpectedCount());
|
||||||
copy.setHeadUserId(o.getHeadUserId());
|
copy.setHeadUserId(o.getHeadUserId());
|
||||||
// ⚠️ 是 `deputy_user_ids`(逗号分隔的多个人),不是单数的 deputy_user_id ——
|
|
||||||
// 按"应该叫这个"猜字段名,编译期才会发现
|
|
||||||
copy.setDeputyUserIds(o.getDeputyUserIds());
|
|
||||||
copy.setStatus(o.getStatus());
|
copy.setStatus(o.getStatus());
|
||||||
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
|
// 刻意**不**复制二维码 URL:二维码是"每个任务的入口",来源任务的码
|
||||||
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
|
// 扫进去是旧任务 —— 复制过来会出现"扫新任务的码、进旧任务"这种最难查的错
|
||||||
|
|||||||
+18
-83
@@ -27,7 +27,6 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +42,11 @@ import java.util.Set;
|
|||||||
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
|
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
|
||||||
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
|
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
|
||||||
* 就出现在 A 校的审批流里。</li>
|
* 就出现在 A 校的审批流里。</li>
|
||||||
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。
|
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER})。
|
||||||
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
|
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
|
||||||
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li>
|
* 学籍材料。放开这一条等于把授权能力变成任意授权。
|
||||||
|
* ⚠️ 这里以前还接受 {@code DEPUTY_TEACHER}(副班主任);
|
||||||
|
* 该角色与「协办人」字段一起删掉了,现在是**一个班一个初审人**。</li>
|
||||||
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
|
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
|
||||||
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
|
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
|
||||||
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
|
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
|
||||||
@@ -60,8 +61,8 @@ import java.util.Set;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TaskOrgService {
|
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) + ",覆盖班级不可再改");
|
"任务已" + statusText(status) + ",覆盖班级不可再改");
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req);
|
normalizeAndValidate(req);
|
||||||
|
|
||||||
persist(taskId, req, deputiesOf);
|
persist(taskId, req);
|
||||||
|
|
||||||
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
|
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
|
||||||
req.getOrgs().size());
|
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)
|
// 完全指不出是哪个班的问题(PG 只说 duplicate key)
|
||||||
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||||
@@ -148,32 +150,15 @@ public class TaskOrgService {
|
|||||||
|
|
||||||
assertOrgsAreClasses(wanted.keySet());
|
assertOrgsAreClasses(wanted.keySet());
|
||||||
|
|
||||||
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>();
|
Set<Long> headUserIds = new LinkedHashSet<>();
|
||||||
Set<Long> allUserIds = new LinkedHashSet<>();
|
for (TaskOrgSaveReq.Item item : wanted.values()) {
|
||||||
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);
|
|
||||||
|
|
||||||
if (item.getHeadUserId() != null) {
|
if (item.getHeadUserId() != null) {
|
||||||
allUserIds.add(item.getHeadUserId());
|
headUserIds.add(item.getHeadUserId());
|
||||||
}
|
}
|
||||||
allUserIds.addAll(deputies);
|
|
||||||
}
|
}
|
||||||
|
if (!headUserIds.isEmpty()) {
|
||||||
if (!allUserIds.isEmpty()) {
|
assertAssignableTeachers(headUserIds);
|
||||||
assertAssignableTeachers(allUserIds);
|
|
||||||
}
|
}
|
||||||
return deputiesOf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
|
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
|
||||||
@@ -237,7 +222,7 @@ public class TaskOrgService {
|
|||||||
SysUser user = users.get(userId);
|
SysUser user = users.get(userId);
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
|
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
|
||||||
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色");
|
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」角色");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +236,7 @@ public class TaskOrgService {
|
|||||||
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
|
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
|
||||||
* 这种"保存成功但没保存"是最难查的一类 bug。
|
* 这种"保存成功但没保存"是最难查的一类 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<>();
|
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||||
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
|
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
|
||||||
wanted.put(item.getOrgId(), item);
|
wanted.put(item.getOrgId(), item);
|
||||||
@@ -283,7 +268,6 @@ public class TaskOrgService {
|
|||||||
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
||||||
Long orgId = entry.getKey();
|
Long orgId = entry.getKey();
|
||||||
TaskOrgSaveReq.Item item = entry.getValue();
|
TaskOrgSaveReq.Item item = entry.getValue();
|
||||||
String deputies = joinIds(deputiesOf.get(orgId));
|
|
||||||
|
|
||||||
CollectTaskOrg reuse = activeByOrg.get(orgId);
|
CollectTaskOrg reuse = activeByOrg.get(orgId);
|
||||||
if (reuse == null) {
|
if (reuse == null) {
|
||||||
@@ -299,7 +283,6 @@ public class TaskOrgService {
|
|||||||
insert.setTaskId(taskId);
|
insert.setTaskId(taskId);
|
||||||
insert.setOrgId(orgId);
|
insert.setOrgId(orgId);
|
||||||
insert.setHeadUserId(item.getHeadUserId());
|
insert.setHeadUserId(item.getHeadUserId());
|
||||||
insert.setDeputyUserIds(deputies);
|
|
||||||
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
|
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
|
||||||
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
|
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
|
||||||
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
|
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
|
||||||
@@ -311,7 +294,6 @@ public class TaskOrgService {
|
|||||||
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
|
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
|
||||||
.eq(CollectTaskOrg::getId, reuse.getId())
|
.eq(CollectTaskOrg::getId, reuse.getId())
|
||||||
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
|
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
|
||||||
.set(CollectTaskOrg::getDeputyUserIds, deputies)
|
|
||||||
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
|
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
|
||||||
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
|
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
|
||||||
}
|
}
|
||||||
@@ -327,15 +309,11 @@ public class TaskOrgService {
|
|||||||
|
|
||||||
Set<Long> orgIds = new LinkedHashSet<>();
|
Set<Long> orgIds = new LinkedHashSet<>();
|
||||||
Set<Long> userIds = new LinkedHashSet<>();
|
Set<Long> userIds = new LinkedHashSet<>();
|
||||||
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
|
|
||||||
for (CollectTaskOrg row : rows) {
|
for (CollectTaskOrg row : rows) {
|
||||||
orgIds.add(row.getOrgId());
|
orgIds.add(row.getOrgId());
|
||||||
List<Long> deputies = parseIds(row.getDeputyUserIds());
|
|
||||||
deputiesOf.put(row.getId(), deputies);
|
|
||||||
if (row.getHeadUserId() != null) {
|
if (row.getHeadUserId() != null) {
|
||||||
userIds.add(row.getHeadUserId());
|
userIds.add(row.getHeadUserId());
|
||||||
}
|
}
|
||||||
userIds.addAll(deputies);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<Long, String> orgNames = loadOrgNames(orgIds);
|
Map<Long, String> orgNames = loadOrgNames(orgIds);
|
||||||
@@ -348,11 +326,6 @@ public class TaskOrgService {
|
|||||||
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
|
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
|
||||||
vo.setHeadUserId(row.getHeadUserId());
|
vo.setHeadUserId(row.getHeadUserId());
|
||||||
vo.setHeadUserName(userNames.get(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.setExpectedCount(row.getExpectedCount());
|
||||||
vo.setStatus(row.getStatus());
|
vo.setStatus(row.getStatus());
|
||||||
out.add(vo);
|
out.add(vo);
|
||||||
@@ -396,44 +369,6 @@ public class TaskOrgService {
|
|||||||
return task;
|
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) {
|
private static String statusText(int status) {
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case CollectTask.STATUS_DRAFT -> "草稿";
|
case CollectTask.STATUS_DRAFT -> "草稿";
|
||||||
|
|||||||
+6
-3
@@ -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()} 而不是
|
* <p>⚠️ 平台这一支必须用 {@link LoginUser#isPlatformStaff()} 而不是
|
||||||
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
|
* {@code isPlatformAdmin()}:平台**代管某所学校**时(请求头 {@code X-Tenant-Id})
|
||||||
@@ -226,7 +230,7 @@ public class TaskService {
|
|||||||
*/
|
*/
|
||||||
private boolean seesWholeTenant() {
|
private boolean seesWholeTenant() {
|
||||||
LoginUser me = UserContext.require();
|
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.deleteExportFiles(id)
|
||||||
+ cascadeMapper.deleteExportTasks(id)
|
+ cascadeMapper.deleteExportTasks(id)
|
||||||
+ cascadeMapper.deleteSubmissions(id)
|
+ cascadeMapper.deleteSubmissions(id)
|
||||||
+ cascadeMapper.deleteJoinRequests(id)
|
|
||||||
+ cascadeMapper.deleteRoster(id)
|
+ cascadeMapper.deleteRoster(id)
|
||||||
+ cascadeMapper.deleteFormSchemas(id)
|
+ cascadeMapper.deleteFormSchemas(id)
|
||||||
+ cascadeMapper.deleteMaterialDefs(id)
|
+ cascadeMapper.deleteMaterialDefs(id)
|
||||||
|
|||||||
+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} 自定义。 */
|
/** {@code 1} 内置(不可删,权限由代码决定)/ {@code 2} 自定义。 */
|
||||||
private Integer roleType;
|
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} 仅本人。 */
|
/** {@code 1} 全平台 / {@code 2} 本租户 / {@code 3} 本组织及下级 / {@code 4} 仅本人。 */
|
||||||
private Integer dataScope;
|
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 cn.tianbaotong.fill.module.user.entity.SysRole;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
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> {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+231
-80
@@ -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.UserImportResult;
|
||||||
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
|
import cn.tianbaotong.fill.module.user.dto.UserUpsertReq;
|
||||||
import cn.tianbaotong.fill.module.user.dto.UserVO;
|
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.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.UserMapper;
|
||||||
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
|
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
|
||||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||||
|
import cn.tianbaotong.fill.security.PermissionDef;
|
||||||
import cn.tianbaotong.fill.support.RoleProvisioner;
|
import cn.tianbaotong.fill.support.RoleProvisioner;
|
||||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||||
import com.alibaba.excel.EasyExcel;
|
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 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} 的角色。判断"还有没有人能管账号"只需看这两个。 */
|
/** 拥有 {@code user:manage} 的角色。判断"还有没有人能管账号"只需看这两个。 */
|
||||||
private static final Set<String> ADMIN_ROLE_CODES = Set.of("PLATFORM_ADMIN", "SCHOOL_ADMIN");
|
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>。那行注释写的是
|
* <p>⚠️ <b>不要照 {@code sys_user} 那行 DDL 注释写</b>。那行注释写的是
|
||||||
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
|
* 「1教师/管理员 2填报人(学生) 3社会人员」,与接口契约的
|
||||||
@@ -94,16 +90,12 @@ public class UserService {
|
|||||||
*
|
*
|
||||||
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
|
* <p>这个不一致已经造成过一次真实误判:把 {@code 3} 当成"社会人员(二期)"而拒掉,
|
||||||
* 而 {@code 3} 恰恰是<b>班主任</b> —— 最高频的一种账号。
|
* 而 {@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(
|
private static final Map<Integer, String> USER_TYPE_NAMES = Map.of(
|
||||||
1, "平台运营",
|
1, "平台运营",
|
||||||
2, "学校管理员",
|
2, "学校管理员",
|
||||||
@@ -138,7 +130,9 @@ public class UserService {
|
|||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final UserRoleMapper userRoleMapper;
|
private final UserRoleMapper userRoleMapper;
|
||||||
private final OrgMapper orgMapper;
|
private final OrgMapper orgMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
private final RoleProvisioner roleProvisioner;
|
private final RoleProvisioner roleProvisioner;
|
||||||
|
private final DbRoleGrantSource roleGrantSource;
|
||||||
private final SensitiveCodec codec;
|
private final SensitiveCodec codec;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final LoginGuard loginGuard;
|
private final LoginGuard loginGuard;
|
||||||
@@ -184,9 +178,18 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleCode != null && BuiltinRole.ofCode(roleCode) != null) {
|
if (roleCode != null) {
|
||||||
// roleCode 已经过内置角色白名单校验,拼串是安全的。
|
// ⚠️ 角色码**不认识时必须返回空**,绝不能"忽略这个过滤条件"。
|
||||||
// 用子查询而不是 JOIN,是为了让 MP 的租户过滤与逻辑删除条件继续作用于主表。
|
// 忽略的后果是:调用方以为筛出了"班主任",实际拿到**全校账号(含学生)**——
|
||||||
|
// 而且不报任何错。这个坑真实发生过: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,
|
q.inSql(SysUser::getId,
|
||||||
"SELECT ur.user_id FROM sys_user_role ur"
|
"SELECT ur.user_id FROM sys_user_role ur"
|
||||||
+ " JOIN sys_role r ON r.id = ur.role_id"
|
+ " JOIN sys_role r ON r.id = ur.role_id"
|
||||||
@@ -215,7 +218,7 @@ public class UserService {
|
|||||||
String username = req.getUsername().trim();
|
String username = req.getUsername().trim();
|
||||||
|
|
||||||
assertUsernameUnique(username, null);
|
assertUsernameUnique(username, null);
|
||||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||||
|
|
||||||
String initialPassword = MaskUtil.generateInitialPassword();
|
String initialPassword = MaskUtil.generateInitialPassword();
|
||||||
@@ -230,12 +233,12 @@ public class UserService {
|
|||||||
codec.applyPhone(u, req.getPhone());
|
codec.applyPhone(u, req.getPhone());
|
||||||
userMapper.insert(u);
|
userMapper.insert(u);
|
||||||
|
|
||||||
for (BuiltinRole br : roles) {
|
for (SysRole role : roles) {
|
||||||
roleProvisioner.bindRole(tenantId, u.getId(), br);
|
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
|
log.info("新增账号 {}(id={}, 角色={})", username, u.getId(), roles.stream()
|
||||||
.map(BuiltinRole::getCode).toList());
|
.map(SysRole::getRoleCode).toList());
|
||||||
|
|
||||||
return new CreateResult(u.getId(), initialPassword);
|
return new CreateResult(u.getId(), initialPassword);
|
||||||
}
|
}
|
||||||
@@ -254,7 +257,7 @@ public class UserService {
|
|||||||
SysUser exist = requireUser(id);
|
SysUser exist = requireUser(id);
|
||||||
long tenantId = UserContext.tenantId();
|
long tenantId = UserContext.tenantId();
|
||||||
|
|
||||||
List<BuiltinRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
List<SysRole> roles = resolveAssignableRoles(req.getRoleCodes());
|
||||||
assertRoleMatchesUserType(req.getUserType(), roles);
|
assertRoleMatchesUserType(req.getUserType(), roles);
|
||||||
|
|
||||||
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
|
// 停用会被改成停用时,先过一遍"别把自己/最后一个管理员锁死"
|
||||||
@@ -276,6 +279,12 @@ public class UserService {
|
|||||||
assertAnotherAdminExists(exist, "取消管理员角色");
|
assertAnotherAdminExists(exist, "取消管理员角色");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 二期新增的一条自锁:**自定义角色**让"管理员"不再等于那两个内置角色码。
|
||||||
|
// 上面的判据只看内置管理员角色,所以「自己靠自定义角色管账号、再把自己换成别的角色」
|
||||||
|
// 会绕过它 —— 账号还在、还能登录,但再也进不了账号管理与角色授权。
|
||||||
|
// 这类错误不可逆(只能找人用 SQL 救),所以单独判一次。
|
||||||
|
assertKeepsAccountManage(exist, roles);
|
||||||
|
|
||||||
SysUser patch = new SysUser();
|
SysUser patch = new SysUser();
|
||||||
patch.setId(id);
|
patch.setId(id);
|
||||||
patch.setRealName(req.getRealName().trim());
|
patch.setRealName(req.getRealName().trim());
|
||||||
@@ -382,7 +391,7 @@ public class UserService {
|
|||||||
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
|
public UserImportResult importExcel(MultipartFile file, String defaultRoleCode) {
|
||||||
assertImportFile(file);
|
assertImportFile(file);
|
||||||
|
|
||||||
BuiltinRole fallbackRole = resolveFallbackRole(defaultRoleCode);
|
SysRole fallbackRole = resolveFallbackRole(defaultRoleCode);
|
||||||
|
|
||||||
List<Map<Integer, String>> rows;
|
List<Map<Integer, String>> rows;
|
||||||
try (InputStream in = file.getInputStream()) {
|
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()) {
|
if (err == null && roleText != null && !roleText.isBlank()) {
|
||||||
role = resolveRoleByText(roleText);
|
role = resolveRoleByText(roleText);
|
||||||
if (role == null) {
|
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) {
|
if (err == null && userType == null) {
|
||||||
err = new UserImportResult.RowError(excelRow, "role", roleText,
|
err = new UserImportResult.RowError(excelRow, "role", roleText,
|
||||||
"该角色没有对应的用户类型");
|
"该角色没有对应的用户类型");
|
||||||
@@ -506,7 +515,7 @@ public class UserService {
|
|||||||
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
|
u.setOrgId(orgId != null ? orgId : orgByCode.get(orgText.trim()));
|
||||||
}
|
}
|
||||||
userMapper.insert(u);
|
userMapper.insert(u);
|
||||||
roleProvisioner.bindRole(tenantId, u.getId(), role);
|
roleProvisioner.bindRoleByCode(tenantId, u.getId(), role.getRoleCode());
|
||||||
|
|
||||||
seenInFile.add(username.trim());
|
seenInFile.add(username.trim());
|
||||||
result.setSuccess(result.getSuccess() + 1);
|
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>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
|
* <p>两道防线:本方法按"当前租户能分配什么角色"白名单校验;
|
||||||
* 同时 {@code RoleProvisioner} 不再给学校租户发放 {@code PLATFORM_ADMIN} 角色行。
|
* 同时 {@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();
|
Long tenantId = UserContext.tenantId();
|
||||||
boolean platform = tenantId != null && tenantId == LoginUser.PLATFORM_TENANT_ID;
|
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<>();
|
LinkedHashSet<String> distinct = new LinkedHashSet<>();
|
||||||
if (codes != null) {
|
if (codes != null) {
|
||||||
@@ -554,16 +593,25 @@ public class UserService {
|
|||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请至少选择一个角色");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<BuiltinRole> roles = new ArrayList<>();
|
// 一次读出本租户全部启用中的角色(内置 + 自定义),再按码取 —— 避免逐条查库
|
||||||
for (String c : distinct) {
|
Map<String, SysRole> byCode = new LinkedHashMap<>();
|
||||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
for (SysRole r : roleMapper.selectList(Wrappers.<SysRole>lambdaQuery()
|
||||||
if (br == null) {
|
.eq(SysRole::getTenantId, tenantId)
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在:" + c);
|
.eq(SysRole::getStatus, 1))) {
|
||||||
|
byCode.put(r.getRoleCode(), r);
|
||||||
}
|
}
|
||||||
if (!allowed.contains(c)) {
|
|
||||||
|
List<SysRole> roles = new ArrayList<>();
|
||||||
|
for (String c : distinct) {
|
||||||
|
SysRole role = byCode.get(c);
|
||||||
|
if (role == null) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "角色不存在或已停用:" + c);
|
||||||
|
}
|
||||||
|
BuiltinRole br = BuiltinRole.ofCode(c);
|
||||||
|
if (br != null && !allowed.contains(c)) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
||||||
}
|
}
|
||||||
roles.add(br);
|
roles.add(role);
|
||||||
}
|
}
|
||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
@@ -572,20 +620,20 @@ public class UserService {
|
|||||||
* 用户类型必须与所选角色自洽。
|
* 用户类型必须与所选角色自洽。
|
||||||
*
|
*
|
||||||
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
|
* <p>做法不是"逐条 if",而是<b>从角色反推用户类型再比对</b>:
|
||||||
* 角色与用户类型本来就是一对一绑定的(见 {@link #ROLE_TO_USER_TYPE}),
|
* 角色与用户类型本来就是一对一绑定的(见 {@link #effectiveUserType}),
|
||||||
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
|
* 反过来推只有一个正确答案,写成一堆 if 迟早会漏掉某个组合。
|
||||||
*
|
*
|
||||||
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
|
* <p>多角色时要求它们属于同一类(比如「班主任 + 副班主任」都是 3 可以,
|
||||||
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
|
* 「班主任 + 审核员」是 3 与 2 就不能共存)—— 否则"这个人到底是几"没有答案。
|
||||||
*/
|
*/
|
||||||
private void assertRoleMatchesUserType(Integer userType, List<BuiltinRole> roles) {
|
private void assertRoleMatchesUserType(Integer userType, List<SysRole> roles) {
|
||||||
if (userType == null) {
|
if (userType == null) {
|
||||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择用户类型");
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<Integer> expected = new LinkedHashSet<>();
|
Set<Integer> expected = new LinkedHashSet<>();
|
||||||
for (BuiltinRole br : roles) {
|
for (SysRole role : roles) {
|
||||||
Integer t = ROLE_TO_USER_TYPE.get(br.getCode());
|
Integer t = effectiveUserType(role);
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
expected.add(t);
|
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) {
|
private void assertUsernameUnique(String username, Long excludeId) {
|
||||||
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
|
SysUser dup = userMapper.selectOne(Wrappers.<SysUser>lambdaQuery()
|
||||||
.eq(SysUser::getUsername, username)
|
.eq(SysUser::getUsername, username)
|
||||||
@@ -663,9 +723,9 @@ public class UserService {
|
|||||||
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
|
* <p>只有在目标<b>原本确实是管理员</b>时才返回 true ——
|
||||||
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
|
* 否则把一个普通班主任换成另一个普通角色也会被上面那次检查拦住,那是误伤。
|
||||||
*/
|
*/
|
||||||
private boolean losingAdminRole(SysUser target, List<BuiltinRole> newRoles) {
|
private boolean losingAdminRole(SysUser target, List<SysRole> newRoles) {
|
||||||
boolean stillAdmin = newRoles.stream()
|
boolean stillAdmin = newRoles.stream()
|
||||||
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getCode()));
|
.anyMatch(r -> ADMIN_ROLE_CODES.contains(r.getRoleCode()));
|
||||||
if (stillAdmin) {
|
if (stillAdmin) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -673,6 +733,64 @@ public class UserService {
|
|||||||
return now != null && now > 0;
|
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) {
|
private void assertPasswordStrength(String plain) {
|
||||||
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
|
cn.tianbaotong.fill.common.util.MaskUtil.assertPasswordStrong(plain);
|
||||||
}
|
}
|
||||||
@@ -699,11 +817,11 @@ public class UserService {
|
|||||||
return u;
|
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()
|
userRoleMapper.delete(Wrappers.<cn.tianbaotong.fill.module.user.entity.SysUserRole>lambdaQuery()
|
||||||
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
|
.eq(cn.tianbaotong.fill.module.user.entity.SysUserRole::getUserId, userId));
|
||||||
for (BuiltinRole br : roles) {
|
for (SysRole role : roles) {
|
||||||
roleProvisioner.bindRole(tenantId, userId, br);
|
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());
|
* <p>二期改成查库:可选角色不再只有 6 个内置角色,租户自己建的角色也要能当默认值。
|
||||||
if (br == null) {
|
* 返回 {@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);
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "默认角色不存在:" + defaultRoleCode);
|
||||||
}
|
}
|
||||||
if (resolveAssignableRoles(List.of(br.getCode())).isEmpty()) {
|
return roles.get(0);
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "无权分配角色:" + br.getName());
|
|
||||||
}
|
|
||||||
return br;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -825,30 +952,45 @@ public class UserService {
|
|||||||
return s.isEmpty() ? null : s;
|
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();
|
String t = text.trim();
|
||||||
BuiltinRole byCode = BuiltinRole.ofCode(t.toUpperCase());
|
for (SysRole role : tenantRoles()) {
|
||||||
if (byCode != null) {
|
if (role.getRoleCode() != null && role.getRoleCode().equalsIgnoreCase(t)) {
|
||||||
|
return assignableRole(role.getRoleCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
resolveAssignableRoles(List.of(byCode.getCode()));
|
List<SysRole> roles = resolveAssignableRoles(List.of(roleCode));
|
||||||
return byCode;
|
return roles.isEmpty() ? null : roles.get(0);
|
||||||
} catch (BizException e) {
|
} catch (BizException e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (BuiltinRole br : BuiltinRole.all()) {
|
|
||||||
if (br.getName().equals(t)) {
|
|
||||||
try {
|
|
||||||
resolveAssignableRoles(List.of(br.getCode()));
|
|
||||||
return br;
|
|
||||||
} catch (BizException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 表头原文,用 | 连接,供界面展示确认。 */
|
/** 表头原文,用 | 连接,供界面展示确认。 */
|
||||||
private static String joinHeaderText(Map<Integer, String> headerCells) {
|
private static String joinHeaderText(Map<Integer, String> headerCells) {
|
||||||
@@ -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());
|
List<UserVO> list = new ArrayList<>(users.size());
|
||||||
for (SysUser u : users) {
|
for (SysUser u : users) {
|
||||||
UserVO vo = new UserVO();
|
UserVO vo = new UserVO();
|
||||||
@@ -953,13 +1106,11 @@ public class UserService {
|
|||||||
|
|
||||||
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
|
List<String> codes = rolesByUser.getOrDefault(u.getId(), List.of());
|
||||||
vo.setRoleCodes(codes);
|
vo.setRoleCodes(codes);
|
||||||
// 一期角色全部内置(没有自定义角色的界面),名字直接从代码取;
|
// 二期:角色名读库(sys_role.role_name)—— 自定义角色的名字不在代码里。
|
||||||
// 二期支持自定义角色后,这里要改成从 sys_role.role_name 读。
|
// 内置角色的行也在库里(开通租户时由 RoleProvisioner 写入),所以一条查询覆盖两种;
|
||||||
|
// 查不到(角色被删)时回落成角色码,至少不会显示一片空白。
|
||||||
vo.setRoleNames(codes.stream()
|
vo.setRoleNames(codes.stream()
|
||||||
.map(c -> {
|
.map(c -> roleNames.getOrDefault(c, c))
|
||||||
BuiltinRole br = BuiltinRole.ofCode(c);
|
|
||||||
return br == null ? c : br.getName();
|
|
||||||
})
|
|
||||||
.toList());
|
.toList());
|
||||||
list.add(vo);
|
list.add(vo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,15 +43,14 @@ public enum BuiltinRole {
|
|||||||
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
|
PermissionDef.TASK_MANAGE, PermissionDef.TASK_VIEW, PermissionDef.TASK_CLONE,
|
||||||
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
|
PermissionDef.FORM_MANAGE, PermissionDef.MATERIAL_MANAGE, PermissionDef.PLAN_IMPORT,
|
||||||
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
|
PermissionDef.ROSTER_MANAGE, PermissionDef.ROSTER_VIEW,
|
||||||
PermissionDef.JOIN_REQUEST_AUDIT,
|
|
||||||
PermissionDef.NOTIFY_SEND,
|
PermissionDef.NOTIFY_SEND,
|
||||||
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
|
PermissionDef.PROGRESS_VIEW_ALL, PermissionDef.PROGRESS_VIEW_OWN,
|
||||||
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
|
PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL, PermissionDef.AUDIT_WITHDRAW,
|
||||||
PermissionDef.FILE_PROXY_UPLOAD,
|
PermissionDef.FILE_PROXY_UPLOAD,
|
||||||
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED, PermissionDef.CLEANUP_RUN,
|
PermissionDef.EXPORT_DOWNLOAD, PermissionDef.EXPORT_UNMASKED,
|
||||||
PermissionDef.LOG_VIEW_TENANT),
|
PermissionDef.LOG_VIEW_TENANT),
|
||||||
Set.of("dashboard", "org", "user", "role", "task",
|
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),
|
PermissionDef.AUDIT_FIRST, PermissionDef.FILE_PROXY_UPLOAD),
|
||||||
Set.of("dashboard", "task", "audit")),
|
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,
|
STUDENT("STUDENT", "学生", UserType.STUDENT, DataScope.SELF, 90,
|
||||||
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
|
Set.of(PermissionDef.SUBMISSION_OWN, PermissionDef.PROGRESS_VIEW_OWN),
|
||||||
@@ -161,7 +145,7 @@ public enum BuiltinRole {
|
|||||||
return codes;
|
return codes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开通租户时默认创建的 6 个内置角色。 */
|
/** 开通租户时默认创建的 4 个内置角色。 */
|
||||||
public static Set<BuiltinRole> all() {
|
public static Set<BuiltinRole> all() {
|
||||||
return new LinkedHashSet<>(Arrays.asList(values()));
|
return new LinkedHashSet<>(Arrays.asList(values()));
|
||||||
}
|
}
|
||||||
@@ -196,4 +180,64 @@ public enum BuiltinRole {
|
|||||||
}
|
}
|
||||||
return merged;
|
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.LoginUser;
|
||||||
import cn.tianbaotong.fill.common.context.UserContext;
|
import cn.tianbaotong.fill.common.context.UserContext;
|
||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限判定。
|
* 权限判定。
|
||||||
*
|
*
|
||||||
* <p><b>为什么不查库</b>:内置角色的权限由代码决定({@link BuiltinRole}),
|
* <p><b>两个来源,各有各的权威</b>:
|
||||||
* 直接从 JWT 里的 {@code roles} 推导即可,零 IO。自定义角色的授权在 DB 里,
|
*
|
||||||
* 但一期不开放自定义角色,故暂不需要查询。二期开放自定义角色时,
|
* <ul>
|
||||||
* 在这里加一层"角色码 → DB 授权"的补充查询即可,调用方无感。
|
* <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
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PermissionChecker {
|
public class PermissionChecker {
|
||||||
|
|
||||||
|
private final RoleGrantSource roleGrantSource;
|
||||||
|
|
||||||
/** 当前用户是否拥有指定权限。 */
|
/** 当前用户是否拥有指定权限。 */
|
||||||
public boolean has(PermissionDef perm) {
|
public boolean has(PermissionDef perm) {
|
||||||
LoginUser user = UserContext.get();
|
LoginUser user = UserContext.get();
|
||||||
@@ -29,7 +43,7 @@ public class PermissionChecker {
|
|||||||
if (user.isPlatformAdmin()) {
|
if (user.isPlatformAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return BuiltinRole.mergePermissions(user.roles()).contains(perm.getCode());
|
return effective(user).contains(perm.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasAny(PermissionDef... perms) {
|
public boolean hasAny(PermissionDef... perms) {
|
||||||
@@ -63,12 +77,49 @@ public class PermissionChecker {
|
|||||||
if (user.isPlatformAdmin()) {
|
if (user.isPlatformAdmin()) {
|
||||||
return PermissionDef.allCodes();
|
return PermissionDef.allCodes();
|
||||||
}
|
}
|
||||||
return BuiltinRole.mergePermissions(user.roles());
|
return effective(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
|
/** 当前用户的菜单键集合(用于 {@code GET /auth/me} 与前端菜单渲染)。 */
|
||||||
public Set<String> currentMenus() {
|
public Set<String> currentMenus() {
|
||||||
LoginUser user = UserContext.require();
|
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>菜单与权限的关系:一个菜单可能由多个权限共同决定可见性(如「审核」菜单
|
||||||
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
|
* 初审和终审权限都能看到),所以菜单单独建模,不做成权限的附属。
|
||||||
|
*
|
||||||
|
* <p>{@link #group} 是给「角色与授权」页的权限矩阵分组用的:
|
||||||
|
* 28 条权限平铺成一张表没人看得懂,而分组**只在展示层有意义**,不影响任何校验 ——
|
||||||
|
* 所以它是枚举上的一个字符串,而不是一张表。
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
public enum PermissionDef {
|
public enum PermissionDef {
|
||||||
|
|
||||||
// ── 平台运营 ──────────────────────────────────
|
// ── 平台运营 ──────────────────────────────────
|
||||||
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC),
|
TENANT_MANAGE("tenant:manage", "租户与套餐管理", Platform.PC, "平台运营"),
|
||||||
TENANT_VIEW("tenant:view", "租户查看", Platform.PC),
|
TENANT_VIEW("tenant:view", "租户查看", Platform.PC, "平台运营"),
|
||||||
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC),
|
LOG_VIEW_ALL("log:viewAll", "全量操作日志", Platform.PC, "平台运营"),
|
||||||
|
|
||||||
// ── 组织与账号 ────────────────────────────────
|
// ── 组织与账号 ────────────────────────────────
|
||||||
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC),
|
ORG_MANAGE("org:manage", "组织架构管理", Platform.PC, "组织与账号"),
|
||||||
ORG_VIEW("org:view", "组织架构查看", Platform.PC),
|
ORG_VIEW("org:view", "组织架构查看", Platform.PC, "组织与账号"),
|
||||||
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC),
|
USER_MANAGE("user:manage", "账号管理(含批量导入)", Platform.PC, "组织与账号"),
|
||||||
USER_VIEW("user:view", "账号查看", Platform.PC),
|
USER_VIEW("user:view", "账号查看", Platform.PC, "组织与账号"),
|
||||||
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC),
|
ROLE_MANAGE("role:manage", "角色与授权", Platform.PC, "组织与账号"),
|
||||||
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC),
|
LOG_VIEW_TENANT("log:viewTenant", "本租户操作日志", Platform.PC, "组织与账号"),
|
||||||
|
|
||||||
// ── 收集任务 ──────────────────────────────────
|
// ── 收集任务 ──────────────────────────────────
|
||||||
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC),
|
TASK_MANAGE("task:manage", "创建与配置收集任务", Platform.PC, "收集任务"),
|
||||||
TASK_VIEW("task:view", "任务查看", Platform.PC),
|
TASK_VIEW("task:view", "任务查看", Platform.PC, "收集任务"),
|
||||||
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC),
|
TASK_CLONE("task:clone", "任务克隆与复用", Platform.PC, "收集任务"),
|
||||||
FORM_MANAGE("form:manage", "表单设计", Platform.PC),
|
FORM_MANAGE("form:manage", "表单设计", Platform.PC, "收集任务"),
|
||||||
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC),
|
MATERIAL_MANAGE("material:manage", "材料规则配置", Platform.PC, "收集任务"),
|
||||||
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC),
|
PLAN_IMPORT("plan:import", "评价计划导入", Platform.PC, "收集任务"),
|
||||||
|
|
||||||
// ── 名册 ──────────────────────────────────────
|
// ── 名册 ──────────────────────────────────────
|
||||||
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC),
|
ROSTER_MANAGE("roster:manage", "名册导入与修改(全租户)", Platform.PC, "名册"),
|
||||||
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC),
|
ROSTER_MANAGE_OWN("roster:manageOwn", "名册修改(仅本班)", Platform.PC, "名册"),
|
||||||
ROSTER_VIEW("roster:view", "名册查看", Platform.PC),
|
ROSTER_VIEW("roster:view", "名册查看", Platform.PC, "名册"),
|
||||||
JOIN_REQUEST_AUDIT("join:audit", "名单加入申请审批", Platform.BOTH),
|
|
||||||
|
|
||||||
// ── 通知与进度 ────────────────────────────────
|
// ── 通知与进度 ────────────────────────────────
|
||||||
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH),
|
NOTIFY_SEND("notify:send", "催报与发送通知", Platform.BOTH, "通知与进度"),
|
||||||
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH),
|
PROGRESS_VIEW_ALL("progress:viewAll", "填报进度看板(全租户)", Platform.BOTH, "通知与进度"),
|
||||||
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH),
|
PROGRESS_VIEW_OWN("progress:viewOwn", "填报进度看板(本班/本人)", Platform.BOTH, "通知与进度"),
|
||||||
|
|
||||||
// ── 审核 ──────────────────────────────────────
|
// ── 审核 ──────────────────────────────────────
|
||||||
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH),
|
AUDIT_FIRST("audit:first", "初审(班主任)", Platform.BOTH, "审核"),
|
||||||
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC),
|
AUDIT_FINAL("audit:final", "终审(管理员)", Platform.PC, "审核"),
|
||||||
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC),
|
AUDIT_WITHDRAW("audit:withdraw", "撤回审核", Platform.PC, "审核"),
|
||||||
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH),
|
FILE_PROXY_UPLOAD("file:proxyUpload", "教师代传材料", Platform.BOTH, "审核"),
|
||||||
|
|
||||||
// ── 导出 ──────────────────────────────────────
|
// ── 导出 ──────────────────────────────────────
|
||||||
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC),
|
EXPORT_DOWNLOAD("export:download", "汇总导出与下载", Platform.PC, "导出"),
|
||||||
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC),
|
EXPORT_UNMASKED("export:unmasked", "数据脱敏导出(需二次确认)", Platform.PC, "导出"),
|
||||||
CLEANUP_RUN("cleanup:run", "历史数据整理器", Platform.PC),
|
|
||||||
|
|
||||||
// ── 学生端 ────────────────────────────────────
|
// ── 学生端 ────────────────────────────────────
|
||||||
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP);
|
SUBMISSION_OWN("submission:own", "填报、修改与撤回(仅本人)", Platform.MP, "学生端");
|
||||||
|
|
||||||
/** 端类型。 */
|
/** 端类型。 */
|
||||||
public enum Platform {
|
public enum Platform {
|
||||||
@@ -88,10 +90,14 @@ public enum PermissionDef {
|
|||||||
/** 适用范围。 */
|
/** 适用范围。 */
|
||||||
private final Platform platform;
|
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.code = code;
|
||||||
this.label = label;
|
this.label = label;
|
||||||
this.platform = platform;
|
this.platform = platform;
|
||||||
|
this.group = group;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PermissionDef ofCode(String code) {
|
public static PermissionDef ofCode(String code) {
|
||||||
@@ -106,4 +112,23 @@ public enum PermissionDef {
|
|||||||
Arrays.stream(values()).forEach(p -> set.add(p.code));
|
Arrays.stream(values()).forEach(p -> set.add(p.code));
|
||||||
return set;
|
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;
|
package cn.tianbaotong.fill.support;
|
||||||
|
|
||||||
import cn.tianbaotong.fill.module.user.entity.SysRole;
|
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.entity.SysUserRole;
|
||||||
import cn.tianbaotong.fill.module.user.mapper.RoleMapper;
|
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.mapper.UserRoleMapper;
|
||||||
|
import cn.tianbaotong.fill.module.user.service.RoleGrantWriter;
|
||||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -28,10 +27,20 @@ import java.util.Set;
|
|||||||
public class RoleProvisioner {
|
public class RoleProvisioner {
|
||||||
|
|
||||||
private final RoleMapper roleMapper;
|
private final RoleMapper roleMapper;
|
||||||
private final RolePermissionMapper rolePermissionMapper;
|
|
||||||
private final UserRoleMapper userRoleMapper;
|
private final UserRoleMapper userRoleMapper;
|
||||||
|
private final RoleGrantWriter grantWriter;
|
||||||
|
|
||||||
/** 幂等:已存在的角色跳过。 */
|
/**
|
||||||
|
* 幂等:角色已存在就补齐定义,并把**授权对齐到代码**。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 第一版是「已存在就 {@code continue}」,看起来更省事,但结果是:
|
||||||
|
* 代码里改了某条权限之后,**已经开通的租户永远停在发放那一刻** ——
|
||||||
|
* 库里的授权与代码不一致,而且没有任何地方会报错。
|
||||||
|
* 所以现在即使角色已存在,也要走一遍 {@link #syncGrants}(值一样时一行都不写)。
|
||||||
|
*
|
||||||
|
* <p>内置角色的权限**永远以代码为准**(见 {@code PermissionChecker}):
|
||||||
|
* 这里的同步是为了"库里看到的 = 实际生效的",让人查库时不会被误导。
|
||||||
|
*/
|
||||||
public void provisionBuiltinRoles(long tenantId) {
|
public void provisionBuiltinRoles(long tenantId) {
|
||||||
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
|
// ⚠️ 平台运营角色只发给平台租户(tenant_id=0)。
|
||||||
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
|
// 以前这里是「所有租户都发全部内置角色」,看起来更"整齐",
|
||||||
@@ -47,28 +56,55 @@ public class RoleProvisioner {
|
|||||||
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
|
if (!platformTenant && br == BuiltinRole.PLATFORM_ADMIN) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (findRole(tenantId, br.getCode()) != null) {
|
SysRole role = findRole(tenantId, br.getCode());
|
||||||
continue;
|
if (role == null) {
|
||||||
}
|
role = new SysRole();
|
||||||
SysRole role = new SysRole();
|
|
||||||
role.setTenantId(tenantId);
|
role.setTenantId(tenantId);
|
||||||
role.setRoleCode(br.getCode());
|
role.setRoleCode(br.getCode());
|
||||||
role.setRoleName(br.getName());
|
role.setRoleName(br.getName());
|
||||||
role.setRoleType(1);
|
role.setRoleType(1);
|
||||||
role.setDataScope(br.getDataScope().code());
|
role.setDataScope(br.getDataScope().code());
|
||||||
role.setSortNo(br.getSortNo());
|
role.setSortNo(br.getSortNo());
|
||||||
|
role.setUserType(br.getUserType().code());
|
||||||
role.setStatus(1);
|
role.setStatus(1);
|
||||||
role.setRemark("内置角色,权限由代码决定,不可修改");
|
role.setRemark("内置角色,权限由代码决定,不可修改");
|
||||||
roleMapper.insert(role);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (String permCode : br.permissionCodes()) {
|
/**
|
||||||
SysRolePermission rp = new SysRolePermission();
|
* 把库里那一行按代码对齐(名字 / 数据范围 / 排序 / 用户类型),**有变化才写库**。
|
||||||
rp.setTenantId(tenantId);
|
*
|
||||||
rp.setRoleId(role.getId());
|
* <p>为什么不连 {@code status} 一起刷:内置角色在界面上不可停用,
|
||||||
rp.setPermCode(permCode);
|
* 但万一有人用 SQL 停用了它,每天定时任务把它悄悄打开会更糟 ——
|
||||||
rolePermissionMapper.insert(rp);
|
* 那等于"系统自己改了别人的决定"。所以状态不在同步范围内。
|
||||||
|
*
|
||||||
|
* @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) {
|
public SysRole findRole(long tenantId, String roleCode) {
|
||||||
@@ -84,6 +120,26 @@ public class RoleProvisioner {
|
|||||||
if (role == null) {
|
if (role == null) {
|
||||||
throw new IllegalStateException("内置角色未创建:" + br.getCode());
|
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();
|
SysUserRole ur = new SysUserRole();
|
||||||
ur.setTenantId(tenantId);
|
ur.setTenantId(tenantId);
|
||||||
ur.setUserId(userId);
|
ur.setUserId(userId);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
# 数据信息填报系统 · 主配置
|
# 深学在线 · 主配置
|
||||||
# 环境差异全部放 application-{dev,test,prod}.yml
|
# 环境差异全部放 application-{dev,test,prod}.yml
|
||||||
# ⚠️ 生产密钥一律走环境变量,不写在这里
|
# ⚠️ 生产密钥一律走环境变量,不写在这里
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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)';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 数据信息填报系统 · 本地开发基础设施
|
# 深学在线 · 本地开发基础设施
|
||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# cd deploy
|
# cd deploy
|
||||||
|
|||||||
+28
-13
@@ -1,6 +1,6 @@
|
|||||||
# 交付计划与进度
|
# 交付计划与进度
|
||||||
|
|
||||||
> 项目:数据信息填报系统(填报通 / CollectFlow)
|
> 项目:深学在线
|
||||||
> 起始:2026-09-28
|
> 起始:2026-09-28
|
||||||
> 状态:**进行中**
|
> 状态:**进行中**
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
| — | 主 PRD(含库表设计、接口清单、15 条校验规则、18 张表 DDL) | V2.3 | ✅ |
|
| — | 主 PRD(含库表设计、接口清单、15 条校验规则、18 张表 DDL) | V2.3 | ✅ |
|
||||||
| — | 功能逻辑卷(纯功能运转逻辑,不含库表/接口/代码) | V1.9 | ✅ |
|
| — | 功能逻辑卷(纯功能运转逻辑,不含库表/接口/代码) | V1.9 | ✅ |
|
||||||
| — | PostgreSQL 建表脚本 | — | ✅ **已在 PG 16 实测通过** |
|
| — | PostgreSQL 建表脚本 | — | ✅ **已在 PG 16 实测通过** |
|
||||||
| — | 学生端原型(**11 屏**) | V1.2 | ✅ 含「申请加入名单」 |
|
| — | 学生端原型(**10 屏**) | V1.3 | ✅(原 STEP 03B「申请加入名单」已于 2026-10 需求收敛删除) |
|
||||||
| — | 班主任端原型(9 屏) | V1.1 | ✅ |
|
| — | 班主任端原型(9 屏) | V1.1 | ✅ |
|
||||||
| — | 管理端 PC 原型(**11 屏**) | V1.2 | ✅ 含表单设计器/材料规则/整理器/申请审批 |
|
| — | 管理端 PC 原型(**10 屏**) | V1.3 | ✅ 含表单设计器/材料规则/角色与授权(整理器与申请审批两屏已于 2026-10 删除) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **P1** | **客户与认定机构确认函** | `01-客户与机构确认函.md` | 外部依赖最长。**5 项高优先级阻塞开发**(300dpi 能否接受、名单外的人怎么办、社会收集名单从哪来…),越早发出越早回来 | ✅ |
|
| **P1** | **客户与认定机构确认函** | `01-客户与机构确认函.md` | 外部依赖最长。**5 项高优先级阻塞开发**(300dpi 能否接受、名单外的人怎么办、社会收集名单从哪来…),越早发出越早回来 | ✅ |
|
||||||
| **P2** | **需求追溯矩阵** | `02-需求追溯矩阵.md` | 自审:把 PRD 需求 ↔ 原型屏 ↔ 库表 ↔ 接口 逐条对应,找出**说了没做/做了没说**的缺口。放在接口文档之前,避免接口漏做 | ✅ |
|
| **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 追溯矩阵** | 完成 `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 | **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 | **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 | **D7 降级** | 重新评估「文件名识别真实准确率」这个待办:因**输出命名取自名册**、**归属靠证件号反查名册**、**识别有槽位兜底**、**失败一律放行**,该指标**不影响任何交付项**。降级为「上传页引导文案校准」——班主任在群里问两句即可,**取消样本索取**。至此**全部外部数据依赖清零** |
|
||||||
| 2026-09-28 | **一期范围收窄** | 客户确认「**先做学校内的填报流程**」→ 一期只做**学校收集 + 自主认定**(4 类材料全部有实测样本);社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。**D4 因此不再是待办**。工期 −5 人日(392→387),关键路径不变仍 15 周 |
|
| 2026-09-28 | **范围收窄** | 客户确认「**先做学校内的填报流程**」→ 只做**学校收集 + 自主认定**(4 类材料全部有实测样本);社会收集 / 社会评价 / 专项职业能力**不做**(2026-10 进一步明确为取消,不再叫"后置二期"),**数据结构与枚举保留不启用**。工期 −5 人日(392→387),关键路径不变仍 15 周 |
|
||||||
| 2026-09-28 | **移除联系方式展示** | 客户指出"学生本来就有班主任联系方式,不需要系统里面体现"。删除 `GET /mp/roster/contact` 接口 + 门禁失败页「联系老师」按钮,改为只保留两条系统内动作(重新填写 / 申请加入名单)。**接口总数 122 → 121**。纯文案提示保留(不涉及联系方式数据) |
|
| 2026-09-28 | **移除联系方式展示** | 客户指出"学生本来就有班主任联系方式,不需要系统里面体现"。删除 `GET /mp/roster/contact` 接口 + 门禁失败页「联系老师」按钮。**接口总数 122 → 121**。⚠️ 当时保留的两条系统内动作里,「申请加入名单」已于 2026-10 撤销,现在只剩「重新填写」+ 一句"请联系老师" |
|
||||||
| 2026-09-28 | **归属校验修正** | 客户指出"系统不存在名称错误"——纠正原设计中「把文件名不符自动按名册修正」的缺陷(会把传错的文件静默洗白)。改为 **VAL-21 文件归属校验 + 阻断提交**;判定优先级重排为 证件号反查名册 > OCR > 姓名(仅辅助);姓名不再作为独立规则。另补全「社会评价」定义与 `cert_type` 第三取值 |
|
| 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 | **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 条关键决策速查、目录结构与快速上手、数据基线、两条踩坑教训 |
|
| 2026-09-28 | **P7 文档导航** | 完成 ../README.md(10KB)。按角色给出读法、三分钟看懂产品、13 条关键决策速查、目录结构与快速上手、数据基线、两条踩坑教训 |
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
| 2026-09-28 | **P4 核心模块设计** | 完成 `04-核心模块设计说明.md`(54KB / 1218 行)。六个模块全部伪代码级:M1 命名(含证件号三源裁决表)、M2 压缩(五档+二分降质+二次有损防护)、M3 识别(四层递进+成本闸)、M4 门禁(**并发绑定必须 UPDATE...WHERE bind_user_id IS NULL**)、M5 校验、M6 导出(快照先固化+中文文件名编码)。**校验中发现并修正三处真问题**,见下条教训 |
|
| 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-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 | **验收走查后的四项修正** | ① **去掉 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 的说明) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -79,12 +80,12 @@
|
|||||||
| 组 | 项数 | 需开发 | 结论 |
|
| 组 | 项数 | 需开发 | 结论 |
|
||||||
|---|:--:|:--:|---|
|
|---|:--:|:--:|---|
|
||||||
| A 认定机构 | 6 | **0** | 全部与现有设计一致(当初就按实测数据设计) |
|
| A 认定机构 | 6 | **0** | 全部与现有设计一致(当初就按实测数据设计) |
|
||||||
| B 校方业务 | 9 | **1** | 仅 **B1(申请加入名单通道)** 是功能增量 |
|
| B 校方业务 | 9 | **1**(⚠️ B1 已于 2026-10 需求回退取消) | 仅 **B1(申请加入名单通道)** 是功能增量,该通道已于 2026-10 删除 |
|
||||||
| C 商务法务 | 5 | 0 | C1 小程序资质按建议推进 + H5 保底 |
|
| C 商务法务 | 5 | 0 | C1 小程序资质按建议推进 + H5 保底 |
|
||||||
| D 内部技术 | 7 | 0 | **D4 已后置二期**、**D7 已降级清除**;仅 D6(微信三条来源通道体积上限)需真机自测,**无外部依赖** |
|
| D 内部技术 | 7 | 0 | **D4 已随"取消二期"作废**、**D7 已降级清除**;仅 D6(微信三条来源通道体积上限)需真机自测,**无外部依赖** |
|
||||||
|
|
||||||
**一期范围(客户确认后收窄)**:只做 **学校收集 + 自主认定**(4 类材料全部有实测样本)。
|
**一期范围(客户确认后收窄)**:只做 **学校收集 + 自主认定**(4 类材料全部有实测样本)。
|
||||||
社会收集 / 社会评价 / 专项职业能力后置二期,**数据结构与枚举保留不启用**。
|
社会收集 / 社会评价 / 专项职业能力**不做**(2026-10 取消,不再是"后置"),**数据结构与枚举保留不启用**。
|
||||||
⇒ **D4(社会评价样本)因此不再是待办**;工期 **392 → 387 人日**,关键路径不变仍 **15 周**。
|
⇒ **D4(社会评价样本)因此不再是待办**;工期 **392 → 387 人日**,关键路径不变仍 **15 周**。
|
||||||
|
|
||||||
**本次变更(V1)**:新增 **FR-509 系列**(10 条)+ **1 张表**(`collect_join_request`)+ **5 个接口** + **2 屏原型**。
|
**本次变更(V1)**:新增 **FR-509 系列**(10 条)+ **1 张表**(`collect_join_request`)+ **5 个接口** + **2 屏原型**。
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
| 指标 | 变更前 | 变更后 |
|
| 指标 | 变更前 | 变更后 |
|
||||||
|---|:--:|:--:|
|
|---|:--:|:--:|
|
||||||
| FR 总数 | 115 | **125** |
|
| FR 总数 | 115 | **125** |
|
||||||
| 接口总数 | 117 | **121** |
|
| 接口总数 | 117 | **121**(2026-10 收敛后为 **119**) |
|
||||||
| 库表 | 19 | **20** |
|
| 库表 | 19 | **20** |
|
||||||
| VAL 规则 | 20 | **21**(新增 VAL-21 归属校验) |
|
| VAL 规则 | 20 | **21**(新增 VAL-21 归属校验) |
|
||||||
| 错误码 | 50 | **57** |
|
| 错误码 | 50 | **57** |
|
||||||
@@ -108,6 +109,20 @@
|
|||||||
| # | 风险 | 影响 | 对策 |
|
| # | 风险 | 影响 | 对策 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R1 | ~~客户迟迟不确认 P1 的 5 项高优先级问题~~ | — | ✅ **已解除**:客户确认全部 23 项按建议执行,需求已冻结 |
|
| R1 | ~~客户迟迟不确认 P1 的 5 项高优先级问题~~ | — | ✅ **已解除**:客户确认全部 23 项按建议执行,需求已冻结 |
|
||||||
| R2 | ~~强特征词表未在"社会评价"批次数据上标定~~ | — | ✅ **已消除**:一期范围收窄为「学校收集 + 自主认定」,**不涉及**那两类零样本材料(D4 已后置二期) |
|
| R2 | ~~强特征词表未在"社会评价"批次数据上标定~~ | — | ✅ **已消除**(2026-10 取消二期) |
|
||||||
| R3 | VLM 选型未定 | 中 | P4 中把 VLM 抽成**可替换接口**,不绑定供应商 |
|
| 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
|
> 发出日期:2026-09-28
|
||||||
> 回复期限:建议 **5 个工作日内**
|
> 回复期限:建议 **5 个工作日内**
|
||||||
> 版本:V1.0
|
> 版本:V1.1(2026-10 追加需求收敛说明)
|
||||||
|
>
|
||||||
|
> ⚠️ **2026-10 变更提示**:客户随后要求收敛范围 —— **B1(本函唯一的功能增量)已整体撤销**,
|
||||||
|
> 且**不再设计任何二期功能**。本函正文保留当时原文(它是"当时确认过什么"的记录),
|
||||||
|
> 但凡出现"二期 / 后置 / 待实施"的表述,**一律读作"不做"**。详见 `docs/交接摘要.md` **§一·三十六**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,7 +190,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **问题** | 是否需要把数据直接推送到机构系统,而不是导出文件包? |
|
| **问题** | 是否需要把数据直接推送到机构系统,而不是导出文件包? |
|
||||||
| **建议方案** | ✅ **建议一期不做**(导出标准包已可满足),**但预留开放接口**,二期按需启用 |
|
| **建议方案** | ✅ **不做**(导出标准包已可满足),**但预留开放接口**,将来按需启用 |
|
||||||
| **回复** | ☐ 一期不做 ☐ 一期就必须对接(请提供对方接口文档) |
|
| **回复** | ☐ 一期不做 ☐ 一期就必须对接(请提供对方接口文档) |
|
||||||
|
|
||||||
### B9. 社会收集是否需公安实名核验?
|
### B9. 社会收集是否需公安实名核验?
|
||||||
@@ -257,7 +261,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
**建议**:一期按 H5 单腿上线(学生扫 H5 码即可填报),小程序作为第二条腿,
|
**建议**:一期按 H5 单腿上线(学生扫 H5 码即可填报),小程序作为第二条腿,
|
||||||
在开放平台绑定完成后一起开。**这样不必等它,但必须知道它挡着什么。**
|
在开放平台绑定完成后一起开。**这样不必等它,但必须知道它挡着什么。**
|
||||||
|
|
||||||
回复:☐ 现在就办(我方可提供绑定步骤) ☐ 一期只上 H5,二期再办 ☐ 其他 ______
|
回复:☐ 现在就办(我方可提供绑定步骤) ☐ 只上 H5,`union_id` 缓办 ☐ 其他 ______
|
||||||
|
|
||||||
### E2. D3 门禁限流阈值口径已更新(备案,无需回复)
|
### E2. D3 门禁限流阈值口径已更新(备案,无需回复)
|
||||||
|
|
||||||
@@ -282,18 +286,19 @@ B1 选 A,但希望增加审批环节
|
|||||||
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
|
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
|
||||||
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
|
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
|
||||||
|
|
||||||
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
|
### E3. ~~二期(社会收集 / 社会评价 / 专项职业能力)的启动时点~~ —— **已取消**(2026-10)
|
||||||
|
|
||||||
一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
|
一期范围已按约定锁死为「学校收集 + 自助认定」(其他模式一律返回 `40044`)。
|
||||||
但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
> ⚠️ **2026-10 已确定:不做二期**,本节作废(保留原文供追溯)。原话是:
|
||||||
|
> 但**二期什么时候启动**会影响两件现在就能做、也可以等的事:
|
||||||
|
|
||||||
| 事项 | 如果二期在 3 个月内启动 | 如果更晚 |
|
| ~~事项~~ | ~~如果二期在 3 个月内启动~~ | ~~如果更晚~~ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| D4 材料识别强特征词表标定 | 建议现在就在社会评价批次数据上标定 | 可现在只做学校批次的标定 |
|
| D4 材料识别强特征词表标定 | 建议现在就在社会评价批次数据上标定 | 可现在只做学校批次的标定 |
|
||||||
| D5 一寸照片形态阈值标定 | 同上 | 同上 |
|
| D5 一寸照片形态阈值标定 | 同上 | 同上 |
|
||||||
| 二期审核流与会话设计 | 建议现在出一版概要设计,避免一期代码挡住它 | 一期交付后再说 |
|
| ~~二期审核流与会话设计~~ | **不做**(2026-10 取消二期) | — |
|
||||||
|
|
||||||
回复:☐ 二期预计 ____ 启动 ☐ 暂无计划
|
回复:☑ **暂无计划(2026-10 已确认取消二期)**
|
||||||
|
|
||||||
### E4. 国标职业库数据的来源(字典页「职业(国标)」目前是空的)
|
### E4. 国标职业库数据的来源(字典页「职业(国标)」目前是空的)
|
||||||
|
|
||||||
@@ -342,7 +347,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
|
|
||||||
| # | 确认结论 | 落地动作 | 状态 |
|
| # | 确认结论 | 落地动作 | 状态 |
|
||||||
|:--:|---|---|:--:|
|
|:--:|---|---|:--:|
|
||||||
| **B1** | **选 A:增加「申请加入名单 → 管理员审批」通道** | ⚠️ **需开发**:新增 FR-509 系列 + 1 张表 + 4 个接口 + 2 屏原型 | 🔨 **待实施** |
|
| ~~**B1**~~ | ~~选 A:增加「申请加入名单 → 管理员审批」通道~~ → **已撤销**(2026-10 需求回退):该通道、表、接口、学生端页面与错误码已全部删除 | ⚠️ **需求回退,建议补变更确认单**:名单外的人改由老师在任务里**手工加人**;系统内不展示联系方式 | ❌ **已撤销** |
|
||||||
| B2 | 社会收集名单**由报名点/机构维护预报名名单**,导入后开放;**填报期内可随时追加** | 已一致:FR-508 追加导入;补充说明写入 PRD §5.5 | ✅ 已一致 |
|
| B2 | 社会收集名单**由报名点/机构维护预报名名单**,导入后开放;**填报期内可随时追加** | 已一致:FR-508 追加导入;补充说明写入 PRD §5.5 | ✅ 已一致 |
|
||||||
| B3 | **字段三分类**:姓名/证件号/报考三项锁定,其余可核对修改并留痕 | 已一致:FR-502b + `changed_fields` 机制 | ✅ 已一致 |
|
| B3 | **字段三分类**:姓名/证件号/报考三项锁定,其余可核对修改并留痕 | 已一致:FR-502b + `changed_fields` 机制 | ✅ 已一致 |
|
||||||
| B4 | **不加手机号验证码** | 无需改动(当前门禁即姓名+证件号) | ✅ 已一致 |
|
| B4 | **不加手机号验证码** | 无需改动(当前门禁即姓名+证件号) | ✅ 已一致 |
|
||||||
@@ -352,8 +357,8 @@ B1 选 A,但希望增加审批环节
|
|||||||
| B8 | **一期不对接机构系统,预留开放接口** | 已一致:§14 开放接口已设计但不启用 | ✅ 已一致 |
|
| B8 | **一期不对接机构系统,预留开放接口** | 已一致:§14 开放接口已设计但不启用 | ✅ 已一致 |
|
||||||
| B9 | **不做公安实名核验** | 无需改动 | ✅ 已一致 |
|
| B9 | **不做公安实名核验** | 无需改动 | ✅ 已一致 |
|
||||||
|
|
||||||
> **结论:B 组 9 项中 8 项零改动,仅 B1 需开发。**
|
> **结论:B 组 9 项中 8 项零改动;B1 曾是我方主动提出的功能增量、客户采纳后已开发,但于 2026-10 随需求收敛整体撤销。**
|
||||||
> B1 的建议是我方主动提出的(原需求只有"只提示"),客户采纳后**这是一次功能增量**,已按 §变更记录 追加设计与排期。
|
> ⚠️ 它是本函里**唯一的功能增量**,因此这次撤销属**需求回退**,建议补一张变更确认单。
|
||||||
|
|
||||||
### C 组 · 商务法务(5 项)
|
### C 组 · 商务法务(5 项)
|
||||||
|
|
||||||
@@ -372,7 +377,7 @@ B1 选 A,但希望增加审批环节
|
|||||||
| D1 | OCR 引擎选型(腾讯云/百度/本地) | ⏳ M2 结束前定 |
|
| D1 | OCR 引擎选型(腾讯云/百度/本地) | ⏳ M2 结束前定 |
|
||||||
| D2 | VLM 抽成可替换接口,不绑定供应商 | ✅ 已按此设计(04 文档 M3 §3.6) |
|
| D2 | VLM 抽成可替换接口,不绑定供应商 | ✅ 已按此设计(04 文档 M3 §3.6) |
|
||||||
| D3 | 门禁限流:openid 10 次/小时、IP 20 次/分钟 | ✅ 已实现口径 |
|
| D3 | 门禁限流:openid 10 次/小时、IP 20 次/分钟 | ✅ 已实现口径 |
|
||||||
| D4 | **需索取「社会评价」批次的原始材料样本** | ✅ **已后置为二期**(一期只做学校收集 + 自主认定,不涉及社会评价材料) |
|
| D4 | **需索取「社会评价」批次的原始材料样本** | ✅ **已作废**(2026-10 取消二期,不再需要该样本) |
|
||||||
|
|
||||||
#### D4 补充说明:为什么需要「社会评价」的样本
|
#### D4 补充说明:为什么需要「社会评价」的样本
|
||||||
|
|
||||||
@@ -404,8 +409,8 @@ B1 选 A,但希望增加审批环节
|
|||||||
| 退而求其次 | **10~20 份**,但**必须覆盖「学信网备案表」和「各类佐证材料」**(这两类现在零样本) |
|
| 退而求其次 | **10~20 份**,但**必须覆盖「学信网备案表」和「各类佐证材料」**(这两类现在零样本) |
|
||||||
| 特别注意 | 记录**材料来源与格式**——社会人员可能直接从学信网下载 **PDF**(而非拍照/扫描)。若是,`file_types` 白名单、预览、压缩策略都要跟着调整 |
|
| 特别注意 | 记录**材料来源与格式**——社会人员可能直接从学信网下载 **PDF**(而非拍照/扫描)。若是,`file_types` 白名单、预览、压缩策略都要跟着调整 |
|
||||||
|
|
||||||
> ✅ **已后置为二期**(2026-09-28 客户确认):一期范围收窄为「**学校收集 + 自主认定**」,材料只有 4 类(一寸照片 / 身份证 / 学籍表 / 工作证明),**全部有 112 个真实文件实测**。
|
> ✅ **已作废**(2026-10 取消二期;2026-09-28 曾收窄为「**学校收集 + 自主认定**」),材料只有 4 类(一寸照片 / 身份证 / 学籍表 / 工作证明),**全部有 112 个真实文件实测**。
|
||||||
> 因此 D4 **不再是待办、也不阻塞任何里程碑**。待二期做「社会评价」时再索取即可。
|
> 因此 D4 **不是待办、也不阻塞任何里程碑**;由于二期已取消,**该样本不再需要索取**。
|
||||||
| D5 | 一寸照片形态阈值用真实样本标定 | ✅ 已有实测(984×1378,5:7) |
|
| D5 | 一寸照片形态阈值用真实样本标定 | ✅ 已有实测(984×1378,5:7) |
|
||||||
| D6 | **微信三条来源通道体积上限需真机实测** | ⏳ 待测 |
|
| D6 | **微信三条来源通道体积上限需真机实测** | ⏳ 待测 |
|
||||||
| D7 | ~~文件名规则在"学生原始乱名"上测真实准确率~~ | ✅ **已降级为文案校准**(不需样本) |
|
| D7 | ~~文件名规则在"学生原始乱名"上测真实准确率~~ | ✅ **已降级为文案校准**(不需样本) |
|
||||||
|
|||||||
+10
-10
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 用途:把 PRD 的每条需求对上「**原型 / 库表 / 接口 / 验收**」四个交付面,找出**说了没做**和**做了没说**。
|
> 用途:把 PRD 的每条需求对上「**原型 / 库表 / 接口 / 验收**」四个交付面,找出**说了没做**和**做了没说**。
|
||||||
> 生成日期:2026-09-28
|
> 生成日期: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**
|
> 结论:**主流程全覆盖。第三节的 10 个接口缺口已于 P3 补齐(共新增 21 个接口);配置类页面缺口见 3.2**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
| 模块 | 条数 | P0 | 库表 | 接口 | 原型 | 判定 |
|
| 模块 | 条数 | P0 | 库表 | 接口 | 原型 | 判定 |
|
||||||
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||||
| FR-100 租户与组织 | 5 | 5 | ✅ | ✅ | ❌ | ⚠️ 缺平台运营端原型 |
|
| 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-300 收集任务 | 12 | 9 | ✅ | ✅ | ⚠️ | ✅ 暂停接口已补(P3) |
|
||||||
| FR-400 动态表单 | 8 | 6 | ✅ | ✅ | ❌ | ⚠️ **缺表单设计器原型** |
|
| FR-400 动态表单 | 8 | 6 | ✅ | ✅ | ❌ | ⚠️ **缺表单设计器原型** |
|
||||||
| FR-500 名单管理 | 24 | 21 | ✅ | ✅ | ✅ | ✅ **全覆盖**(含客户确认新增的 FR-509 系列 10 条) |
|
| FR-500 名单管理 | 24 | 21 | ✅ | ✅ | ✅ | ✅ **全覆盖**(含客户确认新增的 FR-509 系列 10 条) |
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
| FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
|
| FR-1100 数据看板 | 5 | 2 | ✅ | ✅ | ✅ | ✅ **全覆盖** |
|
||||||
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
|
| FR-1200 系统管理 | 6 | 5 | ⚠️ | ✅ | ❌ | ⚠️ 接口已补(P3),原型仍缺 |
|
||||||
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
| FR-1300 SaaS 计费 | 4 | 2 | ⚠️ | ⚠️ | ❌ | ⚠️ 缺平台运营端 |
|
||||||
| FR-1400 历史整理器 | 6 | 0 | ✅ | ❌ | ❌ | ❌ **三面全缺**(P1,本轮未做) |
|
| ~~FR-1400 历史整理器~~ | ~~6~~ | ~~0~~ | — | — | — | **已取消**(2026-10 需求收敛:模块/表/接口/页面全部删除) |
|
||||||
| **FR-700 校验引擎** | — | — | ✅ | ✅ | ✅ | ✅ 以 VAL-01~21 实现,三端均有体现 |
|
| **FR-700 校验引擎** | — | — | ✅ | ✅ | ✅ | ✅ 以 VAL-01~21 实现,三端均有体现 |
|
||||||
|
|
||||||
**图例**:✅ 完整覆盖 ⚠️ 部分覆盖 ❌ 未覆盖
|
**图例**:✅ 完整覆盖 ⚠️ 部分覆盖 ❌ 未覆盖
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
| # | 缺口 | 对应需求 | 影响 | 建议接口 |
|
| # | 缺口 | 对应需求 | 影响 | 建议接口 |
|
||||||
|:--:|---|---|---|---|
|
|:--:|---|---|---|---|
|
||||||
| I1 | **报考范围读取与确认** | FR-307~311 | 管理端④整屏无数据来源 | `GET/PUT /api/v1/tasks/{id}/plan-scope` |
|
| 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` |
|
| I3 | **字典管理** | FR-1201 | 学历/等级/补考科目等下拉项无法维护 | `GET/POST/PUT /api/v1/dicts` |
|
||||||
| I4 | **操作日志** | FR-1202 | 审计留痕无法查询 | `GET /api/v1/logs/operations` |
|
| I4 | **操作日志** | FR-1202 | 审计留痕无法查询 | `GET /api/v1/logs/operations` |
|
||||||
| I5 | **登录日志** | FR-1203 | 同上 | `GET /api/v1/logs/logins` |
|
| I5 | **登录日志** | FR-1203 | 同上 | `GET /api/v1/logs/logins` |
|
||||||
@@ -75,14 +75,14 @@
|
|||||||
|:--:|---|---|:--:|---|
|
|:--:|---|---|:--:|---|
|
||||||
| P1 | **表单设计器** | FR-401~408 | 高 | ⚠️ **必须补**——拖拽式字段编排是 P0,且交互复杂,光靠文字描述开发必理解偏 |
|
| P1 | **表单设计器** | FR-401~408 | 高 | ⚠️ **必须补**——拖拽式字段编排是 P0,且交互复杂,光靠文字描述开发必理解偏 |
|
||||||
| P2 | **材料规则配置**(命名模板编辑器) | FR-601~609 | 高 | ⚠️ **建议补**——管理端②只做了"试跑",配置界面本身没画 |
|
| P2 | **材料规则配置**(命名模板编辑器) | FR-601~609 | 高 | ⚠️ **建议补**——管理端②只做了"试跑",配置界面本身没画 |
|
||||||
| P3 | 历史数据整理器 | FR-1401~1406 | 中 | 建议补(差异化获客功能,客户演示时最吸睛) |
|
| ~~P3~~ | ~~历史数据整理器~~ | — | — | **已取消**(2026-10 需求收敛) |
|
||||||
| P4 | 登录页 | FR-201 | 低 | 可略 |
|
| P4 | 登录页 | FR-201 | 低 | 可略 |
|
||||||
| P5 | 组织与用户管理 | FR-103、203~206 | 中 | 可略(标准 CRUD) |
|
| P5 | 组织与用户管理 | FR-103、203~206 | 中 | 可略(标准 CRUD) |
|
||||||
| P6 | 任务列表与详情 | FR-301~306 | 中 | 建议补简版(克隆、二维码、暂停) |
|
| P6 | 任务列表与详情 | FR-301~306 | 中 | 建议补简版(克隆、二维码、暂停) |
|
||||||
| P7 | 通知模板管理 | FR-1001~1002 | 低 | 可略 |
|
| P7 | 通知模板管理 | FR-1001~1002 | 低 | 可略 |
|
||||||
| P8 | 字典管理 | FR-1201 | 低 | 可略 |
|
| P8 | 字典管理 | FR-1201 | 低 | 可略 |
|
||||||
| P9 | 操作日志查询 | FR-1202~1203 | 低 | 可略 |
|
| P9 | 操作日志查询 | FR-1202~1203 | 低 | 可略 |
|
||||||
| P10 | 平台运营端(租户/套餐/计费) | FR-101~102、1301~1304 | 中 | 建议二期做(一期客户是学校,不关心平台运营) |
|
| P10 | 平台运营端(租户/套餐/计费) | FR-101~102、1301~1304 | 中 | **不做**(2026-10:不再设计二期;平台运营端只保留租户开通与用量) |
|
||||||
|
|
||||||
### 3.3 需求条目本身的核查
|
### 3.3 需求条目本身的核查
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
规则引擎(命名/压缩/识别) ████████████████████ 100%
|
规则引擎(命名/压缩/识别) ████████████████████ 100%
|
||||||
配置类页面 ██████░░░░░░░░░░░░░░ 30%
|
配置类页面 ██████░░░░░░░░░░░░░░ 30%
|
||||||
平台运营 ░░░░░░░░░░░░░░░░░░░░ 0%
|
平台运营 ░░░░░░░░░░░░░░░░░░░░ 0%
|
||||||
历史整理器 ░░░░░░░░░░░░░░░░░░░░ 0%(仅库表)
|
~~历史整理器~~ ░░░░░░░░░░░░░░░░░░░░ 已取消(2026-10)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 必须行动的 3 项
|
### 必须行动的 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-1100 | FR-1101 ~ 1105 | 5 | 看板 |
|
||||||
| FR-1200 | FR-1201 ~ 1206 | 6 | 系统管理 |
|
| FR-1200 | FR-1201 ~ 1206 | 6 | 系统管理 |
|
||||||
| FR-1300 | FR-1301 ~ 1304 | 4 | 计费 |
|
| 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** |
|
| **合计** | | **125** | **92 P0 / 30 P1 / 3 P2** |
|
||||||
|
|
||||||
**校验规则**:VAL-01 ~ VAL-21(21 条,编号连续;18~20 为 P4 补充,**21 为客户确认后补充的归属校验**)
|
**校验规则**: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 张"从略"标准表
|
**库表**:**20 张**完整 DDL + 4 张"从略"标准表
|
||||||
|
|||||||
+181
-362
@@ -14,16 +14,16 @@
|
|||||||
| §1 | 通用约定(域名、鉴权、响应、分页、幂等、限流、脱敏) |
|
| §1 | 通用约定(域名、鉴权、响应、分页、幂等、限流、脱敏) |
|
||||||
| §2 | 枚举字典全集 |
|
| §2 | 枚举字典全集 |
|
||||||
| §3 | 通用数据结构(Schema 定义) |
|
| §3 | 通用数据结构(Schema 定义) |
|
||||||
| §4 | 接口总览(**121 个**) |
|
| §4 | 接口总览(**119 个**) |
|
||||||
| §5 | 认证与账号(13) |
|
| §5 | 认证与账号(13) |
|
||||||
| §6 | 组织与租户(15) |
|
| §6 | 组织与租户(15) |
|
||||||
| §7 | 任务与配置(30) |
|
| §7 | 任务与配置(25) |
|
||||||
| §8 | 名册(12) |
|
| §8 | 名册(7) |
|
||||||
| §9 | 填报端 · 小程序(16) |
|
| §9 | 填报端 · 小程序(16) |
|
||||||
| §10 | 班主任移动端(10) |
|
| §10 | 班主任移动端(10) |
|
||||||
| §11 | 审核(9) |
|
| §11 | 审核(9) |
|
||||||
| §12 | 校验 / 导出 / 通知(12) |
|
| §12 | 校验 / 导出 / 通知(12) |
|
||||||
| §13 | **本次补齐的缺口接口(21)** |
|
| §13 | **本次补齐的缺口接口(16)** |
|
||||||
| §14 | 开放接口 · 供认定机构对接(4) |
|
| §14 | 开放接口 · 供认定机构对接(4) |
|
||||||
| §15 | 错误码全集 |
|
| §15 | 错误码全集 |
|
||||||
|
|
||||||
@@ -212,6 +212,12 @@
|
|||||||
|
|
||||||
> ⚠️ **`WORK_PROOF` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。
|
> ⚠️ **`WORK_PROOF` 的名称陷阱**:按机构模板,**「职业技能等级个人申报表」的规范文件名是「工作证明」**。这是机构既有习惯,不要"修正"。
|
||||||
|
|
||||||
|
> ⚠️ **一期实际只有 4 类材料**:`PHOTO1` / `IDCARD` / `SCHOOL_ROLL` / `WORK_PROOF`。
|
||||||
|
> `XJW_BA` 与 `OTHER_PROOF` 是需求收敛(2026-10)**之前**留下的编码 ——
|
||||||
|
> **历史数据读得到、但"新增材料"选不到**(`GET /api/v1/material-catalog` 的可选项不含它们),
|
||||||
|
> 内置材料模板也只剩 `TPL_SCHOOL_ROLL` 一个(原 `TPL_SOCIAL` / `TPL_ZHUANXIANG` 已删)。
|
||||||
|
> 这样处理是为了**不让历史任务里的材料项悬空**:删编码会让已配的任务读不出材料名。
|
||||||
|
|
||||||
### 2.2 收集模式 `collectMode`
|
### 2.2 收集模式 `collectMode`
|
||||||
|
|
||||||
| 值 | 含义 | 审核流 |
|
| 值 | 含义 | 审核流 |
|
||||||
@@ -266,7 +272,7 @@
|
|||||||
| 枚举 | 取值 |
|
| 枚举 | 取值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 |
|
| `userType` | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生 |
|
||||||
| `roleCode` | `PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `DEPUTY_TEACHER` / `STUDENT` / `AUDITOR` |
|
| `roleCode` | `PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `STUDENT` |
|
||||||
| `taskStatus` | `0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` **已暂停** (与后端 `CollectTask.STATUS_*` 及 DDL 列注释一致)|
|
| `taskStatus` | `0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` **已暂停** (与后端 `CollectTask.STATUS_*` 及 DDL 列注释一致)|
|
||||||
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
|
| `rosterSubmitStatus` | `0` 未提交 / `1` 已提交 / `2` 已通过 / `3` 已驳回 |
|
||||||
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
|
| `fileValidateStatus` | `0` 待校验 / `1` 通过 / `2` 警告 / `3` 不通过 / `4` 待人工 |
|
||||||
@@ -275,10 +281,8 @@
|
|||||||
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
|
| `detectSource` | `1` 文件名 / `2` 形态 / `3` OCR 内容 / `4` VLM / `5` 用户指定 |
|
||||||
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
|
| `compressProfile` | `P1` 证件照 / `P2` 证件翻拍 / `P3` A4 扫描件 / `P4` 截图 / `P5` 兜底 |
|
||||||
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
|
| `notifyChannel` | `1` 微信订阅消息 / `2` 短信 / `3` 公众号模板 |
|
||||||
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` **催报** / `6` **导出完成** / `7` 加入名单审批 |
|
| `notifyBizType` | `1` 任务发布 / `2` 临期提醒 / `3` 驳回通知 / `4` 审核通过 / `5` **催报** / `6` **导出完成** |
|
||||||
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
|
| `exportStatus` | `0` 排队中 / `1` 打包中 / `2` 已完成 / `3` 失败 / `4` 已过期 |
|
||||||
| `cleanupSourceType` | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
|
|
||||||
| `cleanupStatus` | `0` 排队 / `1` 解析中 / `2` 处理中 / `3` 已完成 / `4` 失败 |
|
|
||||||
|
|
||||||
> ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
|
> ✅ **`notifyBizType` 的 `5`/`6` 曾经在本文件内部对调过(已修正,2026-09-30 客户确认)**:
|
||||||
> 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
|
> 本表旧版写「`5` 导出完成 / `6` 催报」,与 §13.5 消息中心示例、落库值
|
||||||
@@ -487,7 +491,7 @@
|
|||||||
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
|
| `vlmCalled` | bool | `vlm_called` | 是否调用过 VLM(**成本监控用**) |
|
||||||
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
|
| `validateStatus` | int | `validate_status` | 见 §2.7 `fileValidateStatus` |
|
||||||
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
|
| `issues` | array | — | 该文件的 `ValidationIssueVO[]` |
|
||||||
| `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入 |
|
| `source` | int | `source` | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(**该模块已取消,仅历史数据**) |
|
||||||
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
|
| `rawExpireTime` | string | `raw_expire_time` | **原图过期时间(默认 30 天)** |
|
||||||
|
|
||||||
> ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
|
> ✅ **`collect_file.source` 的口径曾经有两套,已确认(2026-09-30 客户确认)**:本表旧版写
|
||||||
@@ -573,7 +577,6 @@
|
|||||||
| `UserVO` | `id` `username` `realName` `phone` `orgId` `orgName` `roleCodes[]` `userType` `status` `lastLoginTime` |
|
| `UserVO` | `id` `username` `realName` `phone` `orgId` `orgName` `roleCodes[]` `userType` `status` `lastLoginTime` |
|
||||||
| `ExportTaskVO` | `id` `taskId` `exportName` `scopeJson` `splitSizeMb` `desensitize` `status` `progress` `fileCount` `fileSizeMb` `resultFiles[]{ name, sizeMb, url }` `errorMsg` `expireTime` `finishTime` |
|
| `ExportTaskVO` | `id` `taskId` `exportName` `scopeJson` `splitSizeMb` `desensitize` `status` `progress` `fileCount` `fileSizeMb` `resultFiles[]{ name, sizeMb, url }` `errorMsg` `expireTime` `finishTime` |
|
||||||
| `NotifyRecordVO` | `id` `taskId` `channel` `bizType` `receiverId` `receiverName` `receiverRole` `templateCode` `content` `status` `failReason` `sendTime` |
|
| `NotifyRecordVO` | `id` `taskId` `channel` `bizType` `receiverId` `receiverName` `receiverRole` `templateCode` `content` `status` `failReason` `sendTime` |
|
||||||
| `CleanupJobVO` | `id` `jobName` `sourceType` `sourcePath` `targetTaskId` `status` `progress` `totalFiles` `resultPath` `reportJson` `finishTime` |
|
|
||||||
| `DictItemVO` | `id` `dictCode` `dictName` `itemCode` `itemName` `sortNo` `parentItemCode` `status` `builtin` |
|
| `DictItemVO` | `id` `dictCode` `dictName` `itemCode` `itemName` `sortNo` `parentItemCode` `status` `builtin` |
|
||||||
| `MessageVO` | `id` `bizType` `title` `content` `read` `linkType` `linkId` `createTime` |
|
| `MessageVO` | `id` `bizType` `title` `content` `read` `linkType` `linkId` `createTime` |
|
||||||
|
|
||||||
@@ -581,7 +584,10 @@
|
|||||||
|
|
||||||
## §4 接口总览
|
## §4 接口总览
|
||||||
|
|
||||||
> 合计 **121 个**:PRD §8 已定义 96 个 + 追溯矩阵补齐 **21 个** + 客户确认新增 **5 个**,减去客户要求移除的联系方式接口 1 个(标 ⭐)。补齐项见 §4.9 索引,客户确认新增见 §8.7。
|
> 合计 **119 个**:PRD §8 已定义 96 个 + 追溯矩阵补齐 **16 个** + **「角色与授权」8 个**,减去客户要求移除的联系方式接口 1 个(标 ⭐)。
|
||||||
|
> 补齐项见 §4.9 索引,角色与授权见 §4.12 与 §5.10。
|
||||||
|
> ⚠️ **需求收敛(2026-10)的减法**:原「客户确认新增 5 个」(加入申请,确认函 B1)**整条通道取消**,
|
||||||
|
> 原「补齐 21 个」中的「历史整理器 5 个」**随模块删除** → 21 − 5 = 16,故 96 + 16 + 8 − 1 = **119**。
|
||||||
> 「权限」列:`公开` / `学生` / `教师` / `管理` / `运营`(平台方)。
|
> 「权限」列:`公开` / `学生` / `教师` / `管理` / `运营`(平台方)。
|
||||||
> 「修补」列指该接口是补齐追溯矩阵审计发现的缺口。
|
> 「修补」列指该接口是补齐追溯矩阵审计发现的缺口。
|
||||||
|
|
||||||
@@ -589,14 +595,15 @@
|
|||||||
|---|:--:|
|
|---|:--:|
|
||||||
| 认证与账号 | 13 |
|
| 认证与账号 | 13 |
|
||||||
| 组织与租户 | 15 |
|
| 组织与租户 | 15 |
|
||||||
| 任务与配置 | 30 |
|
| 任务与配置 | 25 |
|
||||||
| 名册 | 12 |
|
| 名册 | 7 |
|
||||||
| 填报端 · 小程序 | 16 |
|
| 填报端 · 小程序 | 16 |
|
||||||
| 班主任移动端 | 10 |
|
| 班主任移动端 | 10 |
|
||||||
| 审核 | 9 |
|
| 审核 | 9 |
|
||||||
| 校验 / 导出 / 通知 | 12 |
|
| 校验 / 导出 / 通知 | 12 |
|
||||||
| 开放接口 | 4 |
|
| 开放接口 | 4 |
|
||||||
| **合计** | **121** |
|
| ⭐ 角色与授权 | 8 |
|
||||||
|
| **合计** | **119** |
|
||||||
|
|
||||||
### 4.1 认证与账号(13)
|
### 4.1 认证与账号(13)
|
||||||
|
|
||||||
@@ -636,7 +643,7 @@
|
|||||||
| ⭐ GET | `/logs/operations` | **操作日志** | 管理 |
|
| ⭐ GET | `/logs/operations` | **操作日志** | 管理 |
|
||||||
| ⭐ GET | `/logs/logins` | **登录日志** | 管理 |
|
| ⭐ GET | `/logs/logins` | **登录日志** | 管理 |
|
||||||
|
|
||||||
### 4.3 任务与配置(30)
|
### 4.3 任务与配置(25)
|
||||||
|
|
||||||
| | 路径 | 说明 | 权限 |
|
| | 路径 | 说明 | 权限 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -665,13 +672,8 @@
|
|||||||
| GET | `/tasks/{id}/qrcode` | 填报二维码 | 管理 |
|
| GET | `/tasks/{id}/qrcode` | 填报二维码 | 管理 |
|
||||||
| GET | `/rule-templates` | 规则模板列表 | 管理 |
|
| GET | `/rule-templates` | 规则模板列表 | 管理 |
|
||||||
| POST | `/rule-templates/{id}/apply` | 应用模板 | 管理 |
|
| POST | `/rule-templates/{id}/apply` | 应用模板 | 管理 |
|
||||||
| ⭐ POST | `/cleanup/jobs` | **创建整理任务** | 管理 |
|
|
||||||
| ⭐ GET | `/cleanup/jobs` | **整理任务列表** | 管理 |
|
|
||||||
| ⭐ GET | `/cleanup/jobs/{id}` | **整理进度** | 管理 |
|
|
||||||
| ⭐ GET | `/cleanup/jobs/{id}/report` | **体检报告** | 管理 |
|
|
||||||
| ⭐ POST | `/cleanup/jobs/{id}/import` | **结果导入为正式任务** | 管理 |
|
|
||||||
|
|
||||||
### 4.4 名册(12)
|
### 4.4 名册(7)
|
||||||
|
|
||||||
| | 路径 | 说明 | 权限 |
|
| | 路径 | 说明 | 权限 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -682,11 +684,6 @@
|
|||||||
| GET | `/tasks/{id}/roster/template` | 下载导入模板 | 管理 |
|
| GET | `/tasks/{id}/roster/template` | 下载导入模板 | 管理 |
|
||||||
| POST | `/roster/{rid}/notify` | 定向催报 | 管理/教师 |
|
| POST | `/roster/{rid}/notify` | 定向催报 | 管理/教师 |
|
||||||
| POST | `/tasks/{id}/roster/notify-unsubmitted` | 一键催报未提交者 | 管理/教师 |
|
| POST | `/tasks/{id}/roster/notify-unsubmitted` | 一键催报未提交者 | 管理/教师 |
|
||||||
| ⭐ POST | `/mp/roster/join-request` | **提交加入申请** | 学生 |
|
|
||||||
| ⭐ GET | `/mp/roster/join-request` | **我的申请状态** | 学生 |
|
|
||||||
| ⭐ GET | `/roster/join-requests` | **申请待办列表** | 管理 |
|
|
||||||
| ⭐ POST | `/roster/join-requests/{id}/approve` | **审批通过** | 管理 |
|
|
||||||
| ⭐ POST | `/roster/join-requests/{id}/reject` | **审批驳回** | 管理 |
|
|
||||||
|
|
||||||
### 4.5 填报端 · 小程序(16)
|
### 4.5 填报端 · 小程序(16)
|
||||||
|
|
||||||
@@ -757,10 +754,11 @@
|
|||||||
| GET | `/notify/records` | 通知记录 | 管理 |
|
| GET | `/notify/records` | 通知记录 | 管理 |
|
||||||
| POST | `/callback/wechat/msg` | 微信订阅消息回调 | 公开 |
|
| POST | `/callback/wechat/msg` | 微信订阅消息回调 | 公开 |
|
||||||
|
|
||||||
### 4.9 ⭐ 本次新增接口索引(21)
|
### 4.9 ⭐ 本次新增接口索引(16)
|
||||||
|
|
||||||
> 本表是**索引**,不是额外接口——其中 5 个已在上方 §4.3 / §4.7 列出,**不计入总数**。
|
> 本表是**索引**,不是额外接口——其中 5 个已在上方 §4.3 / §4.7 列出,**不计入总数**。
|
||||||
> 表内 18 行覆盖 **21 个接口**(含 GET/PUT、GET/POST 合并行)。
|
> 表内 13 行覆盖 **16 个接口**(含 GET/PUT、GET/POST 合并行)。
|
||||||
|
> ⚠️ 需求收敛(2026-10)后,原表里的**历史整理器 5 个**随模块删除,故 21 − 5 = **16**。
|
||||||
|
|
||||||
| | 路径 | 说明 | 详情节 |
|
| | 路径 | 说明 | 详情节 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -768,11 +766,6 @@
|
|||||||
| ⭐ | `/tasks/{id}/pause` `/resume` | 暂停 / 恢复任务 | §7.4 |
|
| ⭐ | `/tasks/{id}/pause` `/resume` | 暂停 / 恢复任务 | §7.4 |
|
||||||
| ⭐ | `/audit/submissions/{id}/revoke` | 撤回已审 | §11.4 |
|
| ⭐ | `/audit/submissions/{id}/revoke` | 撤回已审 | §11.4 |
|
||||||
| ⭐ | `/mp/files/{fileId}/process` | **文件规范化总入口**(原仅在 §8.9 详述,未登记进清单) | §9.6 |
|
| ⭐ | `/mp/files/{fileId}/process` | **文件规范化总入口**(原仅在 §8.9 详述,未登记进清单) | §9.6 |
|
||||||
| ⭐ | `/cleanup/jobs` | 创建整理任务 | §13.2 |
|
|
||||||
| ⭐ | `/cleanup/jobs` | 整理任务列表 | §13.2 |
|
|
||||||
| ⭐ | `/cleanup/jobs/{id}` | 整理进度 | §13.2 |
|
|
||||||
| ⭐ | `/cleanup/jobs/{id}/report` | 体检报告 | §13.2 |
|
|
||||||
| ⭐ | `/cleanup/jobs/{id}/import` | 结果导入为正式任务 | §13.2 |
|
|
||||||
| ⭐ | `/dicts` | 字典列表 | §13.3 |
|
| ⭐ | `/dicts` | 字典列表 | §13.3 |
|
||||||
| ⭐ | `/dicts/{code}/items` | 字典项(GET / POST) | §13.3 |
|
| ⭐ | `/dicts/{code}/items` | 字典项(GET / POST) | §13.3 |
|
||||||
| ⭐ | `/dicts/items/{id}` | 修改字典项 | §13.3 |
|
| ⭐ | `/dicts/items/{id}` | 修改字典项 | §13.3 |
|
||||||
@@ -783,7 +776,7 @@
|
|||||||
| ⭐ | `/mp/teacher/messages` | 教师消息中心 | §13.5 |
|
| ⭐ | `/mp/teacher/messages` | 教师消息中心 | §13.5 |
|
||||||
| ⭐ | `/mp/subscribe` | 订阅消息额度登记 | §13.6 |
|
| ⭐ | `/mp/subscribe` | 订阅消息额度登记 | §13.6 |
|
||||||
|
|
||||||
**新增 21 个的分布**
|
**新增 16 个的分布**
|
||||||
|
|
||||||
| 归属 | 数量 | 明细 |
|
| 归属 | 数量 | 明细 |
|
||||||
|---|:--:|---|
|
|---|:--:|---|
|
||||||
@@ -791,7 +784,6 @@
|
|||||||
| 任务状态 | 2 | pause / resume |
|
| 任务状态 | 2 | pause / resume |
|
||||||
| 审核 | 1 | revoke |
|
| 审核 | 1 | revoke |
|
||||||
| 文件处理 | 1 | process(登记遗漏) |
|
| 文件处理 | 1 | process(登记遗漏) |
|
||||||
| 历史整理器 | 5 | cleanup 全套 |
|
|
||||||
| 字典 | 4 | dicts |
|
| 字典 | 4 | dicts |
|
||||||
| 日志 | 2 | operations / logins |
|
| 日志 | 2 | operations / logins |
|
||||||
| 消息中心 | 3 | 学生 2 + 教师 1 |
|
| 消息中心 | 3 | 学生 2 + 教师 1 |
|
||||||
@@ -806,33 +798,35 @@
|
|||||||
| GET | `/open/v1/tasks/{id}/files` | 批量获取文件签名 URL |
|
| GET | `/open/v1/tasks/{id}/files` | 批量获取文件签名 URL |
|
||||||
| POST | `/open/v1/callback/push` | 推送审核结果到机构系统 |
|
| POST | `/open/v1/callback/push` | 推送审核结果到机构系统 |
|
||||||
|
|
||||||
### 4.11 ⭐ 客户确认后新增(5)
|
### 4.11 ⭐ 加入申请(已撤销 · 0 个)
|
||||||
|
|
||||||
> 来源:确认函 **B1**(客户确认采用"申请加入名单 → 管理员审批"通道)。这是本次唯一的功能增量。
|
> ⚠️ **本节原为「客户确认后新增 5 个」**(确认函 **B1**:申请加入名单 → 管理员审批)。
|
||||||
> 详述见 **§8.7**。
|
> **需求收敛(2026-10)后整条通道取消**:模块 `module/join`、5 个接口、`join:audit` 权限码、
|
||||||
|
> `collect_join_request` 表、学生端「申请加入名单」页与 5 个专用错误码
|
||||||
|
> (`40037` / `40038` / `40039` / `40040` / `40042`)**已全部删除**。
|
||||||
|
>
|
||||||
|
> **门禁失败后的口径**:只保留「**重新填写**」一个系统内动作 + 一句纯文案提示
|
||||||
|
> "如确认已报名,请联系老师把你加入名单"。名单外的人由老师在任务里**手工加人**(`FR-508` 追加导入),
|
||||||
|
> **不展示任何联系方式**(铁律:联系方式不进系统)。
|
||||||
|
>
|
||||||
|
> ⚠️ **治理提示**:B1 是确认函里**唯一的功能增量**,本次属**需求回退**,建议补一张变更确认单。
|
||||||
|
|
||||||
|
### 4.12 ⭐ 角色与授权(8)
|
||||||
|
|
||||||
|
> 来源:**FR-205「角色自定义与数据权限」**。内置角色 **4 个**(平台运营 / 学校管理员 / 班主任 / 学生,
|
||||||
|
> 权限由代码 `BuiltinRole` 决定、界面只读),租户可另建**自定义角色**并配置权限码与可见菜单。
|
||||||
|
> 详述见 **§5.10**。
|
||||||
|
|
||||||
| | 路径 | 说明 | 权限 |
|
| | 路径 | 说明 | 权限 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ⭐ | `/mp/roster/join-request` | 提交加入申请 / 查看我的申请(GET / POST) | 学生 |
|
| ⭐ GET | `/roles` | 角色列表(内置 + 自定义) | 管理 |
|
||||||
| ⭐ | `/roster/join-requests` | 申请待办列表 | 管理 |
|
| ⭐ GET | `/permissions` | 权限码目录(按分组) | 管理(`role:manage`) |
|
||||||
| ⭐ | `/roster/join-requests/{id}/approve` | 审批通过(自动建名册项 + 自动绑定) | 管理 |
|
| ⭐ POST | `/roles` | 新增自定义角色 | 管理(`role:manage`) |
|
||||||
| ⭐ | `/roster/join-requests/{id}/reject` | 审批驳回(原因必填) | 管理 |
|
| ⭐ PUT | `/roles/{id}` | 修改自定义角色(名称 / 用户类型 / 备注) | 管理(`role:manage`) |
|
||||||
|
| ⭐ DELETE | `/roles/{id}` | 删除自定义角色(软删) | 管理(`role:manage`) |
|
||||||
**设计边界(三条)**
|
| ⭐ PUT | `/roles/{id}/permissions` | 设置权限码(覆盖式) | 管理(`role:manage`) |
|
||||||
|
| ⭐ PUT | `/roles/{id}/menus` | 设置可见菜单(覆盖式) | 管理(`role:manage`) |
|
||||||
| # | 边界 | 理由 |
|
| ⭐ PUT | `/roles/{id}/status` | 启用 / 停用角色 | 管理(`role:manage`) |
|
||||||
|:--:|---|---|
|
|
||||||
| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后学生**仍需重新核验**(或凭已绑定 openid 直接放行) |
|
|
||||||
| 2 | **证件号仅存 HMAC,不落明文** | 与门禁口径一致;申请记录保留 2 年 |
|
|
||||||
| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高,与"批量通过只带绿色项"同源 |
|
|
||||||
|
|
||||||
**与现有机制的关系**
|
|
||||||
|
|
||||||
| 机制 | 区别 |
|
|
||||||
|---|---|
|
|
||||||
| `FR-508` 名册追加导入 | 老师批量加人;申请通道是**学生自助 + 逐条审批** |
|
|
||||||
| `FR-504` 统一失败提示 | 申请通道**不破坏**统一提示——`40039`(已在名单中)不泄露新信息 |
|
|
||||||
| `FR-504d` 已被他人绑定 | 申请通过后若该证件号已被占用 → `40041`,不覆盖 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -988,8 +982,9 @@
|
|||||||
> ② 有维护成本——班主任换人、号码变更都要同步;
|
> ② 有维护成本——班主任换人、号码变更都要同步;
|
||||||
> ③ 多一处隐私暴露面——即使是脱敏号,也意味着系统里要多存一份联系方式数据。
|
> ③ 多一处隐私暴露面——即使是脱敏号,也意味着系统里要多存一份联系方式数据。
|
||||||
>
|
>
|
||||||
> **替代**:门禁失败页只保留两条**系统内的动作**——「重新填写」与「申请加入名单」(FR-509)。
|
> **替代**:门禁失败页只保留「**重新填写**」一个**系统内动作**(原「申请加入名单」通道已于 2026-10 需求收敛时取消)。
|
||||||
> 纯文案提示(如"如有疑问请联系老师")保留,因为**不涉及任何联系方式数据**。
|
> 纯文案提示(如"如确认已报名,请联系老师把你加入名单")保留,因为**不涉及任何联系方式数据**;
|
||||||
|
> 名单外的人由老师在任务里手工加人(`FR-508` 追加导入)。
|
||||||
|
|
||||||
### 5.6 `GET /api/v1/users`
|
### 5.6 `GET /api/v1/users`
|
||||||
|
|
||||||
@@ -1048,6 +1043,92 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 5.10 角色与授权(§4.12 的 8 个接口)
|
||||||
|
|
||||||
|
> 对应 **FR-205**。内置角色 **4 个**(`PLATFORM_ADMIN` / `SCHOOL_ADMIN` / `HEAD_TEACHER` / `STUDENT`),
|
||||||
|
> 权限由代码 `BuiltinRole` 决定、界面**只读**(能看、能当模板,改不了);
|
||||||
|
> 租户可另建**自定义角色**,于是"角色 → 权限码"与"角色 → 可见菜单"两件事可以由租户自己配。
|
||||||
|
> 改授权**立即生效**(服务端写授权后清缓存),不需要重新登录。
|
||||||
|
>
|
||||||
|
> 权限码本身**仍然定义在代码里**(`PermissionDef`,**28 个**),没有 `sys_permission` 定义表 ——
|
||||||
|
> 它是前后端契约:后端用它校验、前端用它控制按钮显隐,存 DB 就会漂移。
|
||||||
|
>
|
||||||
|
> ⚠️ **需求收敛(2026-10)**:本条不再是"二期功能",它就是一期能力;同时
|
||||||
|
> **数据范围(本人/本班/本院系/全租户)只展示、不提供修改入口** ——
|
||||||
|
> 它目前由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,做成可配要先把判据收口(**本次不做**)。
|
||||||
|
|
||||||
|
**五条口径(实现与文档同源,改一处必须改另一处)**
|
||||||
|
|
||||||
|
| # | 口径 | 为什么 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **内置角色只读** | `roleType=1` 的角色可以看权限矩阵,但改 / 删 / 授权 / 停用一律 `40001`。一次误操作就能把管理员锁在系统外,而这类错误不可逆 |
|
||||||
|
| 2 | **内置角色的权限以代码为准** | 列表里内置角色的 `permCodes` / `menus` 由代码返回,**不读库**;库里的行只是"看得见、可审计"(每日定时任务把两者对齐,见 §17.2 D-13) |
|
||||||
|
| 3 | **平台级能力与入口不给学校** | 学校租户授予 `tenant:manage` / `tenant:view` / `log:viewAll` → `40301`;配置平台专用菜单 `tenant` → `40301`;建"平台运营"类角色(`userType=1`)→ `40301`;发 `PLATFORM_ADMIN` 给账号 → `40301` |
|
||||||
|
| 4 | **防自锁** | 不允许去掉"自己进入本页所依赖的最后一条 `role:manage` 权限 / `role` 菜单",也不允许停用或删除那个角色;改账号角色时同理保护 `user:manage` |
|
||||||
|
| 5 | **改完立即生效** | 每次写授权都清该角色的缓存,所以**不需要重新登录**;停用角色立刻不再给权限(不是等 token 过期)。⚠️ 改**账号的角色归属**仍是"下次登录生效"(角色码在 token 里) |
|
||||||
|
|
||||||
|
**`GET /api/v1/roles`** 权限:`role:manage` 或 `user:view` / `user:manage`(账号页的角色下拉要用它)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | long | 角色 ID |
|
||||||
|
| `roleCode` | string | 角色码(建后不可改) |
|
||||||
|
| `roleName` | string | 角色名 |
|
||||||
|
| `roleType` | int | `1` 内置(只读)/ `2` 自定义 |
|
||||||
|
| `userType` | int | `1` 平台运营 / `2` 学校管理员 / `3` 班主任 / `4` 学生(账号的 `userType` 由它推导) |
|
||||||
|
| `dataScope` | int | `1` 全平台 / `2` 本租户 / `3` 本组织及下级 / `4` 仅本人(**本期只展示**,见下) |
|
||||||
|
| `status` | int | `0` 停用 / `1` 正常 |
|
||||||
|
| `userCount` | int | 有多少账号在用(> 0 时不可删除) |
|
||||||
|
| `editable` | bool | 能否编辑 / 删除 / 停用(内置恒 `false`) |
|
||||||
|
| `assignable` | bool | 能否发给账号(内置的「平台运营」在学校租户里 `false`:**看得见、发不出去**) |
|
||||||
|
| `permCodes` | string[] | 权限码(内置取代码,自定义取库) |
|
||||||
|
| `menus` | string[] | 可见菜单键(与前端 `XtMenu.key` 一致) |
|
||||||
|
|
||||||
|
> ⚠️ **`dataScope` 本期只展示、不提供修改入口**:数据范围(本人 / 本班 / 本院系 / 全租户)目前由
|
||||||
|
> "拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核 / 催报 / 代传 / 看板各处;
|
||||||
|
> 做成可配要先把那些判据收口(单独一批)。让界面出现一个"改了不生效"的开关,比没有它更糟。
|
||||||
|
|
||||||
|
**`GET /api/v1/permissions`** 权限:`role:manage`
|
||||||
|
|
||||||
|
返回按分组排列的 **28** 个权限码:`[{ group, items: [{ code, label, platform, platformOnly }] }]`。
|
||||||
|
`platform` 为 `PC` / `MP` / `BOTH`;`platformOnly=true` 的码学校租户不可授予(界面禁用勾选,服务端 `40301`)。
|
||||||
|
|
||||||
|
**`POST /api/v1/roles`** 权限:`role:manage`
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|---|:--:|---|
|
||||||
|
| `roleCode` | ✅ | 大写字母 / 数字 / 下划线,以字母开头,租户内唯一,**不得占用内置角色码** |
|
||||||
|
| `roleName` | ✅ | 显示名(可改) |
|
||||||
|
| `userType` | ✅ | 该角色对应的用户类型(学校租户不能选 `1`) |
|
||||||
|
| `remark` | — | 备注 |
|
||||||
|
| `permCodes` | ✅ | 至少一项(服务端校验存在性与平台级白名单) |
|
||||||
|
| `menus` | ✅ | 至少一个(服务端校验是否为现存菜单键、是否平台专用) |
|
||||||
|
|
||||||
|
返回新角色 `id`。建后**不能改 `roleCode`**(它在 JWT 与操作日志里,改了历史留痕就对不上)。
|
||||||
|
|
||||||
|
**`PUT /api/v1/roles/{id}`** 权限:`role:manage` 只改 `roleName` / `userType` / `remark`。
|
||||||
|
⚠️ 角色还有账号在用(`userCount > 0`)时改 `userType` → `40001`:那会让账号的 `user_type` 与角色不一致,
|
||||||
|
而 `user_type` 决定"他算学生还是老师"。
|
||||||
|
|
||||||
|
**`DELETE /api/v1/roles/{id}`** 权限:`role:manage` 软删(含授权与菜单行)。
|
||||||
|
还有账号在用 → **`40045`**;命中防自锁 → `40001`。
|
||||||
|
|
||||||
|
**`PUT /api/v1/roles/{id}/permissions`** / **`.../menus`** 权限:`role:manage`
|
||||||
|
请求体 `{ permCodes: [] }` / `{ menus: [] }`,**覆盖式**;返回 `void`。写后立即失效该角色的授权缓存。
|
||||||
|
|
||||||
|
**`PUT /api/v1/roles/{id}/status?status=0|1`** 权限:`role:manage`
|
||||||
|
停用 = 该角色立刻不再给权限(**不必等 token 过期**);内置角色不可停用。
|
||||||
|
|
||||||
|
**错误码**
|
||||||
|
|
||||||
|
| 码 | 场景 |
|
||||||
|
|---|---|
|
||||||
|
| `40001` | 角色码不合法 / 重复 / 占用内置码;权限码或菜单键不存在;改内置角色;`userType` 与在用的账号冲突;命中防自锁 |
|
||||||
|
| `40301` | 无 `role:manage`;授予平台级权限码;配置平台专用菜单;建平台运营类角色;跨租户(平台未选定学校) |
|
||||||
|
| `40045` | 角色还有账号在用,不可删除 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## §6 组织与租户
|
## §6 组织与租户
|
||||||
|
|
||||||
### 6.1 `GET /api/v1/orgs/tree`
|
### 6.1 `GET /api/v1/orgs/tree`
|
||||||
@@ -1167,7 +1248,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
**响应**:`{ "id": 2026090001, "taskCode": "2026090001", "status": 0 }`
|
**响应**:`{ "id": 2026090001, "taskCode": "2026090001", "status": 0 }`
|
||||||
|
|
||||||
> **社会收集的审核配置默认值**(二期):`needClassAudit: false`(跳过初审),由服务端强制设置,客户端传 true 会被忽略并返回 `warnings`。
|
> **社会收集的审核配置默认值**(该模式**本次不做**,仅保留数据结构):`needClassAudit: false`(跳过初审),由服务端强制设置,客户端传 true 会被忽略并返回 `warnings`。
|
||||||
> ⚠️ **一期只支持 `collectMode=1`(学校收集)**。传 `2` 会被拒绝并返回 `40044`。
|
> ⚠️ **一期只支持 `collectMode=1`(学校收集)**。传 `2` 会被拒绝并返回 `40044`。
|
||||||
|
|
||||||
### 7.3 `POST /api/v1/tasks/{id}/publish`
|
### 7.3 `POST /api/v1/tasks/{id}/publish`
|
||||||
@@ -1363,8 +1444,10 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| 模板 | 材料项 |
|
| 模板 | 材料项 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `TPL_SCHOOL_ROLL` | 一寸照片 + 身份证 + 学籍表 + 工作证明 |
|
| `TPL_SCHOOL_ROLL` | 一寸照片 + 身份证 + 学籍表 + 工作证明 |
|
||||||
| `TPL_SOCIAL` | 一寸照片 + 身份证 + 工作证明 + 学信网备案表 |
|
|
||||||
| `TPL_ZHUANXIANG` | 一寸照片 + 身份证 + 工作证明 |
|
> ⚠️ **需求收敛(2026-10)后模板只剩这一个**:原 `TPL_SOCIAL`(社会收集)与
|
||||||
|
> `TPL_ZHUANXIANG`(专项职业能力)随"二期取消"一并删除;传这两个码会得到**不认识的模板**,
|
||||||
|
> 不再有对应的初始化行为。
|
||||||
|
|
||||||
### 7.11 `GET /api/v1/tasks/{id}/statistics`
|
### 7.11 `GET /api/v1/tasks/{id}/statistics`
|
||||||
|
|
||||||
@@ -1518,184 +1601,15 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8.7 名单加入申请(FR-509 · 客户确认后新增)
|
> ⚠️ **本节已撤销(需求收敛 2026-10)**:原「名单加入申请」通道(`FR-509`,确认函 **B1**)
|
||||||
|
> 连同 5 个接口(`POST/GET /mp/roster/join-request`、`GET /roster/join-requests`、
|
||||||
> **背景**:门禁不通过的人原先只能"联系老师",老师再手工加名单。几百人的社会收集场景下,老师负担极重。
|
> `POST /roster/join-requests/{id}/approve|reject`)、`join:audit` 权限码、`collect_join_request` 表
|
||||||
> 本组接口提供一条**受控通道**:学生自助申请 → 管理员审批 → 自动入册。
|
> 与错误码 `40037` / `40038` / `40039` / `40040` / `40042` **一并删除**。
|
||||||
>
|
>
|
||||||
> ⚠️ **关键边界**:这条通道**只解决"进名单",不绕过门禁**——申请期间学生仍不能填报。
|
> **现在的口径**:门禁失败页只给「**重新填写**」+ 一句"如确认已报名,请联系老师把你加入名单";
|
||||||
|
> 名单外的人由老师在任务里**手工加人**(§8.1 追加导入,`FR-508`)。**系统内不展示任何联系方式。**
|
||||||
#### 8.7.1 `POST /api/v1/mp/roster/join-request`
|
>
|
||||||
|
> **治理提示**:B1 是客户确认函里**唯一的功能增量**,本次属**需求回退**,建议补一张变更确认单。
|
||||||
**权限**:学生(**须已过门禁失败流程**) **限流**:同 openid 每任务 ≤2 次
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `taskId` | int64 | ✅ | — |
|
|
||||||
| `realName` | string | ✅ | 姓名(明文存储,业务必需) |
|
|
||||||
| `idCardNo` | string | ✅ | 身份证号(**服务端转 HMAC,不落明文**) |
|
|
||||||
| `phone` | string | ✅ | 手机号(**仅存 HMAC + 脱敏值**) |
|
|
||||||
| `orgHint` | string | — | 自述班级 |
|
|
||||||
| `reason` | string | — | 申请说明,如"我是转班过来的" |
|
|
||||||
|
|
||||||
**响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"requestId": 7001,
|
|
||||||
"status": 0,
|
|
||||||
"statusText": "已提交,等待学校审核",
|
|
||||||
"estimatedHours": 24,
|
|
||||||
"remainAttempts": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**主要错误**
|
|
||||||
|
|
||||||
| 码 | 场景 | 提示 |
|
|
||||||
|---|---|---|
|
|
||||||
| ⭐ `40037` | 同任务申请次数超限(>2) | "该任务申请次数已达上限,请联系老师" |
|
|
||||||
| ⭐ `40038` | 该证件号已有待审申请 | "你已提交过申请,请耐心等待" |
|
|
||||||
| ⭐ `40039` | 该证件号已在名册中 | "你已在名单中,请直接返回重新核验" |
|
|
||||||
| `40008` | 任务已截止/暂停 | — |
|
|
||||||
|
|
||||||
> **`40039` 是必要的**:若证件号已在名册,说明学生**只是输错了姓名**,该引导他回去重填门禁,而不是提交申请。
|
|
||||||
> 这一条不违反"统一失败提示"原则——因为学生已经主动提供了正确证件号,此时告知"你已在名单中"**不泄露任何新信息**。
|
|
||||||
|
|
||||||
#### 8.7.2 `GET /api/v1/mp/roster/join-request`
|
|
||||||
|
|
||||||
**Query**:`taskId`
|
|
||||||
|
|
||||||
**响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{ "requestId": 7001, "taskId": 2026090001, "taskName": "2026年9月职业技能认定",
|
|
||||||
"status": 0, "statusText": "待审核",
|
|
||||||
"realName": "李四", "idCardMask": "3610**********10",
|
|
||||||
"orgHint": "24中技数控2班",
|
|
||||||
"createTime": "2026-09-28T18:00:00+08:00",
|
|
||||||
"auditTime": null, "rejectReason": null,
|
|
||||||
"rosterId": null }
|
|
||||||
],
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**驳回态**:`status: 2` + `rejectReason` 明文返回(学生需要知道原因)。
|
|
||||||
|
|
||||||
**通过态**:`status: 1` + `rosterId`,并提示"已加入名单,请返回重新核验进入填报"。
|
|
||||||
|
|
||||||
#### 8.7.3 `GET /api/v1/roster/join-requests`
|
|
||||||
|
|
||||||
**权限**:管理 **Query**:`taskId`、`status`(默认 `0` 待审)、`orgId`、`keyword`、分页
|
|
||||||
|
|
||||||
**响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"list": [
|
|
||||||
{ "requestId": 7001, "taskId": 2026090001,
|
|
||||||
"realName": "李四", "idCardMask": "3610**********10",
|
|
||||||
"phoneMask": "138****8888",
|
|
||||||
"orgHint": "24中技数控2班",
|
|
||||||
"reason": "我是转班过来的",
|
|
||||||
"status": 0,
|
|
||||||
"createTime": "2026-09-28T18:00:00+08:00",
|
|
||||||
"waitHours": 2.5,
|
|
||||||
"duplicateHint": null }
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"counts": { "pending": 1, "approved": 12, "rejected": 3 },
|
|
||||||
"alert": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`duplicateHint`**:若该证件号与**其他任务的名册**匹配上,返回提示(帮管理员判断是否本校学生):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"duplicateHint": { "type": "IN_OTHER_TASK", "taskName": "2026年3月认定", "realName": "李四" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**`alert`**:单任务待审 > 50 条时返回 `{ "level": 2, "text": "待审申请已达 53 条,建议集中处理" }`(FR-509c)。
|
|
||||||
|
|
||||||
#### 8.7.4 `POST /api/v1/roster/join-requests/{id}/approve`
|
|
||||||
|
|
||||||
**权限**:管理 **幂等**:是
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `orgId` | int64 | ✅ | **指定班级**(默认取自述班级,无则必填) |
|
|
||||||
| `realName` | string | — | 修正姓名(管理员核对后可改) |
|
|
||||||
| `idCardNo` | string | — | 修正证件号 |
|
|
||||||
| `seqNo` | int | — | 序号(默认追加到末尾) |
|
|
||||||
|
|
||||||
**服务端动作(事务内)**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 校验申请状态 = 待审,否则 40040
|
|
||||||
2. 校验该 task 下证件号未被占用,否则 40041
|
|
||||||
3. 在 collect_roster 新建名册项:
|
|
||||||
real_name / id_card_no / id_card_hash / id_card_mask ← 申请值(或管理员修正值)
|
|
||||||
org_id ← 指定班级
|
|
||||||
bind_user_id ← 申请人 uid(FR-509g:自动绑定,免重输)
|
|
||||||
bind_type ← 3(来自申请)
|
|
||||||
ext_json.source ← 'JOIN_REQUEST'
|
|
||||||
4. 回写 collect_join_request:status=1, audit_by, audit_time, roster_id
|
|
||||||
5. 写操作日志 JOIN_APPROVE
|
|
||||||
6. 推送通知 bizType=7
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"requestId": 7001, "status": 1,
|
|
||||||
"rosterId": 9029,
|
|
||||||
"rosterCreated": true,
|
|
||||||
"notifySent": true,
|
|
||||||
"canFillNow": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **`canFillNow: true`** 表示该 openid 已绑定,学生再次进入**直接放行**。
|
|
||||||
|
|
||||||
#### 8.7.5 `POST /api/v1/roster/join-requests/{id}/reject`
|
|
||||||
|
|
||||||
**权限**:管理 **幂等**:是
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `reason` | string | ✅ | **驳回原因(必填)**,会原样推送给学生 |
|
|
||||||
| `reasonCode` | string | — | 快捷原因码(`NOT_THIS_SCHOOL` / `INFO_MISMATCH` / `NOT_IN_SCOPE` / `OTHER`) |
|
|
||||||
|
|
||||||
**响应**:`{ "requestId": 7001, "status": 2, "notifySent": true }`
|
|
||||||
|
|
||||||
**约束**
|
|
||||||
|
|
||||||
| 条件 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| 已通过后再驳回 | `40042`(如需撤销请走名册删除) |
|
|
||||||
| 任务已归档 | `40034` |
|
|
||||||
| 批量审批 | **支持**,但**必须逐条确认**(不可一键全通过——涉及身份认定) |
|
|
||||||
|
|
||||||
> ⚠️ **批量审批的设计取舍**:批量**通过**允许(管理员已看过列表),但**驳回必须逐条填原因**;且批量通过时**不提供"全选"快捷键**。
|
|
||||||
> 理由与"批量通过只带绿色项"同源:**身份认定环节的误操作代价极高**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §9 填报端 · 小程序
|
## §9 填报端 · 小程序
|
||||||
|
|
||||||
@@ -1827,7 +1741,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
|
| `fileName` | string | ✅ | 原始文件名(**可能是乱的,正常**) |
|
||||||
| `fileSize` | int64 | ✅ | 字节 |
|
| `fileSize` | int64 | ✅ | 字节 |
|
||||||
| `contentType` | string | ✅ | MIME |
|
| `contentType` | string | ✅ | MIME |
|
||||||
| `source` | int | — | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(口径见 §3.6 注) |
|
| `source` | int | — | `1` 本人上传 / `2` 班主任代传 / `3` 管理员代传 / `4` 整理器导入(**模块已取消,仅历史数据**;口径见 §3.6 注) |
|
||||||
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
|
| `fileHash` | string | — | 客户端 SHA-256(**用于秒传与去重**) |
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
@@ -1863,7 +1777,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
> 记录问题(含 VAL-06 自动修复),默认**只存规范件**(`fill.storage.keep-raw=false`)。
|
> 记录问题(含 VAL-06 自动修复),默认**只存规范件**(`fill.storage.keep-raw=false`)。
|
||||||
> 所以本接口**只在「保留了原始件」时有用**:对原始件重新跑一遍规范化
|
> 所以本接口**只在「保留了原始件」时有用**:对原始件重新跑一遍规范化
|
||||||
> (典型场景:改了材料规则后重压)。默认配置下多数文件没有原始件,调用会明确说明这一点,
|
> (典型场景:改了材料规则后重压)。默认配置下多数文件没有原始件,调用会明确说明这一点,
|
||||||
> 而不是假装处理了一遍。识别 / OCR 的四层递进是二期(M3/M4),见 §17.2 D-12。
|
> 而不是假装处理了一遍。识别 / OCR 的四层递进**缓做**(等 OCR 供应商选型,见 §17.2 D-12 与《01》D1)——**不再作为二期功能设计**。
|
||||||
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
|
> 下面的响应结构保留作为「规范化产物」的字段参考(上传接口的产物与之同构)。
|
||||||
|
|
||||||
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
|
**说明**:对**有原始件**的文件重新执行规范化(压缩 + 改名 + 校验)。
|
||||||
@@ -1970,7 +1884,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
|
### 9.9 `POST /api/v1/mp/ocr/idcard`(一期缓做)
|
||||||
|
|
||||||
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
|
> ⚠️ **一期未实现(等 OCR 供应商选型,《01-客户与机构确认函》D1)**。
|
||||||
> 这是 121 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
|
> 这是 119 个契约接口里**唯一**没有实现的一个;表单预填与校验位校验在学生端走人工填写。
|
||||||
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
|
> 选型要同时评估**数据出境合规**。选型定了之后本节按原设计实现,以下为设计参考。
|
||||||
|
|
||||||
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
|
**说明**:身份证 OCR,用于表单预填与校验。**这是唯一返回明文姓名的 OCR 场景**。
|
||||||
@@ -2047,7 +1961,7 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**社会收集**(二期):`nextNode` 直接返回 `ADMIN_AUDIT`(跳过初审)。
|
**社会收集**(本次不做,传 `collectMode=2` 直接 `40044`):`nextNode` 返回 `ADMIN_AUDIT`(跳过初审)。
|
||||||
|
|
||||||
### 9.11 `POST /api/v1/mp/submissions/{id}/withdraw`
|
### 9.11 `POST /api/v1/mp/submissions/{id}/withdraw`
|
||||||
|
|
||||||
@@ -2518,110 +2432,22 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| I8 审核撤回 | **§11.4**(已定义) | ✅ |
|
| I8 审核撤回 | **§11.4**(已定义) | ✅ |
|
||||||
| I9 任务暂停/恢复 | **§7.4**(已定义) | ✅ |
|
| I9 任务暂停/恢复 | **§7.4**(已定义) | ✅ |
|
||||||
| I10 材料识别结果读取 | **并入 §11.2 详情响应**(不新增接口) | ✅ |
|
| I10 材料识别结果读取 | **并入 §11.2 详情响应**(不新增接口) | ✅ |
|
||||||
| I2 历史数据整理器 | **§13.2** | ✅ |
|
|
||||||
| I3 字典管理 | **§13.3** | ✅ |
|
| I3 字典管理 | **§13.3** | ✅ |
|
||||||
| I4 / I5 日志 | **§13.4** | ✅ |
|
| I4 / I5 日志 | **§13.4** | ✅ |
|
||||||
| I6 消息中心 | **§13.5** | ✅ |
|
| I6 消息中心 | **§13.5** | ✅ |
|
||||||
| I7 订阅额度登记 | **§13.6** | ✅ |
|
| I7 订阅额度登记 | **§13.6** | ✅ |
|
||||||
|
|
||||||
### 13.2 历史数据整理器(5 个)· 差异化获客功能
|
### 13.2 历史数据整理器(已取消 · 0 个)
|
||||||
|
|
||||||
#### `POST /api/v1/cleanup/jobs`
|
> ⚠️ **本节已撤销(需求收敛 2026-10)**:历史整理器整块能力(`module/cleanup`)连同
|
||||||
|
> 5 个接口(`POST/GET /cleanup/jobs`、`GET /cleanup/jobs/{id}`、`.../report`、`.../import`)、
|
||||||
**说明**:上传已有压缩包/文件夹,用**与正式收集完全相同的规则**做规范化。
|
> `cleanup:run` 权限码、`collect_cleanup_job` 表、PC 端「历史整理器」菜单与「整理器」原型屏
|
||||||
|
> **一并删除**,不再设计。
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
>
|
||||||
|---|---|---|---|
|
> 保留这一节标题只是为了让旧的交叉引用(`§13.2`)仍能落到一个说清"为什么没有了"的地方 ——
|
||||||
| `jobName` | string | ✅ | 如 `24中技数控1班-历史整理` |
|
> 节号不重排,`§13.3` 起的编号与代码注释里的引用都不受影响。
|
||||||
| `sourceType` | int | ✅ | `1` 压缩包 / `2` 文件夹 / `3` 云盘链接 |
|
>
|
||||||
| `uploadToken` / `objectKey` | string | 条件 | `sourceType=1/2` 时必传(先走通用上传) |
|
> ⚠️ **别误伤**:`POST /api/v1/exports/cleanup-run` 是**导出包的过期清理**,属导出中心,**仍在**。
|
||||||
| `sourceUrl` | string | 条件 | `sourceType=3` 时必传 |
|
|
||||||
| `namingTemplate` | string | — | 缺省用系统默认 |
|
|
||||||
| `compressConfig` | object | — | 缺省见 3.11 |
|
|
||||||
| `rosterTaskId` | int64 | — | **关联到已有任务的名册**(用于证件号纠错与串档检测) |
|
|
||||||
|
|
||||||
**响应**:`{ "jobId": 301, "status": 0, "estimatedSeconds": 120 }`
|
|
||||||
|
|
||||||
> `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化,**有名单才能做证件号校验、疑似串档检测**——而"疑似串档 1 例"正是报告里最有价值的一条。
|
|
||||||
|
|
||||||
#### `GET /api/v1/cleanup/jobs`
|
|
||||||
|
|
||||||
**Query**:`status`、`keyword`、分页 **响应**:`{ list: CleanupJobVO[], total }`
|
|
||||||
|
|
||||||
#### `GET /api/v1/cleanup/jobs/{id}`
|
|
||||||
|
|
||||||
**响应**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"id": 301, "jobName": "24中技数控1班-历史整理", "status": 3, "progress": 100,
|
|
||||||
"totalFiles": 112, "processedFiles": 112,
|
|
||||||
"parsedStudents": 28, "parsedMaterials": 4,
|
|
||||||
"rawSizeMb": 436, "finalSizeMb": 37, "compressRatio": "91.5%",
|
|
||||||
"resultPath": "/cleanup/301/result.zip",
|
|
||||||
"finishTime": "2026-09-28T16:00:00+08:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /api/v1/cleanup/jobs/{id}/report` ⭐ 体检报告
|
|
||||||
|
|
||||||
**Query**:`format`(`json` / `xlsx`)
|
|
||||||
|
|
||||||
**响应(json)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"data": {
|
|
||||||
"jobId": 301,
|
|
||||||
"summary": {
|
|
||||||
"totalFiles": 112,
|
|
||||||
"nameInvalid": 59, "sizeExceeded": 31, "formatConverted": 56,
|
|
||||||
"idcardMismatch": 5, "idcardAutoFixed": 3, "idcardNeedConfirm": 2,
|
|
||||||
"crossFileSuspected": 1, "lowResolution": 1, "orphanImages": 0
|
|
||||||
},
|
|
||||||
"autoHandled": 90,
|
|
||||||
"needManual": 1,
|
|
||||||
"highlights": [
|
|
||||||
{ "type": "CROSS_FILE", "level": 3, "rosterId": 9012, "realName": "张三",
|
|
||||||
"title": "疑似串档", "detail": "该生 4 项材料的证件号与名册均不同,且 4 项彼此一致",
|
|
||||||
"suggestion": "可能整个文件夹被换过,请核对学生本人" },
|
|
||||||
{ "type": "LOW_RESOLUTION", "level": 2, "rosterId": 9008, "realName": "李四",
|
|
||||||
"title": "分辨率偏低", "materialCode": "IDCARD",
|
|
||||||
"detail": "558×745,同批其他人为 1279×1706", "suggestion": "建议索取原图" }
|
|
||||||
],
|
|
||||||
"nameFixedSamples": [
|
|
||||||
{ "original": "身份证.jpg.jpg", "fixed": "曾伊豪-361029200907295110-身份证.jpg", "rule": "VAL-06" }
|
|
||||||
],
|
|
||||||
"idcardConflicts": [
|
|
||||||
{ "rosterId": 9012, "realName": "张三",
|
|
||||||
"rosterValue": "3610292009******10", "fileNameValue": "3610292009******99",
|
|
||||||
"ocrValue": "3610292009******10",
|
|
||||||
"verdict": "AUTO_FIXED", "verdictText": "OCR 与名册一致,按名册修正文件名" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **报告里最重要的一句话是 `autoHandled: 90 / needManual: 1`**——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。这是最有说服力的销售话术,也是接口必须算出来的数字。
|
|
||||||
|
|
||||||
#### `POST /api/v1/cleanup/jobs/{id}/import`
|
|
||||||
|
|
||||||
**说明**:把整理结果**导入为正式任务**(名册 + 材料一次成型)。
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `taskName` | string | ✅ | 新任务名 |
|
|
||||||
| `collectMode` | int | ✅ | `1` / `2` |
|
|
||||||
| `batchNo` | string | ✅ | — |
|
|
||||||
| `importRoster` | bool | — | 是否把解析出的学员建成名册(默认 true) |
|
|
||||||
| `importFiles` | bool | — | 是否把文件挂到填报单上(默认 true) |
|
|
||||||
| `markAsSubmitted` | bool | — | 是否直接置为"已提交待审"(默认 true) |
|
|
||||||
|
|
||||||
**响应**:`{ "taskId": 2026090002, "rosterCount": 28, "fileCount": 112 }`
|
|
||||||
|
|
||||||
### 13.3 字典管理(4 个)
|
### 13.3 字典管理(4 个)
|
||||||
|
|
||||||
@@ -2767,7 +2593,6 @@ Excel 列:`层级` `组织名称` `组织编码` `上级编码` `负责人手
|
|||||||
| `AUDIT_RESULT` | 提交成功后 | 通过 / 驳回通知 |
|
| `AUDIT_RESULT` | 提交成功后 | 通过 / 驳回通知 |
|
||||||
| `DEADLINE_REMIND` | 进入填报页时 | 临期提醒 |
|
| `DEADLINE_REMIND` | 进入填报页时 | 临期提醒 |
|
||||||
| `EXPORT_READY` | 管理端创建导出时 | 导出完成 |
|
| `EXPORT_READY` | 管理端创建导出时 | 导出完成 |
|
||||||
| `JOIN_APPROVE` | 申请加入名单时 | 审批结果 |
|
|
||||||
|
|
||||||
**额度不足的降级链**
|
**额度不足的降级链**
|
||||||
|
|
||||||
@@ -2855,7 +2680,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|
|
||||||
## §15 错误码全集
|
## §15 错误码全集
|
||||||
|
|
||||||
> 在 PRD 附录 D(20 个码)基础上**新增 38 个**(标 ⭐),本节共 **58 个**(含 `0` 成功码)。所有码在 `code` 字段返回,HTTP 状态码见 §1.3。
|
> 在 PRD 附录 D(20 个码)基础上**新增 34 个**(标 ⭐),本节共 **54 个**(含 `0` 成功码)。所有码在 `code` 字段返回,HTTP 状态码见 §1.3。
|
||||||
|
|
||||||
### 15.1 客户端错误(4xxxx)
|
### 15.1 客户端错误(4xxxx)
|
||||||
|
|
||||||
@@ -2899,14 +2724,10 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| ⭐ `40034` | 已归档,不可撤回 | — |
|
| ⭐ `40034` | 已归档,不可撤回 | — |
|
||||||
| ⭐ `40035` | **存在不合格文件,禁止导出** | 禁用导出按钮 + 展示 blocking 清单 |
|
| ⭐ `40035` | **存在不合格文件,禁止导出** | 禁用导出按钮 + 展示 blocking 清单 |
|
||||||
| ⭐ `40036` | 字典项有下级,不可停用 | 先处理下级 |
|
| ⭐ `40036` | 字典项有下级,不可停用 | 先处理下级 |
|
||||||
| ⭐ `40037` | **加入申请次数超限**(同 openid 每任务 >2) | 提示联系老师 |
|
|
||||||
| ⭐ `40038` | **该证件号已有待审申请** | "你已提交过申请,请耐心等待" |
|
|
||||||
| ⭐ `40039` | **该证件号已在名册中** | "你已在名单中,请返回重新核验"(引导回门禁,**不泄露新信息**) |
|
|
||||||
| ⭐ `40040` | 申请状态已变更(非待审) | 刷新列表 |
|
|
||||||
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
|
| ⭐ `40041` | **名册中该证件号已被占用** | 不覆盖,提示管理员核查 |
|
||||||
| ⭐ `40042` | 申请已通过,不可再驳回 | 如需撤销请走名册删除 |
|
|
||||||
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
|
| ⭐ `40043` | **文件归属存疑**(文件名指向名册中的另一个人,或归属无法确证) | **阻断提交**,提示"这份材料可能不是你的",引导重新上传(**无"仍然继续"选项**) |
|
||||||
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
|
| ⭐ `40044` | **一期不支持该收集模式 / 认定类型** | 请使用学校收集 + 自主认定(见 PRD「一期范围声明」) |
|
||||||
|
| ⭐ `40045` | **角色还有账号在用,不可删除** | 先给这些账号换一个角色(界面会说明还有几个账号) |
|
||||||
| `40101` | 未登录 / token 失效 | 静默重新登录 |
|
| `40101` | 未登录 / token 失效 | 静默重新登录 |
|
||||||
| `40301` | 无权限(含跨租户) | 跳转 403 |
|
| `40301` | 无权限(含跨租户) | 跳转 403 |
|
||||||
| `40302` | 签名 URL 已过期 | 重新获取 |
|
| `40302` | 签名 URL 已过期 | 重新获取 |
|
||||||
@@ -2932,6 +2753,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更 |
|
| 版本 | 日期 | 变更 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **V2.7** | **2026-10** | **需求收敛:删角色 / 删两模块 / 取消二期**。① 内置角色 **6 → 4**(删 `DEPUTY_TEACHER` 副班主任、`AUDITOR` 审核员;挂这两个角色的存量账号由 **V19 迁移**直接删除);② 删「**历史整理器**」(5 个接口 + `cleanup:run` 权限码 + `collect_cleanup_job` 表 + §13.2 整节)与「**加入申请**」(5 个接口 + `join:audit` 权限码 + `collect_join_request` 表 + §8.7 整节 + 错误码 `40037`/`40038`/`40039`/`40040`/`40042`);③ `collect_task_org.deputy_user_ids`(协办人)**整列删除**,审核范围由三级回退收窄为**两级**(`head_user_id` → `sys_org.leader_user_id`);④ **不再有任何二期功能设计** —— 社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 / 角色变更即时失效 / 平台角色页一律**不做**,OCR 与材料识别改为「**缓做,等供应商选型**」;⑤ 内置材料模板 **3 → 1**(仅 `TPL_SCHOOL_ROLL`),`XJW_BA` / `OTHER_PROOF` **读得到、选不到**。**数字**:契约 **129 → 119**、错误码 **59 → 54**、权限码 **30 → 28**、PC 菜单 **12 → 10**、小程序页面 **10 → 9**、原型 **32 → 30 屏**。⚠️ **加入申请(确认函 B1)是客户唯一确认的功能增量,本次属需求回退,建议补变更确认单**。详见《交接摘要》§一·三十六 |
|
||||||
| **V2.6** | **2026-09-30** | **六处口径修正 + 新增 §17(全部经客户确认)**:① §2.7 `notifyBizType` 5/6 对调修正(5 催报 / 6 导出完成,F-30);② §2.7 `auditAction` 补全为 7 个动作、撤回=7(F-7);③ §3.6/§9.5 `source` 改为建表口径「谁传的」(F-38/39);④ §9.5 改为「上传前置检查」定位 + §9.6 `process` 语义改为「有原始件时的重新规范化」(F-3/71/72);⑤ §9.9 OCR 标注一期缓做;⑥ §11.4 撤回约束补「状态 2 可撤(撤初审)」、留痕 `action=7`;⑦ §12.1 `validation/run` 改同步并返回统计(F-51);⑧ **新增 §17 实现补充与偏差记录**(41 个 §4 未列接口按六类分组 + 12 条已确认偏离 D-1~D-12)。同步记录见《08-完成度盘点与问题清单》 |
|
| **V2.6** | **2026-09-30** | **六处口径修正 + 新增 §17(全部经客户确认)**:① §2.7 `notifyBizType` 5/6 对调修正(5 催报 / 6 导出完成,F-30);② §2.7 `auditAction` 补全为 7 个动作、撤回=7(F-7);③ §3.6/§9.5 `source` 改为建表口径「谁传的」(F-38/39);④ §9.5 改为「上传前置检查」定位 + §9.6 `process` 语义改为「有原始件时的重新规范化」(F-3/71/72);⑤ §9.9 OCR 标注一期缓做;⑥ §11.4 撤回约束补「状态 2 可撤(撤初审)」、留痕 `action=7`;⑦ §12.1 `validation/run` 改同步并返回统计(F-51);⑧ **新增 §17 实现补充与偏差记录**(41 个 §4 未列接口按六类分组 + 12 条已确认偏离 D-1~D-12)。同步记录见《08-完成度盘点与问题清单》 |
|
||||||
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
|
| **V2.5** | **2026-09-30** | **两处"文档内部口径冲突"加注(不改口径,只把冲突摆到明处)**:① §2 枚举 `notifyBizType` 的 `5`/`6`(本文件写「5 导出完成 / 6 催报」)与 §13.5 消息中心示例、落库值 `collect_notify_record.biz_type`(「**5 催报 / 6 导出完成**」)不一致 —— **实现以 §13.5 为准**,需要客户确认以哪边为准(若以 §2 为准,要同时改两个常量与已落库数据);② §3.6 与 §9.5 的 `collect_file.source` 字段表写的是"入口来源"(1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传),而建表注释与实现(`CollectFile.SOURCE_*`)是"**谁传的**"(1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入)—— **实现以建表口径为准**(§10.6 要求代传可追溯,要回答的是"谁替他传的")。两处均已在原文加 ⚠️ 注。记录见《08-完成度盘点与问题清单》**F-30 / F-38 / F-39**。 |
|
||||||
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按**明文手机号**的长度建的;但 PRD §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 **52 字符**,插入会直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 **`varchar(128)`**,与 `id_card_no` 对齐。**发现方式**:M0 开通租户实测时数据库报错暴露。**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 |
|
||||||
@@ -2945,25 +2767,25 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|
|
||||||
| 检查项 | 结果 |
|
| 检查项 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| §4 各模块表行数合计 | **121** ✓ 与声称一致 |
|
| §4 各模块表行数合计 | **119** ✓ 与声称一致 |
|
||||||
| §4 内路径重复 | 无 ✓ |
|
| §4 内路径重复 | 无 ✓ |
|
||||||
| Markdown 表格列数一致性 | 0 行不符 ✓ |
|
| Markdown 表格列数一致性 | 0 行不符 ✓ |
|
||||||
| 文档引用的字段名 | 116 个唯一值,**全部存在于 DDL** ✓ |
|
| 文档引用的字段名 | 116 个唯一值,**全部存在于 DDL** ✓ |
|
||||||
| 错误码数量 | 58 个(新增 38 个标 ⭐)✓ |
|
| 错误码数量 | 54 个(新增 34 个标 ⭐)✓ |
|
||||||
| 文件规模 | 约 100 KB / 2570 行 |
|
| 文件规模 | 约 89 KB / 2870 行 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §17 实现补充与偏差记录
|
## §17 实现补充与偏差记录
|
||||||
|
|
||||||
> **§4 的 121 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
|
> **§4 的 119 个是设计口径**。本节回答两件事:① 代码里还有哪些 §4 没列的接口;
|
||||||
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
|
> ② 哪些地方**有意偏离**了本文档的原始写法、为什么、谁确认的。
|
||||||
>
|
>
|
||||||
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
|
> ⚠️ 17.1 的清单由工具生成:`python scripts/check-api-parity.py --extra`
|
||||||
> (当前 **41 个**,生成于 2026-09-30)。**手抄清单会过期,以命令输出为准**;
|
> (当前 **38 个**,2026-10 复核)。**手抄清单会过期,以命令输出为准**;
|
||||||
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
|
> 下表按"为什么不在 §4"分组,便于决定"补进 §4 还是留在本节"。
|
||||||
|
|
||||||
### 17.1 代码有、§4 清单没有的接口(41 个,按偏离原因分组)
|
### 17.1 代码有、§4 清单没有的接口(38 个,按偏离原因分组)
|
||||||
|
|
||||||
**A · 材料访问通道(7 个)** —— 审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的);上传是一步 multipart
|
**A · 材料访问通道(7 个)** —— 审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的);上传是一步 multipart
|
||||||
|
|
||||||
@@ -2992,7 +2814,7 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/v1/exports/{id}/file` | 导出包的流式下载。§12.6 只定义了"取下载信息";真正下文件走这条,**URL 有有效期、不能入库/外发** |
|
| `GET /api/v1/exports/{id}/file` | 导出包的流式下载。§12.6 只定义了"取下载信息";真正下文件走这条,**URL 有有效期、不能入库/外发** |
|
||||||
|
|
||||||
**D · 界面与接口必需、清单漏列(17 个)**
|
**D · 界面与接口必需、清单漏列(16 个)**
|
||||||
|
|
||||||
| 接口 | 说明 |
|
| 接口 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -3012,7 +2834,6 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| `POST /api/v1/dicts/{code}/items/{itemCode}/toggle` | 内置字典项停用/恢复(§13.3) |
|
| `POST /api/v1/dicts/{code}/items/{itemCode}/toggle` | 内置字典项停用/恢复(§13.3) |
|
||||||
| `GET /api/v1/exports/precheck` | 导出前置检查(只检查不创建;界面靠它禁用按钮) |
|
| `GET /api/v1/exports/precheck` | 导出前置检查(只检查不创建;界面靠它禁用按钮) |
|
||||||
| `GET /api/v1/exports/preview` | 导出目录树预览(与真正打包共用同一份构建代码) |
|
| `GET /api/v1/exports/preview` | 导出目录树预览(与真正打包共用同一份构建代码) |
|
||||||
| `GET /api/v1/cleanup/jobs/{id}/result` | 整理器报告(§13.2 界面轮询用) |
|
|
||||||
|
|
||||||
**E · 契约链条原本断在第一步(8 个,已实现)**
|
**E · 契约链条原本断在第一步(8 个,已实现)**
|
||||||
|
|
||||||
@@ -3026,21 +2847,19 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| `POST /api/v1/open-apps/{id}/toggle` | 启停接入方 |
|
| `POST /api/v1/open-apps/{id}/toggle` | 启停接入方 |
|
||||||
| `GET /api/v1/open-apps/push-logs` | 推送记录(人工重推的依据) |
|
| `GET /api/v1/open-apps/push-logs` | 推送记录(人工重推的依据) |
|
||||||
|
|
||||||
**F · 运维与调试动作(4 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
|
**F · 运维与调试动作(2 个)** —— 定时任务在测试里"等不起",所以都有手工入口(权限走运营侧 `log:view:all`)
|
||||||
|
|
||||||
| 接口 | 说明 |
|
| 接口 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
|
| `POST /api/v1/schedules/run` | 手工触发定时任务(`job` 取值 close/urge/timeout/raw/usage/retry/expire) |
|
||||||
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
|
| `POST /api/v1/exports/cleanup-run` | 手工触发导出包过期清理(可重复执行) |
|
||||||
| `POST /api/v1/cleanup/jobs/json` | 整理器:直接提交 JSON(不走上传) |
|
|
||||||
| `POST /api/v1/cleanup/jobs/parse-preview` | 整理器:解析预览(不落库) |
|
|
||||||
|
|
||||||
### 17.2 有意偏离契约的地方(均已确认,2026-09-30)
|
### 17.2 有意偏离契约的地方(均已确认,2026-09-30)
|
||||||
|
|
||||||
| # | 偏离点 | 契约原口径 | 实现口径 | 依据 |
|
| # | 偏离点 | 契约原口径 | 实现口径 | 依据 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| D-1 | `notifyBizType` 5/6 | §2.7 旧表「5 导出完成 / 6 催报」 | 「**5 催报 / 6 导出完成**」(与 §13.5、落库值一致) | F-30;按旧表实现催报去重会静默失效 |
|
| D-1 | `notifyBizType` 5/6 | §2.7 旧表「5 导出完成 / 6 催报」 | 「**5 催报 / 6 导出完成**」(与 §13.5、落库值一致) | F-30;按旧表实现催报去重会静默失效 |
|
||||||
| D-2 | `collect_file.source` | §3.6 旧表「入口来源」(聊天记录/相册/手机文件/代传) | 「**谁传的**」:1 本人 / 2 班主任代传 / 3 管理员代传 / 4 整理器(按建表注释) | F-38/F-39;§10.6 代传可追溯 |
|
| D-2 | `collect_file.source` | §3.6 旧表「入口来源」(聊天记录/相册/手机文件/代传) | 「**谁传的**」:1 本人 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入(**模块已取消,仅历史数据**;按建表注释) | F-38/F-39;§10.6 代传可追溯 |
|
||||||
| D-3 | 上传链路 | OSS 直传 + `upload-token` 凭证 | **服务端一步上传**;`upload-token` 定位为**预检+秒传+压缩建议**(非安全边界) | F-3/F-71/F-72;直传需配回调且把凭证交前端 |
|
| D-3 | 上传链路 | OSS 直传 + `upload-token` 凭证 | **服务端一步上传**;`upload-token` 定位为**预检+秒传+压缩建议**(非安全边界) | F-3/F-71/F-72;直传需配回调且把凭证交前端 |
|
||||||
| D-4 | `process` 语义 | 「命名+压缩+识别+校验总入口」 | 上传当场完成这些;`process` = **有原始件时的重新规范化** | F-3;默认 `keep-raw=false` |
|
| D-4 | `process` 语义 | 「命名+压缩+识别+校验总入口」 | 上传当场完成这些;`process` = **有原始件时的重新规范化** | F-3;默认 `keep-raw=false` |
|
||||||
| D-5 | `POST /validation/run` | `async=true` + jobId | **同步跑完**并返回 `checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs` | F-51;全契约没有查 jobId 的接口 |
|
| D-5 | `POST /validation/run` | `async=true` + jobId | **同步跑完**并返回 `checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs` | F-51;全契约没有查 jobId 的接口 |
|
||||||
@@ -3050,5 +2869,5 @@ X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
|
|||||||
| D-9 | 任务删除 | 已发布任务不可删(`40001`) | **已归档(status=3)的任务可删**:管理员显式动作 + 操作留痕(`DELETE_TASK`,记行数/文件数);**子表级联物理删**(名册/材料/审核记录等 14 张表)、**任务行本身软删**(占住 `task_code` 不复用 —— 编码是文件目录名) | F-14;否则任务列表只增不减 |
|
| D-9 | 任务删除 | 已发布任务不可删(`40001`) | **已归档(status=3)的任务可删**:管理员显式动作 + 操作留痕(`DELETE_TASK`,记行数/文件数);**子表级联物理删**(名册/材料/审核记录等 14 张表)、**任务行本身软删**(占住 `task_code` 不复用 —— 编码是文件目录名) | F-14;否则任务列表只增不减 |
|
||||||
| D-10 | 租户域名 | 未写 | 能力已实现(Host 定位 + token 授权),**默认关闭**;启用四步见《交接摘要》§一·二十六 | F-10;客户决定先不做 |
|
| D-10 | 租户域名 | 未写 | 能力已实现(Host 定位 + token 授权),**默认关闭**;启用四步见《交接摘要》§一·二十六 | F-10;客户决定先不做 |
|
||||||
| D-11 | 通知降级链 | 「订阅消息→公众号模板→短信→站内信」四级 | 一期**两级**(订阅消息→站内信);公众号模板需已认证服务号、短信需采购通道(商务动作),不假装可发 | F-28 |
|
| D-11 | 通知降级链 | 「订阅消息→公众号模板→短信→站内信」四级 | 一期**两级**(订阅消息→站内信);公众号模板需已认证服务号、短信需采购通道(商务动作),不假装可发 | F-28 |
|
||||||
| D-12 | OCR(§9.9) | 已定义 | **一期缓做**(等供应商选型,见《01》D1,需评估数据出境合规) | F-3;121 个接口里唯一未实现 |
|
| D-12 | OCR(§9.9) | 已定义 | **一期缓做**(等供应商选型,见《01》D1,需评估数据出境合规) | F-3;119 个接口里唯一未实现 |
|
||||||
|
|
||||||
|
|||||||
+13
-100
@@ -212,7 +212,7 @@ FUNCTION normalize(rawName, rosterItem, materialDef, template):
|
|||||||
expected=mask(roster.idCardNo),
|
expected=mask(roster.idCardNo),
|
||||||
actual=owner.rawFileNameIdcard, autoFixed=true)
|
actual=owner.rawFileNameIdcard, autoFixed=true)
|
||||||
|
|
||||||
# ── 名册为空(未导名单 / 历史整理器无名单)──
|
# ── 名册为空(未导名单)──
|
||||||
CASE NO_ROSTER:
|
CASE NO_ROSTER:
|
||||||
IF idcard.found:
|
IF idcard.found:
|
||||||
name = pick_name_like(tokens) OR UNKNOWN_NAME
|
name = pick_name_like(tokens) OR UNKNOWN_NAME
|
||||||
@@ -346,9 +346,9 @@ FUNCTION find_idcard_in(text):
|
|||||||
| 空文件名 / 纯扩展名 | 全部字段用名册兜底;无名册则 `FAIL` 但放行 |
|
| 空文件名 / 纯扩展名 | 全部字段用名册兜底;无名册则 `FAIL` 但放行 |
|
||||||
| 超长文件名(>200 字符) | 截断,**优先保留证件号与材料名**,截姓名部分 |
|
| 超长文件名(>200 字符) | 截断,**优先保留证件号与材料名**,截姓名部分 |
|
||||||
| 文件名全是乱码 | 尝试 UTF-8→GBK 转码;仍失败则用名册全兜底 |
|
| 文件名全是乱码 | 尝试 UTF-8→GBK 转码;仍失败则用名册全兜底 |
|
||||||
| 无名册(历史整理器无名单) | `keepOriginalIfNoRoster=true`:只做格式清理,**不猜姓名与证件号** |
|
| 无名册(任务未导名单) | `keepOriginalIfNoRoster=true`:只做格式清理,**不猜姓名与证件号** |
|
||||||
| 文件名里的证件号指向名册中他人 | **阻断**(VAL-21),不做任何自动修正 |
|
| 文件名里的证件号指向名册中他人 | **阻断**(VAL-21),不做任何自动修正 |
|
||||||
| 学生申诉"这就是我的材料" | 不是技术问题——**走「申请加入名单」通道**(FR-509)由管理员核实并入册;系统内不展示联系方式 |
|
| 学生申诉"这就是我的材料" | 不是技术问题——**由老师在任务里核实并手工加人/改材料**(原「申请加入名单」通道已于 2026-10 撤销);系统内不展示联系方式 |
|
||||||
| 模板变量全部为空 | 返回 `FAIL`,材料名用槽位名,其余用 `UNKNOWN_*` |
|
| 模板变量全部为空 | 返回 `FAIL`,材料名用槽位名,其余用 `UNKNOWN_*` |
|
||||||
| 同一文件重复处理 | 幂等:比对 `file_hash`,已处理且输入未变则直接返回缓存结果 |
|
| 同一文件重复处理 | 幂等:比对 `file_hash`,已处理且输入未变则直接返回缓存结果 |
|
||||||
|
|
||||||
@@ -670,11 +670,11 @@ FUNCTION detect_by_shape(meta, ocrHint):
|
|||||||
| 身份证、公民身份、人像面、国徽面 | `IDCARD` | ✅ 实测 |
|
| 身份证、公民身份、人像面、国徽面 | `IDCARD` | ✅ 实测 |
|
||||||
| 一寸、证件照、免冠 | `PHOTO1` | ✅ 实测 |
|
| 一寸、证件照、免冠 | `PHOTO1` | ✅ 实测 |
|
||||||
| 申报表、工作证明、公章 | `WORK_PROOF` | ✅ 实测 |
|
| 申报表、工作证明、公章 | `WORK_PROOF` | ✅ 实测 |
|
||||||
| 学信网、备案表、电子注册 | `XJW_BA` | ⚠️ **零样本**——词是从官方模板文字要求里抄的,**待「社会评价」批次样本标定** |
|
| 学信网、备案表、电子注册 | `XJW_BA` | ⚠️ **不做**(2026-10 取消二期):编码**读得到、选不到**,强特征词从未标定 |
|
||||||
| **(写不出词)** | `OTHER_PROOF` | ⚠️ **零样本**——含毕业证/社保单/职称/技能证书四类**版式完全不同**的东西,**可能不该做识别**,改为默认识别失败放行 + 学生自选槽位 |
|
| **(写不出词)** | `OTHER_PROOF` | ⚠️ **不做**(2026-10 取消二期):编码**读得到、选不到**,倾向"不做识别、纯手动槽位" |
|
||||||
|
|
||||||
> ⚠️ **强特征词表目前只覆盖了自主认定批次的 4 类材料**(实测 112 个文件)。
|
> ⚠️ **强特征词表目前只覆盖了自主认定批次的 4 类材料**(实测 112 个文件)。
|
||||||
> `XJW_BA` 与 `OTHER_PROOF` **没有一份真实样本**——它们的词、形态阈值、压缩档位都是**推测**。
|
> `XJW_BA` 与 `OTHER_PROOF` **没有一份真实样本**(且已确定**不做**)——它们的词、形态阈值、压缩档位都是**推测**。
|
||||||
> 详见确认函 D4。
|
> 详见确认函 D4。
|
||||||
|
|
||||||
**禁用词**(**禁止入表**——实测这些词在所有材料里都出现,无区分力)
|
**禁用词**(**禁止入表**——实测这些词在所有材料里都出现,无区分力)
|
||||||
@@ -693,6 +693,7 @@ FUNCTION detect_by_shape(meta, ocrHint):
|
|||||||
FUNCTION detect_by_ocr(ocrText, rosterItem):
|
FUNCTION detect_by_ocr(ocrText, rosterItem):
|
||||||
score = {}
|
score = {}
|
||||||
score[IDCARD] += 3 IF /公民身份号码|中华人民共和国居民身份证/
|
score[IDCARD] += 3 IF /公民身份号码|中华人民共和国居民身份证/
|
||||||
|
# ⚠️ XJW_BA / OTHER_PROOF 已于 2026-10 取消(不做识别);下面两行保留仅作历史记录
|
||||||
score[XJW_BA] += 3 IF /教育部学历证书电子注册备案表|在线验证码/
|
score[XJW_BA] += 3 IF /教育部学历证书电子注册备案表|在线验证码/
|
||||||
score[SCHOOL_ROLL] += 2 IF /学籍|在读|入学日期|学号/
|
score[SCHOOL_ROLL] += 2 IF /学籍|在读|入学日期|学号/
|
||||||
score[WORK_PROOF] += 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 需求收敛)
|
||||||
|
|
||||||
> **这不是门禁的一部分,而是门禁的"旁路出口"**——学生进不来时,给他一条**受控的**求助路径。
|
> ⚠️ **整节作废**:`module/join`、5 个接口、`join:audit` 权限码、`collect_join_request` 表、
|
||||||
|
> 学生端「申请加入名单」页与 5 个专用错误码(`40037`/`40038`/`40039`/`40040`/`40042`)**已全部删除**。
|
||||||
#### 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)。
|
|
||||||
>
|
>
|
||||||
> ⚠️ **第 ⑦ 步在事务外**:通知发送失败不该回滚审批(用户已经点过了)。失败进重试队列。
|
> **现在的口径**:门禁失败只提示核对 + "请联系老师把你加入名单",由老师在任务的「名单管理」里**手工加人**;
|
||||||
|
> 系统内**不展示任何联系方式**。安全要求(限流、失败原因不区分、名单不下发、不记明文日志)**继续适用于门禁本身**,
|
||||||
#### 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 | **不启用**;代码分支不做 |
|
| 社会收集审核流(跳过初审) | 配置驱动,≈0 | **不启用**;代码分支不做 |
|
||||||
| 社会评价材料(`XJW_BA` 学信网备案表) | 需样本标定 | **不做**;识别词表暂缺 |
|
| 社会评价材料(`XJW_BA` 学信网备案表) | 需样本标定 | **不做**(2026-10 取消二期);编码读得到、选不到 |
|
||||||
| 专项职业能力支持 | 配置驱动,≈0 | **不启用**;`FR-310` 标二期 |
|
| 专项职业能力支持 | 配置驱动,≈0 | **不做**(2026-10 取消);`FR-310` 标注为不做 |
|
||||||
| 其它佐证材料(`OTHER_PROOF`) | 需样本标定 | **不做**;且倾向"不做识别、纯手动槽位" |
|
| 其它佐证材料(`OTHER_PROOF`) | 需样本标定 | **不做**(2026-10 取消二期);编码读得到、选不到 |
|
||||||
|
|
||||||
**受影响的 5 个任务(各减 1 人日)**
|
**受影响的 5 个任务(各减 1 人日)**
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ M0 ──→ M1 ──→ M2 ─────────────→ M3 ─
|
|||||||
| M7-08 Golden Set 回归脚本 | 4 | **3** | 一期只覆盖 4 类材料 |
|
| M7-08 Golden Set 回归脚本 | 4 | **3** | 一期只覆盖 4 类材料 |
|
||||||
| M7-11 用户手册 / 培训 | 3 | **2** | 一期流程更简单 |
|
| M7-11 用户手册 / 培训 | 3 | **2** | 一期流程更简单 |
|
||||||
|
|
||||||
> ⚠️ **二期启用时必须补的**:`XJW_BA` / `OTHER_PROOF` 的识别标定与回归断言、社会收集审核流、专项职业能力字段隐藏。
|
> ⚠️ ~~**二期启用时必须补的**~~ → **2026-10 已取消二期**,下列内容**不再排期**:
|
||||||
> 这些都已在文档中保留描述,只是标注为二期。
|
> 文档里保留描述只是追溯用途;**不要**再按"二期"理解成"已经排期"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
|||||||
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
||||||
|---|---|:--:|:--:|---|---|
|
|---|---|:--:|:--:|---|---|
|
||||||
| M1-01 | 任务 CRUD + 状态机(草稿/发布/暂停/恢复/截止/归档) | M0-05 | 6 | 后端 | 接口 §7.1~§7.5;**暂停与截止语义不同** |
|
| 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-03 | 表单 schema 版本化存储与冲突检测 | M1-01 | 4 | 后端 | 接口 §7.9;`baseVersion` 冲突返回 40025 |
|
||||||
| M1-04 | 材料项定义 CRUD | M1-01 | 4 | 后端 | 接口 §7.10;28 个字段 |
|
| M1-04 | 材料项定义 CRUD | M1-01 | 4 | 后端 | 接口 §7.10;28 个字段 |
|
||||||
| M1-05 | 规则模板保存与应用 | M1-04 | 3 | 后端 | 接口 §7.13 |
|
| M1-05 | 规则模板保存与应用 | M1-04 | 3 | 后端 | 接口 §7.13 |
|
||||||
@@ -179,9 +179,9 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
|||||||
| M2-10 | 学生端门禁屏 + 统一失败页 + 形近字确认弹层 | M0-12 | 5 | 前端 | 原型 ①②③ |
|
| M2-10 | 学生端门禁屏 + 统一失败页 + 形近字确认弹层 | M0-12 | 5 | 前端 | 原型 ①②③ |
|
||||||
| M2-11 | 学生端"我的任务列表" | M0-12 | 3 | 前端 | 接口 §9.1 |
|
| M2-11 | 学生端"我的任务列表" | M0-12 | 3 | 前端 | 接口 §9.1 |
|
||||||
| M2-12 | 班主任端班级看板 | M0-12 | 4 | 前端 | 接口 §10.1;**完成率=已提交率 82%** |
|
| 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-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-16 | **管理端审批页**(列表 + 通过选班 + 驳回填原因) | M2-09 | 2 | 前端 | 驳回原因必填;**不提供"全选"** |
|
||||||
| ⭐ M2-17 | 社会收集名单来源落地(B2):与报名点对齐预报名名单流程 | M2-02 | 1 | 后端 | 说明性工作 + 与客户确认字段口径 |
|
| ⭐ M2-17 | 社会收集名单来源落地(B2):与报名点对齐预报名名单流程 | M2-02 | 1 | 后端 | 说明性工作 + 与客户确认字段口径 |
|
||||||
| **小计** | | | **58** | | |
|
| **小计** | | | **58** | | |
|
||||||
@@ -236,7 +236,7 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
|||||||
|
|
||||||
| ID | 任务 | 依赖 | 人日 | 角色 | 备注 |
|
| 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-02 | M3 识别层 2(形态特征 + 零文字判据) | M4-01 | 2 | 后端 | 零文字才是 PHOTO1 的可靠判据。**一期只覆盖自主认定批次形态** |
|
||||||
| M4-03 | **OCR 引擎接入与选型落地** | M0-01 | 3 | 后端 | 确认函 D1;需评估数据出境 |
|
| M4-03 | **OCR 引擎接入与选型落地** | M0-01 | 3 | 后端 | 确认函 D1;需评估数据出境 |
|
||||||
| M4-04 | M3 识别层 3(OCR 内容打分) | M4-03 | 2 | 后端 | — |
|
| M4-04 | M3 识别层 3(OCR 内容打分) | M4-03 | 2 | 后端 | — |
|
||||||
@@ -325,9 +325,9 @@ M{里程碑号}-{序号} 例:M3-04 表示第 3 个里程碑的第 4
|
|||||||
| M7-03 | 字典管理(6 个内置字典 + 租户自维护) | M0-05 | 4 | 后端 | **职业字典只做拼写校验,不强约束** |
|
| M7-03 | 字典管理(6 个内置字典 + 租户自维护) | M0-05 | 4 | 后端 | **职业字典只做拼写校验,不强约束** |
|
||||||
| M7-04 | 操作日志 + 登录日志(含敏感操作留痕) | M0-06 | 4 | 后端 | `VIEW_IDCARD_FULL` 单独留痕 |
|
| M7-04 | 操作日志 + 登录日志(含敏感操作留痕) | M0-06 | 4 | 后端 | `VIEW_IDCARD_FULL` 单独留痕 |
|
||||||
| M7-05 | 日志查询 UI | M7-04 | 2 | 前端 | — |
|
| M7-05 | 日志查询 UI | M7-04 | 2 | 前端 | — |
|
||||||
| M7-06 | **历史数据整理器**(上传→解析→规范化→报告) | M6-02 | 8 | 后端 | 差异化获客功能 |
|
| ~~M7-06~~ | ~~**历史数据整理器**(上传→解析→规范化→报告)~~ | — | — | — | **已取消**(2026-10 需求收敛) |
|
||||||
| M7-07 | 整理器 UI(体检报告 + 结果导入) | M7-06 | 5 | 前端 | 原型 ⑩;`autoHandled: 90 / needManual: 1` |
|
| ~~M7-07~~ | ~~整理器 UI(体检报告 + 结果导入)~~ | — | — | — | **已取消**(2026-10) |
|
||||||
| M7-08 | **Golden Set 回归脚本**(15 项断言自动化) | M3-05 | 3 | 测试 | 04 文档附录 B 的基线。**一期只覆盖 4 类材料**,二期补 `XJW_BA`/`OTHER_PROOF` |
|
| M7-08 | **Golden Set 回归脚本**(15 项断言自动化) | M3-05 | 3 | 测试 | 04 文档附录 B 的基线。**只覆盖 4 类材料**(`XJW_BA`/`OTHER_PROOF` 已取消) |
|
||||||
| M7-09 | 压测与调优(门禁并发/上传并发/导出大任务) | M6-02 | 5 | 后端 | 重点关注 M4 并发绑定 |
|
| M7-09 | 压测与调优(门禁并发/上传并发/导出大任务) | M6-02 | 5 | 后端 | 重点关注 M4 并发绑定 |
|
||||||
| M7-10 | 灰度发布 + 生产环境准备 + 数据迁移预案 | M7-09 | 5 | 全员 | — |
|
| M7-10 | 灰度发布 + 生产环境准备 + 数据迁移预案 | M7-09 | 5 | 全员 | — |
|
||||||
| M7-11 | 用户手册 / 培训材料 / 客服话术 | M7-07 | 2 | 产品 | 一期只有学校收集一条流程,手册更简单 |
|
| 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 张真实图 → 看结果"的验证循环,**两人并行做反而要重跑**。
|
**加人不会缩短它**。例如"压缩引擎"的 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 项偏差 |
|
| R4 | 压缩引擎调优超期 | 中 | 高 | **M3-05 单独设 3 人日缓冲**;用 Golden Set 做回归门禁 | 回归超 2 项偏差 |
|
||||||
| R5 | 门禁并发绑定出问题 | 低 | **极高** | 04 文档 M4 已给正确实现;**M7-09 压测必测 100 并发** | 压测出现重复绑定 |
|
| R5 | 门禁并发绑定出问题 | 低 | **极高** | 04 文档 M4 已给正确实现;**M7-09 压测必测 100 并发** | 压测出现重复绑定 |
|
||||||
| R6 | 客户名单格式更复杂 | 中 | 中 | 表头探测已支持候选回传;M2 加 20% 缓冲 | dryRun 识别率 < 90% |
|
| R6 | 客户名单格式更复杂 | 中 | 中 | 表头探测已支持候选回传;M2 加 20% 缓冲 | dryRun 识别率 < 90% |
|
||||||
| R7 | 需求变更(如加"申请加入名单"通道) | 高 | 中 | 已列入确认函 B1;**若确认则 M2 追加 3 人日** | 客户回复 |
|
| ~~R7~~ | ~~需求变更(如加"申请加入名单"通道)~~ | — | — | **已关闭**:该通道 2026-10 已撤销,**且不再设计二期**;新需求一律走变更确认单 | 已关闭 |
|
||||||
| R8 | 导出大任务内存溢出 | 中 | 中 | 分卷 + 流式写入;快照固化避免重查 | 单任务 > 5000 文件 |
|
| R8 | 导出大任务内存溢出 | 中 | 中 | 分卷 + 流式写入;快照固化避免重查 | 单任务 > 5000 文件 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -444,11 +467,11 @@ M0-01 环境 ──→ M0-02 后端骨架 ──→ M0-05 组织 ──→ M0-06
|
|||||||
|
|
||||||
| 演示 | 时点 | 演示什么 | 目的 |
|
| 演示 | 时点 | 演示什么 | 目的 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **里程碑 A** | 第 5 周 | 任务配置闭环 + **历史数据整理器**(可用假数据先做简版) | 让客户看到"配置灵活",并**提前锁定需求** |
|
| **里程碑 A** | 第 5 周 | 任务配置闭环(~~历史数据整理器~~ 已于 2026-10 取消) | 让客户看到"配置灵活",并**提前锁定需求** |
|
||||||
| **里程碑 B** | 第 11 周 | 真机全流程 + **用真实 112 个文件跑出的处理报告** | 最有说服力的一次——数字都是他们自己的数据 |
|
| **里程碑 B** | 第 11 周 | 真机全流程 + **用真实 112 个文件跑出的处理报告** | 最有说服力的一次——数字都是他们自己的数据 |
|
||||||
| **里程碑 C** | 第 15 周 | 压测 + 导出包 | 交付验收 |
|
| **里程碑 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.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.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**,排期不变 |
|
| **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 | 学生扫码进小程序 | 进入门禁页 | ① |
|
| A1 | 学生扫码进小程序 | 进入门禁页 | ① |
|
||||||
| A2 | 输入名单上的姓名 + 证件号 | 通过,显示姓名与班级 | ② |
|
| A2 | 输入名单上的姓名 + 证件号 | 通过,显示姓名与班级 | ② |
|
||||||
| A3 | 刻意输错一位证件号 | **统一提示**"没有找到你的信息",不透露哪项错 | ③ |
|
| A3 | 刻意输错一位证件号 | **统一提示**"没有找到你的信息",不透露哪项错 | ③ |
|
||||||
| A4 | 点"申请加入名单" | 进入申请表单;**系统内不展示任何联系方式** | ③B |
|
| ~~A4~~ | ~~点"申请加入名单"~~ | **已取消**(2026-10 需求收敛:学生端不再有自助申请入口;门禁失败只提示核对 + "请联系老师",且不展示任何联系方式) | — |
|
||||||
| A5 | 正确登录后进填报页 | 18 个字段已预填,标注"老师已填,请核对" | ④ |
|
| A5 | 正确登录后进填报页 | 18 个字段已预填,标注"老师已填,请核对" | ④ |
|
||||||
| A6 | 修改"联系电话" | 可改,`changedFields` 记录 phone | ④ |
|
| A6 | 修改"联系电话" | 可改,`changedFields` 记录 phone | ④ |
|
||||||
| A7 | 尝试改"身份证号" | **只读**,点击提示"如有误请联系老师" | ④ |
|
| A7 | 尝试改"身份证号" | **只读**,点击提示"如有误请联系老师" | ④ |
|
||||||
@@ -264,9 +264,9 @@ with unzip(result.zip) as z:
|
|||||||
| A22 | 管理员导出 | 前置检查通过 → 打包 → 下载 zip | ⑦ |
|
| A22 | 管理员导出 | 前置检查通过 → 打包 → 下载 zip | ⑦ |
|
||||||
| A23 | **Windows 资源管理器解压** | **中文名不乱码**,目录树正确 | — |
|
| 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 | 班主任打开待审列表 | **看不到该任务**(社会收集不经班主任) |
|
| B2 | 班主任打开待审列表 | **看不到该任务**(社会收集不经班主任) |
|
||||||
| B3 | 管理员终审 → 导出 | 正常 |
|
| B3 | 管理员终审 → 导出 | 正常 |
|
||||||
|
|
||||||
### 3.3 主流程 C · 历史数据整理器
|
### 3.3 主流程 C · 历史数据整理器 —— **已取消**(2026-10)
|
||||||
|
|
||||||
| 步 | 操作 | 期望 |
|
| 步 | 操作 | 期望 |
|
||||||
|:--:|---|---|
|
|:--:|---|---|
|
||||||
@@ -310,8 +310,8 @@ with unzip(result.zip) as z:
|
|||||||
| E19 | 微信订阅消息额度耗尽 | **静默降级**,学生端无异常提示 |
|
| E19 | 微信订阅消息额度耗尽 | **静默降级**,学生端无异常提示 |
|
||||||
| E20 | 网络中断后重进 | 草稿保留,续填 |
|
| E20 | 网络中断后重进 | 草稿保留,续填 |
|
||||||
| E21 | 申请提交后管理员未处理,任务截止 | 申请自动置为已失效,学生收到提示 |
|
| E21 | 申请提交后管理员未处理,任务截止 | 申请自动置为已失效,学生收到提示 |
|
||||||
| E22 | 两人同时申请同一证件号 | 只有 1 条落库成功(部分唯一索引),另一条得 40038 |
|
| ~~E22~~ | ~~两人同时申请同一证件号~~ | **已取消**(2026-10:加入申请模块删除) |
|
||||||
| E23 | 管理员在审批页停留很久后点通过 | 状态已变更时返回 40040,不重复建册 |
|
| ~~E23~~ | ~~管理员在审批页停留很久后点通过~~ | **已取消**(2026-10) |
|
||||||
| E24 | 学生传了同学的材料后点提交 | **被阻断**,提示文件名对应的是谁,**无"仍然继续"** |
|
| E24 | 学生传了同学的材料后点提交 | **被阻断**,提示文件名对应的是谁,**无"仍然继续"** |
|
||||||
| E25 | 学生把文件名改成自己的名字后重传,内容仍是同学的 | 证件号/OCR 仍会暴露 → 阻断或转人工(**改名绕不过去**) |
|
| E25 | 学生把文件名改成自己的名字后重传,内容仍是同学的 | 证件号/OCR 仍会暴露 → 阻断或转人工(**改名绕不过去**) |
|
||||||
| E26 | 归属问题在异步 OCR 后才判定可放行 | 自动解除阻断 + 推送通知;**阶段二只解除、不新增阻断** |
|
| E26 | 归属问题在异步 OCR 后才判定可放行 | 自动解除阻断 + 推送通知;**阶段二只解除、不新增阻断** |
|
||||||
@@ -343,6 +343,12 @@ with unzip(result.zip) as z:
|
|||||||
| F-200-4 | 一班主任多班 | 看板聚合展示多个班 |
|
| F-200-4 | 一班主任多班 | 看板聚合展示多个班 |
|
||||||
| F-200-5 | 批量导入用户 | 返回体检报告 + 初始密码清单 |
|
| F-200-5 | 批量导入用户 | 返回体检报告 + 初始密码清单 |
|
||||||
| F-200-6 | 停用有在办任务的用户 | 拒绝 `40014` |
|
| 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)
|
### F-300 收集任务(FR-300)
|
||||||
|
|
||||||
@@ -353,7 +359,7 @@ with unzip(result.zip) as z:
|
|||||||
| F-300-3 | 发布前置校验 5 项 | 任一不通过阻断,`force` 无法绕过 |
|
| F-300-3 | 发布前置校验 5 项 | 任一不通过阻断,`force` 无法绕过 |
|
||||||
| F-300-4 | 任务克隆 | 名册复制但**清空绑定与提交状态** |
|
| F-300-4 | 任务克隆 | 名册复制但**清空绑定与提交状态** |
|
||||||
| F-300-5 | 二维码/短链 | 带租户+任务+班级参数,截止后扫码提示 |
|
| F-300-5 | 二维码/短链 | 带租户+任务+班级参数,截止后扫码提示 |
|
||||||
| F-300-6 | 社会收集审核流 | **二期**。强制 `needClassAudit=false`,传 true 被忽略并告警 |
|
| ~~F-300-6~~ | ~~社会收集审核流~~ | **不做**(2026-10 取消二期;传 `collectMode=2` 返回 `40044`) |
|
||||||
|
|
||||||
### F-400 动态表单(FR-400)
|
### F-400 动态表单(FR-400)
|
||||||
|
|
||||||
@@ -384,29 +390,12 @@ with unzip(result.zip) as z:
|
|||||||
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
|
| F-500-12 | 失败不留明文 | 查库确认只有 hash |
|
||||||
| F-500-13 | 已绑定提示 | `40072`,**这一条明确提示** |
|
| F-500-13 | 已绑定提示 | `40072`,**这一条明确提示** |
|
||||||
|
|
||||||
### F-509 名单加入申请(客户确认新增)
|
### ~~F-509 名单加入申请(客户确认新增)~~ —— **已撤销**(2026-10 需求收敛)
|
||||||
|
|
||||||
| # | 验收项 | 期望 |
|
> ⚠️ 本节 8 条验收全部作废:加入申请通道(含 `40037`/`40038`/`40039`/`40040`/`40042`)
|
||||||
|:--:|---|---|
|
> 连同模块、表、接口、学生端页面一并删除。
|
||||||
| 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 年 |
|
|
||||||
|
|
||||||
### F-600 材料与规则(FR-600)
|
### F-600 材料与规则(FR-600)
|
||||||
|
|
||||||
@@ -425,7 +414,7 @@ with unzip(result.zip) as z:
|
|||||||
| F-600-11 | 散图分拣 | 唯一允许建议落位的场景 |
|
| F-600-11 | 散图分拣 | 唯一允许建议落位的场景 |
|
||||||
| F-600-12 | 来源引导语 | 学生端可见自定义文案 |
|
| F-600-12 | 来源引导语 | 学生端可见自定义文案 |
|
||||||
| F-600-13 | **无拍照入口** | 只有聊天记录/相册/手机文件三种来源 |
|
| 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-22 | **一期收集模式** | 创建 `collectMode=2` 的任务被拒绝(`40044`) |
|
||||||
| F-600-14 | **归属校验:他人材料** | 文件名证件号指向名册中另一人 → **阻断提交**,提示"文件名对应的是「李四」" |
|
| F-600-14 | **归属校验:他人材料** | 文件名证件号指向名册中另一人 → **阻断提交**,提示"文件名对应的是「李四」" |
|
||||||
| F-600-15 | **归属校验:无"仍然继续"** | 归属问题的提示里**不提供**"仍然使用/强制提交"按钮(`canForce=false`) |
|
| F-600-15 | **归属校验:无"仍然继续"** | 归属问题的提示里**不提供**"仍然使用/强制提交"按钮(`canForce=false`) |
|
||||||
@@ -486,7 +475,7 @@ with unzip(result.zip) as z:
|
|||||||
| F-1200-3 | 操作日志 | 9 类敏感操作全留痕,证件号脱敏存储 |
|
| F-1200-3 | 操作日志 | 9 类敏感操作全留痕,证件号脱敏存储 |
|
||||||
| F-1200-4 | 查看完整身份证号 | 单独接口、单独留痕 `VIEW_IDCARD_FULL` |
|
| F-1200-4 | 查看完整身份证号 | 单独接口、单独留痕 `VIEW_IDCARD_FULL` |
|
||||||
| F-1300-1 | 用量与套餐 | 四项用量 + 超限告警 |
|
| F-1300-1 | 用量与套餐 | 四项用量 + 超限告警 |
|
||||||
| F-1400-1 | 整理器上传解析 | 436MB rar → 28 人 4 类 |
|
| ~~F-1400-1~~ | ~~整理器上传解析~~ | **已取消**(2026-10:模块删除) |
|
||||||
| F-1400-2 | **体检报告数字** | 59/31/5/1 全部命中 |
|
| F-1400-2 | **体检报告数字** | 59/31/5/1 全部命中 |
|
||||||
| F-1400-3 | 报告结论 | autoHandled 90 / needManual 1 |
|
| F-1400-3 | 报告结论 | autoHandled 90 / needManual 1 |
|
||||||
| F-1400-4 | 结果导入为任务 | 名册 + 112 文件一次成型 |
|
| F-1400-4 | 结果导入为任务 | 名册 + 112 文件一次成型 |
|
||||||
@@ -501,7 +490,7 @@ with unzip(result.zip) as z:
|
|||||||
|---|:--:|---|
|
|---|:--:|---|
|
||||||
| `student-app.html` | 10 | 门禁失败页文案(不透露哪项错)、三态字段样式、结果卡命名前后对比、无拍照入口 |
|
| `student-app.html` | 10 | 门禁失败页文案(不透露哪项错)、三态字段样式、结果卡命名前后对比、无拍照入口 |
|
||||||
| `teacher-app.html` | 9 | 卡片红黄绿三色、**只能右滑**、驳回二次确认、按钮 44px 触达区 |
|
| `teacher-app.html` | 9 | 卡片红黄绿三色、**只能右滑**、驳回二次确认、按钮 44px 触达区 |
|
||||||
| `admin-web.html` | 10 | 表格信息密度、目录树预览、设计器内置字段锁标记、整理器报告布局 |
|
| `admin-web.html` | 10 | 表格信息密度、目录树预览、设计器内置字段锁标记、角色与授权矩阵 |
|
||||||
| `index.html` | — | 三端入口 |
|
| `index.html` | — | 三端入口 |
|
||||||
|
|
||||||
### 视觉验收要点
|
### 视觉验收要点
|
||||||
|
|||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
# 数据信息填报系统 · 开发实施计划
|
# 深学在线 · 开发实施计划
|
||||||
|
|
||||||
> **版本**:V1.1 **日期**:2026-09-28 **状态**:M0 后端骨架已完成并实测通过(35/35 验收项)
|
> **版本**:V1.1 **日期**:2026-09-28 **状态**:M0 后端骨架已完成并实测通过(35/35 验收项)
|
||||||
>
|
>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
| 图形验证码 | Hutool Captcha | 5.8.47 | 避免自己画 Java2D |
|
| 图形验证码 | Hutool Captcha | 5.8.47 | 避免自己画 Java2D |
|
||||||
| Excel | EasyExcel | 4.0.3 | 名单/用户导入导出 |
|
| Excel | EasyExcel | 4.0.3 | 名单/用户导入导出 |
|
||||||
| API 文档 | springdoc-openapi | 3.1.1 | Boot 4 线(**2.x 是 Boot 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 保底 |
|
| 小程序 | **uni-app**(Vue 3 + TS) | — | **一份代码 → 微信小程序 + H5**,直接落地 M0-13 的 C1 保底 |
|
||||||
| 图片处理 | 独立服务,**libvips** | M2 决定 | R11(1.8 亿像素 OOM)要求内存隔离,**不放进主应用** |
|
| 图片处理 | 独立服务,**libvips** | M2 决定 | R11(1.8 亿像素 OOM)要求内存隔离,**不放进主应用** |
|
||||||
|
|
||||||
@@ -82,9 +82,9 @@ DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
|
|||||||
| `PLATFORM_ADMIN` | 平台运营 | 1 | 全平台(跨租户) |
|
| `PLATFORM_ADMIN` | 平台运营 | 1 | 全平台(跨租户) |
|
||||||
| `SCHOOL_ADMIN` | 学校管理员 | 2 | 本租户全部 |
|
| `SCHOOL_ADMIN` | 学校管理员 | 2 | 本租户全部 |
|
||||||
| `HEAD_TEACHER` | 班主任 | 3 | 本人所带班级 |
|
| `HEAD_TEACHER` | 班主任 | 3 | 本人所带班级 |
|
||||||
| `DEPUTY_TEACHER` | 副班主任 | 3 | 本人所带班级 |
|
| ~~`DEPUTY_TEACHER`~~ | ~~副班主任~~ | — | **已删除**(2026-10 需求收敛) |
|
||||||
| `STUDENT` | 学生 | 4 | 仅本人 |
|
| `STUDENT` | 学生 | 4 | 仅本人 |
|
||||||
| `AUDITOR` | 审核员 | 2 | 本租户(只读 + 审核) |
|
| ~~`AUDITOR`~~ | ~~审核员~~ | — | **已删除**(2026-10 需求收敛;审核只由班主任初审 + 学校管理员终审) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ DDL 里的 **20 张是业务表**,M0 还需要 **6 张标准表**:
|
|||||||
| 4 | **导入类接口一律返回体检报告结构**,必须回传 `headerRow` | 实测两份真实文件表头在第 1 行 vs 第 6 行 |
|
| 4 | **导入类接口一律返回体检报告结构**,必须回传 `headerRow` | 实测两份真实文件表头在第 1 行 vs 第 6 行 |
|
||||||
|
|
||||||
> ⚠️ **一期范围**:`collectMode=2`(社会收集)与 `certType=2/3`(社会评价/专项职业能力)
|
> ⚠️ **一期范围**:`collectMode=2`(社会收集)与 `certType=2/3`(社会评价/专项职业能力)
|
||||||
> **一期不启用**,传入返回 `40044`。字段与枚举保留,二期直接开。
|
> **不启用**,传入返回 `40044`。字段与枚举保留(配置驱动、留着不增加维护成本),但**没有"二期开放"的计划** —— 要用时按新需求重新评估。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+44
-16
@@ -4,6 +4,32 @@
|
|||||||
> 口径:以**代码与实测**为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。
|
> 口径:以**代码与实测**为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。
|
||||||
> 盘点时间:2026-09-29 · 基线提交 `5213bc2`
|
> 盘点时间: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 → **1595 条**,0 失败 0 跳过) |
|
||||||
|
>
|
||||||
|
> **删除的三块**:① 内置角色「副班主任 `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 失败** |
|
| 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 错 / 构建通过 |
|
| 3 | 管理端 | `pnpm typecheck` / `pnpm build` | 0 错 / 构建通过 |
|
||||||
| 4 | 库表 | `docker exec xt-pg psql -U fill -d filldb` | 26 张表(20 业务 + `sys_*` 业务 5 + flyway 1),其中 4 张业务表**恒为 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 项全通过**(含本文件) |
|
| 5 | 文档一致性 | `python scripts/verify-docs.py` | **20 项全通过**(含本文件) |
|
||||||
@@ -24,7 +50,7 @@
|
|||||||
> 「脚本审计前先验证脚本本身」是同一类错误。
|
> 「脚本审计前先验证脚本本身」是同一类错误。
|
||||||
>
|
>
|
||||||
> 现在这件事固化成 `scripts/check-api-parity.py`(**可复现**):
|
> 现在这件事固化成 `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% |
|
| 后端接口 | 118 个(M0/M1/M2/M3 主链路 + M5 审核闭环与班主任看板 + M6 导出、分卷与过期清理 + 学生端我的任务/撤回/进度 + 通知消息与催报 + 名册改删 + 操作/登录日志 + 字典管理 + 代传与教师端文件访问 + 校验问题清单 / 通知模板与手工发送 / 微信回调 + 任务克隆 / 任务统计 / 规则模板 / 名册导入模板 / 审核统计 / PC 端代传 + 开放接口(认定机构对接) + 小程序登录与绑手机号 / 组织导入 / 租户用量 / 文件视图·直传凭证·批量处理);**已删除**:B1 加入申请、历史数据整理器(2026-10) | 1 个 | 99% |
|
||||||
| 后端里程碑 | **契约 §4 的 121 个接口里 120 个已实现**(M0~M3 主链路、M5 审核闭环、M6 导出与交付、M7 整理器与运维接口、开放接口、小程序登录与文件接口) | **只剩 M4 的 `/mp/ocr/idcard`**(等 OCR 选型)+ 前端页面与 CI/CD | 约 7.5/8 |
|
| 后端里程碑 | **契约 §4 的 119 个接口里 118 个已实现**(2026-10 收敛后的口径;原为 129 里 128) | **只剩 M4 的 `/mp/ocr/idcard`**(等 OCR 供应商选型)+ 前端页面与 CI/CD | 约 7.5/8 |
|
||||||
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 加入申请 / 通知记录 / 操作日志 / 字典管理 | 角色、**历史整理器页面**(后端已就绪)、表单设计器 | 约 92% |
|
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 通知记录 / 操作日志 / 字典管理 / **角色与授权**(**纯 Vue 3 + Naive UI**,2026-10 重搭);**已删除**:加入申请页、历史整理器页(2026-10) | 表单设计器已上线 | 约 100% |
|
||||||
| 学生端(H5/小程序) | 落地页、门禁核验、填报、**我的任务 / 撤回 / 进度 / 消息中心 / 加入申请** | 临期提醒页、催报 | 约 85% |
|
| 学生端(H5/小程序) | 落地页、门禁核验、填报、**我的任务 / 撤回 / 进度 / 消息中心**(**加入申请页已删除**,门禁失败只提示联系老师) | 临期提醒页、催报 | 约 90% |
|
||||||
| 班主任端(小程序) | 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ **代传材料 / 催报 / 学生材料预览**(后端与 MP 页面均已就绪) | 临期提醒入口 | 约 95% |
|
| 班主任端(小程序) | 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ **代传材料 / 催报 / 学生材料预览**(后端与 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**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**37 个 E2E 脚本(断言数以 `verify-all` 为准)+ 4 个机械校验脚本** | CI/CD、test/prod 环境、压测、灰度 | 约 58% |
|
||||||
|
|
||||||
**契约接口实现度(按 §4 分类)**
|
**契约接口实现度(按 §4 分类)**
|
||||||
|
|
||||||
@@ -74,7 +100,8 @@
|
|||||||
|
|
||||||
## 四、未完成(按"能不能上线"排序)
|
## 四、未完成(按"能不能上线"排序)
|
||||||
|
|
||||||
> ⚠️ **截至 2026-09-30,剩下的全部是外部依赖或二期范围** —— 没有一条是"我们还没写"。
|
> ⚠️ **截至 2026-09-30,剩下的全部是外部依赖或(当时的)二期范围** —— 没有一条是"我们还没写"。
|
||||||
|
> **2026-10 更新**:其中的"二期范围"已**整体取消**(不再排期),只剩**外部依赖**(OCR 供应商 / 小程序类目 / `union_id` / 真机验证)。
|
||||||
> 所以每条都指向 `docs/01-客户与机构确认函.md` 的具体编号:**卡在谁那里、要谁做什么**。
|
> 所以每条都指向 `docs/01-客户与机构确认函.md` 的具体编号:**卡在谁那里、要谁做什么**。
|
||||||
>
|
>
|
||||||
> | 剩项 | 卡点 | 找谁 | 编号 |
|
> | 剩项 | 卡点 | 找谁 | 编号 |
|
||||||
@@ -107,10 +134,10 @@
|
|||||||
| **P1-1** | **M4 识别与校验**(形态 / OCR / VLM / 串档)—— **现在唯一的大块**(§4.8 的 7 个缺口全在这里) | M4-01~07、M4-09(→ `docs/01` **D1**;识别失败一律放行) |
|
| **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-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-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-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-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-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-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 |
|
| ~~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-13~~ | ~~开放接口(认定机构对接)~~ → ✅ **已完成**(§六 6.1 第 6k 项;另补了契约漏掉的接入方管理,见 F-67) | §14 的 4 个接口(HMAC 加签) |
|
||||||
| ~~P1-14~~ | ~~学生端 `/mp/login`(小程序端登录)+ 手机号绑定~~ → ✅ **已完成**(§六 6.1 第 6l 项) | §5.3、§4.1 |
|
| ~~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-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-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-4~~ | ~~用户手册 / 培训材料 / 客服话术~~ → ✅ **本轮完成**:`docs/10-用户手册与客服话术.md`(三角色操作手册 + 高频问题标准回答 + 30 分钟培训提纲 + 上线首日检查单);并新增 `scripts/check-manual.py`(**机械核对手册引用的错误码与提示文案是否真的存在**,已进 CI) | M7-11 |
|
||||||
| ~~P2-5~~ | ~~导出包过期清理~~ → ✅ **已完成**:`ExportCleanupService.cleanupExpired()`(`@Scheduled` 每小时第 17 分跑) | M6-07 |
|
| ~~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-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-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-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 里那句过期的说明已同步** | 开发体验 |
|
| ~~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),
|
⚠️ 更要紧的是:**这个数会继续长**(每加一处界面必需接口就 +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 路径前缀)。
|
- ✅ **已闭环(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` |
|
| 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 |
|
| 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` |
|
| 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) |
|
| 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` |
|
| 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 错 |
|
| 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**(含待办范围判据的回归防线) |
|
| 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) |
|
| 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/小程序两端构建都验过**(小程序产物仍是写死地址) |
|
| 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 句提示文案全部与代码一致** |
|
| 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 绿 |
|
| 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 各步骤全通过 |
|
| 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** |
|
| 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 错 |
|
| 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 错;**截图留证** |
|
| 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** |
|
| 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** |
|
| 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** |
|
||||||
|
|||||||
+14
-1
@@ -97,7 +97,7 @@ curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
|
|||||||
换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码,
|
换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码,
|
||||||
或者门禁核验永远匹配不上(`id_card_hash` 也变了)。
|
或者门禁核验永远匹配不上(`id_card_hash` 也变了)。
|
||||||
|
|
||||||
正确做法(本项目尚未实现,属二期工程):
|
正确做法(本项目**缓做**,等 OCR 供应商选型;**不再作为二期功能设计**):
|
||||||
1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表");
|
1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表");
|
||||||
2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**;
|
2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**;
|
||||||
3. 全部迁完之后,旧密钥才下线。
|
3. 全部迁完之后,旧密钥才下线。
|
||||||
@@ -468,3 +468,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,注意先看导出分卷与磁盘占用 |
|
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
|
||||||
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
|
| **生产规模下的长跑(数小时~数天)** | 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` 只放跨页面样式 |
|
||||||
|
|||||||
+34
-19
@@ -48,7 +48,7 @@
|
|||||||
那不是你的问题 —— 是同一个 WiFi 下有人多次试错,等一分钟即可。
|
那不是你的问题 —— 是同一个 WiFi 下有人多次试错,等一分钟即可。
|
||||||
- 如果提示「**该信息已被登记**」(`40072`):说明这个名册项已经被**另一个微信号**绑定过。
|
- 如果提示「**该信息已被登记**」(`40072`):说明这个名册项已经被**另一个微信号**绑定过。
|
||||||
常见原因:你用两个微信号都扫了码。**联系班主任处理**,不要重复试。
|
常见原因:你用两个微信号都扫了码。**联系班主任处理**,不要重复试。
|
||||||
- 如果名册里确实没有你 → 点「**申请加入名单**」,由管理员审批后入册(见 2.4)。
|
- 如果名册里确实没有你 → **联系班主任**,请他在任务的「名单管理」里把你加进去(⚠️ 系统里**不再有自助申请入口**,也不会展示任何联系方式 —— 见 2.4)。
|
||||||
|
|
||||||
**③ 填表**
|
**③ 填表**
|
||||||
- 表里有一部分**已经替你填好了**(报考职业/工种/等级/学校/专业等)——
|
- 表里有一部分**已经替你填好了**(报考职业/工种/等级/学校/专业等)——
|
||||||
@@ -80,20 +80,21 @@
|
|||||||
| 操作过于频繁,请稍后再试 | 试错次数太多(同一个人 1 小时 10 次) | 等几分钟,或让班主任核对 |
|
| 操作过于频繁,请稍后再试 | 试错次数太多(同一个人 1 小时 10 次) | 等几分钟,或让班主任核对 |
|
||||||
| 当前网络下尝试核验的人较多 | **不是你的问题**:同一 WiFi 有人多次试错 | 等 1 分钟 |
|
| 当前网络下尝试核验的人较多 | **不是你的问题**:同一 WiFi 有人多次试错 | 等 1 分钟 |
|
||||||
| 该信息已被登记 | 这个名册项已被另一个微信号绑定 | 联系班主任解绑或处理 |
|
| 该信息已被登记 | 这个名册项已被另一个微信号绑定 | 联系班主任解绑或处理 |
|
||||||
| 你已在名单中,请返回重新核验 | 你已经在名册里了,不用申请加入 | 返回上一步直接核验 |
|
|
||||||
| 你已提交过申请,请耐心等待 | 加入申请重复提交 | 等管理员审批 |
|
|
||||||
| 这份材料可能不是你的,请重新上传 | 系统发现材料上的证件号与你不一致 | 确认没传错人的材料,重传 |
|
| 这份材料可能不是你的,请重新上传 | 系统发现材料上的证件号与你不一致 | 确认没传错人的材料,重传 |
|
||||||
| 已进入审核,需先撤回才能删材料 | 你已提交,材料被锁 | 点「撤回修改」后再改 |
|
| 已进入审核,需先撤回才能删材料 | 你已提交,材料被锁 | 点「撤回修改」后再改 |
|
||||||
| 已通过终审,不可撤回 | 已经审完了 | 联系学校管理员 |
|
| 已通过终审,不可撤回 | 已经审完了 | 联系学校管理员 |
|
||||||
| 任务已截止、已归档或已暂停 | 这个任务不能再填了 | 联系班主任 |
|
| 任务已截止、已归档或已暂停 | 这个任务不能再填了 | 联系班主任 |
|
||||||
|
|
||||||
### 2.4 名册里没有我(申请加入名单)
|
### 2.4 名册里没有我,怎么办
|
||||||
|
|
||||||
1. 核验页点「**申请加入名单**」;
|
1. **先核对**姓名与身份证号是不是和老师给的一致(生僻字、空格、末位 X 大小写最常见);
|
||||||
2. 填姓名、身份证号、班级、联系电话,可附说明;
|
2. 核对无误后,**联系班主任**,请他在任务里把你加进名册
|
||||||
3. 管理员在管理端「**加入申请**」里审批;
|
(管理端「收集任务 → 名单管理 → 追加导入」);
|
||||||
4. 通过后你会收到消息,回到核验页重新核验即可。
|
3. 老师加好后,回到核验页**重新核验**即可填报。
|
||||||
- ⚠️ 申请**次数有上限**(`40037`),别反复提交;重复提交会提示「你已提交过申请,请耐心等待」。
|
|
||||||
|
> ⚠️ **系统里没有"申请加入名单"这个入口**(2026-10 需求收敛时已取消),
|
||||||
|
> 也**不会展示任何联系方式** —— 学生本来就在班级群里,找老师即可。
|
||||||
|
> 这样守住的仍是同一条规则:**没有名册,就不能填报**;只是"加人"这个动作留在老师手里。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,16 +167,29 @@
|
|||||||
- 通过后导出,产物保留 **7 天**,过期后需重新导出(会明确提示「导出包已过期(保留 7 天),请重新导出」)。
|
- 通过后导出,产物保留 **7 天**,过期后需重新导出(会明确提示「导出包已过期(保留 7 天),请重新导出」)。
|
||||||
- 几百人以上会自动**分卷**(`-part1.zip`、`-part2.zip`…),每一卷都是**完整可解**的压缩包。
|
- 几百人以上会自动**分卷**(`-part1.zip`、`-part2.zip`…),每一卷都是**完整可解**的压缩包。
|
||||||
|
|
||||||
### 4.4 历史整理器(把以前那堆乱文件夹变成能用的数据)
|
### 4.4 角色与授权(给不同的人不同的能力范围)
|
||||||
|
|
||||||
「配置 → **历史整理器**」→ 上传一个 zip:
|
「组织与账号 → **角色与授权**」→ 左边选一个角色,右边看/改它能做什么。
|
||||||
1. 填任务名;
|
|
||||||
2. **关联一个已有任务的名册**(⚠️ 这一步决定了报告的价值,见下);
|
|
||||||
3. 等它跑完 → 看报告。
|
|
||||||
|
|
||||||
报告里最该看的一句是「**N 个文件里,X 个系统全自动处理,只有 Y 个必须你亲自判断**」。
|
| 你想干的事 | 怎么做 |
|
||||||
- **有关联名册**才有「证件号校验」与「疑似串档检测」—— 而"疑似串档 1 例"是报告里最有价值的一条;
|
|---|---|
|
||||||
- 不关联也能跑,但只能做文件名规范化。
|
| 看看某个角色到底能做什么 | 点它 → 右边列出全部权限码与菜单(**内置角色**就在这一屏看,改不了) |
|
||||||
|
| 让某类人只能看任务、不能建任务 | **新建**一个自定义角色 → 勾「任务查看」(**不要**勾「创建与配置收集任务」)→ 再把它发给账号 |
|
||||||
|
| 让某个人只看得到自己带的班 | 勾带「本班/本人」字样的权限(如「名册修改(仅本班)」);**看全租户**的能力是「填报进度看板(全租户)」这类码 |
|
||||||
|
| 改完什么时候生效 | **立即生效**,不需要让人重新登录(保存后让他刷新一下页面即可) |
|
||||||
|
| 某个人不该再管账号了 | 账号管理里把他的角色换掉(或停用那个自定义角色) |
|
||||||
|
|
||||||
|
**三条要记住的规矩**:
|
||||||
|
|
||||||
|
1. **内置角色不能改**。它的权限写在后端代码里 —— 这是刻意的:一次误操作就能把管理员锁在系统外。
|
||||||
|
要不同的能力范围,请**新建一个自定义角色**(可以照着内置的那份矩阵勾)。
|
||||||
|
2. **「看得见」和「有权限」是两件事**。菜单只决定左边显示哪些入口;真正的权限是上面那一栏的权限码。
|
||||||
|
只给菜单不给权限,点进去仍然会被拦住(提示"无权限访问")。
|
||||||
|
3. **删角色前要先给账号换角色**。还有账号在用时删不掉,会提示「该角色还有 N 个账号在用,请先改掉这些账号的角色」。
|
||||||
|
|
||||||
|
> ⚠️ **「数据范围」(本人 / 本班 / 本院系 / 全租户)这一列目前只展示**:
|
||||||
|
> 范围由"拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,**不靠这一列**。
|
||||||
|
> 想让人看全租户,请给他勾「填报进度看板(全租户)」这类权限。
|
||||||
|
|
||||||
### 4.5 其它
|
### 4.5 其它
|
||||||
|
|
||||||
@@ -184,6 +198,7 @@
|
|||||||
| 加/停用账号、重置密码 | 组织与账号 → 账号管理 |
|
| 加/停用账号、重置密码 | 组织与账号 → 账号管理 |
|
||||||
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
| 改学校/院系/班级结构 | 组织与账号 → 组织架构(也支持 Excel 导入) |
|
||||||
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
| 加一个下拉选项(如多了种学历写法) | 配置 → 字典管理(**内置项不可改名**,要加就追加自定义项) |
|
||||||
|
| 给某类人配一套能力范围 | 组织与账号 → 角色与授权(见 §4.5) |
|
||||||
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
| 查"谁在什么时候看了证件号/导出了什么" | 平台运维 → 操作日志 |
|
||||||
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
| 查"通知到底发出去没有" | 收集管理 → 通知记录 |
|
||||||
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
| 看某个任务的分班进度 | 工作台 → 数据看板(选一个任务) |
|
||||||
@@ -195,7 +210,7 @@
|
|||||||
### 5.1 三条必须能讲清楚的产品规则
|
### 5.1 三条必须能讲清楚的产品规则
|
||||||
|
|
||||||
1. **必须先有名册**(门禁铁律)。名单外的人填不了 —— 这是为了防冒名与串档,
|
1. **必须先有名册**(门禁铁律)。名单外的人填不了 —— 这是为了防冒名与串档,
|
||||||
不是系统限制。名单外的人走「申请加入名单」。
|
不是系统限制。**名单外的人由老师手工加进名册**(系统内没有自助申请入口,也不展示联系方式)。
|
||||||
2. **材料是自动改名与自动压缩的**。学生传的文件名乱、体积大都不是问题;
|
2. **材料是自动改名与自动压缩的**。学生传的文件名乱、体积大都不是问题;
|
||||||
交付物是压缩后的"规范件"。
|
交付物是压缩后的"规范件"。
|
||||||
3. **驳回必须写原因**,原因会原样发给学生。所以客服看到"学生说不知道为什么被驳回",
|
3. **驳回必须写原因**,原因会原样发给学生。所以客服看到"学生说不知道为什么被驳回",
|
||||||
@@ -206,7 +221,7 @@
|
|||||||
**Q:学生说"扫码进去说找不到我的信息"**
|
**Q:学生说"扫码进去说找不到我的信息"**
|
||||||
A:这是名册门禁。请班主任核对名册上他的**姓名写法**与**身份证号**(常见是生僻字、
|
A:这是名册门禁。请班主任核对名册上他的**姓名写法**与**身份证号**(常见是生僻字、
|
||||||
空格、身份证号末位 X 大小写)。系统**故意不告诉**是姓名错还是号码错。
|
空格、身份证号末位 X 大小写)。系统**故意不告诉**是姓名错还是号码错。
|
||||||
如果确实不在名册里,让他点「申请加入名单」。
|
如果确实不在名册里,让班主任在任务的「名单管理」里把他加进去(学生端没有自助入口)。
|
||||||
|
|
||||||
**Q:学生说"提示操作过于频繁"**
|
**Q:学生说"提示操作过于频繁"**
|
||||||
A:分两种,看文案:
|
A:分两种,看文案:
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
-- ============ 1) 建表 ============
|
-- ============ 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 (
|
CREATE TABLE sys_tenant (
|
||||||
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
|||||||
Vendored
+127
-212
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>填报通 · 管理端(PC)交互原型</title>
|
<title>深学在线 · 管理端(PC)交互原型</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3; --ink4:#b3b9c1;
|
--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{padding:17px 17px 15px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:9px}
|
||||||
.brand .mark{
|
.brand .mark{
|
||||||
width:28px;height:28px;border-radius:7px;background:var(--ink);color:#fff;flex:none;
|
width:28px;height:28px;border-radius:7px;flex:none;
|
||||||
display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:15px;font-weight:600;
|
/* 深学在线 标识:方(卷宗)+ 环(在线)。与 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 .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}
|
.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">
|
<header class="masthead">
|
||||||
<div class="kicker">Prototype · 管理端 PC · V1.2</div>
|
<div class="kicker">Prototype · 管理端 PC · V1.2</div>
|
||||||
<h1>填报通 · <em>管理端</em>交互原型</h1>
|
<h1>深学在线 · <em>管理端</em>交互原型</h1>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
11 屏:<strong>工作台 → 任务向导(命名试跑) → 名单导入 → 报考范围 → 数据看板 → 终审 → 导出中心</strong>,加上 <strong>表单设计器 / 材料规则配置 / 历史数据整理器 / 名单加入申请审批</strong>。<br>
|
10 屏:<strong>工作台 → 任务向导(命名试跑) → 名单导入 → 报考范围 → 数据看板 → 终审 → 导出中心</strong>,加上 <strong>表单设计器 / 材料规则配置 / 角色与授权</strong>。<br>
|
||||||
移动端是「档案室」,桌面端往「<strong>台账</strong>」方向走——表格、树形、批量操作。信息密度比手机端高一个量级,因为管理员是坐在电脑前做批量决策的人。<br>
|
移动端是「档案室」,桌面端往「<strong>台账</strong>」方向走——表格、树形、批量操作。信息密度比手机端高一个量级,因为管理员是坐在电脑前做批量决策的人。<br>
|
||||||
<span style="font-size:12.5px;color:var(--ink3)">后三屏是**追溯矩阵审计后补的**:三者都是复杂交互,光靠文字描述开发会理解偏。</span>
|
<span style="font-size:12.5px;color:var(--ink3)">后三屏是**追溯矩阵审计后补的**:三者都是复杂交互,光靠文字描述开发会理解偏。</span>
|
||||||
</p>
|
</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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a class="on"><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a class="on"><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=7</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/roster/import</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/scope</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/1/dashboard</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/review?status=pending_final</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/export/new</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=4</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<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 class="url"><svg class="ico"><use href="#i-shield"/></svg>https://admin.tianbaotong.cn/tasks/new?step=5</div>
|
||||||
</div><div class="view">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<div class="grp">收集管理</div>
|
||||||
@@ -1254,240 +1258,151 @@ td.num{font-family:var(--font-mono);text-align:right}
|
|||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<!-- ⑩ 历史数据整理器 -->
|
<!-- ⑪ 角色与授权 -->
|
||||||
<figure>
|
<figure>
|
||||||
<div class="frame-wrap"><div class="origin">
|
<div class="frame-wrap"><div class="origin">
|
||||||
<div class="win"><div class="wbar">
|
<div class="win"><div class="wbar">
|
||||||
<div class="lights"><i></i><i></i><i></i></div>
|
<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">
|
</div><div class="view">
|
||||||
<aside class="side">
|
<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">
|
<nav class="nav">
|
||||||
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
<a><svg class="ico"><use href="#i-home"/></svg>工作台</a>
|
||||||
<div class="grp">收集管理</div>
|
<div class="grp">组织与账号</div>
|
||||||
<a><svg class="ico"><use href="#i-task"/></svg>收集任务</a>
|
<a><svg class="ico"><use href="#i-org"/></svg>组织架构</a>
|
||||||
<a><svg class="ico"><use href="#i-users"/></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 class="on"><svg class="ico"><use href="#i-lock"/></svg>角色与授权</a>
|
||||||
<a><svg class="ico"><use href="#i-chart"/></svg>数据看板</a>
|
<div class="grp">平台运维</div>
|
||||||
<a><svg class="ico"><use href="#i-download"/></svg>导出中心</a>
|
<a><svg class="ico"><use href="#i-list"/></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>
|
|
||||||
</nav>
|
</nav>
|
||||||
<div class="who"><div class="av">王</div><div><div class="wn">王老师</div><div class="wr">学校管理员</div></div></div>
|
<div class="who"><div class="av">王</div><div><div class="wn">王老师</div><div class="wr">学校管理员</div></div></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h2>名单加入申请</h2><span class="crumb">24中技数控1班 · 2026年9月认定</span>
|
<h2>角色与授权</h2><span class="crumb">江西省机电技师学院 · 4 个内置角色 + 1 个自定义</span>
|
||||||
<span class="sp"><button class="btn ghost sm">导出申请清单</button></span>
|
<span class="sp"><span class="hint">内置角色的权限由代码决定,不可修改</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="note info" style="margin-bottom:16px">
|
<div style="display:grid;grid-template-columns:236px 1fr;gap:16px;align-items:start">
|
||||||
<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:1fr 300px;gap:16px;align-items:start">
|
|
||||||
<div class="card" style="margin:0">
|
<div class="card" style="margin:0">
|
||||||
<header>待审申请<span class="sp"><span class="pill warn">5 条待处理</span></span></header>
|
<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:0">
|
<div class="body" style="padding:6px">
|
||||||
<table>
|
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||||
<thead><tr>
|
<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>
|
||||||
<th style="width:26px"><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></th>
|
</div>
|
||||||
<th>申请人</th><th>身份证号</th><th>手机号</th><th>自述班级 / 说明</th><th style="width:74px">等待</th><th style="width:132px">操作</th>
|
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||||
</tr></thead>
|
<span style="flex:1">学校管理员</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">1 人</span>
|
||||||
<tbody>
|
</div>
|
||||||
<tr>
|
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||||
<td><input type="checkbox" style="accent-color:var(--jade);width:14px;height:14px"></td>
|
<span style="flex:1">班主任</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">1 人</span>
|
||||||
<td><b>李四</b></td>
|
</div>
|
||||||
<td class="mono">361004********3318</td>
|
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px">
|
||||||
<td class="mono">138****8888</td>
|
<span style="flex:1">学生</span><span class="pill gray" style="font-size:10px">内置</span><span style="font-size:11px;color:var(--ink3)">11 人</span>
|
||||||
<td><span style="font-size:11.5px">24中技数控2班</span><br><span style="font-size:11px;color:var(--ink3)">我是这学期转到2班的</span></td>
|
</div>
|
||||||
<td class="mono">2.5h</td>
|
<div style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;font-size:12.5px;background:var(--jade-soft)">
|
||||||
<td><button class="btn jade sm">通过</button> <button class="btn ghost sm">驳回</button></td>
|
<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>
|
||||||
</tr>
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:14px">
|
<div style="display:flex;flex-direction:column;gap:14px">
|
||||||
<div class="card" style="margin:0">
|
<div class="card" style="margin:0">
|
||||||
<header style="font-size:12.5px;padding:11px 13px">批量处理</header>
|
<header>
|
||||||
<div class="body" style="padding:13px">
|
年级审核员 <span class="mono" style="font-weight:400;color:var(--ink3)">GRADE_AUDITOR</span>
|
||||||
<button class="btn jade" style="width:100%;justify-content:center;margin-bottom:9px">通过已选(2 条)</button>
|
<span class="sp">
|
||||||
<div class="note warn" style="padding:9px 11px;font-size:11.5px;line-height:1.65;margin-bottom:9px">
|
<button class="btn ghost sm">刷新</button>
|
||||||
<svg class="ico" style="font-size:13px"><use href="#i-alert"/></svg>
|
<button class="btn ghost sm">编辑</button>
|
||||||
<div><b>驳回必须逐条填原因</b>,不支持批量驳回</div>
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin:0">
|
<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="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="note info" style="margin-bottom:0">
|
||||||
<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>
|
<svg class="ico"><use href="#i-lock"/></svg>
|
||||||
<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><b>只读。</b>权限矩阵照着显示(可以拿它当模板照着勾),但勾选框是禁用的、没有「保存授权」按钮。<br>
|
||||||
<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>
|
<span style="font-size:11.5px;color:var(--ink3)">要不同的能力范围,请<b>新建一个自定义角色</b>——而不是去改内置的。</span></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></div></div></div>
|
</div></div></div></div>
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<span class="tag">STEP 11 · 客户确认新增</span>
|
<span class="tag">STEP 10 · 角色与授权</span>
|
||||||
<span class="title">名单加入申请:给门禁一条受控的出口,而不是开后门</span>
|
<span class="title">权限是契约、不是配置:内置角色只读,自定义角色可配</span>
|
||||||
门禁不通过的人原先只能"联系老师",老师再手工加名单。<b>几百人的社会收集场景下,老师会被拖垮</b>。这一屏把加人这件事从"老师手工"变成"学生自助 + 老师逐条裁决"。
|
内置角色现在只剩 4 个(平台运营 / 学校管理员 / 班主任 / 学生),权限写在后端代码里(<span class="mono">BuiltinRole</span>)、界面上<b>只读</b>。租户可另建自定义角色,于是"谁能做什么"第一次变成客户可以自己配的东西 —— 而这类功能一旦做松,代价是<b>管理员把自己锁在系统外</b>。
|
||||||
<span class="note">四个细节:<br>
|
<span class="note">五个细节:<br>
|
||||||
① <b>「他任务已有」标记</b>——王芳的证件号在「2026年3月认定」的名册里匹配上了,系统主动提示"这可能是本校学生",帮管理员判断;<br>
|
① <b>内置角色只读</b>——可以看、不能改。它的权限是前后端契约(权限码定义在代码里),改它等于改代码;界面上正确的做法是"照着它新建一个";<br>
|
||||||
② <b>「第 2 次申请」标记</b>——孙悦上次被驳回,管理员能看到上次的驳回原因,避免重复纠结;<br>
|
② <b>「看得见」与「有权限」分开</b>——菜单只决定左侧显示哪些入口,真正的访问控制是权限码校验(绕过前端只会拿到 40301);<br>
|
||||||
③ <b>「驳回必须逐条填原因」</b>且批量通过不提供全选——身份认定环节的误操作代价极高,与"批量通过只带绿色项"同源;<br>
|
③ <b>平台级权限与平台专用菜单在界面上禁用</b>,服务端也会以 40301 拒绝——"学校管理员给自己人造一个平台账号"是这套系统里最不可逆的越权;<br>
|
||||||
④ 顶部那句<b>「申请本身不能填报」</b>是设计边界:通道只解决"进名单",不绕过门禁。</span>
|
④ <b>改完立即生效</b>——服务端在写授权时清掉该角色的缓存,所以不必"重新登录看看生效没有";停用角色同理(立刻不再给权限,而不是等 token 过期);<br>
|
||||||
|
⑤ <b>防自锁</b>——不允许去掉自己进入本页所依赖的最后一条「角色与授权」权限/菜单,也不允许删掉唯一的管理员角色;这类错误只能靠 SQL 救回来。</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -1529,8 +1444,8 @@ td.num{font-family:var(--font-mono);text-align:right}
|
|||||||
如果下拉里只写"P1 / P2 / P3",管理员必须去翻文档才知道选哪个。**把档位的适用条件直接写在选项里**,配置界面本身就变成说明书。
|
如果下拉里只写"P1 / P2 / P3",管理员必须去翻文档才知道选哪个。**把档位的适用条件直接写在选项里**,配置界面本身就变成说明书。
|
||||||
</div>
|
</div>
|
||||||
<div class="aside-card">
|
<div class="aside-card">
|
||||||
<h4><span class="n">09</span>为什么整理器是获客利器</h4>
|
<h4><span class="n">09</span>为什么内置角色只读、自定义角色才能改</h4>
|
||||||
客户手里普遍已有历史乱数据(本例 436MB 的 rar)。让他们**先看到价值再谈上线**:不重新收集,30 秒内出一份体检报告和规范包。报告里"<b>90 个系统全自动处理,只有 1 个必须你亲自判断</b>"这句话,比任何功能列表都有说服力。
|
内置角色的权限<b>定义在后端代码里</b>(权限码是前后端契约:后端用它校验、前端用它控制按钮显隐)。把它做成"界面上可改",等于把一份契约拆成两处维护——而两处一旦不一致,表现是"按钮显示出来了、点了报 40301"。更要紧的是:一次误操作就能把管理员锁在系统外,而这类错误<b>不可逆</b>。所以界面上只读,需要别的能力范围时<b>新建一个自定义角色</b>。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Vendored
+5
-7
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>填报通 · 原型索引</title>
|
<title>深学在线 · 原型索引</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
||||||
@@ -68,7 +68,7 @@ a.card:hover .go{color:var(--seal)}
|
|||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="kicker">Prototype Index · V1.0</div>
|
<div class="kicker">Prototype Index · V1.0</div>
|
||||||
<h1>填报通 · <em>交互原型</em></h1>
|
<h1>深学在线 · <em>交互原型</em></h1>
|
||||||
<p>职业技能等级认定报名材料收集系统 · 两端原型<br>设计语言:「档案室 / 电子卷宗」</p>
|
<p>职业技能等级认定报名材料收集系统 · 两端原型<br>设计语言:「档案室 / 电子卷宗」</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,10 +76,9 @@ a.card:hover .go{color:var(--seal)}
|
|||||||
<a class="card" href="student-app.html">
|
<a class="card" href="student-app.html">
|
||||||
<div class="ic"><svg class="ico"><use href="#i-user"/></svg></div>
|
<div class="ic"><svg class="ico"><use href="#i-user"/></svg></div>
|
||||||
<h2>学生填报端</h2>
|
<h2>学生填报端</h2>
|
||||||
<div class="meta">11 SCREENS</div>
|
<div class="meta">10 SCREENS</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>门禁核验 · 形近字确认 · 统一失败提示</li>
|
<li>门禁核验 · 形近字确认 · 统一失败提示</li>
|
||||||
<li><b>申请加入名单</b>(门禁失败后的受控出口)</li>
|
|
||||||
<li>填报主页(字段三类规则:锁定/可改/材料)</li>
|
<li>填报主页(字段三类规则:锁定/可改/材料)</li>
|
||||||
<li>材料来源三通道(聊天记录/相册/文件,无拍照)</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">
|
<a class="card adm" href="admin-web.html">
|
||||||
<div class="ic"><svg class="ico"><use href="#i-desktop"/></svg></div>
|
<div class="ic"><svg class="ico"><use href="#i-desktop"/></svg></div>
|
||||||
<h2>管理端(PC)</h2>
|
<h2>管理端(PC)</h2>
|
||||||
<div class="meta">11 SCREENS · 1180px</div>
|
<div class="meta">10 SCREENS · 1180px</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>工作台(结论优先 + 待办排序)</li>
|
<li>工作台(结论优先 + 待办排序)</li>
|
||||||
<li><b>命名规则试跑</b>——发布前的唯一闸门</li>
|
<li><b>命名规则试跑</b>——发布前的唯一闸门</li>
|
||||||
<li>名单导入(表头定位可视化)</li>
|
<li>名单导入(表头定位可视化)</li>
|
||||||
<li>报考范围确认(聚合 + 一致性体检)</li>
|
<li>报考范围确认(聚合 + 一致性体检)</li>
|
||||||
<li>数据看板 · 终审 · 导出中心(目录树预览)</li>
|
<li>数据看板 · 终审 · 导出中心(目录树预览)</li>
|
||||||
<li><b>表单设计器 · 材料规则配置 · 历史数据整理器</b></li>
|
<li><b>表单设计器 · 材料规则配置 · 角色与授权</b></li>
|
||||||
<li><b>名单加入申请审批</b>(客户确认新增)</li>
|
|
||||||
<li>支持 100% / 82% / 66% 缩放查看</li>
|
<li>支持 100% / 82% / 66% 缩放查看</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="go">打开原型 <svg class="ico"><use href="#i-arrow"/></svg></div>
|
<div class="go">打开原型 <svg class="ico"><use href="#i-arrow"/></svg></div>
|
||||||
|
|||||||
Vendored
+7
-76
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>填报通 · 学生端交互原型</title>
|
<title>深学在线 · 学生端交互原型</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
--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.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: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}
|
.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{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{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}
|
.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">
|
<header class="masthead">
|
||||||
<div class="kicker">Prototype · 学生端 · V1.2</div>
|
<div class="kicker">Prototype · 学生端 · V1.2</div>
|
||||||
<h1>填报通 · <em>学生填报端</em>交互原型</h1>
|
<h1>深学在线 · <em>学生填报端</em>交互原型</h1>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
11 屏完整流程:<strong>门禁核验 → 信息核对 → 选材上传 → 自动规范化 → 提交审核</strong>。<br>
|
10 屏完整流程:<strong>门禁核验 → 信息核对 → 选材上传 → 自动规范化 → 提交审核</strong>。<br>
|
||||||
<span style="font-size:12.5px;color:var(--ink3)">新增 STEP 03B「申请加入名单」——门禁失败后的受控出口,客户确认后加入。</span><br>
|
<span style="font-size:12.5px;color:var(--ink3)">门禁失败只有「重新填写」一条系统内出路;确实不在名单里时由老师手工加人(系统内不展示联系方式)。</span><br>
|
||||||
设计方向为「档案室 / 电子卷宗」——本系统替代的是纸质材料袋与红章审批,界面应让人感到<strong>有秩序、可信、像一份正经公文</strong>。<br>
|
设计方向为「档案室 / 电子卷宗」——本系统替代的是纸质材料袋与红章审批,界面应让人感到<strong>有秩序、可信、像一份正经公文</strong>。<br>
|
||||||
图标为内联 SVG 线性图标(统一 1.7px 描边、继承文字色),不用 emoji——避免跨平台渲染不一致,也避免消费级观感。<br>
|
图标为内联 SVG 线性图标(统一 1.7px 描边、继承文字色),不用 emoji——避免跨平台渲染不一致,也避免消费级观感。<br>
|
||||||
全部文案遵循「零术语 · 指出位置 · 给出动作」三原则;数据取自真实批次 <code>24中技数控1班</code> 的 28 名学生。
|
全部文案遵循「零术语 · 指出位置 · 给出动作」三原则;数据取自真实批次 <code>24中技数控1班</code> 的 28 名学生。
|
||||||
@@ -446,7 +441,7 @@ figcaption .tag{
|
|||||||
<div class="pagehead">
|
<div class="pagehead">
|
||||||
<div class="glyph gray"><svg class="ico xl"><use href="#i-search"/></svg></div>
|
<div class="glyph gray"><svg class="ico xl"><use href="#i-search"/></svg></div>
|
||||||
<h2>没有找到你的信息</h2>
|
<h2>没有找到你的信息</h2>
|
||||||
<p>请核对姓名和身份证号是否与老师提供的名单一致。<br>如果确实已报名,可以申请加入名单,由老师审核。</p>
|
<p>请核对姓名和身份证号是否与老师提供的名单一致。<br>如确认已报名,请联系老师把你加入名单。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -461,10 +456,6 @@ figcaption .tag{
|
|||||||
</div>
|
</div>
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<button class="btn primary" style="width:100%;margin-bottom:13px">重新填写</button>
|
<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 class="center-note">连续 10 次未匹配将暂时锁定,1 小时后可重试</div>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
@@ -473,73 +464,13 @@ figcaption .tag{
|
|||||||
<b>门禁失败(统一提示)</b>
|
<b>门禁失败(统一提示)</b>
|
||||||
<span style="color:var(--seal)">故意不区分失败原因</span>——无论"号码不在名单"还是"姓名不符",文案完全一致。<br>
|
<span style="color:var(--seal)">故意不区分失败原因</span>——无论"号码不在名单"还是"姓名不符",文案完全一致。<br>
|
||||||
否则这个接口就能被用来<b style="display:inline">逐个探测身份证号是否在名单里</b>。这一屏是安全设计,不是文案设计。
|
否则这个接口就能被用来<b style="display:inline">逐个探测身份证号是否在名单里</b>。这一屏是安全设计,不是文案设计。
|
||||||
<span class="note">底部只有两条出路,都是<b>系统内的动作</b>:<br>
|
<span class="note">底部只有一条<b>系统内的出路</b>:<br>
|
||||||
① <b>重新填写</b>(主路径)——大多数情况只是名字打错了;<br>
|
① <b>重新填写</b>(主路径)——大多数情况只是名字打错了;<br>
|
||||||
② <b>申请加入名单</b>(FR-509)——确实不在名单里时自助申请,由管理员逐条审批。<br>
|
② 确实不在名单里时,<b>请联系老师把你加入名单</b>(由老师在任务里手工加人)。<br>
|
||||||
<b>系统内不展示任何联系方式</b>——学生在班级群里本来就有班主任的联系方式,系统再放一份既不必要,也带来维护与隐私成本。</span>
|
<b>系统内不展示任何联系方式</b>——学生在班级群里本来就有班主任的联系方式,系统再放一份既不必要,也带来维护与隐私成本。</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</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>
|
<figure>
|
||||||
<div class="device"><div class="screen">
|
<div class="device"><div class="screen">
|
||||||
|
|||||||
Vendored
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>填报通 · 班主任端交互原型</title>
|
<title>深学在线 · 班主任端交互原型</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
--ink:#1b2430; --ink2:#4a5768; --ink3:#8b95a3;
|
||||||
@@ -338,7 +338,7 @@ figcaption .tag{display:inline-block;font-family:var(--font-mono);font-size:10px
|
|||||||
|
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<div class="kicker">Prototype · 班主任端 · V1.1</div>
|
<div class="kicker">Prototype · 班主任端 · V1.1</div>
|
||||||
<h1>填报通 · <em>班主任端</em>交互原型</h1>
|
<h1>深学在线 · <em>班主任端</em>交互原型</h1>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
9 屏:<strong>花名册看板 → 待审列表 → 学生详情 → 批量通过 / 右滑通过 / 驳回 → 催报 → 消息 → 代传</strong>。<br>
|
9 屏:<strong>花名册看板 → 待审列表 → 学生详情 → 批量通过 / 右滑通过 / 驳回 → 催报 → 消息 → 代传</strong>。<br>
|
||||||
视觉隐喻为「<strong>电子花名册</strong>」——把全班排成网格,一眼扫完;与学生端共用「档案室 / 电子卷宗」的设计语言。
|
视觉隐喻为「<strong>电子花名册</strong>」——把全班排成网格,一眼扫完;与学生端共用「档案室 / 电子卷宗」的设计语言。
|
||||||
|
|||||||
+434
-29
@@ -13,25 +13,26 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一·二、一期范围(2026-09-28 客户确认收窄)
|
## 一·二、一期范围(2026-09-28 收窄 → 2026-10 收敛为最终口径)
|
||||||
|
|
||||||
> **一期只做「学校收集 + 自主认定」一条流程。** 详见 PRD「一期范围声明」。
|
> **只做「学校收集 + 自主认定」一条流程**,且**不再设计任何二期功能**(2026-10 收敛,见 §一·三十六)。详见 PRD「一期范围声明」。
|
||||||
|
|
||||||
| 维度 | 字段 | 一期 | 二期后置 |
|
| 维度 | 字段 | 本期做 | **不做**(已取消,不是"后置") |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 收集模式 | `collect_mode` | `1` 学校收集 | `2` 社会收集 |
|
| 收集模式 | `collect_mode` | `1` 学校收集 | `2` 社会收集(传 2 → `40044`) |
|
||||||
| 认定类型 | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力 |
|
| 认定类型 | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力(传 2/3 → `40044`) |
|
||||||
|
|
||||||
**一期材料只有 4 类,全部有实测样本**:一寸照片 / 身份证 / 学籍表 / 工作证明
|
**材料只有 4 类,全部有实测样本**:一寸照片 / 身份证 / 学籍表 / 工作证明。
|
||||||
**二期才做的 2 类材料零样本**:`XJW_BA` 学信网备案表 / `OTHER_PROOF` 其它佐证材料
|
`XJW_BA` 学信网备案表 与 `OTHER_PROOF` 其它佐证材料 **读得到、选不到**(不在"新增材料"列表里,历史数据仍可读);
|
||||||
|
内置材料模板只剩 `TPL_SCHOOL_ROLL` 一个。
|
||||||
|
|
||||||
**数据结构保留不启用**(二期直接开,不需 DB 迁移)——因为这三项本来就是**配置驱动**的
|
**数据结构保留不启用**:这三项本来就是**配置驱动**的(`needClassAudit` 是开关、字段隐藏走 `visibleWhen`、
|
||||||
(`needClassAudit` 是开关、字段隐藏走 `visibleWhen`、材料差异是材料项配置),
|
材料差异是材料项配置),所以留着不增加维护成本;但**没有"二期开放"的计划** —— 将来要用,按新需求重新评估。
|
||||||
所以**省下的主要是测试成本与一个外部依赖,而不是代码量**。
|
|
||||||
|
|
||||||
**工期**:392 → **387 人日**(−5);**关键路径不变,仍 15 周**。
|
**工期**:392 → **387 人日**(−5);**关键路径不变,仍 15 周**。
|
||||||
|
|
||||||
> ⚠️ **二期启动前必做**:索取社会评价样本(D4)→ 标定 `XJW_BA`/`OTHER_PROOF` → 补社会收集审核流与专项职业能力字段隐藏。
|
> ⚠️ **原「二期启动前必做」清单(索取社会评价样本 / 标定 `XJW_BA`·`OTHER_PROOF` / 补社会收集审核流)已随二期取消而作废**。
|
||||||
|
> 与「二期」有关的一切表述在本仓库一律读作「**不做**」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
|
|
||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + **Gi Admin Pro(Arco Design Vue 2.58)**;小程序: uni-app |
|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: **纯 Vue 3 + Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭,见 §一·三十六);小程序: uni-app |
|
||||||
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
|
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
|
||||||
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
|
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
|
||||||
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
|
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
| **M1-03 表单 schema 版本化** | ✅ `GET/PUT /tasks/{id}/form-schema`(版本只增不改 + `baseVersion` 乐观锁 40025 + 内置字段不可删 40026)+ 官方 21 项内置字段目录;**`e2e-m1-form.py` → 48/48** |
|
| **M1-03 表单 schema 版本化** | ✅ `GET/PUT /tasks/{id}/form-schema`(版本只增不改 + `baseVersion` 乐观锁 40025 + 内置字段不可删 40026)+ 官方 21 项内置字段目录;**`e2e-m1-form.py` → 48/48** |
|
||||||
| **M1-06 填报二维码** | ✅ `GET /tasks/{id}/qrcode`(真调微信生成小程序码)+ 匿名图片接口 `GET /qrcodes/{scene}`;**`e2e-m1-qrcode.py` → 32/32(唯一打外网的 E2E)** |
|
| **M1-06 填报二维码** | ✅ `GET /tasks/{id}/qrcode`(真调微信生成小程序码)+ 匿名图片接口 `GET /qrcodes/{scene}`;**`e2e-m1-qrcode.py` → 32/32(唯一打外网的 E2E)** |
|
||||||
| **小程序端骨架 + 扫码落地页** | ✅ `frontend-mp/`(uni-app,appid `wxf01d3fa60f87f0b7`)+ 匿名 `GET /mp/scene-context`;**已在微信开发者工具里跑通**;`e2e-m1-scene.py` → 31/31 |
|
| **小程序端骨架 + 扫码落地页** | ✅ `frontend-mp/`(uni-app,appid `wxf01d3fa60f87f0b7`)+ 匿名 `GET /mp/scene-context`;**已在微信开发者工具里跑通**;`e2e-m1-scene.py` → 31/31 |
|
||||||
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
|
| PC 前端 | ✅ **纯 Vue 3 + Naive UI**(原 Gi Admin Pro + Arco 已于 2026-10 重搭移除);已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / 收集任务(含配置班级 · 名单 · 表单 · 材料四个抽屉) / 审核 / 导出中心 / 通知记录 / 字典管理 / 角色与授权 / 操作日志 |
|
||||||
| 待做 | M1 剩余(M1-05 规则模板 + **M1-07~M1-10 前端**);**M3 小程序填报主流程**(`/mp/login` 门禁核验 表单);CI/CD;**C1 小程序类目资质(阻塞学生真正开始填报)** |
|
| 待做 | M1 剩余(M1-05 规则模板 + **M1-07~M1-10 前端**);**M3 小程序填报主流程**(`/mp/login` 门禁核验 表单);CI/CD;**C1 小程序类目资质(阻塞学生真正开始填报)** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -629,22 +630,25 @@ Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
|
|||||||
|
|
||||||
| 文件 | 版本/大小 | 状态 |
|
| 文件 | 版本/大小 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `docs/数据信息填报系统PRD.md` | V2.4 / 189KB / 3019 行 | ✅ 含 **125 条 FR**、**21 条 VAL**、**121 个接口**、**20 表 DDL**、实测附录 |
|
| `docs/数据信息填报系统PRD.md` | V2.5 / — | ✅ 含 **125 条 FR**、**21 条 VAL**、**119 个接口**、**20 表 DDL**、实测附录 |
|
||||||
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
|
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V2.0 / — | ✅ 纯功能逻辑,**不含库表/接口/代码** |
|
||||||
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
|
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
|
||||||
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
|
| `docs/prototype/student-app.html` | **10 屏** | ✅ 门禁核验 → 填报 → 规范化 → 提交审核(STEP 03B「申请加入名单」已随需求收敛删除) |
|
||||||
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
|
| `docs/prototype/teacher-app.html` | 9 屏 | ✅ |
|
||||||
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
|
| `docs/prototype/admin-web.html` | **10 屏** | ✅ 含表单设计器、材料规则配置、**角色与授权**(整理器与申请审批两屏已随需求收敛删除) |
|
||||||
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
|
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
|
||||||
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
|
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
|
||||||
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
|
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(⚠️ 其中 B1「加入申请」已于 2026-10 需求回退取消,见 §一·三十六) |
|
||||||
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
|
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
|
||||||
| `docs/03-接口设计.md` | **115KB / 2883 行** | ✅ **P3 121 个接口**字段级定义 + 枚举全集 + 12 个 VO + **58 个错误码**;§4 逐节声称数与表行数一致 |
|
| `docs/03-接口设计.md` | — | ✅ **119 个接口**字段级定义 + 枚举全集 + 12 个 VO + **54 个错误码**;§4 逐节声称数与表行数一致 |
|
||||||
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
|
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
|
||||||
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
|
| `../README.md` | — | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
|
||||||
| `docs/04-核心模块设计说明.md` | **69KB / 1453 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
|
| `docs/04-核心模块设计说明.md` | — | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
|
||||||
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
|
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
|
||||||
|
|
||||||
|
> **合计屏数 : 29**(学生端 10 + 班主任端 9 + 管理端 10)—— `scripts/verify-docs.py` 会拿这一行与三个 html 的
|
||||||
|
> `<figcaption>` 计数核对,**改原型必须同步这一行**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、待办计划
|
## 四、待办计划
|
||||||
@@ -664,7 +668,7 @@ Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
|
|||||||
|
|
||||||
```
|
```
|
||||||
✅ I1 报考范围读取/确认 GET/PUT /api/v1/tasks/{id}/plan-scope
|
✅ I1 报考范围读取/确认 GET/PUT /api/v1/tasks/{id}/plan-scope
|
||||||
✅ I2 历史数据整理器 POST /api/v1/cleanup/jobs(共 5 个)
|
❌ I2 历史数据整理器 ~~POST /api/v1/cleanup/jobs(共 5 个)~~ → **已随 2026-10 需求收敛删除**
|
||||||
✅ I3 字典管理 /api/v1/dicts(4 个)
|
✅ I3 字典管理 /api/v1/dicts(4 个)
|
||||||
✅ I4 操作日志 GET /api/v1/logs/operations
|
✅ I4 操作日志 GET /api/v1/logs/operations
|
||||||
✅ I5 登录日志 GET /api/v1/logs/logins
|
✅ I5 登录日志 GET /api/v1/logs/logins
|
||||||
@@ -2265,9 +2269,11 @@ E2E 一开始给材料传了张 900×1200 的小图 —— 而**身份证材料
|
|||||||
|
|
||||||
### 现在的坐标(都可当场核对)
|
### 现在的坐标(都可当场核对)
|
||||||
|
|
||||||
|
> ⚠️ **下表是 2026-09-30 的快照**。**最新数字见 §一·三十六**(接口 **119** / 错误码 **54** / 权限码 **28** / 内置角色 **4** / 原型 **29 屏**)。
|
||||||
|
|
||||||
| 项 | 现在 | 怎么核对 |
|
| 项 | 现在 | 怎么核对 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 契约接口实现度 | **121 个里 120 个**(99%),唯一缺口 `/mp/ocr/idcard` | `python scripts/check-api-parity.py --missing` |
|
| 契约接口实现度 | **129 个里 128 个**(99%),唯一缺口 `/mp/ocr/idcard` | `python scripts/check-api-parity.py --missing` |
|
||||||
| 数据库迁移 | **Flyway V17**(不是 1~6) | `docker exec xt-pg psql -U fill -d filldb -c "select version,description from flyway_schema_history order by installed_rank desc limit 3"` |
|
| 数据库迁移 | **Flyway V17**(不是 1~6) | `docker exec xt-pg psql -U fill -d filldb -c "select version,description from flyway_schema_history order by installed_rank desc limit 3"` |
|
||||||
| 实体 / 表 | **27 个实体 / 33 张表**一致 | `python scripts/check-entity-columns.py` |
|
| 实体 / 表 | **27 个实体 / 33 张表**一致 | `python scripts/check-entity-columns.py` |
|
||||||
| E2E | **36 个脚本 / 1548 条断言 / 0 失败** | 依次跑 `scripts/e2e-*.py`(或看 §三 的说明) |
|
| E2E | **36 个脚本 / 1548 条断言 / 0 失败** | 依次跑 `scripts/e2e-*.py`(或看 §三 的说明) |
|
||||||
@@ -2322,7 +2328,9 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
| 菜单 | 真相 |
|
| 菜单 | 真相 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 名单管理 / 材料规则 / 表单设计器 | ✅ **功能早就做好了**,它们是**按任务**的,入口在「收集任务」每一行的链接里。「待建」页现在会明确指路并给跳转按钮 |
|
| 名单管理 / 材料规则 / 表单设计器 | ✅ **功能早就做好了**,它们是**按任务**的,入口在「收集任务」每一行的链接里。「待建」页现在会明确指路并给跳转按钮 |
|
||||||
| 角色与授权 | ⏸ **二期范围**(内置角色的权限由代码决定、不可改) |
|
| 角色与授权 | ✅ **已上线**(2026-10-09;原列在「二期」,2026-10 收敛后按**一期功能**计):内置角色只读 + 自定义角色的权限码 / 菜单可配,页面 `frontend-pc/src/views/role/`,接口见 `docs/03` §4.12 / §5.10。**「数据范围可配」仍是下一批** |
|
||||||
|
|
||||||
|
> ⇒ 至此 **`ready:false` 的菜单为 0**,「待建」页只作为"新增菜单但页面还没排到"的兜底保留。
|
||||||
|
|
||||||
### 卡在哪(**全部是外部依赖**,每一条都指向确认函的具体编号)
|
### 卡在哪(**全部是外部依赖**,每一条都指向确认函的具体编号)
|
||||||
|
|
||||||
@@ -2332,7 +2340,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
| 小程序类目资质 | 法务/商务 | `docs/01` **C1** |
|
| 小程序类目资质 | 法务/商务 | `docs/01` **C1** |
|
||||||
| `union_id`(H5 与小程序不能同时上线) | 需微信开放平台账号并绑定 | `docs/01` **E1**(第二轮新增) |
|
| `union_id`(H5 与小程序不能同时上线) | 需微信开放平台账号并绑定 | `docs/01` **E1**(第二轮新增) |
|
||||||
| 真机验证(压缩与体积上限) | 需真机 | `docs/01` **D6** |
|
| 真机验证(压缩与体积上限) | 需真机 | `docs/01` **D6** |
|
||||||
| 二期启动时点 | 客户 | `docs/01` **E3** |
|
| ~~二期启动时点~~ | — | **已取消**(不再设计任何二期功能,见 §一·三十六) |
|
||||||
|
|
||||||
⚠️ **不要在没有这些输入的情况下去"顺手实现"**:M4 的识别链路一旦按某家供应商写死,
|
⚠️ **不要在没有这些输入的情况下去"顺手实现"**:M4 的识别链路一旦按某家供应商写死,
|
||||||
换供应商要重写;`union_id` 没拿到之前,"按 union_id 认人"也无从下手
|
换供应商要重写;`union_id` 没拿到之前,"按 union_id 认人"也无从下手
|
||||||
@@ -2504,12 +2512,12 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
| # | 问题一句话 | 修法 |
|
| # | 问题一句话 | 修法 |
|
||||||
|:--:|---|---|
|
|:--:|---|---|
|
||||||
| **F-101** | **FR-1002「通知模板可配置」只兑现了手工发送那一半** —— 驳回/通过/催报这些**自动**通知把文案硬编码在 Java 里,而 FR-1002 点名的 5 类**全是自动类** | 新增 `NotifyTextResolver`(单向依赖,避开循环注入):**库里改过的 > 内置**,与模板列表页同一份口径;`NotifyRequest` 加 `subjectCode/subjectVars/extraText`;**模板停用 = 这类通知不发** |
|
| **F-101** | **FR-1002「通知模板可配置」只兑现了手工发送那一半** —— 驳回/通过/催报这些**自动**通知把文案硬编码在 Java 里,而 FR-1002 点名的 5 类**全是自动类** | 新增 `NotifyTextResolver`(单向依赖,避开循环注入):**库里改过的 > 内置**,与模板列表页同一份口径;`NotifyRequest` 加 `subjectCode/subjectVars/extraText`;**模板停用 = 这类通知不发** |
|
||||||
| **F-102** | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现",而渠道早做好了) | 提交 → 通知该审批的人(班主任/副班主任 + 学校管理员兜底);通过/驳回 → 通知学生(含原因) |
|
| **F-102** | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现",而渠道早做好了) | 提交 → 通知该审批的人(班主任 + 学校管理员兜底);通过/驳回 → 通知学生(含原因)。⚠️ **该模块已于 2026-10 需求收敛时整体删除,本条随之关闭** |
|
||||||
| **F-103** | 导出完成/失败不通知(异步打包,用户只能守进度条) | 通知发起人;⚠️ **正文不放签名 URL**(站内信落库,链接几十分钟就失效)—— 内置模板里原本写着 `{下载链接}`,一并改掉 |
|
| **F-103** | 导出完成/失败不通知(异步打包,用户只能守进度条) | 通知发起人;⚠️ **正文不放签名 URL**(站内信落库,链接几十分钟就失效)—— 内置模板里原本写着 `{下载链接}`,一并改掉 |
|
||||||
| **F-104** | ⚠️⚠️ **自动催报从来没发出过任何一条**:`end_time = date::date` 与库里的 `23:59:59` **永不相等** | 改 `end_time::date = #{date}::date`;并补 FR-1003 原文的**「+ 截止当日」**(0 天,文案与"最后一天"分开) |
|
| **F-104** | ⚠️⚠️ **自动催报从来没发出过任何一条**:`end_time = date::date` 与库里的 `23:59:59` **永不相等** | 改 `end_time::date = #{date}::date`;并补 FR-1003 原文的**「+ 截止当日」**(0 天,文案与"最后一天"分开) |
|
||||||
| **F-105** | 驳回达上限只写一行日志,而契约说"**同时通知管理员**" | 新增 `AUDIT_ESCALATE` 模板 + 发给本租户学校管理员(按角色找,不写死账号) |
|
| **F-105** | 驳回达上限只写一行日志,而契约说"**同时通知管理员**" | 新增 `AUDIT_ESCALATE` 模板 + 发给本租户学校管理员(按角色找,不写死账号) |
|
||||||
| **F-106** | 字典管理 / 通知记录**没有任何角色持有菜单键** → 页面做得好也点不到(**手册还写着"去那里办"**) | 给 `SCHOOL_ADMIN` 补 `dict` / `notify` |
|
| **F-106** | 字典管理 / 通知记录**没有任何角色持有菜单键** → 页面做得好也点不到(**手册还写着"去那里办"**) | 给 `SCHOOL_ADMIN` 补 `dict` / `notify` |
|
||||||
| **F-107** | 10 个权限码没挂到任何接口(克隆/表单/材料/报考范围/整理器/租户…) | 6 个改挂自己的码;4 个范围类/二期开关进例外表 |
|
| **F-107** | 10 个权限码没挂到任何接口(克隆/表单/材料/报考范围/整理器/租户…) | 6 个改挂自己的码;4 个范围类/开关进例外表。⚠️ 其中「整理器」一项已随模块删除而消失(2026-10) |
|
||||||
| **F-108** | `TASK_PUBLISH` / `TASK_DEADLINE` 零发送点 | **不做**(发布时刻没有收件人:学生要先扫码才绑账号,而发布前没人会扫)→ 登记例外 + 理由 |
|
| **F-108** | `TASK_PUBLISH` / `TASK_DEADLINE` 零发送点 | **不做**(发布时刻没有收件人:学生要先扫码才绑账号,而发布前没人会扫)→ 登记例外 + 理由 |
|
||||||
| **F-109** | `Map.of(...)` 不接受 null → 拼模板变量抛 NPE → 被"通知失败不影响业务"吞成一行 warn(**驳回通知全丢而驳回照样成功**) | 新增 `NotifyRequest.vars(...)`(null 跳过);姓名优先取**名册**上的名字 |
|
| **F-109** | `Map.of(...)` 不接受 null → 拼模板变量抛 NPE → 被"通知失败不影响业务"吞成一行 warn(**驳回通知全丢而驳回照样成功**) | 新增 `NotifyRequest.vars(...)`(null 跳过);姓名优先取**名册**上的名字 |
|
||||||
| **F-110** | schedule 的断言数的是**全库**「审核超时」消息 → 几天后因别的老数据自己变红(代码一行没改) | 断言**限定到本次夹具**(跨租户扫描类功能的断言必须这么做) |
|
| **F-110** | schedule 的断言数的是**全库**「审核超时」消息 → 几天后因别的老数据自己变红(代码一行没改) | 断言**限定到本次夹具**(跨租户扫描类功能的断言必须这么做) |
|
||||||
@@ -2553,7 +2561,7 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
`material:manage` / `roster:*` 照旧,删的只是菜单键(可见性)。
|
`material:manage` / `roster:*` 照旧,删的只是菜单键(可见性)。
|
||||||
- PC 端 `XT_MENUS` 删掉三条;**顺带删掉 `entryAt` 机制**("功能已就绪 + 跳转按钮"
|
- PC 端 `XT_MENUS` 删掉三条;**顺带删掉 `entryAt` 机制**("功能已就绪 + 跳转按钮"
|
||||||
那段)—— 它当初就是为这三条写的,留着就是一段永远走不到的代码。
|
那段)—— 它当初就是为这三条写的,留着就是一段永远走不到的代码。
|
||||||
「待建」页现在只服务「角色与授权」(二期)。
|
「待建」页现在只服务「角色与授权」(二期)——**该页已于 2026-10-09 上线,见 §一·三十四**。
|
||||||
- 班主任/副班主任/审核员的菜单也一起去掉了 `roster`:他们本来也点不进去(没有 `roster` 页)。
|
- 班主任/副班主任/审核员的菜单也一起去掉了 `roster`:他们本来也点不进去(没有 `roster` 页)。
|
||||||
|
|
||||||
### 二、班主任点工作台必弹 403 —— 前端调了"自己没有权限"的接口
|
### 二、班主任点工作台必弹 403 —— 前端调了"自己没有权限"的接口
|
||||||
@@ -2778,4 +2786,401 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
|||||||
(实测分页器顶边 1612 / 886,视口高 900)。修法是给这两页的卡片加同一个 `.g-list-card`
|
(实测分页器顶边 1612 / 886,视口高 900)。修法是给这两页的卡片加同一个 `.g-list-card`
|
||||||
并把表格改成 `y: '100%'`,本轮没动(客户只报了操作日志那一处)。
|
并把表格改成 `y: '100%'`,本轮没动(客户只报了操作日志那一处)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十五 二期「角色与授权」上线(2026-10-09 晚)
|
||||||
|
|
||||||
|
> 起点:客户在页面上看到「自定义角色属二期」,问"系统一共有哪些功能排在二期还没做的"。
|
||||||
|
> 盘完的结论是二期分三档(**已拍板后置**的三种任务类型 / **代码里显式留的口子** / **建议二期待拍板**),
|
||||||
|
> 而「角色与授权」只是其中**最小**的一项。客户选定"先做角色 + 权限码可配"(批次 1-4),
|
||||||
|
> **数据范围可配另排**。这一轮把它做完了。
|
||||||
|
|
||||||
|
### 一、做了什么
|
||||||
|
|
||||||
|
| # | 改动 | 关键点 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **权限判定合并两个来源** | `PermissionChecker`:内置角色按**代码**(`BuiltinRole`)、自定义角色按**库**(`sys_role_permission`)。实现放 `DbRoleGrantSource`(`module` 层),`security` 只依赖接口 `RoleGrantSource` —— 否则角色管理服务要调 `permissionChecker.require(ROLE_MANAGE)`,两个包**迟早成环** |
|
||||||
|
| 2 | **必须有缓存** | 权限判定此前是**零 IO**,而压测基线是"SQL 条数恒定、DB ≤2ms"(《09》§9.5)。缓存放 Redis(本项目**每请求本来就读一次 Redis**:登出黑名单)。三个细节:**空结果也缓存**(否则已删角色的 token 会把库打穿)、**Redis 挂了退化成直读库**(当成"没权限"会让全校登不进去,当成"有权限"是越权)、**TTL 只是兜底**(正常靠写后 `evict`) |
|
||||||
|
| 3 | **V18 迁移** | `sys_role.user_type`(账号的 `user_type` 一直是从角色反推的,自定义角色不在代码映射里 → 没这列就"角色建得出来、发不出去")+ `sys_role_menu`(菜单不做权限的附属,与 `sys_role_permission` 同构) |
|
||||||
|
| 4 | **8 个接口 + `40045`** | `docs/03` §4.12 / §5.10;契约总数 **121 → 129**、错误码 **58 → 59** |
|
||||||
|
| 5 | **内置角色授权同步** | `RoleProvisioner` 从"已存在就 `continue`"改成**幂等回填**,并加每日 03:20 的定时任务(`runNow` 名 `role`)。修掉的是"代码改了权限、已开通租户永远停在发放那一刻"——**这种漂移不会报错、也没有测试会红** |
|
||||||
|
| 6 | **PC 页 + 原型 ⑪** | `views/role/`(左角色列表 + 右权限矩阵 + 菜单勾选 + 保存即生效);内置角色**只读**(能看、能当模板,改不了)。至此 **`ready:false` 的菜单归零**,「待建」页只作为兜底保留 |
|
||||||
|
| 7 | **账号侧全部改读库** | 可选角色 / 用户类型 / 角色名;删掉前端三份写死的镜像表(`SCHOOL_ROLE_OPTIONS` / `ROLE_TO_USER_TYPE` / `ROLE_NAME_TO_CODE`)。留着的后果是"自定义角色推不出类型 → 账号建不出来,但看不出为什么" |
|
||||||
|
|
||||||
|
### 二、四条口径(决定了这个功能长什么样)
|
||||||
|
|
||||||
|
| # | 口径 | 为什么 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **内置角色只读** | 权限码是前后端契约(后端校验、前端控显隐)。做成可改 = 把一份契约拆两处维护,而不一致的表现是"按钮显示出来了、点了报 40301";更要紧的是**一次误操作就能把管理员锁在系统外,不可逆** |
|
||||||
|
| 2 | **平台级能力与入口,界面上禁用 + 服务端 40301** | 学校租户授予 `tenant:manage`/`tenant:view`/`log:viewAll` → 40301;配置平台专用菜单 `tenant` → 40301;建平台运营类角色 → 40301;发 `PLATFORM_ADMIN` → 40301。"界面不显示"只是不提示,**服务端才是防线** |
|
||||||
|
| 3 | **防自锁** | 不允许去掉"自己进入本页所依赖的最后一条 `role:manage` 权限 / `role` 菜单",也不允许停用或删除它;改账号角色时同理保护 `user:manage`(这条是**二期新增的漏洞**:`losingAdminRole` 只看那两个内置管理员码,自定义角色带 `user:manage` 时它会漏) |
|
||||||
|
| 4 | **改完立即生效** | 每次写授权都清该角色缓存 → 不必重新登录;停用角色**立刻**不再给权限(不是等 token 过期,PC token 最长 8 小时)。⚠️ **改账号的角色归属**仍是"下次登录生效"(角色码在 token 里) |
|
||||||
|
|
||||||
|
> ⚠️ **`dataScope` 本期只展示、不提供修改入口**:数据范围(本人 / 本班 / 本院系 / 全租户)目前由
|
||||||
|
> "拿到了哪些权限码 + 任务里被指派了哪些班"共同决定,散在审核 / 催报 / 代传 / 看板各处(6 处判据),
|
||||||
|
> 做成可配要先把它们收口成唯一的 resolver 并补回归断言 —— **单独一批**。让界面出现一个"改了不生效"的开关,比没有它更糟。
|
||||||
|
|
||||||
|
### 三、这一轮踩到的 5 个坑(都值得记)
|
||||||
|
|
||||||
|
1. **HMR 的陈旧模块会伪装成"组件坏了"**:弹窗里的授权矩阵**完全不渲染**、控制台只有一条
|
||||||
|
"Unhandled error during execution of component update",查了三轮都没头绪;
|
||||||
|
**硬刷新后一切正常**(同样的代码、同样的接口)。→ 前端改完先**硬刷新**再判断是不是 bug
|
||||||
|
(`frontend-pc/AGENTS.md` 早就写着这条,这次是"组件整块不渲染"的形态)。
|
||||||
|
2. **我自己的 E2E 断言写错了**:用 `roster:view` / `audit:first` 判"换成内置角色后自定义角色的权限没了",
|
||||||
|
而 `HEAD_TEACHER` **本来就有这两条** → 绿灯功能被判成红的。
|
||||||
|
→ **判据要选"只有被测对象才有"的那条**(改用 `material:manage`)。
|
||||||
|
与《08》F-77/F-97 同一族:**断言打在错的东西上,比没有断言更糟**。
|
||||||
|
3. **登录响应也必须带上自定义角色的菜单与权限**:`AuthService.buildProfile` 原先直接取
|
||||||
|
`BuiltinRole.mergeMenus/mergePermissions`,而登录响应里的这两项前端会**直接落库用**
|
||||||
|
(省一次 `/auth/me` 往返)→ 自定义角色的人"刚登录什么都没有、刷新一下又好了"。
|
||||||
|
改的时候还发现第二个坑:`login()` 里 `UserContext` 一开始是**"只有租户、没有角色"的临时上下文**,
|
||||||
|
而 `PermissionChecker` 是从上下文算的 → 必须**先把上下文升级成完整的 `principal`** 再装配。
|
||||||
|
4. **学校租户能勾到平台专用菜单**(`tenant`):菜单键的合法取值是"所有内置角色菜单的并集",
|
||||||
|
而 `tenant` 只在 `PLATFORM_ADMIN` 里 → 配给学校的角色会在左侧多出一个**点进去必然 403** 的入口。
|
||||||
|
现在**两道**:界面不显示 + 服务端 `BuiltinRole.platformOnlyMenuKeys()` 拒绝。
|
||||||
|
5. **账号页下拉里有一个"点了必然 403"的选项**:demo 租户里有一行历史遗留的 `PLATFORM_ADMIN` 角色,
|
||||||
|
而 `/roles` 会把它列出来 → 学校管理员选了就报错。现在 `RoleVO` 多一个 **`assignable`** 标志
|
||||||
|
(**看得见 ≠ 发得出去**),列表里标成「不可分配」、账号页的选项按它过滤。
|
||||||
|
|
||||||
|
### 四、验证
|
||||||
|
|
||||||
|
| 手段 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/e2e-m10-role.py`(新增) | **77/77**:改授权后**同一个 token** 立刻生效 / 停用立刻失效 / 内置角色只读 / 越权与自锁 / 删除守卫 `40045` / 内置角色权限以代码为准 + 同步回填幂等 / 跨租户 |
|
||||||
|
| `python scripts/verify-all.py --with-e2e` | **9 项全过 / 0 失败 / 0 跳过**;**37 个脚本 / 1595 条断言 / 0 失败**(2026-10 收敛删掉 2 个脚本后已重跑填数) |
|
||||||
|
| 浏览器实测(真点) | 新建自定义角色 → 账号页角色候选出现它(且平台角色已不在候选里)→ 改权限落库(`sys_role_permission` 逐字核对)→ 删除(软删 + 授权/菜单行一并软删);截图留证 |
|
||||||
|
| 机械校验 | `check-api-parity`(契约 **119** / 已实现 118 / 缺口 1)、`verify-docs` 22/22(原型 **29** 屏)、`check-wiring` 未接线 0、`check-entity-columns` 28 实体一致、`check-manual` 0 错 |
|
||||||
|
| 前端 | `pnpm typecheck` 0 错、`eslint` 0 错(`views/role/**`、`apis/xt/role.ts` 与改动过的账号页) |
|
||||||
|
|
||||||
|
### 五、没做(下一批,已与客户确认"另排")
|
||||||
|
|
||||||
|
1. **数据范围可配**(本人 / 本班 / 本院系 / 全租户):需要 `DataScopeResolver` 收口那 6 处判据,
|
||||||
|
并实现"本院系"(今天只有枚举与 DB 列,**没有代码读它**)。⚠️ 会动审核 / 催报 / 代传 / 看板,
|
||||||
|
而它们有 87 / 42 / 33 / 24 条断言锁着。
|
||||||
|
2. **角色变更后"在线 token 立即失效"**:现在改**授权**立即生效,但改**某个人挂哪个角色**要等下次登录
|
||||||
|
(角色码在 JWT 里)。要做就得让 JWT 带上签发时间并与"最近一次改角色时间"比较。
|
||||||
|
3. **平台运营的角色页**:平台选定学校后可以管那所学校的角色(`X-Tenant-Id` 已生效,E2E 断言了
|
||||||
|
"不选学校改不了"),但"平台租户自己的自定义角色"目前没有实际用途(平台身份不受权限码限制)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十六 需求收敛:删角色 / 删两模块 / 取消二期 + PC 端换 Naive UI(2026-10)
|
||||||
|
|
||||||
|
> 一句话:需求从「6 角色 + 12 菜单 + 二期预留」收敛为「**4 角色 + 10 菜单 + 不设二期**」。
|
||||||
|
> 代码 / 库 / E2E / 原型 / 文档五处已同步;本节记 **删了什么、存量数据怎么处理、断言怎么改、
|
||||||
|
> 以及下一轮最容易踩的坑**。**这是唯一有效的当前口径** —— 前面各节里凡与本节冲突的,一律以本节为准。
|
||||||
|
|
||||||
|
### 一、删了什么
|
||||||
|
|
||||||
|
| # | 删除项 | 影响面 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | **副班主任 `DEPUTY_TEACHER`**、**审核员 `AUDITOR`** 两个内置角色 | 内置角色 **6 → 4**(平台运营 / 学校管理员 / 班主任 / 学生);`BuiltinRole` 枚举、权限集、菜单集、PC 端 `ROLE_NAMES`、原型角色页 |
|
||||||
|
| 2 | **历史整理器**(`module/cleanup`) | 5 个契约接口 + `cleanup:run` 权限码 + `collect_cleanup_job` 表 + PC 菜单与页面 + 原型 1 屏 + `e2e-m7-cleanup.py` |
|
||||||
|
| 3 | **加入申请**(`module/join`) | 5 个契约接口 + `join:audit` 权限码 + `collect_join_request` 表 + PC 菜单与页面 + 学生端 `pages/join` + 错误码 `40037`/`40038`/`40039`/`40040`/`40042` + 站内信模板 `JOIN_APPROVE`·`JOIN_SUBMIT` + 微信订阅模板 `JOIN_APPROVE` |
|
||||||
|
| 4 | `collect_task_org.deputy_user_ids`(协办人) | **整列删除**;审核范围三级回退 → **两级**(`head_user_id` → `sys_org.leader_user_id`),连带 6 个 mapper 的 `LIKE '%,id,%'` 分支(审核范围 / 待办 / 教师看板 / 催报 / 超时提醒 / 任务可见范围) |
|
||||||
|
| 5 | **二期**(整体) | 社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 / 角色变更即时失效 / 平台角色页 → **一律不做**;OCR 与材料识别改为「**缓做,等供应商选型**」(`/mp/ocr/idcard` 仍是唯一未实现接口) |
|
||||||
|
| 6 | 二期材料与模板 | 内置材料模板 **3 → 1**(只剩 `TPL_SCHOOL_ROLL`);`XJW_BA` / `OTHER_PROOF` **读得到、选不到**(不在"新增材料"列表里,历史任务仍可读 —— 删编码会让已配的材料项悬空) |
|
||||||
|
|
||||||
|
**数字总账(跨文档必须一致;`verify-docs.py` 与 `check-api-parity.py` 会查)**
|
||||||
|
|
||||||
|
| 项 | 收敛前 | 收敛后 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 契约接口 | 129 | **119** |
|
||||||
|
| 错误码 | 59 | **54** |
|
||||||
|
| 权限码 | 30 | **28** |
|
||||||
|
| 内置角色 | 6 | **4** |
|
||||||
|
| PC 业务菜单 | 12 | **10** |
|
||||||
|
| 小程序页面 | 10 | **9** |
|
||||||
|
| 原型屏 | 32 | **29**(学生 10 / 班主任 9 / 管理端 10) |
|
||||||
|
| E2E 脚本 | 39 | **37**(删 `e2e-m2-join.py`、`e2e-m7-cleanup.py`);断言 1698 → **1595 条** |
|
||||||
|
|
||||||
|
> ⚠️ **两个容易算错的数**(都踩过):
|
||||||
|
> ① **契约是 119,不是 116**。116 是"删 13 个**代码端点**"(cleanup 8 + join 5),
|
||||||
|
> 但契约数是数 `docs/03` §4 的**清单行**,而 cleanup 有 3 个端点(如 `/cleanup/jobs/{id}/result`)
|
||||||
|
> **从来不在 §4 清单里**(属 §17.1「代码有、契约没有」)→ 129 − 10(cleanup 5 行 + join 5 行)= **119**。
|
||||||
|
> ② **原型是 29,不是 30**:32 = 学生 11 + 班主任 9 + 管理端 12,删 3 屏 = 10 + 9 + 10 = **29**。
|
||||||
|
>
|
||||||
|
> 📌 `scripts/check-api-parity.py` 里那个"契约必须恰好 N"的常量已同步改成 **119**(判定逻辑未动);
|
||||||
|
> `verify-docs.py` 的屏数核对此前**是静默失效的**(它要半角冒号的「合计屏数 : N」,而文档里没有这一行),
|
||||||
|
> 本次在 §三 的交付物表下补了「**合计屏数 : 29**」——**改原型必须同步这一行**。
|
||||||
|
|
||||||
|
### 二、V19 迁移怎么处理存量数据
|
||||||
|
|
||||||
|
`backend/xt-server/src/main/resources/db/migration/V19__remove_deputy_auditor_cleanup_join.sql`
|
||||||
|
(**只加不改**:V18 及以前的迁移一律不动,这是 Flyway 的规矩):
|
||||||
|
|
||||||
|
1. **先解绑定、再软删角色** —— 删 `sys_user_role` 里指向这两个角色的行,再软删 `sys_role` / `sys_role_permission` / `sys_role_menu`;
|
||||||
|
2. **账号只在"除这两个角色外没有别的角色"时才删**,且是**软删** ——
|
||||||
|
一个人若同时挂着 `HEAD_TEACHER`,他仍是班主任,只是少了一个角色;
|
||||||
|
3. `collect_task_org.deputy_user_ids` 随列删除;`collect_cleanup_job` / `collect_join_request` 两张表按迁移内策略处理。
|
||||||
|
|
||||||
|
> ⚠️ **为什么必须迁、不能只删代码**:角色码从 `BuiltinRole` 里删掉后,`mergePermissions` 对它返回**空集** ——
|
||||||
|
> 存量账号会变成"能登录、但处处 `40301`",**不抛异常、不打日志**。这类问题没有任何测试会红。
|
||||||
|
|
||||||
|
### 三、受影响的断言怎么改的
|
||||||
|
|
||||||
|
| 脚本 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `e2e-m2-join.py` / `e2e-m7-cleanup.py` | **整体删除**(被测模块已删) |
|
||||||
|
| `e2e-m0-user.py` | 造账号不再用 `DEPUTY_TEACHER`;Excel 导入用例里的角色名改掉 |
|
||||||
|
| `e2e-m1-orgs.py` | 新增断言「**`deputy_user_ids` 列已被 V19 删除**」;配置班级不再有副班主任字段 |
|
||||||
|
| `e2e-m5-notify-wiring.py` | 新增断言「**两张表 / 两个角色 / 两个权限码均已下线 + 权限码总数 28**」;删掉"班主任调整理器被 `40301`"那条 |
|
||||||
|
| `e2e-m10-role.py` | 内置角色基线 6 → 4 |
|
||||||
|
|
||||||
|
> ⚠️ **一条断言写法禁忌(本次踩到)**:`string_agg` 没有 `ORDER BY` 时**结果顺序不稳定**,
|
||||||
|
> 用它拼出"角色名单字符串"再比较,跑单脚本绿、跑全量偶发红。
|
||||||
|
> **断言必须"数个数 + 点名单"**(`count(*)` 加逐项 `EXISTS`),不要比拼接串。
|
||||||
|
|
||||||
|
### 四、⭐ 下一次改动会踩的五个坑(本次新增的防线)
|
||||||
|
|
||||||
|
1. **`UserService.list` 对不认识的角色码由"静默忽略过滤条件"改成失败关闭** ——
|
||||||
|
之前传一个不存在的 `roleCode` 会把过滤条件整个丢掉、**返回全校账号**,而这看起来完全正常(有数据、分页也对)。
|
||||||
|
2. **`e2e-m1-orgs` 断言了 `deputy_user_ids` 列已被 V19 删除** ——
|
||||||
|
谁想把这个字段加回来,会先红一条断言,而不是等某处 SQL 报 `column "deputy_user_ids" does not exist`。
|
||||||
|
3. **`e2e-m5-notify-wiring` 断言了"两张表 / 两个角色 / 两个权限码已下线 + 权限码总数 28"** ——
|
||||||
|
权限码是前后端**契约**,加/删码必须同步改这条断言与 `docs/03` 的清单。
|
||||||
|
4. **PC 端已没有 `v-hasPerm`**:权限判断统一写 `userStore.has('码')`(全库 **8 处**),
|
||||||
|
比对的是 `PermissionDef` 的**原样字符串**(`progress:viewAll` 是驼峰,**不是** `progress:view:all` ——
|
||||||
|
写错只会恒为 false,typecheck / lint / 后端都不报错)。`check-wiring.py` 第 ⑦ 项会核对 PC 端引用的权限码是否都存在。
|
||||||
|
> ⚠️ **这条防线曾经静默失效**:第 ⑦ 项原来只认 `permissions.includes('码')` 与 `v-hasPerm="['码']"`,
|
||||||
|
> 重搭改成 `user.has('码')` 之后它一个码都扫不到,却照样打印"PC 端引用的权限码 0 个"并判为通过
|
||||||
|
> (**看起来是绿,实际是检查器瞎了**)。已补上 `.has()` / `.hasAny()` 的识别(见 §一·三十七)。
|
||||||
|
5. **Naive UI 的 `n-tag` 的 `type` 必须过 `@/utils/tag.ts`**:`apis/xt/*.ts` 里的展示映射是从 Arco 带过来的
|
||||||
|
**色词**(`green` / `arcoblue` / `gold` / `gray`),直接透传给 `n-tag` 会**静默变成默认灰色** ——
|
||||||
|
状态色全丢,且不报错。**不要**去改那些映射的 `color` 值(别处也在引用它们)。
|
||||||
|
|
||||||
|
### 五、PC 端重搭:Gi Admin Pro + Arco → 纯 Vue 3 + Naive UI
|
||||||
|
|
||||||
|
- 依赖换成 **`naive-ui` 2.42**(`package.json` 已无 Arco / Gi);`pnpm dev`(5173,`/api` 代理到 8080)/
|
||||||
|
`pnpm build` / `pnpm typecheck` 三个命令不变,**`pnpm typecheck` 现在 0 错**(学生端仍是已知 2 条 `#ifdef` 重复导出);
|
||||||
|
- **`pnpm lint:style`(Stylelint)已随重搭移除**,只剩 `pnpm lint`(ESLint);
|
||||||
|
- **没有 auto-import**:组件与 API 全**显式 import**;
|
||||||
|
- 图标:`scripts/extract-pc-icons.py` 从 `@iconify-json/icon-park-outline` 抽**本项目实际用到的 37 个**,
|
||||||
|
生成 `frontend-pc/src/icons/icon-data.ts`。`AppIcon` 对**缺失图标是空占位、不报错** ——
|
||||||
|
**图标集升级时必须重跑这个脚本**,否则新加的图标显示成空框(内网离线部署不能运行时去 iconify 拉);
|
||||||
|
- ⚠️ **`frontend-pc/.agents/` 已随重搭整体重写为 Naive UI 版**(`code-css` / `code-vue` / `project-guide`
|
||||||
|
等均已改写,`.agents/README.md` 顶部有说明)—— **但 §一·六 / §一·三十四 等节里的 Arco 经验**
|
||||||
|
(列宽按容器重分配、`y:'100%'`、`flattenData` 二次 slice、下拉选项全渲染在 DOM 里)
|
||||||
|
**重搭后不再适用**,保留它们只是记录当时的排查过程,不要照搬到 Naive UI。
|
||||||
|
|
||||||
|
### 六、⚠️ 治理:这是**需求回退**,不是新功能
|
||||||
|
|
||||||
|
**加入申请(确认函 B1)是客户 23 项确认里唯一的功能增量**,本次取消属**需求回退**。
|
||||||
|
建议补一张**变更确认单**,写清三件事:① 门禁失败页不再有自助通道;
|
||||||
|
② 名单外的人由老师在任务里**手工加人**;③ 系统内**不展示任何联系方式**(铁律不变)。
|
||||||
|
文档侧已在 `docs/03` §4.11 与 §8.7 留下撤销说明,`docs/01` 的 B1 条目也已标注取消。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十七 PC 端 UI 修复:重搭后的"错乱"与卡片式基线(2026-10)
|
||||||
|
|
||||||
|
> 一句话:客户反馈"改完 UI 之后后台页面都有问题,工作台是错乱的,区域间隔与按钮都不明显"。
|
||||||
|
> 逐页截图审计后定位到 **1 个结构性版面缺陷 + 3 类静默故障**,已修并补上两处自动检查。
|
||||||
|
|
||||||
|
### 一、工作台"错乱"的真正原因:`NGrid` 的 `cols` 键写成了像素值
|
||||||
|
|
||||||
|
`cols="1 640:2 1100:4"` 里的 `640` / `1100` **不是断点名**,而 `defaultBreakpoints` 只有
|
||||||
|
`xs/s/m/l/xl/xxl`。`seemly` 的 `parseResponsivePropValue` 在数组里逐个键去 `classObj` 里找,
|
||||||
|
全都找不到 → 回退到 `classObj['']` = `1` → **四张统计卡永远挤成一列**,右侧留下大片空白。
|
||||||
|
正确写法是 `cols="1 s:2 m:4"`(xs<640 / s≥640 / m≥1024 / l≥1280)。
|
||||||
|
|
||||||
|
> ⚠️ 这类错**不报错、不警告**,看起来只是"排版不好看"。
|
||||||
|
|
||||||
|
### 二、五个"静默故障"(编译 / lint / typecheck 全绿,只有从入口走一遍才暴露)
|
||||||
|
|
||||||
|
| # | 现象 | 根因 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| 1 | 「收集任务」的**状态筛选 tab 一个字都不显示**(整行高 12px),审核页的节点 tab 同样 | naive-ui **2.45.3** 的 `Tab` 渲染:`tab="文字"` 属性走 `normalizeVNode(() => render(mergedTab ?? name))`,thunk 返回 Text VNode → 被当块 `createBlock(vnode)` → 只留空注释节点。**必须改用 `#tab` 插槽**(那条分支返回数组,正常) |
|
||||||
|
| 2 | 工作台的 `<n-button>` / `<n-avatar>`、字典页的 `<n-form>`、账号页的 `<n-alert>`、名册抽屉的 `<n-divider>` **样式整块丢失** | **漏 import**(本仓库没有 auto-import)→ Vue 把它当**未知自定义元素**渲染。共 4 个文件 6 处(改造过程中又新增 1 处,被第 ⑧ 项检查逮住) |
|
||||||
|
| 3 | 「操作日志」**一行数据都看不见**:行在 DOM 里、`innerText` 也查得到,屏幕上却只有表头 + 紧贴其下的分页器 | `.page-card` 高度链的**第一环**(卡片内容区要是能撑满的 flex 列)写在 `:deep(.n-card__content)` 里 —— **这个类名不存在**:naive 的内容区是 `.n-card-content`(`cB("card-content")`,**单短横**;而 `.n-button__border` 那种是 `cE` 生成的**双下划线**,两套命名混用)。已改为全局 `.page-card > .n-card-content`,页面不再需要各自传 `:content-style` |
|
||||||
|
| 4 | 审核详情顶部的状态标签**颜色永远不对**("已通过"显示成待终审的蓝) | `AuditDetailDrawer` 的本地状态表照抄了**名册行**的口径(`1 草稿 / 2 待初审 / …`),套到**填报单**上整体错位一格。文案被服务端 `statusText` 兜住了,所以只有颜色错 —— 现在统一放 `apis/xt/audit.ts` 的 `SUBMISSION_STATUS`,并在文件头写清两套枚举起点不同 |
|
||||||
|
| 5 | 名单管理抽屉里「已选 1 个班」三个词被摊到整行两端 | `.org-drawer__pane-title` 是 `display:flex + space-between`,而 **flex 会把裸文本节点各自变成一个 flex item** → 标题必须整个包在一个元素里 |
|
||||||
|
| 6 | **凡是有标题的卡片,第一行内容都贴着标题下面那条灰条**("卡片顶端没有 padding") | Naive 的卡片内容区是 `padding: 0 var(--n-padding-left) var(--n-padding-bottom) …`,`padding-top` 只写在 `&:first-child` 上 —— 也就是**只给"没有卡片头"的卡片**。已改成全局 `.n-card.n-card > .n-card-content { padding: var(--xt-card-pad-y) var(--xt-card-pad-x) }` |
|
||||||
|
|
||||||
|
> ⚠️ 第 6 条有个**连带坑**:全局那条是普通样式,**会被内联样式盖掉** ——
|
||||||
|
> `org/index.vue` 与 `user/index.vue` 的左栏写了 `:content-style="{ padding: '12px' }"`,
|
||||||
|
> 结果那两张卡比右栏紧一圈(12 vs 16/18)。这两处已删掉内边距内联样式;
|
||||||
|
> 要调内边距请改 `--xt-card-pad-x` / `--xt-card-pad-y`。
|
||||||
|
|
||||||
|
### 二·补 发现但**本次没改**的问题(留档,别当成已修)
|
||||||
|
|
||||||
|
1. **`NFormItem` 的 `#feedback` 插槽会把校验错误挡掉**(naive 的渲染是 `feedbackNodes = children || feedback`,
|
||||||
|
有 `#feedback` 就不再渲染 `renderExplains`)→ 字段变红框但**一句说明都没有**。
|
||||||
|
全仓有 **30 处** `#feedback`(多数是静态提示)。`RoleFormModal` / `tenant` 的"主操作永远提交不出去"
|
||||||
|
正是被这一条放大的(见下)。建议的修法:在 `onSubmit` 的 `validate()` catch 里补一个
|
||||||
|
`message.error(第一条错误)`,代价最小;彻底修要包装一个 `XtFormItem`。
|
||||||
|
2. `RoleFormModal` 的 `userType` 与 `tenant/index.vue` 的 `tenantType` 是**数字**却只写 `required` ——
|
||||||
|
已补 `type: 'number'`(数字选择框不带 `type` 时 async-validator 会按 string 走)。
|
||||||
|
这两处**已修**,列在这里是因为它可能与上面第 1 条叠加。
|
||||||
|
3. 左树最深一级遇到 E2E 那种超长组织名("E2E 24数控1班 607655")仍会折成两行;
|
||||||
|
已把字号压到 13px、`indent` 压到 16px,再窄就要破 220~260 的栏宽约定或藏掉节点右侧标签。
|
||||||
|
|
||||||
|
### 三、卡片式基线(新增,页面只引用不再各写一套)
|
||||||
|
|
||||||
|
- `src/styles/index.scss` 的 `:root` 定义 `--xt-bg / --xt-gap / --xt-card-border / --xt-card-radius /
|
||||||
|
--xt-band-bg / --xt-text*` 等;`.page-card` 的高度算式改用 `--xt-header-h` 与 `--xt-content-pad`,
|
||||||
|
**不再写死 `calc(100vh - 56px - 28px)`**(改内边距时不会再漂移);
|
||||||
|
- 新增全局类 `.toolbar`(自带下分隔线)、`.section-title` + `.section-title__note`、`.page-plain`;
|
||||||
|
- 卡片外观(白底 + 描边 + 轻阴影 + 卡片头浅灰色带)由 `.n-card.n-card` **统一**给
|
||||||
|
(权重写成 `.n-card.n-card` 是因为 naive 的样式由 css-render **运行时注入**,先后顺序不保证);
|
||||||
|
- **页面底色才是关键**:`NLayoutContent` 自带白底(`--n-color`),会把外层 `.app-shell__main`
|
||||||
|
的灰底盖掉 —— 上一版因此变成「白卡片浮在白底上」,整页没有任何边界,这正是"区域间隔不明显"的根因。
|
||||||
|
已在 `src/layout/index.vue` 显式改回 `--xt-bg`;
|
||||||
|
- 按钮可见性:`App.vue` 的 `themeOverrides` 里把默认按钮描边由 `#e0e0e6` 加深到 `#d0d5dd`,
|
||||||
|
并把 Card / DataTable 的表头、圆角、阴影统一;**工具条控件统一默认尺寸**(原来全排 `size="small"`,
|
||||||
|
28px 白底小按钮贴在白底表格上几乎看不出是按钮)。
|
||||||
|
|
||||||
|
> ⚠️ naive 的按钮**边框画在子元素 `.n-button__border` 上**,不是根节点 ——
|
||||||
|
> 用 `getComputedStyle(btn).borderTopWidth` 检查会得到 `0px`,别据此判断"边框没生效"。
|
||||||
|
|
||||||
|
### 四、新增/修复的两处自动检查(都在 `scripts/check-wiring.py`)
|
||||||
|
|
||||||
|
| 项 | 内容 | 为什么必须有 |
|
||||||
|
|:--:|---|---|
|
||||||
|
| ⑦(**修**) | 补上 `user.has('码')` / `hasAny(...)` 的识别 | 原来只认 `permissions.includes` 与 `v-hasPerm`,重搭后**一个码都扫不到却判为通过** —— 防线静默失效 |
|
||||||
|
| ⑧(**新**) | 核对模板里的 `<n-*>` 组件是否都在本文件 import 了 | 漏 import 是静默故障(见上表 #2),编译与 lint 都不报 |
|
||||||
|
|
||||||
|
> 📌 两个检查都做过**反向验证**:故意写错一个权限码 / 故意删掉一个 import,它们必须红。
|
||||||
|
> 第 ⑦ 项现识别 **8 个权限码**,与 `PermissionDef` 逐字一致。
|
||||||
|
|
||||||
|
### 四·补 覆盖范围与结果(这一轮实际动过的地方)
|
||||||
|
|
||||||
|
- **页面(11 屏)**:工作台、收集任务、审核、导出中心、通知记录、字典管理、组织架构、账号管理、
|
||||||
|
角色与授权、操作日志、租户管理;另有登录页与 403/404 的对齐、任务页 5 个抽屉与各弹窗的一致性。
|
||||||
|
- **校验**:`pnpm typecheck` **0 错**、`pnpm lint` **0 错**(⚠️ 重搭后曾残留 **8 条**
|
||||||
|
`vue/component-name-in-template-casing` —— 就是上表 #2 那批小写标签,已随 PascalCase 化清零)、
|
||||||
|
`check-wiring.py` ⑦⑧ 全绿且"未接线的声明 0 条"、`verify-docs.py` 22 项全过。
|
||||||
|
- 页面截图存于 `frontend-pc` 之外的 `shots/`(**已在 `.gitignore` 里,不要提交**;
|
||||||
|
其中 `shots/auth.json` 含浏览器登录态,只能本地用)。
|
||||||
|
|
||||||
|
### 五、约定同步
|
||||||
|
|
||||||
|
`frontend-pc/AGENTS.md`、`.agents/skills/code-css`、`code-vue`、`project-guide` 已同步:
|
||||||
|
「最容易写错的约定」由 4 条扩到 **6 条**(新增 `#tab` 插槽、卡片式版面),
|
||||||
|
并把漏 import 的静默后果、按钮边框在子元素上、flex 裸文本节点这几个坑写进去。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·三十八 品牌改名「深学在线」+ 重做品牌标(2026-10)
|
||||||
|
|
||||||
|
> 一句话:客户要求系统改名,并明确「**设计一个 logo,不要直接用文字 logo**」。
|
||||||
|
> 全库旧名(`填报通` / `数据信息填报系统`)已换成 `深学在线`;
|
||||||
|
> 原来的「圆角蓝方块 + 填字」文字标换成几何标,另修掉一个页签图标 404。
|
||||||
|
|
||||||
|
### 一、改名落点(旧名现在**只应出现在刻意保留的地方**,见第二节)
|
||||||
|
|
||||||
|
| 层 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| PC 界面 | `index.html` 的 `<title>`、`router/index.ts` 的 `document.title` 模板(`工作台 · 深学在线`)、`AppSider.vue` 的侧栏品牌名、`views/login/index.vue` 的品牌名与 `alt` |
|
||||||
|
| 学生端 | `manifest.json` 的 `name`(小程序/App 名)、`pages.json` 的 `globalStyle.navigationBarTitleText`、`pages/index/index.vue` 的租户名兜底文案 |
|
||||||
|
| 后端元信息 | `pom.xml` 的 `<name>`、`OpenApiConfig` 的 `/doc.html` 标题、`application.yml` 注释、`FillApplication` 注释、`StorageConfig` 自检写入的探针内容 |
|
||||||
|
| 文档 / 原型 | `README.md`、`frontend-pc/README.md`、`frontend-pc/package.json` 的 description、`docs/00 / 01 / 07`、两份 PRD 的**标题行与产品代号线**、`docs/prototype/*.html` 的 `<title>` 与品牌区、`deploy/docker-compose.yml` 的头注释 |
|
||||||
|
|
||||||
|
### 二、**刻意没改**的地方(改一个就会出事,别"顺手统一")
|
||||||
|
|
||||||
|
| 没改 | 为什么 |
|
||||||
|
|---|---|
|
||||||
|
| 存储对象前缀 `xiaotutongxue`(`application-dev/prod.yml`、`application-local.yml.example`、`scripts/dev-clean-qiniu.py`) | 那是**对象存储里的目录名**,不是显示名。改了以后库里存的 key 全部指向不存在的对象 —— 学生已交的材料会整片 404 |
|
||||||
|
| `deploy/docker-compose.yml` 的 `name: xiaotutongxue` | 这是 compose **项目名**,决定容器与 volume 的归属。改了会新建一套空 volume,看起来像"数据没了" |
|
||||||
|
| `db/migration/V12__file_source_proxy.sql` 注释里的 PRD 文件名 | **迁移文件发布后不许改**(Flyway 校验 checksum) |
|
||||||
|
| `docs/数据信息填报系统PRD*.md` 的**文件名** | `scripts/verify-docs.py` 按**路径**读它,且全库有 20+ 处按文件名引用。改名 = 连带炸脚本与文档交叉引用 |
|
||||||
|
| `docs/01-客户与机构确认函.md` 的**正文** | 该文自己声明"本函正文保留当时原文(它是'当时确认过什么'的记录)",只改了标题行 |
|
||||||
|
| `frontend-mp/src/static/logo.png` | uni-app 模板自带的遗留资产,**全仓库零引用**,本次没动它 |
|
||||||
|
|
||||||
|
### 三、新品牌标:方(卷宗)+ 环(在线)
|
||||||
|
|
||||||
|
原标是「圆角方块 + 『填』字」,属于**文字标**。客户明确要求换成设计的图形标,理由也成立:
|
||||||
|
字形标缩到 16px 页签会退化成一块色斑,且换字体族就走形。
|
||||||
|
|
||||||
|
| 稿 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 卷宗叠层 / 折角卷宗 | 语义最直白,但 24px 以下就是通用的「文档」图标,没有识别度 |
|
||||||
|
| 三层同心圆角方 | 16px 下像相机/录制按钮 |
|
||||||
|
| 扇形三卷宗 | 48px 好看,24px 后排飞散全糊 |
|
||||||
|
| 下潜楔 / 斜地层 / 三源归一 | 小尺寸糊,或像下载、阶梯类通用图标 |
|
||||||
|
| **方 + 环(定稿)** | 16 / 20 / 24px 轮廓都成立 |
|
||||||
|
|
||||||
|
定稿的两个形状都有出处:**实心圆角方 = 卷宗**(承接原型「档案室 / 电子卷宗」的设计语言),
|
||||||
|
**白环 = 在线 / 汇聚点**(这件产品的入口本来就是扫码进来)。环画在方**之前**,
|
||||||
|
只露右上四分之一 —— 一个标里同时读到"在线"与"归档"。
|
||||||
|
|
||||||
|
> ⚠️ **后排为什么是环,不是第二枚圆角方**:两枚错位圆角方是通用的「**复制 / 多副本**」图标,
|
||||||
|
> 会被直接读成那个意思(这是第一版几何稿被否掉的原因)。换成环之后歧义消失;
|
||||||
|
> 环与方是**相叠**的(不是分离的两件东西),所以也不会被读成放大镜。
|
||||||
|
|
||||||
|
构造写在 `frontend-pc/src/assets/images/logo.svg` 的注释里(对齐 64 栅格、四周留白对称 8.5、
|
||||||
|
外接盒 47×49.3 居中)。五轮实测对比稿留在 `shots/logo-lab*.html`(`shots/` 已在 `.gitignore`)。
|
||||||
|
|
||||||
|
> ⚠️ 留白最初写的是 **11.25**,尺寸表一量才发现内容只占底板 **65%** ——
|
||||||
|
> 24px 侧栏下真正画出来的只有 16px,标明显偏小。已按 8.5 重算(等比放大 1.133),
|
||||||
|
> `logo.svg` / `favicon.svg` / 原型 `.mark` 三处同步改过。
|
||||||
|
> **改标之后一定要看尺寸表**(`shots/logo-final.html` 直接引仓库里那两个真文件,不是副本)。
|
||||||
|
|
||||||
|
改标要同步三处,否则品牌标不一致:`public/favicon.svg`(**加粗版**:去掉渐变与高光、
|
||||||
|
描边 5.9 → 7.7,因为 5.9 缩到 16px 只剩 1.5px 会被抗锯齿吃掉)、
|
||||||
|
`AppSider`/login 的引用尺寸(24 / 34px)、`docs/prototype/admin-web.html` 的 `.mark`(10 处,内嵌同形状 data URI)。
|
||||||
|
|
||||||
|
### 四、顺手修掉:页签图标一直是 404
|
||||||
|
|
||||||
|
`index.html` 引的是 `<link rel="icon" href="/favicon.ico">`,而 `public/` 下**只有 `favicon.svg`**,
|
||||||
|
没有 `.ico` —— `/favicon.ico` 实测 404,**页签一直没有图标**(不报错,只是空白)。
|
||||||
|
已改成 `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`(实测 200 / `image/svg+xml`)。
|
||||||
|
|
||||||
|
### 五、第六类静默故障:SVG 注释里的连续两个减号
|
||||||
|
|
||||||
|
第一版 `logo.svg` 的注释里写了一句"与 `src/styles` 的 CSS 变量写法一致",
|
||||||
|
其中那两个减号开头的东西**让整个 SVG not well-formed**(XML 规范禁止注释内出现连续两个减号)。
|
||||||
|
|
||||||
|
后果是**完全静默**的:`<img src="...logo.svg">` 加载失败,浏览器控制台一句话都不打,
|
||||||
|
`vue-tsc` / `eslint` / `check-wiring` 全绿,页面上只是**登录页与侧栏的标变成裂图**。
|
||||||
|
|
||||||
|
已新增第 ⑨ 项检查(`scripts/check-wiring.py`):把每个 `.svg` 当 XML 解析一遍。
|
||||||
|
**做过反向验证**:故意放一个注释含连续减号的 SVG,它报红并指出文件名 + 行列号;撤掉后转绿。
|
||||||
|
目前扫到 **3 个** SVG 资产。
|
||||||
|
|
||||||
|
> 📌 后两项(⑧ 漏 import、⑨ SVG 非法)严格说不是"声明没接线",
|
||||||
|
> 而是**同一族:坏了但没有任何工具会红** —— 放 `check-wiring.py` 就是因为
|
||||||
|
> 要的是"一条命令把这一族扫一遍"。
|
||||||
|
|
||||||
|
### 六、验证
|
||||||
|
|
||||||
|
- `pnpm typecheck` **0 错** · `pnpm lint` **0 错**
|
||||||
|
- `check-wiring.py` ⑦ 8 个权限码一致 / ⑧ 漏 import 0 处 / **⑨ SVG 全部合法** / 未接线的声明 0 条
|
||||||
|
- `verify-docs.py` **22 项全过**
|
||||||
|
- 浏览器实测:`document.title` = `工作台 · 深学在线`;侧栏 24px 与登录页 34px 的标
|
||||||
|
`img.complete = true`、`naturalWidth = 64`(**改坏时这里是裂图**);
|
||||||
|
`link[rel=icon]` = `/favicon.svg`;原型 `.mark` 的 data URI 能解码(`Image.onload` 通过,10 处)
|
||||||
|
- 旧名残留复查:全库仅剩**文件名引用**(PRD 路径)、**存储前缀**、**冻结的迁移文件注释**、
|
||||||
|
`.idea/` 的 IDE 缓存,以及 `docs/01` 那段有意保留的正文
|
||||||
|
|
||||||
|
### 七、构建产物也要重跑(**源码改了产物不会变**)
|
||||||
|
|
||||||
|
改品牌名时最容易漏的一环:`frontend-mp` 的 H5 是**后端托管的构建产物**
|
||||||
|
(`dist/build/h5/`,见 `AGENTS.md` 的"先做 H5"),源码改了它一个字都不会变。
|
||||||
|
本次实测:源码改完后产物里**仍是「填报通」**。
|
||||||
|
|
||||||
|
已重跑并复查:
|
||||||
|
|
||||||
|
| 产物 | 命令 | 复查结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| `frontend-mp/dist/build/h5` | `pnpm build:h5` | 3 处「深学在线」,**无旧名** |
|
||||||
|
| `frontend-pc/dist` | `pnpm build` | `index.html` 标题 = `深学在线`、`link[rel=icon]` = `/favicon.svg`,**无旧名** |
|
||||||
|
| `frontend-mp/dist/dev/mp-weixin` | — | **没重建**:它是 `pnpm dev:mp-weixin` 的 watch 产物,下次跑那个命令就会自动带上新名(小程序端不是当前节奏) |
|
||||||
|
|
||||||
|
> ⚠️ `dist/` 都在 `.gitignore` 里,所以**不会有人从 git 上发现问题** ——
|
||||||
|
> 这一环只能靠"改完记得重跑"。后端同理:改了 `pom.xml` / `OpenApiConfig` 那几处,
|
||||||
|
> 不 `mvn -DskipTests package` 重启,`/doc.html` 还会打旧标题(本次已重打包重启,
|
||||||
|
> `/v3/api-docs` 实测 `info.title = 深学在线 API`)。
|
||||||
|
>
|
||||||
|
> 📌 附带踩到一个进程坑:**`job_kill` 杀掉的是 pwsh 外壳,`java` 子进程不一定跟着走**,
|
||||||
|
> 而 Windows 下 `java -jar` **不独占锁** —— 于是 Maven 会把 jar 覆盖掉,
|
||||||
|
> 变成一个"跑着的 JVM + 被换掉的 jar"。改后端要**先确认 8080 没人监听**再打包。
|
||||||
|
|
||||||
|
### 八、约定同步
|
||||||
|
|
||||||
|
`frontend-pc/AGENTS.md`、`.agents/skills/project-guide/SKILL.md`、`.agents/README.md` 已同步:
|
||||||
|
「最容易写错的约定」由 **6 条扩到 7 条**(新增「品牌标」一条,含 SVG 注释的 XML 陷阱);
|
||||||
|
根 `AGENTS.md` 的接线检查说明补上第 ⑨ 项。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-71
@@ -1,7 +1,7 @@
|
|||||||
# 数据信息填报系统 · 功能逻辑 PRD
|
# 深学在线 · 功能逻辑 PRD
|
||||||
|
|
||||||
> 产品代号:**填报通 / CollectFlow**(暂定)
|
> 产品代号:**深学在线**
|
||||||
> 版本:V1.9(2026-09-28 同步数据库决策与字段落库方式)
|
> 版本:**V2.0**(2026-10 需求收敛:4 角色 / 不设二期 / 门禁失败只留「重新填写」)
|
||||||
> 日期:2026-09-24
|
> 日期:2026-09-24
|
||||||
> 文档定位:**只描述"系统怎么运转",不含数据库设计、接口定义、代码结构**
|
> 文档定位:**只描述"系统怎么运转",不含数据库设计、接口定义、代码结构**
|
||||||
> 配套文档:《数据信息填报系统PRD》(主 PRD,含库表与接口)—— 主 PRD 回答"做什么",本文回答"怎么运转"
|
> 配套文档:《数据信息填报系统PRD》(主 PRD,含库表与接口)—— 主 PRD 回答"做什么",本文回答"怎么运转"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更内容 |
|
| 版本 | 日期 | 变更内容 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| **V2.0** | **2026-10** | **需求收敛**:① 角色由 6 个减为 **4 个**(平台运营 / 学校管理员 / 班主任 / 学生;删「副班主任」「审核员」);② §2.2 社会收集、§3.12 历史数据整理器、专项职业能力相关段落 **整节作废**;③ §3.1 门禁失败**只留「重新填写」**——原「申请加入名单」(FR-509)通道整体撤销,名单外的人由老师手工加人,系统内不展示任何联系方式;④ **不再有任何二期功能设计**(OCR 与材料识别改为「缓做,等供应商」)。跨文档数字:接口 **119** / 错误码 **54** / 权限码 **28** / 原型 **29 屏**。 |
|
||||||
| V1.0 | 2026-09-24 | 初稿:角色/流转/12 个功能逻辑/规则速查/20 个边界场景/文案规范 |
|
| V1.0 | 2026-09-24 | 初稿:角色/流转/12 个功能逻辑/规则速查/20 个边界场景/文案规范 |
|
||||||
| **V1.1** | 2026-09-24 | **压缩逻辑按实测重写**:用 28 张真实 A4 扫描件实测,**300dpi/q85 下 28/28 达标(平均 806KB)**,无需牺牲清晰度。① §3.7.1 新增实测结论;② §3.7.3 由"逐级降质逼近"改为"一次编码到位 + 兜底路径";③ §3.7.4 抽检机制标注为"实测不触发";④ §4 速查表、§7 未决事项同步更新 |
|
| **V1.1** | 2026-09-24 | **压缩逻辑按实测重写**:用 28 张真实 A4 扫描件实测,**300dpi/q85 下 28/28 达标(平均 806KB)**,无需牺牲清晰度。① §3.7.1 新增实测结论;② §3.7.3 由"逐级降质逼近"改为"一次编码到位 + 兜底路径";③ §3.7.4 抽检机制标注为"实测不触发";④ §4 速查表、§7 未决事项同步更新 |
|
||||||
| **V1.2** | 2026-09-24 | **修正材料来源通道**:取消"直接拍照"(实测 4 类材料无一来自现场拍照,均为截图/扫描件/照相馆电子版)。① §3.5 改为"**微信聊天记录 / 手机相册 / 聊天文件**"三通道,拍照默认关闭;② 新增"**必须保留原始文件名**"设计逻辑(聊天记录可拿到原始名,相册拿不到);③ 新增来源引导文案与 4 个相关异常分支;④ §4 速查表、§5 场景表(+4 条)、§6 文案、§7 未决事项同步更新 |
|
| **V1.2** | 2026-09-24 | **修正材料来源通道**:取消"直接拍照"(实测 4 类材料无一来自现场拍照,均为截图/扫描件/照相馆电子版)。① §3.5 改为"**微信聊天记录 / 手机相册 / 聊天文件**"三通道,拍照默认关闭;② 新增"**必须保留原始文件名**"设计逻辑(聊天记录可拿到原始名,相册拿不到);③ 新增来源引导文案与 4 个相关异常分支;④ §4 速查表、§5 场景表(+4 条)、§6 文案、§7 未决事项同步更新 |
|
||||||
@@ -85,8 +86,9 @@
|
|||||||
| 平台运营 | PC | 开通/停用租户、查看全局用量、内容风控 | 查看任何考生的材料内容 |
|
| 平台运营 | PC | 开通/停用租户、查看全局用量、内容风控 | 查看任何考生的材料内容 |
|
||||||
| 学校管理员 | PC(主)+ 手机(次) | 建任务、配规则、导名册、终审、导出上报 | 修改已归档任务的配置 |
|
| 学校管理员 | PC(主)+ 手机(次) | 建任务、配规则、导名册、终审、导出上报 | 修改已归档任务的配置 |
|
||||||
| 班主任 | PC + 手机 | 催报、初审、批量通过、代传材料 | 终审、导出、查看其他班级 |
|
| 班主任 | PC + 手机 | 催报、初审、批量通过、代传材料 | 终审、导出、查看其他班级 |
|
||||||
|
| ~~副班主任~~ / ~~审核员~~ | — | **已删除**(2026-10 需求收敛):内置角色由 6 个减为 **4 个**(平台运营 / 学校管理员 / 班主任 / 学生) | — |
|
||||||
| 填报人(学生) | 微信小程序 | 填报、上传、修改、撤回(未审核前) | 查看他人材料、修改已终审内容 |
|
| 填报人(学生) | 微信小程序 | 填报、上传、修改、撤回(未审核前) | 查看他人材料、修改已终审内容 |
|
||||||
| 社会填报人 | 小程序 / H5 | 自主填报 | 查看他人、指定班主任 |
|
| ~~社会填报人~~ | — | **不做**(2026-10 取消社会收集) | — |
|
||||||
|
|
||||||
**端能力差异(功能逻辑层面)**
|
**端能力差异(功能逻辑层面)**
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@
|
|||||||
填报人扫码 → 微信授权
|
填报人扫码 → 微信授权
|
||||||
↓ 【门禁】填写「姓名 + 身份证号」→ 与名单匹配
|
↓ 【门禁】填写「姓名 + 身份证号」→ 与名单匹配
|
||||||
↓ 匹配成功 → 绑定名单项 → 回填只读信息 → 进入填报
|
↓ 匹配成功 → 绑定名单项 → 回填只读信息 → 进入填报
|
||||||
↓ 匹配失败 → 统一提示页(重新填写 / 申请加入名单),不给填报
|
↓ 匹配失败 → 统一提示页(只有「重新填写」+ 提示联系老师),不给填报
|
||||||
填报人逐项填写表单 → 逐项上传材料
|
填报人逐项填写表单 → 逐项上传材料
|
||||||
↓ 每次上传后立即:命名规范化 → 压缩 → 校验 → (必要时)OCR
|
↓ 每次上传后立即:命名规范化 → 压缩 → 校验 → (必要时)OCR
|
||||||
↓ 填报人看到"文件名已规范 / 体积已压缩 / 有无问题"的即时反馈
|
↓ 填报人看到"文件名已规范 / 体积已压缩 / 有无问题"的即时反馈
|
||||||
@@ -133,22 +135,11 @@
|
|||||||
|
|
||||||
> 🔒 **门禁不变式**:**没有名单,就不能填报。** 名单是唯一的准入依据,不存在"自由填报"。
|
> 🔒 **门禁不变式**:**没有名单,就不能填报。** 名单是唯一的准入依据,不存在"自由填报"。
|
||||||
|
|
||||||
### 2.2 社会收集(差异流程)
|
### 2.2 社会收集(差异流程)—— **不做**(2026-10 取消)
|
||||||
|
|
||||||
```
|
> ⚠️ **本节作废**:社会收集(`collectMode=2`)**不再设计**,传入直接返回 **`40044`**。
|
||||||
【前置】管理员建任务(模式=社会收集,无班主任层,但同样必须先导入名单)
|
> 原设计的差异点(跳过班主任初审、直达管理员终审)记录在此仅供追溯。
|
||||||
↓ 导入名单(预报名名单 / 意向名单 / 报名点收集的名单)
|
> **名单导入与身份核验两项与学校收集完全一致**,这条不变。
|
||||||
↓ 生成公开填报二维码/链接
|
|
||||||
社会人员扫码 → 微信授权
|
|
||||||
↓ 【门禁】填写「姓名 + 身份证号」→ 与名单匹配(规则与学校收集完全一致)
|
|
||||||
↓ 匹配失败 → 统一提示页,不给填报
|
|
||||||
填报 → 上传材料(同样走规范化 / 压缩 / 校验)
|
|
||||||
提交 → 直接进入【待终审】(跳过初审)
|
|
||||||
管理员终审 → 通过 → 导出
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **与学校收集唯一的差异**:审核流跳过班主任层。**名单导入与身份核验两项完全一致。**
|
|
||||||
> 📌 初稿曾写"社会收集名册可选、允许自由填报",本版已废除。
|
|
||||||
|
|
||||||
### 2.3 状态机
|
### 2.3 状态机
|
||||||
|
|
||||||
@@ -219,7 +210,7 @@
|
|||||||
本地先做格式 + 校验位校验 → 不合格当场提示,不发请求
|
本地先做格式 + 校验位校验 → 不合格当场提示,不发请求
|
||||||
↓ ⑤ 提交匹配(在服务端完成)
|
↓ ⑤ 提交匹配(在服务端完成)
|
||||||
↓ ⑥ 命中 → 绑定 → 进入填报页
|
↓ ⑥ 命中 → 绑定 → 进入填报页
|
||||||
未命中 → 失败提示页(重新填写 / 申请加入名单)
|
未命中 → 失败提示页(只有「重新填写」+ 提示联系老师)
|
||||||
```
|
```
|
||||||
|
|
||||||
**匹配判定顺序**
|
**匹配判定顺序**
|
||||||
@@ -244,9 +235,10 @@
|
|||||||
|
|
||||||
> "**没有找到你的信息。**
|
> "**没有找到你的信息。**
|
||||||
> 请核对姓名和身份证号是否与老师提供的名单一致。
|
> 请核对姓名和身份证号是否与老师提供的名单一致。
|
||||||
> 如果确实已报名,可以申请加入名单,由老师审核。"
|
> 如确认已报名,请联系老师把你加入名单。"
|
||||||
|
|
||||||
提供两个动作:**重新填写** / **申请加入名单**(FR-509)——**均为系统内动作,不展示任何联系方式**
|
只提供一个系统内动作:**重新填写**;另加一句纯文案提示"请联系老师把你加入名单"
|
||||||
|
(原「申请加入名单」自助通道已于 2026-10 撤销)——**系统内不展示任何联系方式**
|
||||||
|
|
||||||
**🔒 安全要求(重要,必须实现)**
|
**🔒 安全要求(重要,必须实现)**
|
||||||
|
|
||||||
@@ -270,10 +262,10 @@
|
|||||||
| 同一微信帮多人填(哥哥帮弟弟) | 允许,但**切换填报人需重新走核验**,且记录操作留痕 |
|
| 同一微信帮多人填(哥哥帮弟弟) | 允许,但**切换填报人需重新走核验**,且记录操作留痕 |
|
||||||
| 名单外的人确实要报名 | 本版按需求实现为"**不给填、只提示**",需老师去后台把人加进名单(见下方待决策事项) |
|
| 名单外的人确实要报名 | 本版按需求实现为"**不给填、只提示**",需老师去后台把人加进名单(见下方待决策事项) |
|
||||||
|
|
||||||
> 🤔 **待决策:名单外的人怎么处理?**
|
> ✅ **已决策(2026-10,需求收敛):名单外的人由老师在任务里手工加人,不做自助通道。**
|
||||||
> **修订(FR-509)**:名单外的人原先只能"联系老师",老师需反复去后台加人——这在几百人的社会收集中会成为运营负担。现已改为**学生自助申请 + 管理员逐条审批**,且**系统内不展示联系方式**。
|
> 曾经的"学生自助申请 + 管理员逐条审批"(FR-509)已**整体撤销**(模块、表、接口、页面、错误码一并删除)。
|
||||||
> **建议方案**(未纳入本版):在失败页增加"**申请加入名单**"按钮 → 生成管理员待办 → **管理员批准后自动放行**。
|
> 门禁失败页只保留「重新填写」+ 一句"请联系老师把你加入名单",**系统内不展示联系方式**。
|
||||||
> 这样既守住"必须先有名单"的门禁,又不用老师手工加人。**请确认是否采纳。**
|
> 这样守住的仍是同一条铁律:**没有名单,就不能填报** —— 加人的动作留在老师手里。
|
||||||
|
|
||||||
### 3.2 报考范围聚合与任务创建
|
### 3.2 报考范围聚合与任务创建
|
||||||
|
|
||||||
@@ -323,7 +315,7 @@
|
|||||||
| 整批出现多组不同的报考项 | 正常(一个任务可覆盖多个工种),但需管理员确认无误 |
|
| 整批出现多组不同的报考项 | 正常(一个任务可覆盖多个工种),但需管理员确认无误 |
|
||||||
| 老师发现填错想改 | 管理员可直接修改名单行(留痕),范围自动重新聚合 |
|
| 老师发现填错想改 | 管理员可直接修改名单行(留痕),范围自动重新聚合 |
|
||||||
| 名单里出现参考字典没有的职业/工种 | 仅提示"可能拼写有误",**不阻断**(最终以机构要求为准) |
|
| 名单里出现参考字典没有的职业/工种 | 仅提示"可能拼写有误",**不阻断**(最终以机构要求为准) |
|
||||||
| 认定类型 = 专项职业能力 | 三项中只有"职业"有值,工种/等级允许为空 |
|
| ~~认定类型 = 专项职业能力~~ | **不做**(2026-10 取消;传 `certType=3` 返回 `40044`) |
|
||||||
|
|
||||||
**关键设计**
|
**关键设计**
|
||||||
|
|
||||||
@@ -450,13 +442,13 @@
|
|||||||
|
|
||||||
| 认定类型 / 等级 | 最多可选科目数 | 可选项 |
|
| 认定类型 / 等级 | 最多可选科目数 | 可选项 |
|
||||||
|:--:|:--:|---|
|
|:--:|:--:|---|
|
||||||
| **专项职业能力** | **0** | 无补考 |
|
| ~~**专项职业能力**~~ | ~~0~~ | **不做**(2026-10 取消) |
|
||||||
| 三 / 四 / 五级 | **1** | 补考理论、补考实操 中选一个 |
|
| 三 / 四 / 五级 | **1** | 补考理论、补考实操 中选一个 |
|
||||||
| 一 / 二级 | **2** | 补考理论、补考实操、补考综合 中最多选两个 |
|
| 一 / 二级 | **2** | 补考理论、补考实操、补考综合 中最多选两个 |
|
||||||
|
|
||||||
⇒ 达到上限后,其余科目选项**置灰不可选**(而不是事后报错)。
|
⇒ 达到上限后,其余科目选项**置灰不可选**(而不是事后报错)。
|
||||||
|
|
||||||
6. **专项职业能力的整体降级**:`认定类型 = 专项职业能力` 时,**工种、等级、补考三项全部隐藏**,官方规则 05/06/07 整体跳过。
|
6. ~~**专项职业能力的整体降级**~~:**不做**(2026-10 取消二期;`certType=3` 直接 `40044`)。原设计是:该类型下工种、等级、补考三项全部隐藏,官方规则 05/06/07 整体跳过。
|
||||||
|
|
||||||
7. **下拉严格受限**:官方规定"**若手动输入则只能填写下拉选项中包含的值**"。
|
7. **下拉严格受限**:官方规定"**若手动输入则只能填写下拉选项中包含的值**"。
|
||||||
⇒ 移动端一律用选择器;PC 端允许输入但做**严格值匹配**,不在选项内的值直接拒绝。
|
⇒ 移动端一律用选择器;PC 端允许输入但做**严格值匹配**,不在选项内的值直接拒绝。
|
||||||
@@ -494,7 +486,7 @@
|
|||||||
| 提交后又想改 | 未进入审核前可"撤回";已进入审核需联系老师驳回 |
|
| 提交后又想改 | 未进入审核前可"撤回";已进入审核需联系老师驳回 |
|
||||||
| **报考范围已关闭**(认定机构批次结束) | 提示"本批次已结束",不允许新建填报单;已提交的不受影响 |
|
| **报考范围已关闭**(认定机构批次结束) | 提示"本批次已结束",不允许新建填报单;已提交的不受影响 |
|
||||||
| **学生发现自己的报考职业/工种/等级不对** | 提示"该三项由老师统一确定,请联系老师修改",**不允许自行输入** |
|
| **学生发现自己的报考职业/工种/等级不对** | 提示"该三项由老师统一确定,请联系老师修改",**不允许自行输入** |
|
||||||
| **选了"专项职业能力"却想填工种/等级** | 该三项不显示;若历史数据带出旧值,自动清空 |
|
| ~~选了"专项职业能力"却想填工种/等级~~ | **不做**(2026-10 取消该认定类型) |
|
||||||
|
|
||||||
### 3.5 材料上传与材料识别逻辑
|
### 3.5 材料上传与材料识别逻辑
|
||||||
|
|
||||||
@@ -634,7 +626,7 @@
|
|||||||
| # | 场景 | 问题 |
|
| # | 场景 | 问题 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 填报上传 | 用户在"身份证"槽位传了学籍表 → 需要发现并提示 |
|
| 1 | 填报上传 | 用户在"身份证"槽位传了学籍表 → 需要发现并提示 |
|
||||||
| 2 | 整理器散图分拣 | 一堆没有目录的散图 → 需要判断每张是什么,才能归入目录 |
|
| ~~2~~ | ~~整理器散图分拣~~ | **已取消**(2026-10,整理器模块删除) |
|
||||||
| 3 | 审核辅助 | 班主任想快速确认"这堆材料里有没有传错的" |
|
| 3 | 审核辅助 | 班主任想快速确认"这堆材料里有没有传错的" |
|
||||||
| 4 | 缺失检测兜底 | 例如"其它佐证材料"传了 3 张,需要区分它们分属什么 |
|
| 4 | 缺失检测兜底 | 例如"其它佐证材料"传了 3 张,需要区分它们分属什么 |
|
||||||
|
|
||||||
@@ -795,8 +787,8 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 填报上传 | 每次上传后(异步) | 冲突时提示 + 一键移动 |
|
| 填报上传 | 每次上传后(异步) | 冲突时提示 + 一键移动 |
|
||||||
| 提交前总校验 | 提交时 | 汇总"疑似传错"清单,按 🟡 警告呈现 |
|
| 提交前总校验 | 提交时 | 汇总"疑似传错"清单,按 🟡 警告呈现 |
|
||||||
| 整理器·散图分拣 | 无目录结构时 | 自动归属;判不出的进待人工归属 |
|
| ~~整理器·散图分拣~~ | — | **已取消**(2026-10) |
|
||||||
| 整理器·体检报告 | — | 输出"疑似错位"清单 |
|
| ~~整理器·体检报告~~ | — | **已取消**(2026-10) |
|
||||||
| 审核辅助 | 班主任查看时 | 材料卡片上显示"系统判定:身份证 92%" |
|
| 审核辅助 | 班主任查看时 | 材料卡片上显示"系统判定:身份证 92%" |
|
||||||
| 规则优化 | 定期 | 用人工纠正确认的结果反哺关键词表 |
|
| 规则优化 | 定期 | 用人工纠正确认的结果反哺关键词表 |
|
||||||
|
|
||||||
@@ -1146,38 +1138,13 @@
|
|||||||
- 系统记录每个人剩余的授权额度,额度不足时自动降级到服务号或短信;
|
- 系统记录每个人剩余的授权额度,额度不足时自动降级到服务号或短信;
|
||||||
- 产品层减少催报次数、提升一次填完率(减少返工即减少通知)。
|
- 产品层减少催报次数、提升一次填完率(减少返工即减少通知)。
|
||||||
|
|
||||||
### 3.12 历史数据整理器逻辑
|
### 3.12 历史数据整理器逻辑 —— **已取消**(2026-10 需求收敛)
|
||||||
|
|
||||||
**用途**:处理老师手上已经存在的"乱文件夹",无需重新收集即可产出规范结果。作为获客入口(先看到价值,再谈系统上线)。
|
> ⚠️ **整节作废**:历史整理器(含"散图分拣"与《材料体检报告》)连模块、表、接口、权限码、
|
||||||
|
> PC 页面与原型屏一并删除,**不再排期**。
|
||||||
**触发**:上传一个压缩包,或指定一个已有目录结构。
|
>
|
||||||
|
> 它当年复用的是同一套规则(形态识别 → 命名规范化 → 压缩 → 校验),这条"规则引擎只有一套"的
|
||||||
**处理步骤**
|
> 设计结论仍然成立,只是**不再有"处理历史乱文件夹"这个入口**。
|
||||||
1. **解析文件树**:识别目录与文件名,生成清单。
|
|
||||||
2. **逐文件走同一套规则**:形态识别 → 命名规范化(§3.6)→ 压缩(§3.7)→ 校验(§3.8)。
|
|
||||||
3. **生成《材料体检报告》**,包含:
|
|
||||||
- 命名违规清单(原文件名 → 规范名)
|
|
||||||
- 证件号异常清单(与名册比对的结果,含裁决结论)
|
|
||||||
- 体积超标清单(压缩前后对比)
|
|
||||||
- 分辨率不足清单
|
|
||||||
- 缺失材料清单(同一人缺了哪几项)
|
|
||||||
- 重复文件清单
|
|
||||||
4. **输出规范化后的压缩包**,目录结构可直接上报。
|
|
||||||
5. 报告与结果可生成分享链接给班主任。
|
|
||||||
|
|
||||||
**特殊能力:散图分拣**
|
|
||||||
老师从微信里收到的常常是**一堆没有目录、名字很乱的散图**。整理器需要能:
|
|
||||||
- 按证件号/姓名把散图自动归属到人;
|
|
||||||
- 无法归属的单独列出,供人工拖拽归位;
|
|
||||||
- 归位后按材料类型自动分入对应目录。
|
|
||||||
|
|
||||||
**异常分支**
|
|
||||||
|
|
||||||
| 情况 | 处理 |
|
|
||||||
|---|---|
|
|
||||||
| 压缩包内有嵌套压缩包 | 提示是否一并展开处理 |
|
|
||||||
| 存在无法识别的文件(如 `IMG_001.jpg`) | 归入"待人工归属",不猜测、不放任 |
|
|
||||||
| 名册缺失(没有 Excel) | 允许无名册运行:仅做命名规范与压缩,不做一致性校验,报告中说明该限制 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1214,7 +1181,7 @@
|
|||||||
**准入(门禁)**
|
**准入(门禁)**
|
||||||
- **两种模式都必须先导名单**,没有名单不能填报
|
- **两种模式都必须先导名单**,没有名单不能填报
|
||||||
- 进入填报前必须**填写「姓名 + 身份证号」匹配名单**,不通过只提示、不放行
|
- 进入填报前必须**填写「姓名 + 身份证号」匹配名单**,不通过只提示、不放行
|
||||||
- 匹配失败**不区分原因**(防止探测身份证号),只给"重新填写 / 申请加入名单"两个出口
|
- 匹配失败**不区分原因**(防止探测身份证号),只给「重新填写」一个出口 + 提示联系老师加人
|
||||||
- 匹配接口必须**限流**(同 openid 每小时 ≤10 次)
|
- 匹配接口必须**限流**(同 openid 每小时 ≤10 次)
|
||||||
- 姓名形近字/同音字可纠错,但必须**让用户确认**后才通过
|
- 姓名形近字/同音字可纠错,但必须**让用户确认**后才通过
|
||||||
|
|
||||||
@@ -1284,7 +1251,7 @@
|
|||||||
| 17 | 任务截止了但学生要补交 | 学生忘交 | 任务已截止 | 提示"已截止";管理员可延长截止或点名放行 | 🟡 |
|
| 17 | 任务截止了但学生要补交 | 学生忘交 | 任务已截止 | 提示"已截止";管理员可延长截止或点名放行 | 🟡 |
|
||||||
| 18 | 班主任离职/换人 | 账号停用 | 待办无人处理 | 支持转交;超时自动升级通知管理员 | 🟡 |
|
| 18 | 班主任离职/换人 | 账号停用 | 待办无人处理 | 支持转交;超时自动升级通知管理员 | 🟡 |
|
||||||
| 19 | 导出时有人材料不全 | 3 人未完成 | 前置检查不通过 | 🔴 报错并列出名单,提供"仅导出已通过者" | 🟡 |
|
| 19 | 导出时有人材料不全 | 3 人未完成 | 前置检查不通过 | 🔴 报错并列出名单,提供"仅导出已通过者" | 🟡 |
|
||||||
| 20 | 老师手里已有 475MB 的乱文件夹 | 每班 112 个文件 | 走整理器路径 | 输出体检报告 + 规范包 | ✅(内容核对) |
|
| ~~20~~ | ~~老师手里已有 475MB 的乱文件夹~~ | — | ~~走整理器路径~~ | **已取消**(2026-10:整理器模块删除) | — |
|
||||||
| 21 | 学生从**聊天记录**选了照相馆发来的照片 | 文件带原始名 `张三-...-一寸照片.jpg` | 文件名可读 → 直接提取姓名/证件号/材料名 | ✅ 一次通过;文案"已按文件名自动识别" | ❌ 自动 |
|
| 21 | 学生从**聊天记录**选了照相馆发来的照片 | 文件带原始名 `张三-...-一寸照片.jpg` | 文件名可读 → 直接提取姓名/证件号/材料名 | ✅ 一次通过;文案"已按文件名自动识别" | ❌ 自动 |
|
||||||
| 22 | 学生从**相册**选了截图 | 文件名为 `IMG_4821.PNG`,1025×590 | 无文件名信息 → 走 OCR + 人工确认归属 | 🟡"这张图没有名字信息,已识别为学籍表,请确认" | 🟡 确认 |
|
| 22 | 学生从**相册**选了截图 | 文件名为 `IMG_4821.PNG`,1025×590 | 无文件名信息 → 走 OCR + 人工确认归属 | 🟡"这张图没有名字信息,已识别为学籍表,请确认" | 🟡 确认 |
|
||||||
| 23 | 学生从聊天记录收到的是被微信压缩过的图 | 分辨率明显偏低 | 低于建议值 | 🟡"这张图可能被压缩过,建议让对方发原图" | 🟡 确认 |
|
| 23 | 学生从聊天记录收到的是被微信压缩过的图 | 分辨率明显偏低 | 低于建议值 | 🟡"这张图可能被压缩过,建议让对方发原图" | 🟡 确认 |
|
||||||
@@ -1294,7 +1261,7 @@
|
|||||||
| 27 | 同一个名单项被两个人绑定 | 冒用 / 代填冲突 | 已被绑定 | 🔴"该信息已被登记,如有疑问请联系老师" | 🟡 |
|
| 27 | 同一个名单项被两个人绑定 | 冒用 / 代填冲突 | 已被绑定 | 🔴"该信息已被登记,如有疑问请联系老师" | 🟡 |
|
||||||
| 28 | 老师忘了导入名单就想发布任务 | 操作遗漏 | 发布前校验不通过 | 🔴"请先导入名单,名单必须包含姓名和身份证号" | ❌ 阻断发布 |
|
| 28 | 老师忘了导入名单就想发布任务 | 操作遗漏 | 发布前校验不通过 | 🔴"请先导入名单,名单必须包含姓名和身份证号" | ❌ 阻断发布 |
|
||||||
| 29 | 学生把学籍表传到了"身份证"槽位 | 槽位与内容冲突 | 规则层识别出关键词"学籍",置信度 0.93 | 🟡"这张看起来是**学籍表**,不是身份证。要移过去吗?"[一键移动] | 🟡 用户确认 |
|
| 29 | 学生把学籍表传到了"身份证"槽位 | 槽位与内容冲突 | 规则层识别出关键词"学籍",置信度 0.93 | 🟡"这张看起来是**学籍表**,不是身份证。要移过去吗?"[一键移动] | 🟡 用户确认 |
|
||||||
| 30 | 学生传了一堆散图,没归类 | 微信收到的图片 | 整理器无槽位 → 按证件号归属到人 + 按内容判定材料类型 | 自动归入目录;判不出的进"待人工归属" | 🟡 部分人工 |
|
| ~~30~~ | ~~学生传了一堆散图,没归类~~ | — | ~~整理器无槽位 → 自动归属~~ | **已取消**(2026-10) | — |
|
||||||
| 31 | 学生传了一张纯色背景的图 | 一寸照片(无文字) | 形态规则:5:7 比例 + 背景纯净 → 置信度 0.88 | 🟡"请确认这张是一寸照片" | 🟡 确认 |
|
| 31 | 学生传了一张纯色背景的图 | 一寸照片(无文字) | 形态规则:5:7 比例 + 背景纯净 → 置信度 0.88 | 🟡"请确认这张是一寸照片" | 🟡 确认 |
|
||||||
| 32 | 学生把正确材料放在正确位置,但因图糊识别失败 | 槽位与内容无法比对 | 置信度 <0.4 → **不判断** | ✅ 直接放行(**不因识别失败而打扰用户**) | ❌ 系统静默放行 |
|
| 32 | 学生把正确材料放在正确位置,但因图糊识别失败 | 槽位与内容无法比对 | 置信度 <0.4 → **不判断** | ✅ 直接放行(**不因识别失败而打扰用户**) | ❌ 系统静默放行 |
|
||||||
|
|
||||||
@@ -1323,7 +1290,7 @@
|
|||||||
| **材料从哪来** | — | "这份材料一般在**微信聊天记录**里:照相馆/打印店发给你之后,从这里选,系统能自动帮你填好信息" |
|
| **材料从哪来** | — | "这份材料一般在**微信聊天记录**里:照相馆/打印店发给你之后,从这里选,系统能自动帮你填好信息" |
|
||||||
| **相册图片无名字** | 无法解析文件名 | "这张图没有名字信息,系统已识别为学籍表。请确认是不是这一份" |
|
| **相册图片无名字** | 无法解析文件名 | "这张图没有名字信息,系统已识别为学籍表。请确认是不是这一份" |
|
||||||
| **聊天图片被压缩过** | 图片分辨率偏低 | "这张图好像被微信压缩过,有点糊。可以让对方发一张原图,或者点开图片后选『原图』再保存" |
|
| **聊天图片被压缩过** | 图片分辨率偏低 | "这张图好像被微信压缩过,有点糊。可以让对方发一张原图,或者点开图片后选『原图』再保存" |
|
||||||
| **门禁:匹配失败** | 未找到该身份证号 / 姓名不匹配 | "**没有找到你的信息。**请核对姓名和身份证号是否与老师提供的名单一致。如果确实已报名,可以申请加入名单,由老师审核" |
|
| **门禁:匹配失败** | 未找到该身份证号 / 姓名不匹配 | "**没有找到你的信息。**请核对姓名和身份证号是否与老师提供的名单一致。如确认已报名,请联系老师把你加入名单" |
|
||||||
| **门禁:姓名疑似写错** | 身份证对、姓名形近 | "你是不是 **杨晨**?(老师名单上写的是这个名字)" |
|
| **门禁:姓名疑似写错** | 身份证对、姓名形近 | "你是不是 **杨晨**?(老师名单上写的是这个名字)" |
|
||||||
| **门禁:已被绑定** | 名单项已绑他人 | "该信息已被登记,如有疑问请联系老师" |
|
| **门禁:已被绑定** | 名单项已绑他人 | "该信息已被登记,如有疑问请联系老师" |
|
||||||
| **门禁:限流** | 尝试过于频繁 | "操作过于频繁,请稍后再试" |
|
| **门禁:限流** | 尝试过于频繁 | "操作过于频繁,请稍后再试" |
|
||||||
@@ -1358,9 +1325,9 @@
|
|||||||
| 8 | 导出包默认保留 7 天是否够用 | 低 | 客户 |
|
| 8 | 导出包默认保留 7 天是否够用 | 低 | 客户 |
|
||||||
| 9 | **各来源通道的实际体积上限需实测**(微信聊天记录选文件、相册、文件选择三条通道各自能传多大的文件),并据此设计超限提示与降级路径 | 中 | 研发 |
|
| 9 | **各来源通道的实际体积上限需实测**(微信聊天记录选文件、相册、文件选择三条通道各自能传多大的文件),并据此设计超限提示与降级路径 | 中 | 研发 |
|
||||||
| 10 | **微信相册/相机权限的隐私声明**需在小程序后台同步更新(当前声明需覆盖"从相册选择") | 中 | 研发 + 法务 |
|
| 10 | **微信相册/相机权限的隐私声明**需在小程序后台同步更新(当前声明需覆盖"从相册选择") | 中 | 研发 + 法务 |
|
||||||
| **11** | **名单外的人怎么处理?** 当前按需求实现为"不给填、只提示",需老师去后台加人。建议增加"申请加入名单 → 管理员审批后自动放行"通道,**请确认是否采纳** | **高** | 客户 |
|
| ~~11~~ | ~~名单外的人怎么处理?建议增加"申请加入名单 → 管理员审批后自动放行"通道~~ → **已决策(2026-10):不做自助通道,由老师手工加人** | — | 已关闭 |
|
||||||
| 12 | **门禁限流阈值**(同 openid 每小时 10 次)是否合适,需按实际人数与使用习惯标定 | 中 | 研发 |
|
| 12 | **门禁限流阈值**(同 openid 每小时 10 次)是否合适,需按实际人数与使用习惯标定 | 中 | 研发 |
|
||||||
| 13 | 社会收集的名单从哪来(预报名名单 / 报名点收集 / 机构导出)?若来源不稳定,"先导名单"的落地难度较大 | 中 | 客户 |
|
| ~~13~~ | ~~社会收集的名单从哪来~~ → **已关闭**(2026-10 取消社会收集) | — | 已关闭 |
|
||||||
| 14 | 门禁核验是否还要加**手机号验证码**(防止他人拿到姓名+身份证号后代填) | 中 | 客户 |
|
| 14 | 门禁核验是否还要加**手机号验证码**(防止他人拿到姓名+身份证号后代填) | 中 | 客户 |
|
||||||
| **15** | **材料识别的 VLM 选型与单价**(用于成本核算与供应商比价);触发率 15% 为预估值,需用真实数据实测标定 | 中 | 研发 + 商务 |
|
| **15** | **材料识别的 VLM 选型与单价**(用于成本核算与供应商比价);触发率 15% 为预估值,需用真实数据实测标定 | 中 | 研发 + 商务 |
|
||||||
| 16 | 关键词表由谁维护(平台预置 + 租户自维护);是否需要"从历史文件自动学习关键词"功能 | 中 | 产品 |
|
| 16 | 关键词表由谁维护(平台预置 + 租户自维护);是否需要"从历史文件自动学习关键词"功能 | 中 | 产品 |
|
||||||
|
|||||||
+63
-73
@@ -1,7 +1,7 @@
|
|||||||
# 数据信息填报系统 PRD
|
# 深学在线 PRD
|
||||||
|
|
||||||
> 产品代号:**填报通 / CollectFlow**(暂定)
|
> 产品代号:**深学在线**
|
||||||
> 版本:V2.3(2026-09-28 明确滑动交互规则)
|
> 版本:**V2.5**(2026-10 需求收敛:**4 角色 / 10 菜单 / 不设二期**)
|
||||||
> 日期:2026-09-28
|
> 日期:2026-09-28
|
||||||
> 文档类型:产品需求文档(PRD)+ 数据库设计 + 接口清单
|
> 文档类型:产品需求文档(PRD)+ 数据库设计 + 接口清单
|
||||||
> 适用场景:职业技能等级认定报名材料收集(可扩展至各类"班级/单位材料收集"场景)
|
> 适用场景:职业技能等级认定报名材料收集(可扩展至各类"班级/单位材料收集"场景)
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
| 版本 | 日期 | 变更内容 | 变更人 |
|
| 版本 | 日期 | 变更内容 | 变更人 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| **V2.5** | **2026-10** | **需求收敛:删角色 / 删两模块 / 取消二期**。① 内置角色 **6 → 4**(删「副班主任 `DEPUTY_TEACHER`」与「审核员 `AUDITOR`」,存量账号由 **V19 迁移**处理);② 删「**历史数据整理器**」(FR-1400 系列 + §5.14 + `collect_cleanup_job` 表 + `cleanup:run`)与「**加入申请**」(FR-509 + §7.3.19 + `collect_join_request` 表 + `join:audit` + 错误码 `40037`/`40038`/`40039`/`40040`/`40042`);③ `collect_task_org.deputy_user_ids`(协办人)**整列删除**,审核范围三级回退 → **两级**;④ **不再有任何二期功能设计** —— 社会收集 / 社会评价 / 专项职业能力 / 数据范围可配一律**不做**,OCR 与材料识别改为「**缓做,等供应商选型**」;⑤ 材料类目 6 → 4(`XJW_BA`/`OTHER_PROOF` **读得到、选不到**)、内置材料模板 3 → 1。**数字**:接口 **129 → 119**、错误码 **59 → 54**、权限码 **30 → 28**、PC 菜单 **12 → 10**、小程序页面 **10 → 9**、原型 **32 → 29 屏**。⚠️ **加入申请(确认函 B1)是客户唯一确认的功能增量,本次属需求回退,建议补变更确认单**。 | 开发 |
|
||||||
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷,M0 实测暴露)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按<b>明文手机号</b>长度建的;但 §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 <b>52 字符</b>,插入直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 <b>`varchar(128)`</b> 与 `id_card_no` 对齐。<br>**发现方式**:M0 开通租户实测时数据库报错暴露,非人工审查发现。<br>**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 | 开发 |
|
| **V2.4** | **2026-09-28** | **修正 3 处加密列宽不足(DDL 缺陷,M0 实测暴露)**:`sys_tenant.contact_phone`、`sys_user.phone`、`collect_roster.phone` 原为 `varchar(20)`,是按<b>明文手机号</b>长度建的;但 §10.3 要求手机号 AES-256 加密存储,AES-GCM 密文约 <b>52 字符</b>,插入直接报 `value too long for type character varying(20)`。(`id_card_no` 当初给了 `varchar(128)` 就是考虑到了加密,手机号三列漏了——属设计不一致。)三列统一改为 <b>`varchar(128)`</b> 与 `id_card_no` 对齐。<br>**发现方式**:M0 开通租户实测时数据库报错暴露,非人工审查发现。<br>**审计结论**:其余 `*_mask varchar(32)` 列存的是脱敏值(如 `138****8888`)而非密文,宽度足够,无需改动。 | 开发 |
|
||||||
| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 |
|
| V1.0 | 2026-09-24 | 初稿:背景/流程/功能/规则引擎/库表/接口 | 产品 |
|
||||||
| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):<br>① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)<br>② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"<br>③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG<br>④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例<br>⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)<br>⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)<br>⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)<br>⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项<br>⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 |
|
| **V1.1** | 2026-09-24 | **复审修订**(用 112 个真实文件重新实测验证):<br>① **勘误**:命名违规率 98.2% → **52.7%**(59/112);双扩展名 55 → **56**;原始总量 436MB → **475MB**(436MB 实为 .rar 包体积);需处理并集 62 → **59**(原为重复计数)<br>② **推翻参数假设**:工作证明是 1272dpi A4 扫描件(非手机拍照)→ §6.3 压缩策略由"按长边"重写为"按材料形态分五档"<br>③ 学籍表是 16:9 截图 → 新增 P4 截图档,禁止缩放与转 JPG<br>④ 新增 §6.1 证件号裁决矩阵,人工量由 5 例降至 3 例<br>⑤ **新增风险 R11**(1.8 亿像素导致 OOM)、**R12**(微信订阅消息次数限制)<br>⑥ **补全 §9.4 班主任移动端页面清单**(初稿遗漏)<br>⑦ 修正 §1.4 指标口径混淆(班主任操作时长 vs 端到端周期)<br>⑧ 补充小程序隐私保护指引、DPA、KMS 等合规项<br>⑨ 补 `collect_task_org` 表、名册 `student_no` 字段、附录 D 错误码、附录 E 定时任务 | 产品 |
|
||||||
@@ -50,20 +51,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一期范围声明(2026-09-28 客户确认)
|
## 一期范围声明(2026-09-28 收窄 → 2026-10 收敛为最终口径)
|
||||||
|
|
||||||
> **一期只做「学校收集 + 自主认定」这一条流程。**
|
> **只做「学校收集 + 自主认定」这一条流程**,且**不再设计任何二期功能**(2026-10 客户收敛)。
|
||||||
> 社会收集、社会评价、专项职业能力 **全部后置**;
|
> 社会收集、社会评价、专项职业能力 **一律不做**:`collectMode=2` 与 `certType=2,3` 传入返回 **`40044`**。
|
||||||
> 但 **数据结构与枚举保留不启用**——二期直接开,不需要 DB 迁移。
|
> **数据结构与枚举保留不启用**(留着是因为这三项本来配置驱动、不增加维护成本),
|
||||||
|
> 但**没有"将来直接开"的承诺** —— 要用时按新需求重新评估。
|
||||||
|
|
||||||
### 范围切分
|
### 范围切分
|
||||||
|
|
||||||
| 维度 | 字段 | 一期 | 二期后置 | 为什么这么切 |
|
| 维度 | 字段 | 本期做 | **不做**(已取消,不是"后置") | 为什么这么切 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **收集模式** | `collect_mode` | `1` 学校收集 | `2` 社会收集 | 一期只做一条审核流(学生→班主任→管理员),少一条分支、少一套测试 |
|
| **收集模式** | `collect_mode` | `1` 学校收集 | `2` 社会收集(传 2 → `40044`) | 只保留一条审核流(学生→班主任→管理员),少一条分支、少一套测试 |
|
||||||
| **认定类型** | `cert_type` | `1` 自主认定 | `2` 社会评价<br>`3` 专项职业能力 | 后两者会引入**两类零实测样本的材料**,识别规则目前是推测 |
|
| **认定类型** | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力(传 2/3 → `40044`) | 后两者会引入**两类零实测样本的材料**,识别规则只能是推测 |
|
||||||
|
|
||||||
### 一期支持的材料(4 类,全部有 112 个真实文件实测)
|
### 支持的材料(4 类,全部有 112 个真实文件实测)
|
||||||
|
|
||||||
| 材料编码 | 名称 | 规范文件名示例 | 压缩档位 | 实测 |
|
| 材料编码 | 名称 | 规范文件名示例 | 压缩档位 | 实测 |
|
||||||
|---|---|---|---|:--:|
|
|---|---|---|---|:--:|
|
||||||
@@ -72,49 +74,41 @@
|
|||||||
| `SCHOOL_ROLL` | 学籍表 | `张三-3601...-学籍表.jpg` | P4 截图 | ✅ |
|
| `SCHOOL_ROLL` | 学籍表 | `张三-3601...-学籍表.jpg` | P4 截图 | ✅ |
|
||||||
| `WORK_PROOF` | 工作证明 | `张三-3601...-工作证明.jpg` | P3 扫描件 | ✅ |
|
| `WORK_PROOF` | 工作证明 | `张三-3601...-工作证明.jpg` | P3 扫描件 | ✅ |
|
||||||
|
|
||||||
### 二期才做的材料(**零实测样本**)
|
### 不做、也**不再排期**的东西(2026-10 收敛)
|
||||||
|
|
||||||
| 材料编码 | 名称 | 出现在 | 现状 |
|
| 项 | 现状 |
|
||||||
|---|---|---|---|
|
|---|---|
|
||||||
| `XJW_BA` | 学信网备案表 | 社会评价 | 强特征词是从**官方模板的文字要求**里抄的,**未见一张真实文件** |
|
| `XJW_BA` 学信网备案表 / `OTHER_PROOF` 其它佐证材料 | 编码**保留**(历史数据读得到),但**选不到**(不在"新增材料"列表里);强特征词从未标定 |
|
||||||
| `OTHER_PROOF` | 其它佐证材料 | 社会评价 / 专项 | 含毕业证/社保单/职称/技能证书**四类版式完全不同**的东西,**可能不该做自动识别** |
|
| 社会收集审核流(跳过班主任初审) | **不做** |
|
||||||
|
| 专项职业能力的字段隐藏与校验绕过 | **不做** |
|
||||||
|
| 内置材料模板 `TPL_SOCIAL` / `TPL_ZHUANXIANG` | **已删除**,只剩 `TPL_SCHOOL_ROLL` |
|
||||||
|
| 原「后置清单」6 项(索取样本 / 标定特征词 / 报名点名单流程 / …) | **全部作废** |
|
||||||
|
|
||||||
### 后置清单(二期启动前要准备的事)
|
### 对现有设计的影响:没有
|
||||||
|
|
||||||
| # | 事项 | 依赖 |
|
|
||||||
|:--:|---|---|
|
|
||||||
| 1 | 索取「社会评价」批次原始材料样本(≥10 份,须含备案表与各类佐证) | 客户提供(D4) |
|
|
||||||
| 2 | 标定 `XJW_BA` 的强特征词与形态阈值 | ↑ |
|
|
||||||
| 3 | 决定 `OTHER_PROOF` 是否做自动识别,还是改为纯手动槽位 | ↑ |
|
|
||||||
| 4 | 实现社会收集审核流(跳过班主任初审,直达管理员) | 无 |
|
|
||||||
| 5 | 实现专项职业能力的字段隐藏(工种/等级/补考三项)与校验绕过 | 无 |
|
|
||||||
| 6 | 社会收集的名单来源流程(报名点/机构维护预报名名单) | 客户确认(B2) |
|
|
||||||
|
|
||||||
### 对现有设计的影响:几乎没有
|
|
||||||
|
|
||||||
**因为这三项本来就是配置驱动的**:
|
**因为这三项本来就是配置驱动的**:
|
||||||
|
|
||||||
| 后置项 | 现有实现方式 | 代码改动量 |
|
| 项 | 现有实现方式 | 代码改动量 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 社会收集跳过初审 | `audit_config.needClassAudit` 是一个开关 | 0(只是一个配置值) |
|
| 社会收集跳过初审 | `audit_config.needClassAudit` 是一个开关 | 0(只是一个配置值) |
|
||||||
| 专项职业能力的字段隐藏 | 走 `visibleWhen` 条件显示规则 | 0(配置一条规则) |
|
| 专项职业能力的字段隐藏 | 走 `visibleWhen` 条件显示规则 | 0(配置一条规则) |
|
||||||
| 材料清单差异 | 材料项定义(`collect_material_def`) | 0(配置差异) |
|
| 材料清单差异 | 材料项定义(`collect_material_def`) | 0(配置差异) |
|
||||||
|
|
||||||
> ⇒ **一期收窄省下的主要是「测试与联调成本」和「一个外部依赖(D4)」,而不是代码量。**
|
> ⇒ **收窄省下的主要是「测试与联调成本」和「一个外部依赖(D4)」,而不是代码量。**
|
||||||
>
|
>
|
||||||
> **工期影响**:**−5 人日**(392 → 387)。**关键路径不变,工期仍为 15 周。**
|
> **工期影响**:**−5 人日**(392 → 387)。**关键路径不变,工期仍为 15 周。**
|
||||||
|
|
||||||
### 一期不做的验证项(从测试范围移除)
|
### 从测试范围移除的验证项
|
||||||
|
|
||||||
| 移除项 | 原因 |
|
| 移除项 | 原因 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 主流程 B(社会收集,3 步) | 二期 |
|
| 主流程 B(社会收集,3 步) | 不做 |
|
||||||
| `XJW_BA` / `OTHER_PROOF` 的识别断言 | 零样本,无法定基线 |
|
| `XJW_BA` / `OTHER_PROOF` 的识别断言 | 零样本,无法定基线 |
|
||||||
| 专项职业能力的字段隐藏与 VAL-14/15 绕过用例 | 二期 |
|
| 专项职业能力的字段隐藏与 VAL-14/15 绕过用例 | 不做 |
|
||||||
| 社会收集的跳过初审用例 | 二期 |
|
| 社会收集的跳过初审用例 | 不做 |
|
||||||
|
|
||||||
> ⚠️ **二期启用时必须要补的回归断言**:`XJW_BA` / `OTHER_PROOF` 的识别准确率、社会收集审核流、专项职业能力字段隐藏。
|
> ⚠️ 若将来真要启用,**必须补**的回归断言:`XJW_BA` / `OTHER_PROOF` 的识别准确率、社会收集审核流、专项职业能力字段隐藏。
|
||||||
> 这些都已在本文档与《06-验收测试清单》中保留描述,只是标注为二期。
|
> 描述仍保留在本文档与《06-验收测试清单》里,但**不再标注为"二期"** —— 那会被误读成"已经排期了"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -283,7 +277,7 @@
|
|||||||
| **规范名(Standard Name)** | 由命名规则引擎根据模板生成的最终文件名 |
|
| **规范名(Standard Name)** | 由命名规则引擎根据模板生成的最终文件名 |
|
||||||
| **初审 / 终审** | 班主任审核 / 管理员审核 |
|
| **初审 / 终审** | 班主任审核 / 管理员审核 |
|
||||||
| **汇总包(Export Package)** | 按目录规范打包后可直接上报的文件集合 |
|
| **汇总包(Export Package)** | 按目录规范打包后可直接上报的文件集合 |
|
||||||
| **历史数据整理器** | 对已存在的"乱文件夹/压缩包"做批量规范化与体检的独立工具 |
|
| ~~**历史数据整理器**~~ | **已取消**(2026-10):对历史乱文件夹做批量规范化与体检的独立工具,连模块/表/权限码/页面一并删除 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -501,7 +495,7 @@ DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING
|
|||||||
| FR-307 | **报考范围自动聚合**:名单导入后自动提取"职业 × 工种 × 等级"集合 | **P0** |
|
| FR-307 | **报考范围自动聚合**:名单导入后自动提取"职业 × 工种 × 等级"集合 | **P0** |
|
||||||
| FR-308 | **名单一致性体检**:职业/工种矛盾、等级异常、漏填、拼写不符 → 输出体检报告 | **P0** |
|
| FR-308 | **名单一致性体检**:职业/工种矛盾、等级异常、漏填、拼写不符 → 输出体检报告 | **P0** |
|
||||||
| FR-309 | 报考范围支持管理员**手工修正**,并留痕(谁改的、改前改后) | **P0** |
|
| FR-309 | 报考范围支持管理员**手工修正**,并留痕(谁改的、改前改后) | **P0** |
|
||||||
| FR-310 | **专项职业能力**类型支持:自动隐藏工种、等级、补考相关字段 | ~~P0~~ **二期**<br>⚠️ 一期只做自主认定(见「一期范围声明」) |
|
| FR-310 | **专项职业能力**类型支持:自动隐藏工种、等级、补考相关字段 | ~~P0~~ **不做**(2026-10 取消二期;只做自主认定,见「一期范围声明」) |
|
||||||
| FR-311 | 追加名单时,若出现**范围外的新报考项**,必须告警并由管理员确认后才接受 | **P0** |
|
| FR-311 | 追加名单时,若出现**范围外的新报考项**,必须告警并由管理员确认后才接受 | **P0** |
|
||||||
| FR-312 | 可选:接入国标职业字典做**拼写校验与联想建议**(仅辅助,不作强约束) | P2 |
|
| FR-312 | 可选:接入国标职业字典做**拼写校验与联想建议**(仅辅助,不作强约束) | P2 |
|
||||||
|
|
||||||
@@ -725,7 +719,7 @@ DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING
|
|||||||
| **FR-502a** | **名单即预填报名表**:名单 21 列的值全部入库(**18 个正式列 + 姓名/身份证号已有列**),学生端按"锁定/预填可改/学生必填"三类规则呈现(见 §5.3.1) | **P0** |
|
| **FR-502a** | **名单即预填报名表**:名单 21 列的值全部入库(**18 个正式列 + 姓名/身份证号已有列**),学生端按"锁定/预填可改/学生必填"三类规则呈现(见 §5.3.1) | **P0** |
|
||||||
| **FR-502b** | **名单字段三类规则**:姓名+身份证号+报考职业/工种/等级 → **锁定**(名单为空则阻断发布);其余 → 有值则预填可改、无值则学生必填 | **P0** |
|
| **FR-502b** | **名单字段三类规则**:姓名+身份证号+报考职业/工种/等级 → **锁定**(名单为空则阻断发布);其余 → 有值则预填可改、无值则学生必填 | **P0** |
|
||||||
| FR-503 | **名单核验(门禁)**:填报人进入前必须填写「姓名 + 身份证号」匹配名单,命中才进入填报页;命中后绑定名单项 | P0 |
|
| FR-503 | **名单核验(门禁)**:填报人进入前必须填写「姓名 + 身份证号」匹配名单,命中才进入填报页;命中后绑定名单项 | P0 |
|
||||||
| FR-504 | **未命中统一提示**(不区分"号码不在名单"与"姓名不符",防探测),提供 **"重新填写"** 与 **"申请加入名单"(FR-509)** 两条出路;**取消原"自助认领"** | P0 |
|
| FR-504 | **未命中统一提示**(不区分"号码不在名单"与"姓名不符",防探测),只给 **"重新填写"** 一条系统内出路 + 一句"如确认已报名,请联系老师把你加入名单";**取消原"自助认领"与"申请加入名单"(FR-509)** | P0 |
|
||||||
| FR-504a | **匹配接口限流**:同 openid 每小时 ≤10 次、同 IP 每分钟 ≤20 次,超出锁定并提示 | P0 |
|
| FR-504a | **匹配接口限流**:同 openid 每小时 ≤10 次、同 IP 每分钟 ≤20 次,超出锁定并提示 | P0 |
|
||||||
| FR-504b | 名单**不下发前端**,匹配全程在服务端完成;失败尝试不记录明文证件号 | P0 |
|
| FR-504b | 名单**不下发前端**,匹配全程在服务端完成;失败尝试不记录明文证件号 | P0 |
|
||||||
| FR-504c | 姓名形近字/同音字纠错:身份证号命中但姓名不符时,给出"你是不是 XXX?"让用户确认 | P1 |
|
| FR-504c | 姓名形近字/同音字纠错:身份证号命中但姓名不符时,给出"你是不是 XXX?"让用户确认 | P1 |
|
||||||
@@ -734,7 +728,7 @@ DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING
|
|||||||
| FR-506 | 名单变更留痕(谁在何时把 A 的证件号从 X 改成 Y) | P0 |
|
| FR-506 | 名单变更留痕(谁在何时把 A 的证件号从 X 改成 Y) | P0 |
|
||||||
| FR-507 | **发布前校验**:名单人数 > 0 且必须含「姓名」「身份证号」列,否则阻断发布 | P0 |
|
| FR-507 | **发布前校验**:名单人数 > 0 且必须含「姓名」「身份证号」列,否则阻断发布 | P0 |
|
||||||
| FR-508 | 名单追加导入(按证件号去重合并),**追加后新人立即可填报** | P0 |
|
| FR-508 | 名单追加导入(按证件号去重合并),**追加后新人立即可填报** | P0 |
|
||||||
| **FR-509** | **名单加入申请(客户已确认采纳的建议增量)**:门禁失败页提供「**申请加入名单**」入口 → 生成管理员待办 → **审批通过自动写入名册并放行**。解决了"名单外的人只能找老师手工加人"的运营痛点 | **P0** |
|
| ~~**FR-509**~~ | ~~名单加入申请:门禁失败页提供「申请加入名单」入口 → 管理员待办 → 审批通过自动写入名册并放行~~ → **已撤销**(2026-10 需求回退):名单外的人由老师在任务里**手工加人**(FR-508 追加导入),系统内**不展示任何联系方式**。⚠️ 它是确认函 B1 的唯一功能增量,建议补变更确认单 | ~~P0~~ |
|
||||||
| FR-509a | **通道边界**:入口**只在门禁失败页**,不在填报页内;**申请期间仍不能填报**——通道只解决"进名单",**不绕过门禁** | P0 |
|
| FR-509a | **通道边界**:入口**只在门禁失败页**,不在填报页内;**申请期间仍不能填报**——通道只解决"进名单",**不绕过门禁** | P0 |
|
||||||
| FR-509b | 申请表单:姓名、身份证号、手机号(**均必填**)+ 自述班级 / 说明(选填);提交后展示"已提交,等待学校审核",并可查看审批进度 | P0 |
|
| FR-509b | 申请表单:姓名、身份证号、手机号(**均必填**)+ 自述班级 / 说明(选填);提交后展示"已提交,等待学校审核",并可查看审批进度 | P0 |
|
||||||
| FR-509c | **限流与防灌**:同 openid 每任务 ≤ **2 次**;**同证件号每任务仅保留 1 条待审或已通过申请**(被驳回后可重新申请);单任务待审申请 > 50 条时告警管理员 | P0 |
|
| FR-509c | **限流与防灌**:同 openid 每任务 ≤ **2 次**;**同证件号每任务仅保留 1 条待审或已通过申请**(被驳回后可重新申请);单任务待审申请 > 50 条时告警管理员 | P0 |
|
||||||
@@ -920,20 +914,13 @@ DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING
|
|||||||
| 旗舰版 | 多校区/大型机构 | 不限人数,含定制规则、专属客服、API 对接 |
|
| 旗舰版 | 多校区/大型机构 | 不限人数,含定制规则、专属客服、API 对接 |
|
||||||
| 私有化 | 政企/大型认定机构 | 一次性 + 年维护 |
|
| 私有化 | 政企/大型认定机构 | 一次性 + 年维护 |
|
||||||
|
|
||||||
### 5.14 历史数据整理器(FR-1400)★ 差异化获客功能
|
### 5.14 历史数据整理器(FR-1400)—— **已取消**(2026-10 需求收敛)
|
||||||
|
|
||||||
**背景**:客户手头已有大量"历史乱数据"(如本项目的 436MB `.rar`)。让他们先看到价值,再谈系统上线。
|
> ⚠️ **整节作废**:FR-1401 ~ FR-1406 六条需求、`collect_cleanup_job` 表、`cleanup:run` 权限码、
|
||||||
|
> PC 端「历史整理器」菜单与页面、原型「整理器」屏、`e2e-m7-cleanup.py` **已全部删除**,**不再排期**。
|
||||||
| 编号 | 需求 | 优先级 |
|
>
|
||||||
|---|---|:--:|
|
> 原用途(让老师先看到价值再谈上线)记录在此仅供追溯;若将来重启,按新需求重新评估,
|
||||||
| FR-1401 | 上传 ZIP/RAR 或指定目录结构,系统自动解析文件树 | P1 |
|
> 并注意它当年最有说服力的那条结论仍然成立:**112 个文件里 90 个系统全自动处理,只有 1 个必须人工判断**。
|
||||||
| FR-1402 | 自动识别"姓名 / 证件号 / 材料名"三要素并规范化命名 | P1 |
|
|
||||||
| FR-1403 | 批量压缩到目标体积、格式统一 | P1 |
|
|
||||||
| FR-1404 | 输出**《材料体检报告》**:命名违规清单、证件号异常清单、体积超标清单、缺失材料清单、重复文件清单 | P1 |
|
|
||||||
| FR-1405 | 输出规范化后的 ZIP 包 + 可直接上报的目录结构 | P1 |
|
|
||||||
| FR-1406 | 报告与结果一键分享给班主任(生成链接) | P2 |
|
|
||||||
|
|
||||||
> 用本项目 `24中技数控1班` 做基准测试集,预期报告输出:命名违规 **59 项**、证件号异常 5 项、体积超标 31 项、疑似串档 1 项。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1017,7 +1004,7 @@ DRAFT ──submit──▶ PENDING_CLASS ──班主任通过──▶ PENDING
|
|||||||
> 这正是校验位算法带来的直接收益:它把"这到底是谁的材料"从人判断变成机器判断。
|
> 这正是校验位算法带来的直接收益:它把"这到底是谁的材料"从人判断变成机器判断。
|
||||||
|
|
||||||
> 📌 **已废除**:原矩阵中"不在名册 → 转**待认领**队列"——**"自助认领"机制已在 V1.5 废除**(会绕过门禁)。
|
> 📌 **已废除**:原矩阵中"不在名册 → 转**待认领**队列"——**"自助认领"机制已在 V1.5 废除**(会绕过门禁)。
|
||||||
> 证件号不在名册时的正确出路是:**引导走「申请加入名单」通道**(FR-509,客户已确认采纳),
|
> 证件号不在名册时的正确出路是:**先核对姓名/身份证号是否输错**;确认无误则**联系老师把你加进名单**(老师在任务里手工加人,FR-508)。原「申请加入名单」自助通道已于 2026-10 撤销。
|
||||||
> 由管理员逐条审批后入册,而不是让系统自行认领。
|
> 由管理员逐条审批后入册,而不是让系统自行认领。
|
||||||
|
|
||||||
**容错与纠正对照表(基于真实数据)**
|
**容错与纠正对照表(基于真实数据)**
|
||||||
@@ -1089,7 +1076,7 @@ FUNCTION name_differs_soft(fn, rn): # 仅用于矩阵第 2 条的"留痕"
|
|||||||
合法 ⟺ C[S mod 11] == a[17](X 需大写比较)
|
合法 ⟺ C[S mod 11] == a[17](X 需大写比较)
|
||||||
```
|
```
|
||||||
|
|
||||||
**用途**:填报端实时校验、名册导入体检、历史数据整理器、OCR 结果二次验证。
|
**用途**:填报端实时校验、名册导入体检、OCR 结果二次验证。(原"历史数据整理器"一项已随模块删除)
|
||||||
|
|
||||||
### 6.3 压缩规则(按材料形态分档 + 强制 1M)
|
### 6.3 压缩规则(按材料形态分档 + 强制 1M)
|
||||||
|
|
||||||
@@ -1292,8 +1279,8 @@ erDiagram
|
|||||||
| 业务 | `collect_notify_record` | 通知记录与订阅额度 | ✅ |
|
| 业务 | `collect_notify_record` | 通知记录与订阅额度 | ✅ |
|
||||||
| 业务 | `collect_subscribe_quota` | **微信订阅消息额度余额**(应对 R12 次数限制) | ✅ |
|
| 业务 | `collect_subscribe_quota` | **微信订阅消息额度余额**(应对 R12 次数限制) | ✅ |
|
||||||
| 业务 | `collect_rule_template` | 规则模板(可复用) | ✅ |
|
| 业务 | `collect_rule_template` | 规则模板(可复用) | ✅ |
|
||||||
| 业务 | `collect_cleanup_job` | 历史数据整理任务 | ✅ |
|
| ~~业务~~ | ~~`collect_cleanup_job`~~ | **已删除**(2026-10,见 V19 迁移) | — |
|
||||||
| 业务 | `collect_join_request` | **名单加入申请**(⚠️ 客户确认后新增,FR-509) | ✅ |
|
| ~~业务~~ | ~~`collect_join_request`~~ | **已删除**(2026-10,见 V19 迁移) | — |
|
||||||
|
|
||||||
> ✅ = 本文提供完整 DDL;从略 = 标准表(角色/字典/日志),沿用现有框架实现。
|
> ✅ = 本文提供完整 DDL;从略 = 标准表(角色/字典/日志),沿用现有框架实现。
|
||||||
>
|
>
|
||||||
@@ -1610,7 +1597,7 @@ CREATE TABLE collect_task_org (
|
|||||||
CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id);
|
CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id);
|
||||||
COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定';
|
COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定';
|
||||||
COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id';
|
COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id';
|
||||||
COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔';
|
-- ⚠️ 2026-10 需求收敛:`deputy_user_ids`(协办人)**整列已删除**(V19 迁移)。审核范围由三级回退收窄为两级(`head_user_id` → `sys_org.leader_user_id`)。
|
||||||
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同';
|
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同';
|
||||||
COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效';
|
COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效';
|
||||||
```
|
```
|
||||||
@@ -1891,7 +1878,7 @@ COMMENT ON COLUMN collect_file.detect_conflict IS '是否与槽位冲突(1=冲
|
|||||||
COMMENT ON COLUMN collect_file.need_manual_place IS '是否需要人工归属(散图分拣用)';
|
COMMENT ON COLUMN collect_file.need_manual_place IS '是否需要人工归属(散图分拣用)';
|
||||||
COMMENT ON COLUMN collect_file.vlm_called IS '是否触发了VLM兜底(用于成本核算)';
|
COMMENT ON COLUMN collect_file.vlm_called IS '是否触发了VLM兜底(用于成本核算)';
|
||||||
COMMENT ON COLUMN collect_file.validate_status IS '0未校验 1通过 2警告 3错误';
|
COMMENT ON COLUMN collect_file.validate_status IS '0未校验 1通过 2警告 3错误';
|
||||||
COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入';
|
COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入(⚠️ 整理器模块已于 2026-10 取消,4 仅历史数据)';
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 7.3.12 校验问题
|
#### 7.3.12 校验问题
|
||||||
@@ -2087,6 +2074,7 @@ COMMENT ON COLUMN collect_rule_template.materials IS '材料清单模板';
|
|||||||
#### 7.3.18 历史数据整理任务
|
#### 7.3.18 历史数据整理任务
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
-- ⚠️ 2026-10 需求收敛:**本表已删除**(见 V19 迁移)。以下 DDL 仅作历史记录,不要再照着建。
|
||||||
CREATE TABLE collect_cleanup_job (
|
CREATE TABLE collect_cleanup_job (
|
||||||
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
tenant_id bigint NOT NULL,
|
tenant_id bigint NOT NULL,
|
||||||
@@ -2113,9 +2101,10 @@ COMMENT ON COLUMN collect_cleanup_job.status IS '0排队 1解析中 2处理中 3
|
|||||||
COMMENT ON COLUMN collect_cleanup_job.report_json IS '体检报告:命名违规/证件号异常/体积超标/缺失/重复';
|
COMMENT ON COLUMN collect_cleanup_job.report_json IS '体检报告:命名违规/证件号异常/体积超标/缺失/重复';
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 7.3.19 名单加入申请(⚠️ 客户确认后新增)
|
#### 7.3.19 名单加入申请(**已撤销** · 2026-10 需求收敛)
|
||||||
|
|
||||||
> 对应 FR-509 系列。解决"名单外的人只能找老师手工加人"的运营痛点。
|
> ⚠️ **本表已删除**(见 V19 迁移),FR-509 通道整体取消。以下 DDL 仅作历史记录。
|
||||||
|
> 现在的口径:门禁失败只提示核对 + "请联系老师把你加入名单",由老师手工加人;系统内不展示联系方式。
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE collect_join_request (
|
CREATE TABLE collect_join_request (
|
||||||
@@ -2497,7 +2486,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 首页(任务列表) | `pages/index` | 卡片式展示任务;显示进度环"已完成 3/5 项";倒计时提醒 |
|
| 首页(任务列表) | `pages/index` | 卡片式展示任务;显示进度环"已完成 3/5 项";倒计时提醒 |
|
||||||
| **门禁核验** | `pages/gate/verify` | ⚠️ **新增(入口第一屏)**:填写「姓名 + 身份证号」;身份证输入用数字键盘、自动分段、末位 X 大写、支持粘贴;本地先校验格式与校验位;提交后匹配名单 |
|
| **门禁核验** | `pages/gate/verify` | ⚠️ **新增(入口第一屏)**:填写「姓名 + 身份证号」;身份证输入用数字键盘、自动分段、末位 X 大写、支持粘贴;本地先校验格式与校验位;提交后匹配名单 |
|
||||||
| **门禁失败** | `pages/gate/fail` | 统一提示"没有找到你的信息";**两个系统内动作:重新填写 / 申请加入名单**(FR-509)。**不展示任何联系方式** |
|
| **门禁失败** | `pages/gate/fail` | 统一提示"没有找到你的信息";**只有「重新填写」一个系统内动作**,另加一句"如确认已报名,请联系老师把你加入名单"。**不展示任何联系方式**(原「申请加入名单」通道已于 2026-10 撤销) |
|
||||||
| 任务详情 | `pages/task/detail` | 材料清单、状态、示例图、操作指引 |
|
| 任务详情 | `pages/task/detail` | 材料清单、状态、示例图、操作指引 |
|
||||||
| 信息填写 | `pages/form/index` | 分步表单;已匹配名册自动回填;身份证 OCR 一键识别 |
|
| 信息填写 | `pages/form/index` | 分步表单;已匹配名册自动回填;身份证 OCR 一键识别 |
|
||||||
| 材料上传 | `pages/upload/index` | **来源选择:微信聊天记录 / 手机相册 / 手机文件**(无"拍照");每个材料项附"去哪里找"引导;上传即显示"压缩中 92%…";实时校验结果 |
|
| 材料上传 | `pages/upload/index` | **来源选择:微信聊天记录 / 手机相册 / 手机文件**(无"拍照");每个材料项附"去哪里找"引导;上传即显示"压缩中 92%…";实时校验结果 |
|
||||||
@@ -2533,9 +2522,9 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
│ 没有找到你的信息 │
|
│ 没有找到你的信息 │
|
||||||
│ 请核对姓名和身份证号是否与老师 │
|
│ 请核对姓名和身份证号是否与老师 │
|
||||||
│ 提供的名单一致。 │
|
│ 提供的名单一致。 │
|
||||||
│ 如果确实已报名,可申请加入名单。 │
|
│ 如已报名,请联系老师加你进名单。 │
|
||||||
│ │
|
│ │
|
||||||
│[ 重新填写 ] [ 申请加入名单 ] │
|
│[ 重新填写 ] │
|
||||||
└──────────────────────────────────┘
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2612,7 +2601,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| 导出中心 | 创建导出、进度、历史、下载 |
|
| 导出中心 | 创建导出、进度、历史、下载 |
|
||||||
| 通知中心 | 模板管理、发送记录、催报设置 |
|
| 通知中心 | 模板管理、发送记录、催报设置 |
|
||||||
| 系统设置 | 组织、用户、角色、字典、日志、套餐用量 |
|
| 系统设置 | 组织、用户、角色、字典、日志、套餐用量 |
|
||||||
| 整理器(差异化) | 上传压缩包 → 体检报告 → 一键规范化 → 下载结果 |
|
| ~~整理器(差异化)~~ | **已取消**(2026-10) |
|
||||||
|
|
||||||
**命名规则编辑器**(重点)
|
**命名规则编辑器**(重点)
|
||||||
|
|
||||||
@@ -2739,13 +2728,13 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| **M3 规则引擎(核心)** | 命名规范化、**五档压缩(libvips)**、格式转换、校验位裁决、OCR、**超大图 OOM 防护** | 3.5 周(A/B 验证已因实测达标而取消,较 V1.2 减 0.5 周) | 可上传即规范化 |
|
| **M3 规则引擎(核心)** | 命名规范化、**五档压缩(libvips)**、格式转换、校验位裁决、OCR、**超大图 OOM 防护** | 3.5 周(A/B 验证已因实测达标而取消,较 V1.2 减 0.5 周) | 可上传即规范化 |
|
||||||
| **M4 审核与导出** | 审核工作台、批量审核、汇总导出、分卷打包 | 2 周 | 全流程闭环(PC 端) |
|
| **M4 审核与导出** | 审核工作台、批量审核、汇总导出、分卷打包 | 2 周 | 全流程闭环(PC 端) |
|
||||||
| **M5 移动端** | 小程序填报端 + **班主任端**、通知与催报(含订阅消息授权策略) | 2.5 周 | 手机端可用 |
|
| **M5 移动端** | 小程序填报端 + **班主任端**、通知与催报(含订阅消息授权策略) | 2.5 周 | 手机端可用 |
|
||||||
| **M6 整理器** | 历史数据批量规范化 + 体检报告 | 1.5 周 | 获客工具 |
|
| ~~**M6 整理器**~~ | **已取消**(2026-10):历史数据批量规范化 + 体检报告 | — | — |
|
||||||
| **M7 联调与试运行** | 压测(**必须用 28 张 1.6 亿像素真实扫描件**)、真实数据回归、试点 1–2 个班 | 2 周 | 试运行报告 |
|
| **M7 联调与试运行** | 压测(**必须用 28 张 1.6 亿像素真实扫描件**)、真实数据回归、试点 1–2 个班 | 2 周 | 试运行报告 |
|
||||||
| **合计** | 部分任务可并行 | **12.5–14.5 周** | V1.0 上线 |
|
| **合计** | 部分任务可并行 | **12.5–14.5 周** | V1.0 上线 |
|
||||||
|
|
||||||
**MVP 建议范围(若需 6 周内出可用版本)**
|
**MVP 建议范围(若需 6 周内出可用版本)**
|
||||||
保留:M1 + M2(简化版:表单字段固定为模板)+ M3 + M4 + M5
|
保留:M1 + M2(简化版:表单字段固定为模板)+ M3 + M4 + M5
|
||||||
砍掉:整理器(M6)、证件照规格化、自动催报、部分统计看板
|
砍掉:**整理器(M6,已于 2026-10 正式取消)**、证件照规格化、自动催报、部分统计看板
|
||||||
|
|
||||||
> ⚠️ **不建议再压缩 M3**:超大图 OOM 防护与压缩参数验证是项目成败的关键路径,压缩这部分的工期会直接把风险推到试运行阶段(即"在真实客户面前爆炸")。
|
> ⚠️ **不建议再压缩 M3**:超大图 OOM 防护与压缩参数验证是项目成败的关键路径,压缩这部分的工期会直接把风险推到试运行阶段(即"在真实客户面前爆炸")。
|
||||||
|
|
||||||
@@ -2806,7 +2795,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| R3 | 微信小程序审核受阻(涉及教育/UGC 类目、需资质) | 高 | ① 提前确认类目与资质(可能需 ICP + 办学许可或合作方资质);② 备选 H5 方案(扫码即填)保底 |
|
| R3 | 微信小程序审核受阻(涉及教育/UGC 类目、需资质) | 高 | ① 提前确认类目与资质(可能需 ICP + 办学许可或合作方资质);② 备选 H5 方案(扫码即填)保底 |
|
||||||
| R4 | 个人信息合规(身份证号属敏感个人信息) | 高 | 见 10.3;签约时明确数据处理者角色与责任划分(SaaS 服务商 = 受托处理者) |
|
| R4 | 个人信息合规(身份证号属敏感个人信息) | 高 | 见 10.3;签约时明确数据处理者角色与责任划分(SaaS 服务商 = 受托处理者) |
|
||||||
| R5 | 存储与带宽成本超预期(原始文件 3.8TB/学期) | 中 | 强制原始文件 30 天清理 + 客户端预压缩 + 导出走签名 URL |
|
| R5 | 存储与带宽成本超预期(原始文件 3.8TB/学期) | 中 | 强制原始文件 30 天清理 + 客户端预压缩 + 导出走签名 URL |
|
||||||
| R6 | 老师抵触新系统("又要多学一个东西") | 高 | ① "历史数据整理器"作为钩子,先让老师看到价值;② 名册 Excel 直接导入,无需重复录入;③ 一屏批量审核,比现在更快 |
|
| R6 | 老师抵触新系统("又要多学一个东西") | 高 | ① 名册 Excel 直接导入,无需重复录入;② 一屏批量审核,比现在更快;③ 门禁核验把"谁还没交"变成一眼可见(原"历史数据整理器"钩子已随该模块取消,见 §5.14) |
|
||||||
| R7 | 学生不会操作 / 使用老旧手机 | 中 | 三步极简流程 + 班主任代传兜底 + 大字号模式 |
|
| R7 | 学生不会操作 / 使用老旧手机 | 中 | 三步极简流程 + 班主任代传兜底 + 大字号模式 |
|
||||||
| R8 | 重复提交 / 版本混乱 | 低 | 版本化文件管理 + 导出去重取最新 |
|
| R8 | 重复提交 / 版本混乱 | 低 | 版本化文件管理 + 导出去重取最新 |
|
||||||
| R9 | 与现有系统(如需对接认定机构平台)不兼容 | 中 | 预留开放 API(8.10);导出包格式遵循机构要求 |
|
| R9 | 与现有系统(如需对接认定机构平台)不兼容 | 中 | 预留开放 API(8.10);导出包格式遵循机构要求 |
|
||||||
@@ -2834,7 +2823,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| Q8 | 是否需要向学生收取费用(如报名服务费),涉及支付资质 | 中 | 商务 |
|
| Q8 | 是否需要向学生收取费用(如报名服务费),涉及支付资质 | 中 | 商务 |
|
||||||
| Q9 | 一期是否需要与"认定机构业务系统"对接 | 中 | 客户 |
|
| Q9 | 一期是否需要与"认定机构业务系统"对接 | 中 | 客户 |
|
||||||
| Q10 | 私有化部署版本是否同步开发(当前决策为 SaaS 优先、私有化后置) | 低 | 产品 |
|
| Q10 | 私有化部署版本是否同步开发(当前决策为 SaaS 优先、私有化后置) | 低 | 产品 |
|
||||||
| **Q11** | **名单外的人怎么处理?** 当前按需求实现为"不给填、只提示",老师需去后台加人。建议增加"**申请加入名单 → 管理员审批后自动放行**"通道(既不破门禁、又免手工加人)。**请决策** | **高** | 客户 |
|
| ~~**Q11**~~ | ~~名单外的人怎么处理?建议增加"申请加入名单 → 管理员审批后自动放行"通道~~ → **已决策并已回退**(2026-10):**不做自助通道**,由老师在任务里手工加人;系统内不展示联系方式 | — | 已关闭 |
|
||||||
| **Q12** | **社会收集的名单从哪来?** 社会收集同样"先导名单",但若名单来源不稳定(预报名名单 / 报名点收集 / 机构提供),落地难度较大。需确认名单来源与责任方 | **高** | 客户 |
|
| **Q12** | **社会收集的名单从哪来?** 社会收集同样"先导名单",但若名单来源不稳定(预报名名单 / 报名点收集 / 机构提供),落地难度较大。需确认名单来源与责任方 | **高** | 客户 |
|
||||||
| Q13 | 门禁是否再加一层**手机号验证码**(防止有人拿到他人姓名+身份证号即可代填) | 中 | 客户 |
|
| Q13 | 门禁是否再加一层**手机号验证码**(防止有人拿到他人姓名+身份证号即可代填) | 中 | 客户 |
|
||||||
| Q14 | 门禁限流阈值(同 openid 每小时 10 次)是否合适,需按实际人数标定 | 中 | 研发 |
|
| Q14 | 门禁限流阈值(同 openid 每小时 10 次)是否合适,需按实际人数标定 | 中 | 研发 |
|
||||||
@@ -2854,9 +2843,9 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| `PHOTO1` | 一寸照片 |
|
| `PHOTO1` | 一寸照片 |
|
||||||
| `IDCARD` | 身份证 |
|
| `IDCARD` | 身份证 |
|
||||||
| `SCHOOL_ROLL` | 学籍表 |
|
| `SCHOOL_ROLL` | 学籍表 |
|
||||||
| `XJW_BA` | 学信网备案表 |
|
| `XJW_BA` | 学信网备案表(**读得到、选不到**:不在"新增材料"列表里) |
|
||||||
| `WORK_PROOF` | 工作证明 / 个人申报表 |
|
| `WORK_PROOF` | 工作证明 / 个人申报表 |
|
||||||
| `OTHER_PROOF` | 其它佐证材料 |
|
| `OTHER_PROOF` | 其它佐证材料(**读得到、选不到**:不在"新增材料"列表里) |
|
||||||
|
|
||||||
**收集模式 `collect_mode`**:`1` 学校收集 / `2` 社会收集
|
**收集模式 `collect_mode`**:`1` 学校收集 / `2` 社会收集
|
||||||
**认定类型 `cert_type`**:`1` 自主认定 / `2` 社会评价 / `3` 专项职业能力
|
**认定类型 `cert_type`**:`1` 自主认定 / `2` 社会评价 / `3` 专项职业能力
|
||||||
@@ -2911,7 +2900,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| `40004` | 身份证号校验位不通过 | 高亮错误位,提示核对原件 |
|
| `40004` | 身份证号校验位不通过 | 高亮错误位,提示核对原件 |
|
||||||
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
| `40005` | 证件号与名册不一致 | 展示名册值,要求确认 |
|
||||||
| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 |
|
| `40006` | 疑似串档(校验位均通过但与他人不符) | 转人工,不自动修正 |
|
||||||
| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",提供**重新填写 / 申请加入名单**(不透露是哪一项错) |
|
| `40007` | 门禁未匹配(姓名或身份证号与名单不符) | **统一提示**"没有找到你的信息",只提供**重新填写**(原「申请加入名单」已于 2026-10 撤销;不透露是哪一项错) |
|
||||||
| `40071` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
|
| `40071` | 门禁尝试过于频繁(触发限流) | 提示"操作过于频繁,请稍后再试" |
|
||||||
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
|
| `40072` | 名单项已被他人绑定 | 提示"该信息已被登记,如有疑问请联系老师" |
|
||||||
| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 |
|
| `40008` | 任务已截止 / 已归档 | 禁止提交,提示联系管理员 |
|
||||||
@@ -2938,6 +2927,7 @@ export/{tenantId}/{taskId}/{exportId}/{批次号}-{专业}-{班级}.zip
|
|||||||
| 用量统计 | 每日 01:00 | 租户存储量/OCR/短信用量快照,供计费与超额告警 |
|
| 用量统计 | 每日 01:00 | 租户存储量/OCR/短信用量快照,供计费与超额告警 |
|
||||||
| 压缩失败重试 | 每 10 分钟 | 扫描"压缩受限/处理失败"文件,重试 ≤3 次 |
|
| 压缩失败重试 | 每 10 分钟 | 扫描"压缩受限/处理失败"文件,重试 ≤3 次 |
|
||||||
| 租户到期提醒 | 每日 08:00 | 到期前 30/7/1 天提醒 |
|
| 租户到期提醒 | 每日 08:00 | 到期前 30/7/1 天提醒 |
|
||||||
|
| 内置角色授权同步 | 每日 03:20 | 把 `sys_role_permission` / `sys_role_menu` 里**内置角色**的行对齐到代码定义(「角色与授权」已按**一期功能**计,见 `docs/03` §5.10 口径 2) |
|
||||||
|
|
||||||
## 附录 F:复审方法论说明
|
## 附录 F:复审方法论说明
|
||||||
|
|
||||||
@@ -2979,7 +2969,7 @@ ERROR: relation "collect_task" does not exist ← 上面第 1 个错误的
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `uk_tenant_code` | sys_tenant、collect_task | → `uk_task_tenant_code` |
|
| `uk_tenant_code` | sys_tenant、collect_task | → `uk_task_tenant_code` |
|
||||||
| `idx_name` | sys_occupation_ref、collect_roster | → `idx_occref_name` / `idx_roster_name` |
|
| `idx_name` | sys_occupation_ref、collect_roster | → `idx_occref_name` / `idx_roster_name` |
|
||||||
| `idx_tenant` | collect_material_def、collect_rule_template、collect_cleanup_job | → `idx_matdef_tenant` / `idx_ruletpl_tenant` / `idx_cleanup_tenant` |
|
| `idx_tenant` | collect_material_def、collect_rule_template(`collect_cleanup_job` 已于 2026-10 删除) | → `idx_matdef_tenant` / `idx_ruletpl_tenant` |
|
||||||
| `idx_submission` | collect_file、collect_validation_issue、collect_audit_record | → `idx_file_submission` / `idx_issue_submission` / `idx_audit_submission` |
|
| `idx_submission` | collect_file、collect_validation_issue、collect_audit_record | → `idx_file_submission` / `idx_issue_submission` / `idx_audit_submission` |
|
||||||
|
|
||||||
> ⚠️ **根因**:**MySQL 的索引名只需表内唯一,PG 要求 schema 内全局唯一。**
|
> ⚠️ **根因**:**MySQL 的索引名只需表内唯一,PG 要求 schema 内全局唯一。**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name" : "填报通",
|
"name" : "深学在线",
|
||||||
"appid" : "",
|
"appid" : "",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
|
|||||||
@@ -25,14 +25,6 @@
|
|||||||
"navigationBarTextStyle": "white"
|
"navigationBarTextStyle": "white"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/join/join",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "申请加入名单",
|
|
||||||
"navigationBarBackgroundColor": "#0052D9",
|
|
||||||
"navigationBarTextStyle": "white"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/tasks/tasks",
|
"path": "pages/tasks/tasks",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -87,7 +79,7 @@
|
|||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "white",
|
"navigationBarTextStyle": "white",
|
||||||
"navigationBarTitleText": "填报通",
|
"navigationBarTitleText": "深学在线",
|
||||||
"navigationBarBackgroundColor": "#0052D9",
|
"navigationBarBackgroundColor": "#0052D9",
|
||||||
"backgroundColor": "#F3F3F3"
|
"backgroundColor": "#F3F3F3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<!-- 学生改动 -->
|
<!-- 学生改动 -->
|
||||||
<view v-if="(d.changedDetail || []).length" class="det__card">
|
<view v-if="(d.changedDetail || []).length" class="det__card">
|
||||||
<view class="det__title">学生改动({{ d.changedDetail.length }} 项)</view>
|
<view class="det__title">学生改动({{ (d.changedDetail || []).length }} 项)</view>
|
||||||
<view v-for="c in d.changedDetail" :key="c.field" class="det__change">
|
<view v-for="c in d.changedDetail" :key="c.field" class="det__change">
|
||||||
<text class="det__change-label">{{ c.label || c.field }}</text>
|
<text class="det__change-label">{{ c.label || c.field }}</text>
|
||||||
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
|
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
|
||||||
@@ -94,29 +94,16 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
代传入口(§10.6):只在这个学生**还没提交**时才有意义 ——
|
⚠️ 2026-10:这里原本有一段「帮他传材料」的模板(代传,§10.6),
|
||||||
已提交的单子不能塞材料(那会让审到一半的材料清单变了)。
|
但它引用的 `canProxy / proxyMaterial / proxyReason / proxyBusy / doProxy`
|
||||||
放在最下面而不是和"通过/驳回"并排:它是补救动作,不是主流程。
|
**五个变量在脚本里从未定义** —— 也就是这一段**永远不会渲染**,
|
||||||
|
而 vue-tsc 一直在报 TS2339,看起来像"页面写坏了"。
|
||||||
|
|
||||||
|
现在直接删掉:需求收敛后主流程里没有代传(学生填报 → 班主任/管理员审核),
|
||||||
|
后端 `file:proxyUpload` 接口还在(`e2e-m5-proxy.py` 33 条断言锁着),
|
||||||
|
但**两端都没有代传入口**。将来要做,请重新实现并补 UI 断言,
|
||||||
|
不要照着这段删掉的模板"恢复"。
|
||||||
-->
|
-->
|
||||||
<view v-if="canProxy" class="det__proxy">
|
|
||||||
<view class="det__proxy-title">帮他传材料</view>
|
|
||||||
<view class="det__proxy-hint">
|
|
||||||
学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
|
|
||||||
<text class="det__strong">这份材料会标注「由你代传」</text>,学生也看得到。
|
|
||||||
</view>
|
|
||||||
<view class="det__proxy-mats">
|
|
||||||
<view v-for="g in (d.materials || []).filter((m) => m.required)" :key="g.materialCode"
|
|
||||||
class="det__proxy-mat" :class="{ 'det__proxy-mat--on': proxyMaterial === g.materialCode }"
|
|
||||||
@click="proxyMaterial = g.materialCode">
|
|
||||||
{{ g.materialName }}{{ g.done ? '(已交)' : '' }}
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<input v-model="proxyReason" class="det__proxy-reason"
|
|
||||||
placeholder="代传原因(选填,但学生看得到)" :maxlength="100" />
|
|
||||||
<button class="det__btn det__btn--ghost" :disabled="proxyBusy" @click="doProxy">
|
|
||||||
{{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 驳回弹层:原因快选 + 必须二次确认 -->
|
<!-- 驳回弹层:原因快选 + 必须二次确认 -->
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
|
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
|
||||||
</view>
|
</view>
|
||||||
<view v-if="(cur.changedFields || []).length" class="audit__changed">
|
<view v-if="(cur.changedFields || []).length" class="audit__changed">
|
||||||
学生改了 {{ cur.changedFields.length }} 项预填内容
|
学生改了 {{ (cur.changedFields || []).length }} 项预填内容
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="audit__thumbs">
|
<view class="audit__thumbs">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<!-- 待办:这一屏最该被点的是"待我初审" -->
|
<!-- 待办:这一屏最该被点的是"待我初审" -->
|
||||||
<view class="dash__todos">
|
<view class="dash__todos">
|
||||||
<view class="dash__todo" @click="goAudit">
|
<view class="dash__todo" @click="goAudit()">
|
||||||
<text class="dash__todo-num">{{ t.auditCount }}</text>
|
<text class="dash__todo-num">{{ t.auditCount }}</text>
|
||||||
<text class="dash__todo-label">待我初审</text>
|
<text class="dash__todo-label">待我初审</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ function uploadFor(m: Material) {
|
|||||||
let sendPath = path
|
let sendPath = path
|
||||||
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
|
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
|
||||||
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
|
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
|
||||||
const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || ''
|
const pickedName = ((res.tempFiles as Array<{ name?: string }> | undefined)?.[0])?.name || ''
|
||||||
try {
|
try {
|
||||||
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
|
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
|
||||||
if (plan) {
|
if (plan) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<view class="land">
|
<view class="land">
|
||||||
<!-- 顶部:这是哪个学校、要填什么 -->
|
<!-- 顶部:这是哪个学校、要填什么 -->
|
||||||
<view class="land__hero">
|
<view class="land__hero">
|
||||||
<text class="land__tenant">{{ ctx.tenantName || '填报通' }}</text>
|
<text class="land__tenant">{{ ctx.tenantName || '深学在线' }}</text>
|
||||||
<text class="land__task">{{ ctx.taskName || title }}</text>
|
<text class="land__task">{{ ctx.taskName || title }}</text>
|
||||||
<view v-if="ctx.taskStatusText" class="land__badge" :class="badgeClass">
|
<view v-if="ctx.taskStatusText" class="land__badge" :class="badgeClass">
|
||||||
{{ ctx.taskStatusText }}
|
{{ ctx.taskStatusText }}
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="join">
|
|
||||||
<view class="join__wrap">
|
|
||||||
<view class="join__hero">
|
|
||||||
<text class="join__tenant">{{ ctx.tenantName || '材料填报' }}</text>
|
|
||||||
<text class="join__task">{{ ctx.taskName }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
① 已经申请过:把状态摆在最上面。
|
|
||||||
这条通道最容易让人误解的就是"我提交了,是不是就能填了?" ——
|
|
||||||
所以三个状态各有一句话直说结果,尤其待审时要说清**还不能填**。
|
|
||||||
-->
|
|
||||||
<view v-if="mine" class="join__card">
|
|
||||||
<view class="join__status" :class="`join__status--${mine.status}`">
|
|
||||||
{{ mine.statusText }}
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="join__row">
|
|
||||||
<text class="join__row-label">申请编号</text>
|
|
||||||
<text class="join__row-value">#{{ mine.requestId }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="join__row">
|
|
||||||
<text class="join__row-label">姓名</text>
|
|
||||||
<text class="join__row-value">{{ mine.realName }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="join__row">
|
|
||||||
<text class="join__row-label">身份证号</text>
|
|
||||||
<text class="join__row-value">{{ mine.idCardMask }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="join__row">
|
|
||||||
<text class="join__row-label">提交时间</text>
|
|
||||||
<text class="join__row-value">{{ fmt(mine.createTime) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 待审:说清"还不能填",并给出他能做的事 -->
|
|
||||||
<view v-if="mine.status === 0" class="join__note">
|
|
||||||
老师审核通过后,你<text class="join__strong">返回重新核验</text>就能开始填材料。
|
|
||||||
审核前暂时还不能填 —— 名单是准入依据,这一步不能跳过。
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 通过:直接给"去核验"的入口 -->
|
|
||||||
<view v-else-if="mine.status === 1" class="join__note join__note--ok">
|
|
||||||
已经加入名单了,点下面的按钮重新核验一次即可开始填报。
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 驳回:原因原样显示(这是学生唯一能知道"哪里不对"的来源) -->
|
|
||||||
<view v-else class="join__note join__note--bad">
|
|
||||||
<text class="join__strong">未通过原因:</text>{{ mine.rejectReason || '(老师未填写)' }}
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<button v-if="mine.status === 1" class="join__btn" @click="reverify">
|
|
||||||
重新核验,开始填报
|
|
||||||
</button>
|
|
||||||
<button v-if="mine.status === 2" class="join__btn join__btn--ghost"
|
|
||||||
:disabled="submitting" @click="showForm = true">
|
|
||||||
重新填写申请(还剩 {{ remainHint }})
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- ② 填申请 -->
|
|
||||||
<view v-if="!mine || mine.status === 2" class="join__card">
|
|
||||||
<template v-if="showForm">
|
|
||||||
<view class="join__title">申请加入名单</view>
|
|
||||||
<view class="join__hint">
|
|
||||||
核对不上,通常是因为<text class="join__strong">老师名单里还没有你</text>,
|
|
||||||
或者姓名写得和报名时不一样。填一次申请,老师核对后会把你加进名单。
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="join__field">
|
|
||||||
<text class="join__label">姓名</text>
|
|
||||||
<input v-model="form.realName" class="join__input" placeholder="与报名时一致的姓名"
|
|
||||||
:disabled="submitting" />
|
|
||||||
</view>
|
|
||||||
<view class="join__field">
|
|
||||||
<text class="join__label">身份证号</text>
|
|
||||||
<input v-model="form.idCardNo" class="join__input" placeholder="18 位身份证号"
|
|
||||||
maxlength="18" :disabled="submitting" />
|
|
||||||
</view>
|
|
||||||
<view class="join__field">
|
|
||||||
<text class="join__label">手机号</text>
|
|
||||||
<input v-model="form.phone" class="join__input" placeholder="老师要能联系到你"
|
|
||||||
maxlength="11" type="number" :disabled="submitting" />
|
|
||||||
</view>
|
|
||||||
<view class="join__field">
|
|
||||||
<text class="join__label">班级(选填)</text>
|
|
||||||
<input v-model="form.orgHint" class="join__input" placeholder="如 24中技数控2班"
|
|
||||||
:disabled="submitting" />
|
|
||||||
</view>
|
|
||||||
<view class="join__field">
|
|
||||||
<text class="join__label">说明(选填)</text>
|
|
||||||
<input v-model="form.reason" class="join__input" placeholder="如:我是转班过来的"
|
|
||||||
:disabled="submitting" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<button class="join__btn" :disabled="submitting || !canSubmit" @click="submit">
|
|
||||||
{{ submitting ? '提交中…' : '提交申请' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<view v-if="error" class="join__error">{{ error }}</view>
|
|
||||||
|
|
||||||
<view class="join__foot">
|
|
||||||
身份证号只用于与名单核对,系统<text class="join__strong">不会保存明文</text>。
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<view class="join__title">还没有加入名单?</view>
|
|
||||||
<view class="join__hint">
|
|
||||||
如果你确认姓名和身份证号没填错,但仍然核对不上,可以在这里提交申请,
|
|
||||||
由老师核对后加入名单。
|
|
||||||
</view>
|
|
||||||
<button class="join__btn" @click="showForm = true">申请加入名单</button>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
|
||||||
import { request } from '@/utils/request'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 申请加入名单(FR-509 / 客户确认后新增,原型 STEP 03B)。
|
|
||||||
*
|
|
||||||
* ## 三条必须说清的事(文案不是装饰,是这段流程的一部分)
|
|
||||||
*
|
|
||||||
* 1. **申请期间仍然不能填报。** 这条通道只解决"进名单",不绕过门禁 ——
|
|
||||||
* 学生在待审状态最容易以为"我交了就能填",所以状态卡上直说"暂时还不能填"。
|
|
||||||
* 2. **通过后要重新核验一次。** 通过时服务端已经把他绑到名册行上了,
|
|
||||||
* 所以重进就能过;说清这一步,他就不会以为"通过了还要再填一遍资料"。
|
|
||||||
* 3. **身份证号不存明文。** 未核验的人最该被告知这一点 ——
|
|
||||||
* 否则"填身份证号"这一步会让人犹豫。
|
|
||||||
*/
|
|
||||||
defineOptions({ name: 'JoinRequest' })
|
|
||||||
|
|
||||||
interface SceneContext {
|
|
||||||
tenantName?: string
|
|
||||||
taskName?: string
|
|
||||||
taskId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MineRequest {
|
|
||||||
requestId: number
|
|
||||||
taskId: number
|
|
||||||
taskName?: string
|
|
||||||
status: number
|
|
||||||
statusText: string
|
|
||||||
realName: string
|
|
||||||
idCardMask: string
|
|
||||||
orgHint?: string
|
|
||||||
createTime?: string
|
|
||||||
auditTime?: string
|
|
||||||
rejectReason?: string
|
|
||||||
rosterId?: number
|
|
||||||
canFillNow?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = ref<SceneContext>({})
|
|
||||||
const mine = ref<MineRequest | null>(null)
|
|
||||||
const showForm = ref(false)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
let taskId = 0
|
|
||||||
let scene = ''
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
realName: '',
|
|
||||||
idCardNo: '',
|
|
||||||
phone: '',
|
|
||||||
orgHint: '',
|
|
||||||
reason: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
|
||||||
const f = form.value
|
|
||||||
return f.realName.trim().length > 0
|
|
||||||
&& f.idCardNo.trim().length === 18
|
|
||||||
&& /^1[3-9]\d{9}$/.test(f.phone.trim())
|
|
||||||
})
|
|
||||||
|
|
||||||
const remainHint = computed(() => '最多 2 次机会')
|
|
||||||
|
|
||||||
function token(): string {
|
|
||||||
try {
|
|
||||||
return uni.getStorageSync('token') || ''
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(v?: string) {
|
|
||||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContext(s: string) {
|
|
||||||
if (!s) return
|
|
||||||
try {
|
|
||||||
const data = await request<SceneContext>({
|
|
||||||
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(s)}`
|
|
||||||
})
|
|
||||||
ctx.value = data
|
|
||||||
taskId = data.taskId || 0
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '读取任务信息失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拉"我的申请":决定显示状态卡还是表单 */
|
|
||||||
async function loadMine() {
|
|
||||||
try {
|
|
||||||
const data = await request<{ list: MineRequest[], total: number }>({
|
|
||||||
url: `/api/v1/mp/roster/join-request${taskId ? `?taskId=${taskId}` : ''}`,
|
|
||||||
token: token()
|
|
||||||
})
|
|
||||||
// 只看最近一条:一个人在同一任务下的申请按时间倒序,最新那条决定他现在的处境
|
|
||||||
mine.value = (data.list || [])[0] || null
|
|
||||||
} catch (e) {
|
|
||||||
// 拉不到不算致命:最多是少显示状态,仍可提交
|
|
||||||
error.value = e instanceof Error ? e.message : ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
const f = form.value
|
|
||||||
if (!canSubmit.value) {
|
|
||||||
error.value = '请把姓名、18 位身份证号、手机号填完整'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await request({
|
|
||||||
url: '/api/v1/mp/roster/join-request',
|
|
||||||
method: 'POST',
|
|
||||||
token: token(),
|
|
||||||
data: {
|
|
||||||
taskId,
|
|
||||||
realName: f.realName.trim(),
|
|
||||||
idCardNo: f.idCardNo.trim(),
|
|
||||||
phone: f.phone.trim(),
|
|
||||||
orgHint: f.orgHint.trim() || undefined,
|
|
||||||
reason: f.reason.trim() || undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
uni.showToast({ title: '已提交,等待老师审核', icon: 'none', duration: 2500 })
|
|
||||||
showForm.value = false
|
|
||||||
await loadMine()
|
|
||||||
} catch (e) {
|
|
||||||
// 后端的话原样显示("你已在名单中,请直接返回重新核验"这类提示必须让学生看到)
|
|
||||||
error.value = e instanceof Error ? e.message : '提交失败'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 通过之后回核验页 —— 服务端已绑定,重进就能过 */
|
|
||||||
function reverify() {
|
|
||||||
uni.redirectTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(scene)}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad(async (options) => {
|
|
||||||
scene = options?.scene || ''
|
|
||||||
taskId = Number(options?.taskId || 0)
|
|
||||||
// 从核验页带过来的姓名/证件号:他刚打过一遍,不必再打
|
|
||||||
form.value.realName = options?.name || ''
|
|
||||||
form.value.idCardNo = options?.idCardNo || ''
|
|
||||||
await loadContext(scene)
|
|
||||||
await loadMine()
|
|
||||||
// 带过来的是"门禁没匹配上"的那份输入 —— 直接展开表单,少一次点击
|
|
||||||
if (!mine.value && (form.value.realName || form.value.idCardNo)) {
|
|
||||||
showForm.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
$primary: #0052d9;
|
|
||||||
$success: #00a870;
|
|
||||||
$danger: #e34d59;
|
|
||||||
$warn: #ed7b2f;
|
|
||||||
$page-bg: #f3f3f3;
|
|
||||||
|
|
||||||
page {
|
|
||||||
background-color: $page-bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24rpx;
|
|
||||||
|
|
||||||
&__wrap {
|
|
||||||
max-width: 720rpx;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__hero {
|
|
||||||
padding: 32rpx 28rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__tenant {
|
|
||||||
display: block;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: rgb(255 255 255 / 80%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__task {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
padding: 32rpx 28rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1b2430;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 6rpx 18rpx;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
|
|
||||||
&--0 {
|
|
||||||
color: $warn;
|
|
||||||
background: #fdf2e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--1 {
|
|
||||||
color: $success;
|
|
||||||
background: #e8f6f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--2 {
|
|
||||||
color: $danger;
|
|
||||||
background: #fdecee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__hint {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #4a5768;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1b2430;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14rpx 0;
|
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #8b95a3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row-value {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #1b2430;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__note {
|
|
||||||
padding: 20rpx;
|
|
||||||
margin-top: 20rpx;
|
|
||||||
font-size: 25rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #4a5768;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
color: #2f6b4f;
|
|
||||||
background: #e8f6f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--bad {
|
|
||||||
color: #a03a44;
|
|
||||||
background: #fdecee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
margin-top: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #4a5768;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 84rpx;
|
|
||||||
padding: 0 20rpx;
|
|
||||||
font-size: 30rpx;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__btn {
|
|
||||||
height: 88rpx;
|
|
||||||
margin-top: 36rpx;
|
|
||||||
font-size: 32rpx;
|
|
||||||
color: #fff;
|
|
||||||
background: $primary;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
color: #fff;
|
|
||||||
background: #b9c6dd;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ghost {
|
|
||||||
color: $primary;
|
|
||||||
background: #eaf1fd;
|
|
||||||
|
|
||||||
&[disabled] {
|
|
||||||
color: #8b95a3;
|
|
||||||
background: #f2f3f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__error {
|
|
||||||
padding: 18rpx 20rpx;
|
|
||||||
margin-top: 24rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: $danger;
|
|
||||||
background: #fdecee;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__foot {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #8b95a3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
|||||||
@@ -33,18 +33,13 @@
|
|||||||
<view v-if="error" class="verify__error">{{ error }}</view>
|
<view v-if="error" class="verify__error">{{ error }}</view>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
核验失败时给一条**受控通道**(FR-509 / 客户确认后新增)。
|
核验失败时**只给"重新填写"这一条路**(2026-10 需求收敛:删掉「申请加入名单」)。
|
||||||
注意两件事:
|
刻意不提任何联系方式:名单外的人怎么进名单是老师那边的事,
|
||||||
1) 这条通道**不绕过门禁** —— 申请期间仍然不能填,去申请页会说清这一点;
|
系统内不展示班主任/管理员的电话(客户定过的规矩)。
|
||||||
2) 它只在真失败过之后才出现(`error` 非空),不是一进来就摆个"申请"按钮。
|
|
||||||
否则会有学生绕过核验直接去申请,把管理员的待办当成人工客服。
|
|
||||||
-->
|
-->
|
||||||
<button v-if="error" class="verify__btn verify__btn--ghost" @click="gotoJoin">
|
<view v-if="error" class="verify__foot">
|
||||||
申请加入名单
|
核对不上?请先确认姓名和身份证号没有打错。
|
||||||
</button>
|
如果确实已经报名,请直接联系老师,让老师把你加进名单后再来核对。
|
||||||
|
|
||||||
<view class="verify__foot">
|
|
||||||
核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -225,20 +220,6 @@ function next() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 去申请加入名单(FR-509 的入口)—— 带上已填的姓名与证件号,省他再输一遍。 */
|
|
||||||
function gotoJoin() {
|
|
||||||
const s = sceneFromUrl()
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
scene: s,
|
|
||||||
taskId: String(ctx.value.taskId || 0),
|
|
||||||
// 只把**已经打过的字**带过去。这不是"预填答案":门禁刚判定它不匹配,
|
|
||||||
// 带过去是为了让他少打一遍,而不是暗示它是对的。
|
|
||||||
name: realName.value.trim(),
|
|
||||||
idCardNo: idCardNo.value.trim()
|
|
||||||
})
|
|
||||||
uni.navigateTo({ url: `/pages/join/join?${params.toString()}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
loadContext(options?.scene || '')
|
loadContext(options?.scene || '')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,20 +2,25 @@
|
|||||||
|
|
||||||
本目录为 AI 编码助手规范的**唯一来源**(Single Source of Truth),适用于 Cursor、OpenCode、Codex 等工具。
|
本目录为 AI 编码助手规范的**唯一来源**(Single Source of Truth),适用于 Cursor、OpenCode、Codex 等工具。
|
||||||
|
|
||||||
|
> ⚠️ **2026-10 已整体重写**:PC 端从 **Gi Admin Pro + Arco Design Vue** 换成 **纯 Vue 3 + Naive UI**。
|
||||||
|
> 上一版 skills 里的 Arco 组件名(`a-table` / `a-form` / `a-modal`)、`v-hasPerm`、auto-import、
|
||||||
|
> Stylelint、Gi 的 `useTable/usePagination` **全部不再适用** —— 照着写会编译不过或静默失效。
|
||||||
|
> 唯一保留原样的是 `git-commit`(与框架无关)。
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
```
|
```
|
||||||
.agents/
|
.agents/
|
||||||
├── rules/
|
├── rules/
|
||||||
│ └── agent.md # 唯一常驻规则(Cursor 经 AGENTS.md 内联注入)
|
│ └── agent.md # 唯一常驻规则(AGENTS.md 内联注入)
|
||||||
└── skills/ # 按需加载
|
└── skills/ # 按需加载
|
||||||
├── code-ts/SKILL.md
|
├── code-ts/SKILL.md # TS 命名 / 导入 / 显式 import
|
||||||
├── code-vue/SKILL.md
|
├── code-vue/SKILL.md # SFC 写法与模板约定
|
||||||
├── code-css/SKILL.md
|
├── code-css/SKILL.md # SCSS 与样式边界
|
||||||
├── project-guide/SKILL.md
|
├── project-guide/SKILL.md # 技术栈 / 目录 / 七条最容易写错的约定
|
||||||
├── project-crud/SKILL.md
|
├── project-crud/SKILL.md # 列表 + 表单的标准写法
|
||||||
├── project-api/SKILL.md
|
├── project-api/SKILL.md # 接口调用(没有 Mock)
|
||||||
└── git-commit/SKILL.md
|
└── git-commit/SKILL.md # 提交规范(与框架无关)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 工具适配
|
## 工具适配
|
||||||
@@ -23,7 +28,5 @@
|
|||||||
| 工具 | 适配方式 |
|
| 工具 | 适配方式 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| Cursor | `AGENTS.md`(内联 behavior 规则 + 技能索引)+ `.agents/skills/`(按需) |
|
| Cursor | `AGENTS.md`(内联 behavior 规则 + 技能索引)+ `.agents/skills/`(按需) |
|
||||||
| OpenCode | `AGENTS.md` + `opencode.json` + `.agents/rules/` + `.agents/skills/` |
|
| OpenCode | `AGENTS.md` + `.agents/rules/` + `.agents/skills/` |
|
||||||
| Codex | `AGENTS.md` + `.agents/skills/` |
|
| Codex | `AGENTS.md` + `.agents/skills/` |
|
||||||
|
|
||||||
修改 `agent.md` 后请同步 `AGENTS.md` 的「常驻规则」一节;编码规范改对应 skill,不要重复写入 `AGENTS.md`。
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Agent 行为准则
|
# Agent 行为准则
|
||||||
|
|
||||||
> 适用于 Cursor、OpenCode、Codex 等 AI 编码助手。Cursor 经 `AGENTS.md` 内联本节每轮注入。
|
> 适用于 Cursor、OpenCode、Codex 等 AI 编码助手。AGENTS.md 内联本节每轮注入。
|
||||||
|
|
||||||
## 通用要求
|
## 通用要求
|
||||||
|
|
||||||
@@ -11,26 +11,27 @@
|
|||||||
|
|
||||||
## 应当
|
## 应当
|
||||||
|
|
||||||
- 改代码前先阅读项目既有实现,保持风格一致。
|
- 改代码前先阅读项目既有实现(`src/views/` 里同类页面就是最好的模板),保持风格一致。
|
||||||
- 优先复用项目内已有组件、composables、工具函数与 API 封装。
|
- 优先复用已有组件、composables(`src/composables/`)、工具函数(`src/utils/`)与 API 封装(`src/apis/xt/`)。
|
||||||
- 任务完成后,视情况运行 lint / typecheck / test(与改动相关时)。
|
- 任务完成后运行 `pnpm typecheck`(**当前 0 错,有报错就是新引入的**)与 `pnpm lint`。
|
||||||
- **按需读取** skills,不要预加载全部;写代码前按任务类型加载对应 skill(见 `AGENTS.md` 技能索引)。
|
- **按需读取** skills,不要预加载全部。
|
||||||
|
|
||||||
## 不应
|
## 不应
|
||||||
|
|
||||||
- 为已自动导入的 API / 组件写冗余 import。
|
- ❌ 写 **Arco Design** 的组件与属性(`a-table` / `a-form` / `a-modal` / `arcoblue` …)—— 项目已不用 Arco。
|
||||||
- 引入与项目技术栈不符的 UI 库或全新抽象层。
|
- ❌ 用 `v-hasPerm` 指令或指望 **auto-import**:本仓库组件与 API **全部显式 import**。
|
||||||
- 猜测不存在的 API 路径、响应结构或配置项;以项目代码与类型定义为准。
|
- ❌ 把 `apis/xt/*.ts` 里映射的 **Arco 色词**直接塞给 `n-tag`(会静默变灰,见 `utils/tag.ts`)。
|
||||||
- 在用户未要求时添加测试、文档或无关注释。
|
- ❌ 猜测不存在的接口路径、响应结构或配置项;以 `src/apis/xt/` 与后端契约为准。
|
||||||
|
- ❌ 在用户未要求时添加测试、文档或无关注释。
|
||||||
|
|
||||||
## 编码前必读 skill
|
## 编码前必读 skill
|
||||||
|
|
||||||
| 任务 | 技能 |
|
| 任务 | 技能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 写/改 `.vue` | `code-vue` |
|
| 写/改 `.vue` | `skills/code-vue/SKILL.md` |
|
||||||
| 写/改样式 | `code-css` |
|
| 写/改样式 | `skills/code-css/SKILL.md` |
|
||||||
| 写/改 `.ts` / composable | `code-ts` |
|
| 写/改 `.ts` / composable | `skills/code-ts/SKILL.md` |
|
||||||
| 项目约定、Gi 组件 | `project-guide` |
|
| 项目约定、Naive UI 组件与目录 | `skills/project-guide/SKILL.md` |
|
||||||
| CRUD 列表/表单 | `project-crud` |
|
| CRUD 列表/表单 | `skills/project-crud/SKILL.md` |
|
||||||
| API / Mock | `project-api` |
|
| 接口调用 | `skills/project-api/SKILL.md` |
|
||||||
| 提交代码 | `git-commit` |
|
| git commit | `skills/git-commit/SKILL.md` |
|
||||||
|
|||||||
@@ -1,36 +1,112 @@
|
|||||||
---
|
---
|
||||||
name: code-css
|
name: code-css
|
||||||
description: Vue SFC 样式块、BEM 命名、Stylelint 与 scoped 约定。编写或修改组件样式(.vue style、scss、less、css)时加载本技能。
|
description: SCSS 写法、样式作用域与全局样式的边界。编写或修改 .vue 的 style 块、.scss 文件时加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# CSS 规范(Vue 3)
|
# 样式规范(SCSS + Naive UI)
|
||||||
|
|
||||||
## SFC 样式块
|
> ⚠️ **Stylelint 已随 2026-10 重搭移除**(`pnpm lint:style` 不存在了)。样式只受 ESLint 与人工约定约束。
|
||||||
|
|
||||||
- 位于 `<script>` 之后;默认 **scoped**;全局样式须在注释中说明原因。
|
## 放哪里
|
||||||
- 预处理器按项目约定,同一组件内保持一致。
|
|
||||||
|
|
||||||
## 命名(BEM)
|
| 场景 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 组件的具体样式 | 该 SFC 的 `<style scoped lang="scss">` |
|
||||||
|
| **跨页面**的版面基线(背景、卡片外观、卡片间距、滚动、分页器容器、区块标题) | `src/styles/index.scss`(**只放跨页面的东西**) |
|
||||||
|
| 组件外观的主题变量(Card / Button / DataTable 的描边、圆角、阴影) | `src/App.vue` 的 `themeOverrides` |
|
||||||
|
|
||||||
- 自定义 class:**kebab-case** + **BEM**(`block`、`block__element`、`block--modifier`)。
|
## 设计基线(2026-10 卡片式改造后:**页面只引用,不再各写一套**)
|
||||||
- 一个 SFC 通常一个 Block;元素用 `__`,修饰符用 `--`;**禁止** `block__a__b` 链式元素。
|
|
||||||
- 第三方 UI 类名、全局工具类不在 BEM 约束内。
|
|
||||||
|
|
||||||
```scss
|
`src/styles/index.scss` 的 `:root` 里定义,页面直接 `var()`:
|
||||||
.user-card {
|
|
||||||
&__avatar { width: 48px; }
|
|
||||||
&__badge--vip { color: var(--color-warning); }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 编写原则
|
| 变量 | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--xt-header-h` | `56px` | 顶栏高度(`.app-shell__header` 与 `.page-card` 高度算式**共用**) |
|
||||||
|
| `--xt-content-pad` | `16px` | 内容区内边距(同上,两处共用,别写死数值) |
|
||||||
|
| `--xt-gap` | `16px` | 卡片/区域之间的间距 |
|
||||||
|
| `--xt-bg` | `#f2f4f7` | 页面底色(**卡片要靠它才浮得起来**) |
|
||||||
|
| `--xt-card-border` / `--xt-card-border-weak` | `#e5e8ee` / `#eef1f5` | 卡片描边 / 嵌套卡片描边 |
|
||||||
|
| `--xt-card-radius` | `10px` | 卡片圆角 |
|
||||||
|
| `--xt-card-pad-x` / `--xt-card-pad-y` | `18px` / `16px` | 卡片内容区左右 / 上下内边距(**别在页面里写死**,见下) |
|
||||||
|
| `--xt-band-bg` | `#f7f8fa` | 浅灰色带:卡片头 / 表头 / 卡片内的子格子 |
|
||||||
|
| `--xt-text` / `--xt-text-2` / `--xt-text-3` | `#1f2229` / `#667085` / `#98a2b3` | 主 / 次 / 弱文字 |
|
||||||
|
|
||||||
- 颜色、间距、字号优先复用项目 CSS 变量或设计令牌。
|
### ⚠️ 卡片内边距:**不要让页面自己写**
|
||||||
- 避免内联 `style`(动态样式除外)和滥用 `!important`。
|
|
||||||
- 遵循项目 Stylelint;改动后视情况运行 `lint:style`。
|
|
||||||
|
|
||||||
## 禁止
|
Naive 的规则是 `cB("card-content", "padding: 0 var(--n-padding-left) var(--n-padding-bottom) …")`
|
||||||
|
外加 `c("&:first-child", "padding-top: var(--n-padding-top)")` ——
|
||||||
|
**`padding-top` 只给"内容区是长子"(也就是没有卡片头)的卡片**。
|
||||||
|
所以有标题的卡片,内容区 `padding-top` 是 **0**,第一行内容直接贴在标题下面那条灰条上。
|
||||||
|
|
||||||
- ❌ 自定义 class 用 camelCase 或非语义命名
|
全局 `.n-card.n-card > .n-card-content { padding: var(--xt-card-pad-y) var(--xt-card-pad-x) }`
|
||||||
- ❌ BEM 元素链式嵌套(`block__a__b`)
|
已经统一处理掉了。**但它是普通样式,会被内联样式盖掉** ——
|
||||||
- ❌ 大段内联 style 或过多 `:deep()` 堆叠
|
页面里写 `:content-style="{ padding: '12px' }"` 会让那张卡片比邻居紧一圈
|
||||||
|
(而且那个写法只解决左右,不管上面这件事)。要调内边距就改 `--xt-card-pad-x/y`。
|
||||||
|
|
||||||
|
> 📌 排查手法:`getComputedStyle(card.querySelector(':scope > .n-card-content')).paddingTop`
|
||||||
|
> 应当是 `16px`;是 `0` 说明没匹配上,是别的值说明有内联样式或更具体的选择器在盖。
|
||||||
|
|
||||||
|
现成的全局类(**优先复用,不要另起名字**):
|
||||||
|
|
||||||
|
| 类 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `.page-plain` | 普通(非列表)页根节点:flex 列 + `--xt-gap` 间距 |
|
||||||
|
| `.page-card` + `.page-card__body` | **列表页专用**高度链:卡片撑满内容区 → 表体自滚 → 分页器钉底 |
|
||||||
|
| `.toolbar` / `.toolbar--plain` | 筛选与动作行(自带下分隔线 / 去掉分隔线) |
|
||||||
|
| `.toolbar__hint` | 工具条里的灰色小字提示 |
|
||||||
|
| `.section-title` + `.section-title__note` | 卡片**内部**次级区块标题(左侧蓝色小竖条 + 右侧灰色说明) |
|
||||||
|
|
||||||
|
## 卡片式的三条规矩
|
||||||
|
|
||||||
|
1. **卡片外观是全局给的**:`.n-card.n-card` 统一白底 + 描边 + 轻阴影 + 卡片头浅灰色带
|
||||||
|
(权重写成 `.n-card.n-card` 是因为 naive 的样式由 css-render **运行时注入**,先后顺序不保证)。
|
||||||
|
页面**不要再**自己给卡片写边框/阴影。
|
||||||
|
2. **卡里不要套卡**:卡片内部要分区时,用浅灰底(`--xt-band-bg`)+ 弱描边(`--xt-card-border-weak`)
|
||||||
|
的小格子,或 `.section-title` + `1px` 分隔线。嵌套的 `NCard` 已被全局降级为无阴影 + 无色带,
|
||||||
|
但布局上仍然容易糊成一片。
|
||||||
|
3. **区域间隔靠"色带 + 描边",不是靠加大 margin**:卡片之间 `--xt-gap`,卡片内区块 12~20px。
|
||||||
|
上一版的教训是把卡片写成 `:bordered="false"` + 内容区白底 → 白卡片浮在白底上,整页没有任何边界。
|
||||||
|
|
||||||
|
## 按钮的可见性
|
||||||
|
|
||||||
|
- 页面**主操作**(新建 / 导入 / 批量通过…)用 `type="primary"`,**默认尺寸**(不要 `size="small"`)。
|
||||||
|
- **同一行内控件尺寸必须一致**:一行里混用 `small`(28px) 与默认(34px) 会参差不齐。
|
||||||
|
工具条统一默认尺寸;表格行内动作保持 `text` + `size="small"`。
|
||||||
|
- 默认按钮的描边已在 `App.vue` 里加深(`#d0d5dd`)—— 原来用的 `common.borderColor`(`#e0e0e6`)
|
||||||
|
在白色卡片上几乎看不见,这是"按钮不明显"的主因。
|
||||||
|
- ⚠️ naive 的按钮**边框画在子元素 `.n-button__border` 上**,不是根节点;
|
||||||
|
用 `getComputedStyle(btn).borderTopWidth` 检查会得到 `0px`,别据此判断"边框没生效"。
|
||||||
|
|
||||||
|
## 覆盖 naive 内部类之前:**先确认真实类名**
|
||||||
|
|
||||||
|
⚠️ naive 的类名**不是一套统一规则**,同一个组件里两种命名混着用:
|
||||||
|
|
||||||
|
| 真实类名 | 由什么生成 |
|
||||||
|
|---|---|
|
||||||
|
| `.n-card-header` / `.n-card-content` | `cB("card-header")` / `cB("card-content")` —— **单短横** |
|
||||||
|
| `.n-button__border` / `.n-button__content` | `cE("border")` / `cE("content")` —— **双下划线** |
|
||||||
|
|
||||||
|
所以"想当然按 BEM 写"会**静默失效**:`:deep(.n-card__content)` 匹配不到任何元素,
|
||||||
|
不报错、不影响编译,只是那条样式**什么也没做**。
|
||||||
|
|
||||||
|
> 2026-10 实际踩到:`.page-card` 的高度链第一环(卡片内容区要是能撑满的 flex 列)
|
||||||
|
> 就写在 `:deep(.n-card__content)` 里 —— 结果「操作日志」的表格体高度算成 **0**,
|
||||||
|
> 行**在 DOM 里、数据也查得到,屏幕上一行都看不见**。
|
||||||
|
>
|
||||||
|
> **正确做法**:在 DevTools 里选中那个元素,看它实际的 class;或者去
|
||||||
|
> `node_modules/naive-ui/es/<组件>/src/styles/index.cssr.mjs` 里搜一眼。
|
||||||
|
> 顺带:`.n-card-content` 与 `.n-card-header` 这两个名字已经被 `styles/index.scss` 用到,
|
||||||
|
> 改卡片相关样式时以那里为准。
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- class 用 **kebab-case**;同一块的子元素用 `块__元素` 风格(`task__tabs`、`join__hint`),
|
||||||
|
与现有页面保持一致即可,**不要引入新的方法论或工具链**。
|
||||||
|
- 颜色优先用 Naive UI 的 CSS 变量 / 主题变量,**不要**写死 Arco 时代的 `--arco-*` 变量(已不存在)。
|
||||||
|
- 选择器保持扁平(≤2 层),避免 `!important`;确实要覆盖 naive 内部类时**加注释说明原因**。
|
||||||
|
|
||||||
|
## 踩过的坑(照抄现有写法即可)
|
||||||
|
|
||||||
|
1. **列表页卡片要能自己滚**:分页器被裁出可视区是本项目反复踩的问题 ——
|
||||||
|
列表页统一用 `styles/index.scss` 里的 `.page-card` 容器(卡片撑满内容区 → 表体自己滚 → 分页器钉在卡片底部)。
|
||||||
|
2. 页面根节点用 `NPagination`/`NDataTable` 时别自己写 `overflow: hidden`,会把分页器裁掉。
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
---
|
---
|
||||||
name: code-ts
|
name: code-ts
|
||||||
description: Vue 3 + TypeScript 命名、导入顺序、目录组织与 Lint 约定。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
|
description: Vue 3 + TypeScript 命名、导入顺序、显式 import 与目录组织。编写或修改 .ts/.js 代码、新增 composable 或工具函数时加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# TypeScript 规范(Vue 3)
|
# TypeScript 规范(Vue 3 + Naive UI)
|
||||||
|
|
||||||
Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。
|
Vue SFC 见 [code-vue](../code-vue/SKILL.md),样式见 [code-css](../code-css/SKILL.md)。
|
||||||
|
|
||||||
## 命名
|
## 命名
|
||||||
|
|
||||||
- 组件 / 文件名:**PascalCase**;变量 / 函数:**camelCase**(事件处理推荐 `handle` 前缀)。
|
- 组件文件:**PascalCase**(`AppIcon.vue`、`AuditDetailDrawer.vue`);工具/接口模块:**kebab-case 或小写**(`export-task.ts`、`acting-tenant.ts`)。
|
||||||
- 常量:需要时 **UPPER_SNAKE_CASE**;Composables:`use` 前缀。
|
- 变量 / 函数:**camelCase**;事件处理用 `handle` 前缀;Composables 用 `use` 前缀。
|
||||||
- Props camelCase;模板事件名与 emits 定义 **kebab-case**。
|
- 常量:`UPPER_SNAKE_CASE`(如 `TENANT_STATUS`);展示映射放在 `src/apis/xt/<域>.ts` 里与接口同处。
|
||||||
- 样式 class:**kebab-case** + BEM(见 [code-css](../code-css/SKILL.md));全局组件前缀遵循项目统一约定。
|
- 样式 class:kebab-case(见 [code-css](../code-css/SKILL.md))。
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|
||||||
- `import type` 与值导入分离;对象结构优先 `interface`,联合/工具类型用 `type`。
|
- `import type { ... }` 与值导入**分开写**(ESLint `@antfu` 会强制)。
|
||||||
|
- 对象结构优先 `interface`;联合/工具类型用 `type`。
|
||||||
- Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。
|
- Props 默认值:`withDefaults(defineProps<Type>(), { ... })`;避免 `any`。
|
||||||
- 公共类型集中管理;领域类型靠近业务模块。
|
- **接口出入参类型直接复用 `src/apis/xt/` 里的契约类型**,不要手写一份字段。
|
||||||
|
|
||||||
## 导入顺序
|
## 导入顺序(ESLint 会排)
|
||||||
|
|
||||||
1. 类型导入 → 2. 第三方 → 3. Vue/官方库 → 4. 项目内部(composables、组件、utils、样式)
|
1. 类型导入(`import type`) → 2. 第三方(`vue` / `naive-ui` / `axios`) → 3. 项目内部(`@/apis`、`@/stores`、`@/utils`、`@/icons`、`@/composables`)
|
||||||
|
|
||||||
不要重复 import 已自动注册的符号(类型除外)。
|
⚠️ **本仓库没有 auto-import**:naive-ui 组件、Vue API(`ref`/`computed`/`h`…)、工具函数**必须显式 import**。
|
||||||
|
不要写"为已自动导入的 API 写冗余 import"这类旧规范 —— 那是上一版(Gi 模板)的约定,现在写了才不报错。
|
||||||
|
|
||||||
## 组织
|
## 常用内部模块
|
||||||
|
|
||||||
- 遵循项目现有目录划分;单文件建议不超过 400 行,超出应拆分。
|
| 模块 | 用途 |
|
||||||
- 可复用 UI 放组件目录;跨页面逻辑抽 composables;纯函数抽 utils。
|
|---|---|
|
||||||
- 路径别名遵循项目配置,避免 `../../../` 穿越。
|
| `@/utils/http.ts` | axios 实例:成功直接 resolve `data`、失败弹提示、带 `Authorization` 与 `X-Tenant-Id` |
|
||||||
|
| `@/utils/naive.ts` | 全局 `message`(在组件外用,如 http 拦截器) |
|
||||||
## Lint
|
| `@/utils/tag.ts` | `tagOf()`:状态色词 → `n-tag` 的 `type`(**必须过这一层**) |
|
||||||
|
| `@/utils/format.ts` | `fmtDate` 等格式化 |
|
||||||
- 遵循项目 ESLint、Stylelint、tsconfig,不擅自关闭规则。
|
| `@/composables/pagination.ts` | `listPagination()`:列表页分页器统一参数 |
|
||||||
- 必要的 `eslint-disable` / `@ts-expect-error` 须注释说明原因。
|
| `@/stores/user.ts` | `useUserStore()`:`has('码')` / `hasAny(...)` / `menus` / `isPlatform` |
|
||||||
|
| `@/stores/acting-tenant.ts` | 平台运营的「当前学校」(请求头 `X-Tenant-Id`) |
|
||||||
|
|||||||
@@ -1,67 +1,92 @@
|
|||||||
---
|
---
|
||||||
name: code-vue
|
name: code-vue
|
||||||
description: Vue 3 Composition API、SFC 块顺序、defineEmits、useTemplateRef、响应式约定。编写或修改 .vue 文件时加载本技能。
|
description: Vue 3 Composition API、SFC 块顺序、Naive UI 组件显式导入、props/emits 与模板约定。编写或修改 .vue 文件时加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Vue 规范(Vue 3)
|
# Vue 规范(Vue 3 + Naive UI)
|
||||||
|
|
||||||
适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。
|
适用于 Composition API + `<script setup lang="ts">`。样式见 [code-css](../code-css/SKILL.md)。
|
||||||
|
|
||||||
## 基础
|
## 基础
|
||||||
|
|
||||||
- 新组件一律 **Composition API** + `<script setup>`,禁止 Options API。
|
- 新组件一律 **Composition API** + `<script setup lang="ts">`,禁止 Options API。
|
||||||
- 需要组件名时用 `defineOptions({ name: 'ComponentName' })`;路由页 `name` 与路由配置一致。
|
- 块顺序:`<template>` → `<script setup>` → `<style scoped>`。
|
||||||
- 块顺序:`<template>` → `<script setup>` → `<style>`(遵循项目 ESLint block-order)。
|
- 需要组件名时用 `defineOptions({ name: 'XxxYyy' })`。
|
||||||
|
|
||||||
## Script 内顺序
|
## Script 内顺序
|
||||||
|
|
||||||
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineModel` → `defineProps` + `withDefaults` → `defineEmits` → `defineSlots` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch` → `defineExpose`
|
类型导入 → 第三方 → Vue API → 项目模块 → `defineOptions` → `defineProps` + `withDefaults` → `defineEmits` → 常量/函数 → `ref`/`reactive` → `computed` → 方法 → 生命周期/`watch`。
|
||||||
|
|
||||||
## 自动导入
|
## ⚠️ 显式 import(本仓库没有 auto-import)
|
||||||
|
|
||||||
已自动注册的 API / 组件不要重复 import;仅需类型时用 `import type`。以 `vite.config` 与现有代码为准。
|
```ts
|
||||||
|
import type { DataTableColumns, FormInst } from 'naive-ui'
|
||||||
|
import { NButton, NCard, NDataTable, NForm, NFormItem, NInput, NModal, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||||
|
import * as tenantApi from '@/apis/xt/tenant'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 组件用 **PascalCase** 标签:`<NDataTable :columns="columns" :data="list" />`(**不是** `<a-table>`)。
|
||||||
|
- 弹窗/抽屉、表格、表单全部来自 **naive-ui**;主题与全局配置在 `utils/naive.ts` / `App.vue` 一侧。
|
||||||
|
|
||||||
|
### 漏 import 是**静默故障**(2026-10 实测踩到 4 个文件 6 处)
|
||||||
|
|
||||||
|
写小写标签(`<n-button>`)而忘了 import 时,Vue 把它当**未知自定义元素**渲染:
|
||||||
|
**不报错、不崩、`pnpm typecheck` 与 `pnpm lint` 全绿**,只是样式整块丢掉。
|
||||||
|
实测表现:`<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、`<n-form>` 的表单排版全丢 ——
|
||||||
|
用户看到的就是"这个页面样式坏了"。
|
||||||
|
|
||||||
|
> **统一写 PascalCase 标签**(`<NButton>`),漏 import 时 Vue 会给出
|
||||||
|
> `Failed to resolve component` 警告,比小写标签好排查。
|
||||||
|
> 另外 `python scripts/check-wiring.py` 的**第 ⑧ 项**会静态核对
|
||||||
|
> 「模板里用到的 `<n-*>` ↔ 本文件的 import」,改完 PC 端跑一下它。
|
||||||
|
|
||||||
|
## ⚠️ `n-tab-pane` 的标签必须用 `#tab` **插槽**
|
||||||
|
|
||||||
|
naive-ui 2.45.3 里,`tab="文字"` **属性**写法渲染不出任何文字(只留一个空注释节点),
|
||||||
|
表现是**整排 tab 一个字都不显示、整行高度只剩 12px** —— 筛选功能等于消失,且不报错。
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ 正确 -->
|
||||||
|
<NTabs v-model:value="node">
|
||||||
|
<NTabPane :name="1">
|
||||||
|
<template #tab>
|
||||||
|
初审(班主任)
|
||||||
|
</template>
|
||||||
|
</NTabPane>
|
||||||
|
</NTabs>
|
||||||
|
|
||||||
|
<!-- ❌ 不要:标签会静默消失 -->
|
||||||
|
<NTabPane :name="1" tab="初审(班主任)" />
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:属性值走 `normalizeVNode(() => render(mergedTab ?? name))`,而这个 thunk 返回
|
||||||
|
Text VNode,被当成块 `createBlock(vnode)` 处理;插槽那条分支返回数组,所以正常。
|
||||||
|
|
||||||
|
## 权限与状态色
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const user = useUserStore()
|
||||||
|
// 没有 v-hasPerm 指令,权限判断就是一次函数调用
|
||||||
|
const canManage = computed(() => user.has('org:manage'))
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NTag
|
||||||
|
:type="tagOf(row.status)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ STATUS_NAMES[row.status] }}
|
||||||
|
</NTag>
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **不要**把 `apis/xt/*.ts` 里的 `color`(Arco 色词 `green`/`arcoblue`/`gold`)直接传给 `n-tag` 的 `type` ——
|
||||||
|
会**静默变成默认灰色**且不报错;必须过 `@/utils/tag.ts` 的 `tagOf()`。
|
||||||
|
|
||||||
## 模板
|
## 模板
|
||||||
|
|
||||||
- 事件名、插槽名 **kebab-case**;组件 **PascalCase**。
|
- 事件名、插槽名 **kebab-case**;组件 **PascalCase**。
|
||||||
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法,不要写在模板里。
|
- 表格列用 `h()` 渲染(`NButton` / `NTag` / `AppIcon`),逻辑别堆在模板里。
|
||||||
- 子组件通过 props/emits 通信,避免随意访问父组件实例。
|
- 受控表单优先 `v-model`;复杂逻辑抽到 `computed` 或方法。
|
||||||
|
- 图标统一用 `@/icons/AppIcon.vue`(缺图标是**空占位、不报错**)。
|
||||||
## defineEmits
|
- 子组件通过 props/emits 通信,避免访问父组件实例。
|
||||||
|
|
||||||
使用**基于类型的函数调用签名**,禁止数组 / tuple 形式:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'submit'): void
|
|
||||||
(e: 'change', value: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// ❌ defineEmits<{ submit: [] }>() 或 tuple 形式
|
|
||||||
```
|
|
||||||
|
|
||||||
## 模板 Ref
|
|
||||||
|
|
||||||
优先 **`useTemplateRef`**(Vue 3.5+);模板 `ref` 属性值与参数字符串一致。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const formRef = useTemplateRef<FormInstance>('formRef')
|
|
||||||
// template: <form ref="formRef">
|
|
||||||
```
|
|
||||||
|
|
||||||
## 响应式
|
|
||||||
|
|
||||||
- 原始值 `ref`,对象 `reactive`,派生 `computed`。
|
|
||||||
- 解构 reactive 注意响应性丢失,必要时 `toRefs`。
|
|
||||||
- 深拷贝优先 `structuredClone` 或项目工具,禁止 `JSON.parse(JSON.stringify())`。
|
|
||||||
|
|
||||||
## defineExpose
|
|
||||||
|
|
||||||
仅暴露父组件确实需要调用的方法或属性(如弹窗 `open`/`close`/`reset`)。
|
|
||||||
|
|
||||||
## 禁止
|
|
||||||
|
|
||||||
- ❌ Options API、重复自动导入、defineEmits tuple 形式
|
|
||||||
- ❌ 无关 `ref()` 绑定模板 ref(优先 `useTemplateRef`)
|
|
||||||
- ❌ 模板中写复杂业务逻辑
|
|
||||||
|
|||||||
@@ -1,162 +1,58 @@
|
|||||||
---
|
---
|
||||||
name: project-api
|
name: project-api
|
||||||
description: 在本模板项目中新增或修改 API 接口与类型定义(本仓库已不用 Mock,见开头说明)。涉及 http、baseAPI、分页响应格式、响应体适配时使用。
|
description: 接口调用约定(axios 封装、apis/xt 模块、平台代管头、错误处理)。新增或修改接口调用时加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# API 与 Mock
|
# 接口调用
|
||||||
|
|
||||||
> ⚠️ **本仓库不用 Mock(2026-09-30 核实)**:`mock/` 目录与 `vite-plugin-mock` 都已删除,
|
> ⚠️ **本仓库没有 Mock**:`mock/` 与 `vite-plugin-mock` 都已删除。
|
||||||
> `.env.development` 里 `VITE_API_PREFIX = '/api/v1'`、`VITE_API_BASE_URL = 'http://localhost:8080'`,
|
> `pnpm dev` 把 `/api` 代理到 `http://localhost:8080` → **后端不起着就没有数据**。
|
||||||
> `pnpm dev` 把 `/api` 代理到真实后端 —— **后端不起着就没有数据**(这不是故障)。
|
> (上一版这份 skill 以 Mock 为主线,那是 Gi 模板的写法,**已不适用**。)
|
||||||
>
|
|
||||||
> 所以本文档里所有关于 Mock 的部分(`mock/**`、`defineMock`、`getBaseApi` 生成 mock、
|
|
||||||
> Mock 目录约定)在本仓库**不适用**,保留它们只是因为 Gi 模板升级时要做差异比对。
|
|
||||||
> 新增接口照 `src/apis/xt/*.ts` 的写法:用 `@/utils/http`,响应体 `{ code, msg, data }`
|
|
||||||
> (适配层会补 `records`)已在 `@/utils/http` 统一处理,业务模块不要再自己解一层。
|
|
||||||
|
|
||||||
## 接口层结构
|
## 契约
|
||||||
|
|
||||||
每个业务模块一个文件,例如 `src/apis/system/user.ts`:
|
- 响应恒为 `{ code, msg, data }`;**业务错误也用 HTTP 200**,
|
||||||
|
只有 `401` / `403` / `429` / `5xx` 用**真实状态码**(别在业务码里找 `40301`)。
|
||||||
|
- `code === 0` 是成功。
|
||||||
|
|
||||||
|
## 封装(`src/utils/http.ts`)
|
||||||
|
|
||||||
|
它替业务代码做掉三件事,所以页面里几乎不出现 `code`:
|
||||||
|
|
||||||
|
1. 成功时**直接 resolve `data`** → `const vo = await getTask(id)` 拿到的就是数据;
|
||||||
|
2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(要按码分支也能拿到);
|
||||||
|
3. 自动带 `Authorization: Bearer <token>` 与平台代管头 `X-Tenant-Id`。
|
||||||
|
|
||||||
|
## 写一个接口模块(`src/apis/xt/<域>.ts`)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { getBaseApi } from '@/apis/base'
|
import type { PageResult } from '@/apis/types'
|
||||||
import http from '@/utils/http'
|
import http from '@/utils/http'
|
||||||
|
|
||||||
export interface ListItem {
|
export interface XxxVO { id: number, name: string, status: number }
|
||||||
id: string
|
|
||||||
name: string
|
/** 状态映射与接口同处一个文件(别处也要用,改动只有一处) */
|
||||||
status: Status
|
export const XXX_STATUS: Record<number, { text: string, color: string }> = {
|
||||||
createTime: string
|
0: { text: '停用', color: 'gray' },
|
||||||
// ...
|
1: { text: '正常', color: 'green' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标准 CRUD */
|
export function list(params: { page: number, pageSize: number, keyword?: string }) {
|
||||||
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/user' })
|
return http.get<PageResult<XxxVO>>('/xxx', { params })
|
||||||
|
|
||||||
/** 非标准接口单独声明 */
|
|
||||||
export function getDictDataList(params: { code: string } & Pagination) {
|
|
||||||
return http.get<PageRes<DictDataItem[]>>('/system/dict/getDictDataList', params)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## getBaseApi 约定路径
|
- 新域就**新建一个文件**,并在 `src/apis/xt/index.ts` 里按需导出;
|
||||||
|
- 通用类型(`PageResult`、`BizError` 等)在 `src/apis/types.ts`;
|
||||||
|
- ⚠️ 展示映射里的 `color` 是**上一版 Arco 的色词**,给 `n-tag` 用之前必须过 `@/utils/tag.ts` 的 `tagOf()`。
|
||||||
|
|
||||||
| 方法 | HTTP | 路径 |
|
## 平台代管(`X-Tenant-Id`)
|
||||||
|------|------|------|
|
|
||||||
| getList | GET | `{baseUrl}/getList` |
|
|
||||||
| getDetail | GET | `{baseUrl}/getDetail` |
|
|
||||||
| add | POST | `{baseUrl}/add` |
|
|
||||||
| update | POST | `{baseUrl}/update` |
|
|
||||||
| delete | POST | `{baseUrl}/delete` |
|
|
||||||
|
|
||||||
列表参数:`{ page, size, ...filters }`
|
平台运营的请求要带"当前学校":值存在 `src/stores/acting-tenant.ts`,
|
||||||
删除参数:`{ ids: string[] }`
|
由 `http.ts` 统一注入。**页面不要自己拼这个头**;不选学校时**不要发请求**(发了会把各租户数据混在一起)。
|
||||||
|
|
||||||
## 响应格式
|
## 其它
|
||||||
|
|
||||||
```ts
|
- 下载类接口(导出包、整理结果)必须走 `fetch`/axios **取 blob**:接口要鉴权,
|
||||||
// 单条/布尔
|
直接 `window.open` 或 `<a href>` 会 401,用户看到的是"下载没反应"。
|
||||||
interface ApiRes<T> {
|
- 签名 URL(预览图片等)**每次现取**,不要缓存进本地状态或存库(有有效期)。
|
||||||
code: number
|
|
||||||
data: T
|
|
||||||
message: string
|
|
||||||
success: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页列表 data 结构
|
|
||||||
interface PageRes<T> {
|
|
||||||
records: T
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`useTable` 同时兼容 `data` 为数组或 `PageRes` 两种形态。
|
|
||||||
|
|
||||||
## http 封装
|
|
||||||
|
|
||||||
- 实例:`src/utils/http.ts`,`baseURL` = `import.meta.env.VITE_API_PREFIX`
|
|
||||||
- 开发环境 `.env.development`:`VITE_API_PREFIX = '/mock'`
|
|
||||||
- Token:请求头 `token` 字段(`getToken()`)
|
|
||||||
- 业务失败:`success === false` 时 Message/Notification 提示并 reject
|
|
||||||
- 401:跳转 `/login`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import http from '@/utils/http'
|
|
||||||
|
|
||||||
http.get<PageRes<Item[]>>('/path', { page: 1, size: 10 })
|
|
||||||
http.post<boolean>('/path', { ids: ['1'] })
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mock 开发流程
|
|
||||||
|
|
||||||
### 1. 静态数据
|
|
||||||
|
|
||||||
在 `mock/_data/` 下维护 JSON/TS 数据,如 `system_dict.ts`。
|
|
||||||
|
|
||||||
### 2. 路由 Mock
|
|
||||||
|
|
||||||
在 `mock/system/`(或对应模块)新建文件:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineMock } from '../_base'
|
|
||||||
import { getBaseApi, getDelayTime, resultError, resultSuccess } from '../_utils'
|
|
||||||
|
|
||||||
export default defineMock([
|
|
||||||
...getBaseApi({
|
|
||||||
baseUrl: '/system/xxx',
|
|
||||||
getListData: () => resultSuccess({ total: list.length, records: list }),
|
|
||||||
getDetailData: (query) => {
|
|
||||||
const obj = list.find((i) => i.id === query.id)
|
|
||||||
return obj ? resultSuccess(obj) : resultError(null, '未找到', 400)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
url: '/system/xxx/customAction',
|
|
||||||
method: 'get',
|
|
||||||
timeout: getDelayTime(),
|
|
||||||
response: ({ query }) => {
|
|
||||||
// 筛选、分页后返回
|
|
||||||
return resultSuccess({ total, records })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 工具函数
|
|
||||||
|
|
||||||
| 函数 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `resultSuccess(data)` | `{ code:200, success:true, data, message }` |
|
|
||||||
| `resultError(data, message, code?)` | 失败响应 |
|
|
||||||
| `getDelayTime()` | 50–300ms 随机延迟 |
|
|
||||||
| `getBaseApi({ baseUrl, getListData, ... })` | 生成标准 CRUD mock |
|
|
||||||
|
|
||||||
`defineMock` 会自动给 url 加前缀 `/mock`,与 `VITE_API_PREFIX` 对应。
|
|
||||||
|
|
||||||
### 4. 注册
|
|
||||||
|
|
||||||
确保 mock 文件被 `mock/index` 或同级聚合引入(遵循现有 `mock/system/*.ts` 模式)。
|
|
||||||
|
|
||||||
## 新增模块检查清单
|
|
||||||
|
|
||||||
- [ ] `src/apis/{module}/{name}.ts`:类型 + `baseAPI` 或自定义方法
|
|
||||||
- [ ] `mock/{module}/{name}.ts`:与接口路径一致
|
|
||||||
- [ ] 列表 Mock 返回 `{ total, records }`
|
|
||||||
- [ ] 字段类型与 `Status`、`Gender` 等全局类型对齐
|
|
||||||
- [ ] 在页面通过 `useTable({ listAPI, deleteAPI })` 接入
|
|
||||||
|
|
||||||
## 自定义 listAPI 示例
|
|
||||||
|
|
||||||
```ts
|
|
||||||
useTable({
|
|
||||||
listAPI: (page) => getDictDataList({
|
|
||||||
...page,
|
|
||||||
code: dictCode.value,
|
|
||||||
name: query.name || undefined,
|
|
||||||
status: query.status === '' ? undefined : query.status
|
|
||||||
}),
|
|
||||||
immediate: false
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`immediate: false` 用于依赖左侧选中项后再 `search()` 的场景。
|
|
||||||
|
|||||||
@@ -1,214 +1,121 @@
|
|||||||
---
|
---
|
||||||
name: project-crud
|
name: project-crud
|
||||||
description: 在本模板项目中创建或修改 CRUD 列表页、表格列、搜索区、表单弹窗。使用 GiPageLayout、useTable、GiButton、a-table、GiForm、useDict 时加载本技能。
|
description: 列表页 + 新增/编辑弹窗的标准写法(Naive UI + 本项目约定)。新增或修改 CRUD 页面时加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# CRUD 列表页开发
|
# CRUD 列表 / 表单(标准写法)
|
||||||
|
|
||||||
> 写/改 `.vue` 前先读 [code-vue](../code-vue/SKILL.md);写样式前读 [code-css](../code-css/SKILL.md)。
|
先找一个同类页面照抄结构(`src/views/tenant/index.vue`、`src/views/user/index.vue`、
|
||||||
|
`src/views/dict/index.vue` 是最典型的三份)。
|
||||||
|
|
||||||
## 标准页面结构
|
## 列表页骨架
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
import { NButton, NCard, NDataTable, NSpace, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { h, onMounted, reactive, ref } from 'vue'
|
||||||
|
import * as xxxApi from '@/apis/xt/xxx'
|
||||||
|
import { listPagination } from '@/composables/pagination'
|
||||||
|
import { fmtDate } from '@/utils/format'
|
||||||
|
import { tagOf } from '@/utils/tag'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<XxxVO[]>([])
|
||||||
|
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await xxxApi.list({ ...query })
|
||||||
|
list.value = res.list
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<XxxVO> = [
|
||||||
|
{ title: '名称', key: 'name' },
|
||||||
|
{ title: '状态', key: 'status', render: (row) => h(NTag, { type: tagOf(row.status), size: 'small' }, { default: () => STATUS_NAMES[row.status] }) },
|
||||||
|
{ title: '操作', key: 'actions', render: (row) => h(NSpace, null, { default: () => [
|
||||||
|
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' })
|
||||||
|
] }) }
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GiPageLayout margin collapsed>
|
<!--
|
||||||
<template #left><!-- 可选:树/分类 --></template>
|
高度链必须完整:.page-card(撑满内容区)→ .page-card__body → n-data-table[flex-height],
|
||||||
|
每一层都要 min-height: 0(前两层由 styles/index.scss 给)。少一层 → 分页器被顶出可视区。
|
||||||
<a-row justify="space-between" class="g-row-tool">
|
-->
|
||||||
<a-space wrap>
|
<NCard title="xxx" :bordered="false" class="page-card">
|
||||||
<GiButton type="add" @click="onAdd" />
|
<template #header-extra>
|
||||||
<GiButton type="delete" @click="onBatchDelete" />
|
<span class="toolbar__hint">共 {{ total }} 条</span>
|
||||||
</a-space>
|
|
||||||
<a-space wrap>
|
|
||||||
<!-- 搜索表单项 -->
|
|
||||||
<GiButton type="search" @click="search" />
|
|
||||||
<GiButton type="reset" @click="reset" />
|
|
||||||
</a-space>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-table
|
|
||||||
class="g-table"
|
|
||||||
row-key="id"
|
|
||||||
:loading="loading"
|
|
||||||
:data="tableData"
|
|
||||||
:columns="tableColumns"
|
|
||||||
:bordered="{ cell: true }"
|
|
||||||
:scroll="{ x: '100%', y: '100%', minWidth: 1200 }"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
|
||||||
:selected-keys="selectedKeys"
|
|
||||||
@select="select"
|
|
||||||
@select-all="selectAll"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<XxxFormModal ref="XxxFormModalRef" @save-success="search" />
|
|
||||||
</GiPageLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="tsx">
|
<!-- 工具条:筛选在左/右,主操作 type="primary" + **默认尺寸**;
|
||||||
defineOptions({ name: 'ModuleName' })
|
同一行控件尺寸要一致(别混 size="small",否则 28px 与 34px 参差不齐) -->
|
||||||
</script>
|
<div class="toolbar">
|
||||||
|
<NSpace :size="8">
|
||||||
|
<NButton v-if="canManage" type="primary" @click="openAdd">
|
||||||
|
新建 xxx
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace :size="8">
|
||||||
|
<NInput v-model:value="query.keyword" placeholder="搜索名称" clearable @keyup.enter="search" />
|
||||||
|
<NButton type="primary" ghost @click="search">
|
||||||
|
搜索
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-card__body">
|
||||||
|
<NDataTable
|
||||||
|
remote
|
||||||
|
flex-height
|
||||||
|
:loading="loading"
|
||||||
|
:columns="columns"
|
||||||
|
:data="list"
|
||||||
|
:pagination="listPagination({ page: query.page, pageSize: query.pageSize, itemCount: total })"
|
||||||
|
@update:page="(p) => { query.page = p; load() }"
|
||||||
|
@update:page-size="(s) => { query.page = 1; query.pageSize = s; load() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
## useTable 用法
|
### 三个必须记住的点
|
||||||
|
|
||||||
```ts
|
1. **远程分页要处理两个事件**:`@update:page` **和** `@update:page-size`,
|
||||||
import type * as T from '@/apis/system/user'
|
且都要**重新取数**。naive 的表格在 `pagination` 非 false 时会自己再切一次数据
|
||||||
import { baseAPI } from '@/apis/system/user'
|
(内部 `flattenData`)—— 只改本地每页条数不重新取数,会出现"分页器说 50 条/页、表里还是 20 条"。
|
||||||
import { useDict, useTable } from '@/hooks'
|
2. **分页器容器用 `.page-card` + `.page-card__body`**(`styles/index.scss`):
|
||||||
|
否则卡片变高会把分页器顶出可视区、怎么滚都看不到(本项目反复踩过)。
|
||||||
|
3. **`listPagination()` 而不是手写对象**:手写时 `prefix` 的参数少一个 `?` 就报 TS2322。
|
||||||
|
|
||||||
const { dictData } = useDict(['STATUS'] as const)
|
### 版面(2026-10 卡片式基线)
|
||||||
|
|
||||||
const queryParams = reactive({ status: '', username: '' })
|
- **卡片外观是全局给的**(白底 + 描边 + 轻阴影 + 卡片头浅灰色带),页面**不要**再自己写边框/阴影;
|
||||||
|
卡片之所以"浮"得起来,靠的是 `layout` 那一层的 `--xt-bg` 灰底。
|
||||||
|
- 卡片**内部分区**用 `.section-title`(左侧蓝色小竖条)+ 浅灰小格子,**不要卡里套卡**。
|
||||||
|
- 工具条用 `.toolbar`(自带下分隔线,把"筛选区"和"表格区"切开)。
|
||||||
|
- 详细规则见 [code-css](../code-css/SKILL.md) 的「设计基线」「卡片式的三条规矩」「按钮的可见性」。
|
||||||
|
|
||||||
const {
|
## 表单弹窗
|
||||||
loading,
|
|
||||||
tableData,
|
|
||||||
pagination,
|
|
||||||
selectedKeys,
|
|
||||||
search,
|
|
||||||
select,
|
|
||||||
selectAll,
|
|
||||||
fixed, // 操作列 fixed: 'right'(小屏自动取消)
|
|
||||||
onDelete,
|
|
||||||
onBatchDelete,
|
|
||||||
refresh
|
|
||||||
} = useTable({
|
|
||||||
listAPI: (page) => baseAPI.getList({ ...page, ...queryParams }),
|
|
||||||
deleteAPI: (ids) => baseAPI.delete({ ids }),
|
|
||||||
immediate: true,
|
|
||||||
rowKey: 'id', // 默认 id
|
|
||||||
crossPageSelect: false // 跨页多选时设为 true
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
要点:
|
- `NModal` + `NForm` + `NFormItem`,`ref<FormInst | null>` 拿实例,提交前 `await formRef.value?.validate()`。
|
||||||
|
- 校验规则用 `FormRules`,与后端约束对齐(**但服务端才是权威**,前端只减少无效提交)。
|
||||||
|
- 成功后 `message.success(...)` + 关弹窗 + `load()`。
|
||||||
|
- 危险动作(删除/停用/重置密码)用 `useDialog()` 二次确认,文案写明后果。
|
||||||
|
|
||||||
- `search()` 会重置到第 1 页并清空选中
|
## 权限与展示
|
||||||
- `refresh()` 保持当前页刷新
|
|
||||||
- `listAPI` 参数为 `{ page, size }`,需展开到接口
|
|
||||||
- 空字符串查询条件建议转 `undefined` 再传 API
|
|
||||||
|
|
||||||
## GiButton 预设类型
|
- 按钮显隐:`computed(() => user.has('xxx:manage'))`;**不要**用 `v-hasPerm`。
|
||||||
|
- 状态色:`tagOf(row.status)`;状态文案映射放 `apis/xt/<域>.ts` 里与接口同处。
|
||||||
`add` | `edit` | `delete` | `search` | `reset` | `upload` | `import` | `export`
|
- 敏感字段(手机号/证件号)后端只回**脱敏值** → 编辑时**无法回填原文**,要改就得重填(照抄账号页的写法)。
|
||||||
|
|
||||||
列表工具栏与操作列统一使用 `GiButton`,操作列常用 `size="mini"`。
|
|
||||||
|
|
||||||
## 表格列(TSX)
|
|
||||||
|
|
||||||
使用 `lang="tsx"`,从 `@arco-design/web-vue` 引入 `TableColumnData`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const tableColumns: TableColumnData[] = [
|
|
||||||
{
|
|
||||||
title: '序号',
|
|
||||||
width: 68,
|
|
||||||
align: 'center',
|
|
||||||
render: ({ rowIndex }) => <span>{rowIndex + 1}</span>
|
|
||||||
},
|
|
||||||
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
width: 100,
|
|
||||||
align: 'center',
|
|
||||||
render: ({ record }) => (
|
|
||||||
<GiCellStatus status={record.status} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 160,
|
|
||||||
align: 'center',
|
|
||||||
fixed: 'right', // 或绑定 useTable 的 fixed
|
|
||||||
render: ({ record }) => (
|
|
||||||
<Space>
|
|
||||||
<GiButton type="edit" size="mini" onClick={() => onEdit(record as T.ListItem)} />
|
|
||||||
<Popconfirm content="确定删除?" onBeforeOk={() => onDelete(record as T.ListItem)}>
|
|
||||||
<GiButton type="delete" size="mini" />
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
常用单元格组件:`GiCellAvatar`、`GiCellGender`、`GiCellStatus`(从 `@/components/GiCell` 引入或在 TSX 中直接使用)。
|
|
||||||
|
|
||||||
## GiPageLayout
|
|
||||||
|
|
||||||
| 属性/插槽 | 说明 |
|
|
||||||
|-----------|------|
|
|
||||||
| `#left` | 左侧栏(部门树、字典类型列表等) |
|
|
||||||
| `#header` | 顶栏(少用) |
|
|
||||||
| `margin` | 外边距模式 |
|
|
||||||
| `collapsed` | 显示折叠按钮 |
|
|
||||||
| `size` | 左侧宽度,如 `"280px"` |
|
|
||||||
|
|
||||||
左栏选中后通过事件同步右侧列表,参考 `dict/index.vue` 的 `onDictTypeRefresh`。
|
|
||||||
|
|
||||||
## 表单弹窗(Modal + GiForm)
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<a-modal v-model:visible="visible" :title="title" :mask-closable="false"
|
|
||||||
@before-ok="save" @close="close">
|
|
||||||
<GiForm ref="GiFormRef" :model-value="form" :columns="formColumns"
|
|
||||||
@update:model-value="Object.assign(form, $event)" />
|
|
||||||
</a-modal>
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { FormColumnItem } from '@/components/index'
|
|
||||||
import { useResetReactive } from '@/hooks'
|
|
||||||
|
|
||||||
const [form, resetForm] = useResetReactive({ id: '', name: '', status: '1' as Status })
|
|
||||||
const GiFormRef = useTemplateRef<InstanceType<typeof GiForm>>('GiFormRef')
|
|
||||||
|
|
||||||
const add = () => {
|
|
||||||
detailId.value = ''
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
const edit = async (id: string) => {
|
|
||||||
visible.value = true
|
|
||||||
detailId.value = id
|
|
||||||
Object.assign(form, (await baseAPI.getDetail({ id })).data)
|
|
||||||
}
|
|
||||||
const close = () => {
|
|
||||||
GiFormRef.value?.formRef?.resetFields()
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
const save = async () => {
|
|
||||||
const valid = await GiFormRef.value?.formRef?.validate()
|
|
||||||
if (valid) return false
|
|
||||||
// await baseAPI.add(form) 或 baseAPI.update(form)
|
|
||||||
emit('save-success')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ add, edit })
|
|
||||||
```
|
|
||||||
|
|
||||||
`formColumns` 常用 `type`:`input`、`select`、`tree-select`、`radio-group`、`switch`、`textarea`。校验可用 `required`、`rules`;正则见 `@/utils/regexp`。
|
|
||||||
|
|
||||||
## 字典下拉
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const { dictData } = useDict(['STATUS'] as const)
|
|
||||||
// 模板:<a-select :options="dictData.STATUS" />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 表格增强方案选型
|
|
||||||
|
|
||||||
| 需求 | 方案 |
|
|
||||||
|------|------|
|
|
||||||
| 内置斑马纹/全屏/列设置 | `GiTable` |
|
|
||||||
| 继续用 `a-table` + 工具栏/列设置 | `GiTableSetting` 包裹(见 `views/table/custom3`) |
|
|
||||||
| 简单列表 | `a-table` + `useTable`(系统模块主流) |
|
|
||||||
|
|
||||||
## 检查清单
|
|
||||||
|
|
||||||
- [ ] `defineOptions({ name })` 已设置
|
|
||||||
- [ ] `row-key="id"` 与 `useTable` 的 `rowKey` 一致
|
|
||||||
- [ ] 删除走 `onDelete` / `onBatchDelete` 或自定义 `Popconfirm` + API
|
|
||||||
- [ ] 弹窗 `defineExpose({ add, edit })`,父组件 `useTemplateRef` 调用
|
|
||||||
- [ ] 保存成功后 `emit('save-success')` 并触发 `search`
|
|
||||||
|
|||||||
@@ -1,171 +1,72 @@
|
|||||||
---
|
---
|
||||||
name: project-guide
|
name: project-guide
|
||||||
description: 后台管理模板的项目总览与前端强制规范。涵盖 Vue 3 + Arco Design + Gi 组件库、目录结构、自动导入、CRUD/API 约定与代码风格。在当前仓库内开发、新增页面、查阅架构或不确定项目规范时使用。
|
description: PC 管理端的技术栈、目录结构、菜单与权限模型,以及七条最容易写错的约定。开始任何 PC 端改动前加载本技能。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 项目开发指南
|
# 项目总览(PC 管理端)
|
||||||
|
|
||||||
> **适用范围**:本仓库 `src/**`、`mock/**`
|
|
||||||
> **规范来源**:`AGENTS.md` 常驻行为准则 + 本 skill(项目细节);编码规范按需读 `code-vue` / `code-css` / `code-ts` skill
|
|
||||||
|
|
||||||
## 本项目 Agent 约定
|
|
||||||
- 优先复用 `Gi*`、`hooks`、`getBaseApi`、现有 mock 工具;新增页面/接口对齐参考页与 `getBaseApi` 约定。
|
|
||||||
- API / Mock 以 `src/apis`、`mock` 与类型定义为准,不要猜测路径。
|
|
||||||
- 涉及 CRUD、API、Mock 时按需读取对应 skill;写 `.vue` / 样式 / TS 前读取 [code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md)、[code-ts](../code-ts/SKILL.md)。
|
|
||||||
- 改动后视情况运行 `pnpm typecheck`、`pnpm lint`、`pnpm lint:style`。
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 类别 | 选型 |
|
| 项 | 值 |
|
||||||
|------|------|
|
|---|---|
|
||||||
| 框架 | Vue 3.5 + TypeScript + `<script setup lang="ts">` |
|
| 框架 | **Vue 3.5 + TypeScript + Vite 7** |
|
||||||
| 构建 | Vite 7 |
|
| UI | **Naive UI 2.42**(2026-10 从 Gi Admin Pro + Arco 重搭,**已无 Arco/Gi 依赖**) |
|
||||||
| UI | Arco Design Vue(按需自动导入)+ 本地 `Gi*` 组件 |
|
| 状态 | Pinia(`pinia-plugin-persistedstate`) |
|
||||||
| 状态 | Pinia + pinia-plugin-persistedstate |
|
| 请求 | axios(`src/utils/http.ts` 统一封装) |
|
||||||
| 路由 | Vue Router 4(Hash 模式) |
|
| 样式 | SCSS(无 Stylelint) |
|
||||||
| HTTP | `@/utils/http`,统一响应类型 `ApiRes<T>`、`PageRes<T>` |
|
| 检查 | `pnpm typecheck`(vue-tsc,**当前 0 错**)、`pnpm lint`(@antfu/eslint-config) |
|
||||||
| 工具 | @vueuse/core、lodash-es、dayjs、xe-utils |
|
|
||||||
| Mock | vite-plugin-mock + mockjs(开发默认启用) |
|
|
||||||
|
|
||||||
每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`;通用 Vue 规范见 [code-vue](../code-vue/SKILL.md),本项目补充见下文「本项目 Vue 约定」。
|
## 目录
|
||||||
|
|
||||||
## 路径别名
|
|
||||||
|
|
||||||
- `@` → `src/`
|
|
||||||
- `~` → 项目根目录
|
|
||||||
|
|
||||||
## 目录约定
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── apis/ # 接口(按业务模块分文件,标准 CRUD 用 getBaseApi)
|
├── apis/xt/ 接口模块(按业务域分文件)+ types.ts(通用类型)
|
||||||
├── components/ # Gi* 业务组件 + 部分通用组件
|
├── composables/ pagination.ts(列表页分页器统一参数)
|
||||||
├── hooks/ # 组合式函数(useTable、useDict 等)
|
├── config/ xt-routes.ts(**业务菜单表:菜单 + 路由 + 分组**)
|
||||||
├── hooks/app/ # 业务域 hooks(useDept、useRole)
|
├── icons/ AppIcon.vue + icon-data.ts(由 scripts/extract-pc-icons.py 生成)
|
||||||
├── stores/ # Pinia
|
├── layout/ AppSider / AppHeader / index.vue
|
||||||
├── views/ # 页面(按模块分子目录)
|
├── router/ index.ts(本地菜单表按后端 menuKeys 过滤后注册)
|
||||||
├── router/ # 路由与守卫
|
├── stores/ user.ts(登录态/权限/菜单)、acting-tenant.ts(平台代管学校)、app.ts
|
||||||
├── utils/ # http、auth、regexp、deepClone 等
|
├── styles/ index.scss(只放跨页面样式)
|
||||||
├── types/ # 全局类型(global.d.ts、api.d.ts)
|
├── utils/ http.ts / naive.ts / tag.ts / format.ts / auth.ts / rsa.ts
|
||||||
mock/ # vite-plugin-mock 模拟数据
|
└── views/ 页面(每个菜单一个目录,行内抽屉与弹窗同目录)
|
||||||
```
|
```
|
||||||
|
|
||||||
页面文件命名:
|
## 菜单与权限模型(**后端决定可见性**)
|
||||||
|
|
||||||
```
|
- 前端 `config/xt-routes.ts` 是**唯一**菜单表:`key`(菜单键)↔ 路由 ↔ 视图 ↔ 分组。
|
||||||
src/views/{module}/index.vue # 列表页
|
- **能不能看由后端决定**:`/auth/me` 返回 `menus: string[]`,前端只按它过滤;
|
||||||
src/views/{module}/XxxFormModal.vue # 新增/编辑弹窗(有表单时必建)
|
绕过前端也只会拿到 `40301`。
|
||||||
src/apis/{module}/{name}.ts # 类型定义 + CRUD API(或 apis/system/user.ts)
|
- **能不能做**看权限码:`useUserStore().has('码')`,字符串必须与后端 `PermissionDef` **逐字一致**
|
||||||
mock/{module}/{name}.ts # 与 API 路径一致的 Mock
|
(`progress:viewAll` 是驼峰,**不是** `progress:view:all` —— 写错只会恒为 false,不报错)。
|
||||||
|
- 平台运营要**先选学校**(`stores/acting-tenant.ts` → 请求头 `X-Tenant-Id`);
|
||||||
|
不选就不发请求,否则会把各租户数据混在一起且看起来正常。
|
||||||
|
|
||||||
|
## 七条最容易写错的约定
|
||||||
|
|
||||||
|
1. **没有 auto-import**:组件与 API 全显式 import,标签写 **PascalCase**。
|
||||||
|
漏 import 是**静默故障**(未知自定义元素 → 样式整块丢掉,typecheck/lint 全绿);
|
||||||
|
`python scripts/check-wiring.py` 第 ⑧ 项会核对。
|
||||||
|
2. **没有 `v-hasPerm`**:权限判断写 `user.has('码')`。
|
||||||
|
3. **`n-tag` 的 `type` 必须过 `@/utils/tag.ts`**(Arco 色词直接透传会静默变灰)。
|
||||||
|
4. **图标要重跑脚本**:`scripts/extract-pc-icons.py` → `src/icons/icon-data.ts`(37 个);
|
||||||
|
缺图标是空占位、不报错 → 图标集升级后必须重跑。
|
||||||
|
5. **`n-tab-pane` 的标签用 `#tab` 插槽**,不要用 `tab="文字"` 属性 ——
|
||||||
|
naive-ui 2.45.3 的属性写法只渲染出空注释节点,整排 tab 一个字都不显示(不报错)。
|
||||||
|
6. **卡片式版面**:白卡片靠全局 `--xt-bg` 灰底浮起来;卡片外观由 `styles/index.scss` 统一给,
|
||||||
|
页面不要再自己写;卡片内部分区用 `.section-title` + 浅灰小格子,不要卡里套卡。
|
||||||
|
详见 [code-css](../code-css/SKILL.md) 的「设计基线」一节。
|
||||||
|
7. **品牌标是几何标**:`src/assets/images/logo.svg`(方 = 卷宗,环 = 在线)。
|
||||||
|
**不要换回文字字形标**;页签用 `public/favicon.svg`(加粗版),改一个要同步另一个。
|
||||||
|
⚠️ SVG 注释里**不能出现连续两个减号**(XML 规范禁止)→ 整个文件 not well-formed,
|
||||||
|
图片裂开而控制台一句话都不报;`python scripts/check-wiring.py` 第 ⑨ 项会核对。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # → http://localhost:5173,/api 代理到 8080(后端不起着就没有数据)
|
||||||
|
pnpm typecheck && pnpm lint
|
||||||
```
|
```
|
||||||
|
|
||||||
## 全局类型(`src/types/`)
|
演示账号:`admin / Admin@123` + 租户码 `demo`(**不是** Gi README 里的 `admin/123456`)。
|
||||||
|
|
||||||
- `Status`:`'0' | '1'`(禁用/启用,与字典 `STATUS` 一致)
|
|
||||||
- `Gender`:`'1' | '2' | '3'`
|
|
||||||
- `Pagination`:`{ page, size }`
|
|
||||||
- `ApiRes<T>`、`PageRes<T>`:统一接口响应(分页 `data` 为 `{ records, total }`)
|
|
||||||
|
|
||||||
## CRUD 列表页(强制遵循)
|
|
||||||
|
|
||||||
新建/修改表格页时,**必须**参考并遵循:
|
|
||||||
|
|
||||||
- 参考实现:`src/views/system/user/index.vue`、`UserFormModal.vue`
|
|
||||||
- Hook:`@/hooks/useTable`
|
|
||||||
- API 模板:`src/apis/system/user.ts` + `getBaseApi`
|
|
||||||
- 详细步骤:读取并遵循 [project-crud](../project-crud/SKILL.md)
|
|
||||||
|
|
||||||
核心结构:
|
|
||||||
|
|
||||||
1. `GiPageLayout` 布局(可选 `#left` 树/分类)
|
|
||||||
2. `queryParams`(reactive)+ 搜索区 + `GiButton`(search/reset)
|
|
||||||
3. `tableColumns`(`TableColumnData[]`,复杂列用 `lang="tsx"` + `render`)
|
|
||||||
4. `useTable` 绑定 `listAPI` 与 `deleteAPI`
|
|
||||||
5. `XxxFormModal` ref,`defineExpose({ add, edit })`,`@save-success="search"`
|
|
||||||
|
|
||||||
分页类型:`PageRes<T>`(`records` + `total`)。Mock 与接口详见 [project-api](../project-api/SKILL.md)。
|
|
||||||
|
|
||||||
## API 层模板
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getBaseApi } from '@/apis/base'
|
|
||||||
import http from '@/utils/http'
|
|
||||||
|
|
||||||
export interface ListItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
status: Status
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标准 CRUD */
|
|
||||||
export const baseAPI = getBaseApi<ListItem>({ baseUrl: '/system/xxx' })
|
|
||||||
|
|
||||||
/** 非标准接口 */
|
|
||||||
export function customAction(params: Pagination) {
|
|
||||||
return http.get<PageRes<Item[]>>('/system/xxx/customAction', params)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 开发环境 `VITE_API_PREFIX = '/mock'`(`.env.development`)
|
|
||||||
- 禁止绕过 `@/utils/http` 裸用 axios
|
|
||||||
|
|
||||||
## 代码风格
|
|
||||||
|
|
||||||
- 遵循 `@antfu/eslint-config`(`eslint.config.js`):箭头参数括号、1TBS、无尾随逗号;Vue block 顺序 `[['script', 'template'], 'style']`;宏顺序 `defineOptions/defineModel/defineProps/defineEmits/defineSlots`,`defineExpose` 最后。
|
|
||||||
- 布局放 `src/layout/`,Gi 组件放 `src/components/Gi*`,业务 Hook 放 `hooks/` 或 `hooks/app/`。
|
|
||||||
- 空字符串查询条件传 API 前建议转为 `undefined`。
|
|
||||||
- 通用编码规范见 [code-ts](../code-ts/SKILL.md)、[code-vue](../code-vue/SKILL.md)、[code-css](../code-css/SKILL.md);本项目补充见下文。
|
|
||||||
|
|
||||||
## 本项目编码约定
|
|
||||||
|
|
||||||
- 路径别名:`@` → `src/`,`~` → 项目根;禁止 `../../../` 穿越。
|
|
||||||
- 组件全局前缀 **`Gi`** 仅用于 `src/components/Gi*` 全局组件。
|
|
||||||
- 目录:`src/views` 页面、`src/components` 组件、`src/apis/<domain>/` + `type.ts` 接口类型、`src/types` 公共类型、`src/hooks` composables。
|
|
||||||
- API 统一 `@/utils/http`;状态 Pinia + `pinia-plugin-persistedstate`。
|
|
||||||
- 图标 `@iconify/vue`;样式从 scss/less 导入,避免内联。
|
|
||||||
- 业务注释中文,标识符英文。
|
|
||||||
- 检查:`pnpm lint`、`pnpm lint:style`、`pnpm typecheck`。
|
|
||||||
|
|
||||||
## 本项目 Vue 约定
|
|
||||||
|
|
||||||
- 每个 SFC 必须 `defineOptions({ name: 'XxxYyy' })`,名称与路由/模块对应,并与路由表 `name` 一致以支持缓存。
|
|
||||||
- **自动导入**(`vite.config.ts`,勿重复 import):
|
|
||||||
- Vue API:`ref`、`reactive`、`computed`、`watch`、`useTemplateRef` 等
|
|
||||||
- Gi 组件:`src/components/Gi*`(如 `GiPageLayout`、`GiButton`、`GiForm`)
|
|
||||||
- Arco 组件:`a-table`、`a-modal`、`a-select` 等
|
|
||||||
- 图标:`icon-*`(Iconify `icon-park-outline`)
|
|
||||||
- Gi 组件模板用 **PascalCase**;Arco 用 `a-*` 前缀;禁止手动 import Gi/Arco(类型除外)。
|
|
||||||
- 表格列复杂时用 `lang="tsx"` + `TableColumnData[]` 的 `render`;字典字段用 `useDict(['STATUS'])` 等。
|
|
||||||
- `dayjs` 默认导入变量名必须为 **`Dayjs`**(大写),禁止小写 `dayjs`。
|
|
||||||
- 深拷贝使用 `@/utils` 的 `deepClone`,禁止 `JSON.parse(JSON.stringify())`。
|
|
||||||
- Vue 参考页:`src/views/system/user/index.vue`、`UserFormModal.vue`、`src/views/table/custom3/index.vue`
|
|
||||||
|
|
||||||
## 本项目样式
|
|
||||||
|
|
||||||
- 全局样式:`src/styles/index.scss`(变量 `var.scss`、混入 `mixin.scss`、Arco 主题 `arco-ui/`)
|
|
||||||
- Stylelint:`pnpm lint:style`
|
|
||||||
- 列表页常用工具类:`g-row-tool`(工具栏行)、`g-table`(表格)、`g-full-column`(左栏撑满)、`g-mb`(下边距)
|
|
||||||
- BEM 示例:`src/views/login/LoginLeft.vue`
|
|
||||||
|
|
||||||
## 子技能(按需阅读)
|
|
||||||
|
|
||||||
| 场景 | 技能 |
|
|
||||||
|------|------|
|
|
||||||
| Vue SFC、Emits、Ref | [code-vue](../code-vue/SKILL.md) |
|
|
||||||
| 样式、BEM | [code-css](../code-css/SKILL.md) |
|
|
||||||
| TS 命名、导入 | [code-ts](../code-ts/SKILL.md) |
|
|
||||||
| 新建 CRUD 列表页、表格、弹窗表单 | [project-crud](../project-crud/SKILL.md) |
|
|
||||||
| 新增接口、Mock、baseAPI | [project-api](../project-api/SKILL.md) |
|
|
||||||
|
|
||||||
## 参考页面
|
|
||||||
|
|
||||||
- 标准 CRUD + 左树:`src/views/system/user/index.vue`
|
|
||||||
- 左右分栏 + 字典:`src/views/system/dict/index.vue`
|
|
||||||
- GiTableSetting + a-table:`src/views/table/custom3/index.vue`
|
|
||||||
- 表单弹窗:`src/views/system/user/UserFormModal.vue`
|
|
||||||
|
|
||||||
## 禁止事项
|
|
||||||
|
|
||||||
- ❌ 绕过 `@/utils/http` 直接裸用 axios
|
|
||||||
- ❌ 表格页自行实现分页/删除逻辑,而不使用 `useTable`
|
|
||||||
- ❌ 新建列表页不参考 `system/user` 与 [project-crud](../project-crud/SKILL.md) 约定
|
|
||||||
|
|||||||
@@ -28,3 +28,11 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|
||||||
|
# ⚠️ 这两个是**上游模板时代的自动生成文件**,重搭为纯 Vue 3 + Naive UI 后已经删掉:
|
||||||
|
# · `src/components.d.ts` 是 `unplugin-vue-components` 生成的全局组件声明(内容全是 Arco 组件)
|
||||||
|
# · `src/auto-import.d.ts` 同理(本项目**没有 auto-import**,组件与 API 全显式 import)
|
||||||
|
# 之所以要忽略而不是只删掉:它们曾被误从 git 恢复回来一次 —— 这种"看起来还在用 Arco"的
|
||||||
|
# 残留不会让 typecheck 失败(`skipLibCheck` 跳过 .d.ts),只会误导后来人。
|
||||||
|
src/components.d.ts
|
||||||
|
src/auto-import.d.ts
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user