feat(roster): 名册导入(M2-01/02/03 后端)—— 表头探测 + 体检报告 + 合并/替换
计划里排第一的一步。顺序不是拍的,是依赖方向:
没有名册,门禁匹配不到任何人 → 学生进不来;而名册此前只能靠 SQL 夹具造。
(做了这一步,下一步的前端一上,任务发布就能在界面上走通。)
一、新增(module/roster/)
RosterColumnCatalog 官方 21 列(A~U) ↔ 名册字段 对照 + 别名 + 表头文本归一化
RosterImportService 表头探测 + 行解析 + 体检 + 落库(APPEND / REPLACE)
RosterService 分页(姓名模糊 / 身份证号精确 / 班级 / 状态)
RosterImportReport 体检报告(严格照 §8.1 的 JSON)
RosterVO 名册项(证件号与手机号一律脱敏)
CollectRosterMapper 批量判重 / 按 hash 定位 / 整表替换(都带显式 tenant_id)
RosterController POST /tasks/{id}/roster/import、GET /tasks/{id}/roster
权限码现成且口径天然合上契约:导入 roster:manage(仅管理员),
查看 roster:view(管理员+班主任+审核员)
二、先修了一个隐患:规范化被抄成两份就会出事
门禁是按 id_card_hash(HMAC)等值查名册的,而 HMAC 的输入是**规范化后**的字符串。
原来 normalizeName 是 MpRosterService 的私有方法,导入再抄一份的话,
两处只要有一步不同 → 同一号码算出不同 hash → **名册里明明有这个人却查不到**,
而症状会把人引向"学生是不是填错了"。已抽成 support/IdentityNormalizer
(name/idCard),门禁与导入共用;并注明:加宽松(本次补的全角转换)安全,
收紧是破坏性的(已有名册的学生会突然进不来)。
验证不是"读代码比对",而是**用真门禁去核验导入进来的名册**(E2E 第 3 节)。
三、几个刻意的决定
· dryRun 默认 true —— 不显式要求就不写库
· ignoreEmptyRow(false) + 行号与 Excel 对齐 —— EasyExcel 默认跳过空行会让行号错位,
报告说"第 12 行有问题"而用户翻到第 12 行是空的,整份报告就没人信了
· 未识别的列进 ext_json(列名做键)—— "备注""推荐人"这类列不该被丢掉
· 名册上的 phone 存脱敏值(不是密文):名册不需要回放完整号码
· REPLACE 在已有人核验/提交时直接拒绝(40029)—— 否则那个学生的材料
会挂到空名单上,他下次扫码进不来
· APPEND 命中已绑定/已提交的行 → 跳过不覆盖(ROW_LOCKED):
「报考职业」是锁定字段,盖掉等于把学生已确认的材料变成另一种认定
· 校验位不通过**阻断且不自动改** —— 与"文件名里的号码"策略相反:
名册是权威基准,改它等于我们替机构编了个身份证号
四、E2E:scripts/e2e-m2-roster.py → 58/58
夹具刻意把**表头放在第 6 行**、第一张表全空白、表头带换行、另加一列"备注"。
逐条验:4 个阻断行 + 2 个警告行的**行号与 code 都对**、dryRun 不落库、
密文/脱敏值/ext_json、**完整身份证号能精确搜**(密文搜不了 LIKE)、
响应里不得出现完整证件号、重复导入合并不新增、REPLACE 被拒后名册没被破坏、
40028/40017 两条兜底出路。
⭐ 第 3 节用**真门禁接口**核验导入的名册 —— 导入与门禁的 hash 口径一致才算过。
**本次 E2E 抓到一个真 bug**:write() 里"已存在则更新"的判断写反了
(写成"不在 existingHashes 里才去查"),于是重复导入会插重复行,
撞唯一约束 uk_task_idcard → 50000。也就是"第一次能用、第二次就炸",
而重导一次正是最自然的动作。已修 + 在代码里点名这个坑。
五、又发现一处文档与代码不一致(以代码为准)
接口设计 §3.6 把报考三项的"↔列"写成 ext_json,而建表脚本里它们是真实列。
按 §3.6 实现的话,门禁回填(读真实列)会永远给学生空值。
已在 RosterVO 类注释写明。
六、回归
全量 613 条断言、0 失败(新增 58;门禁 29 照旧通过 → 规范化抽取没改变门禁行为)
verify-docs.py 19 项全通过
七、下一步(第 2 步)
名册导入前端(frontend-pc/src/views/task/):上传 → 表头确认 → 体检报告
(错行定位、红黄区分)→ 名册列表。做完它,任务发布就能在界面上走通了。
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
面向 AI 编码助手的项目指南。**用简体中文交流、用中文写文档。**
|
||||||
|
|
||||||
|
需求已冻结:**文档是需求源,代码是它的实现**,不要按"更合理"去改业务口径。
|
||||||
|
`docs/交接摘要.md` 是**唯一状态源** —— 动手前先读它最近的两节(§一·二十、§一·二十一)
|
||||||
|
与 `## 二、已锁定的铁律`;那里面大量决定"读代码看不出来、但都有理由"。
|
||||||
|
|
||||||
|
## 一、四个可运行部分
|
||||||
|
|
||||||
|
| 目录 | 是什么 | 入口 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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-mp/` | 学生端 **uni-app**(H5 与微信小程序同一套代码) | `pnpm build:h5` / `pnpm dev:mp-weixin` |
|
||||||
|
| `deploy/` | 本地基础设施(PG + Redis,**没有对象存储**) | `docker compose up -d` |
|
||||||
|
|
||||||
|
`docs/prototype/` 是视觉与交互的**唯一基准**(单文件自包含、零外部依赖、**不用 emoji**)。
|
||||||
|
|
||||||
|
**一期范围只做「学校收集 + 自主认定」**:`collectMode=2` / `certType=2,3` 一律返回 `40044`,
|
||||||
|
别顺手把二期功能实现出来。
|
||||||
|
|
||||||
|
## 二、常用命令(备注里是容易猜错的地方)
|
||||||
|
|
||||||
|
### 基础设施
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 本机还有别的项目的 `pg-verify`(55432) 与 `redis-local`(6379),**不要动它们**。
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
mvn -DskipTests package # 改了 Java 必须重新打包
|
||||||
|
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ **工作目录必须是 `backend/`**,两处相对路径依赖它:
|
||||||
|
`application-local.yml`(不提交的本地私有配置)就在 `backend/`;
|
||||||
|
H5 静态目录配的是 `file:../frontend-mp/dist/build/h5/`
|
||||||
|
- 跑的是 **jar 不是热加载** → 改后端要重新 package(先停进程,否则 jar 被占用)
|
||||||
|
- **动过 `db/migration/` 必须 `mvn clean package`**(Flyway 已到 V6;迁移文件发布后不许改)
|
||||||
|
- 本地私有配置机制是 `profiles.active=dev` + `spring.profiles.include: local`
|
||||||
|
(模板 `backend/application-local.yml.example`)。
|
||||||
|
**不要改用 `spring.config.import`** —— 它优先级低于 profile 文件,会静默失效(见 `application.yml` 注释)
|
||||||
|
|
||||||
|
### 管理端(PC)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-pc && pnpm install && pnpm dev
|
||||||
|
pnpm typecheck # vue-tsc pnpm lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
- 用 **pnpm**(仓库里是 `pnpm-lock.yaml`;别 `npm install`,会多出 package-lock.json)
|
||||||
|
- dev 通过 vite proxy 把 `/api` 转发到 `http://localhost:8080`(见 `.env.development`),
|
||||||
|
**没有 Mock** —— `frontend-pc/AGENTS.md` 里"开发默认 Mock"已过时
|
||||||
|
- 我们的接口模块都在 `src/apis/xt/`;`apis/system|user|person|cate|area|test` 是 Gi 模板自带的,别往那儿加
|
||||||
|
- **PC 前端有独立的规范来源:`frontend-pc/.agents/`**(`AGENTS.md` + `skills/*`)。
|
||||||
|
改那里的代码前按它的表加载对应 skill,不要另起一套写法
|
||||||
|
|
||||||
|
### 学生端(uni-app)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend-mp && pnpm install
|
||||||
|
pnpm build:h5 # → dist/build/h5(由后端托管)
|
||||||
|
pnpm dev:mp-weixin # → dist/dev/mp-weixin(导入微信开发者工具)
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠️ **H5 没有可直接联调的独立 dev server**:`vite.config.ts` 没有 proxy,而
|
||||||
|
`src/config/index.ts` 的 `API_BASE_URL` 取 `location.origin` → 只能访问
|
||||||
|
**后端托管的构建产物**(`http://localhost:8080/...`),改完前端要重新 `build:h5`
|
||||||
|
- 改前端后浏览器要**硬刷新**(`/assets/**` 是 immutable 缓存)
|
||||||
|
- 构建时 Dart Sass 的 `legacy-js-api` DEPRECATION 属正常
|
||||||
|
- `vue-tsc` 有 2 条**已知**报错(`src/config/index.ts` 的 `#ifdef` 重复导出),不是新引入的
|
||||||
|
- 当前节奏:**先做 H5**(可自动化测试、不依赖小程序资质);小程序端代码同步维护
|
||||||
|
|
||||||
|
## 三、怎么验证(本仓库最不显然的一点)
|
||||||
|
|
||||||
|
**后端没有单元测试**(`src/test` 是空的)。验证靠 `scripts/e2e-*.py`:
|
||||||
|
打真实接口 + `docker exec` 进 PG 查库断言。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑、xt-pg 容器在跑
|
||||||
|
```
|
||||||
|
|
||||||
|
- 全量:把 `scripts/e2e-*.py` 依次跑一遍(`e2e-m0*` / `e2e-m1-*` / `e2e-m2-roster` / `e2e-m3-*`),
|
||||||
|
目前合计 613 条断言
|
||||||
|
- 脚本**自己造 MP 的 JWT**:读 `application-dev.yml` 的 `fill.jwt.secret` +
|
||||||
|
`application.yml` 的 `issuer` 手搓 HS512 —— 不需要先登录
|
||||||
|
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
|
||||||
|
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
|
||||||
|
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
|
||||||
|
- 清理残留:`python scripts/dev-clean-e2e.py`、`python scripts/dev-clean-test-users.py`
|
||||||
|
- 文档一致性:`python scripts/verify-docs.py`
|
||||||
|
|
||||||
|
## 四、接口与数据契约(不显然、容易做错)
|
||||||
|
|
||||||
|
- 响应恒为 `{code,msg,data}`;**业务错误也用 HTTP 200**,只有 401/403/429/5xxxx 用真状态码
|
||||||
|
- 软删除字段名是 **`deleted`**(不是 `is_deleted`);业务表都带 `tenant_id`
|
||||||
|
- **租户拦截器默认拒绝**:匿名请求触及租户表会直接 `40101`。匿名接口(如扫码落地页)
|
||||||
|
必须走专用 Mapper + `@InterceptorIgnore(tenantLine="true")` + **显式带 tenant_id**,
|
||||||
|
且集中放(`module/mp/mapper/MpSceneMapper.java`),就是为了"关掉框架保护后由人保证隔离"这件事有地方可审
|
||||||
|
- **权限码写在代码里**(`security/PermissionDef`、`security/BuiltinRole`);库里只有
|
||||||
|
`sys_role_permission` 做「角色 → 权限码」的分配,**没有** `sys_permission` 定义表 ——
|
||||||
|
加权限要改代码,不要想着往表里插一行
|
||||||
|
- 动态表单 = 内置字段(固定列、可统计)+ 扩展字段(`extras_json`,只展示与导出)。
|
||||||
|
**字段 key 全是 snake_case**,权威来源 `module/form/service/BuiltinFields.java`(21 项)——
|
||||||
|
改口径要同步它,别按直觉造 key
|
||||||
|
- `collect_task.status`:`0` 草稿 / `1` 收集中 / `2` 已截止 / `3` 已归档 / `4` 暂停
|
||||||
|
(接口设计文档曾写反,**以代码为准**)
|
||||||
|
- `collect_roster.id_card_no` 存的是 **AES 密文**(另有 `id_card_hash` / `id_card_mask`),
|
||||||
|
明文只在导入与门禁时存在 → 提交环节**做不到**身份证校验位校验,别去"补"这个
|
||||||
|
- 材料上传是**一步**:`POST /mp/files/upload` 收上来就压缩 + 按模板改名,默认**只存规范件**
|
||||||
|
(`fill.storage.keep-raw=false`);`process` 只是"有原始件时的重新规范化"
|
||||||
|
- 图片处理有两条**踩过真 bug**的规则,动之前先读交接摘要 §一·二十 / §一·二十一:
|
||||||
|
① 服务端必须按 EXIF 转正,否则手机竖拍的身份证会被存成躺倒的(且 E2E 的测试图不带 EXIF,抓不到);
|
||||||
|
② 浏览器与小程序对 EXIF 的处理**不一致**,前端必须"量出来再决定",写死必错一半
|
||||||
|
|
||||||
|
## 五、环境与流程坑
|
||||||
|
|
||||||
|
- 本地 H5 走花生壳穿透(`http://394nb68658le.vicp.fun` → `192.168.3.139:8080`,
|
||||||
|
配在 `application-dev.yml` 的 `mp-h5`)。**硬刷新后首屏常要 1~2 分钟**,
|
||||||
|
探测要给足,否则会把"还在加载"误判成失败
|
||||||
|
- 打桩登录(仅 dev:`fill.mp-h5.stub-enabled`,**生产必须 false**,代码里还会再判一次 profile):
|
||||||
|
`/api/v1/mp/h5/stub-login?openid=<openid>&scene=t1-k17` → 302 回 H5,token 放在 **URL fragment** 里
|
||||||
|
- `scene` 三段式:`t{tenantId}` / `t{tenantId}-k{taskId}` / `t{tenantId}-k{taskId}-o{orgId}`
|
||||||
|
(微信限 32 字符)。它**可穷举**,所以匿名接口只返回最少信息 + 按 IP 限流
|
||||||
|
- 存储默认 `provider=local`(落 `backend/data/uploads`,**只适合开发**:多副本必然丢文件);
|
||||||
|
七牛用 `provider=qiniu`,AK/SK 只放 `backend/application-local.yml` 或环境变量。
|
||||||
|
**MinIO 已删**,本地不再有 S3,别去找
|
||||||
|
- 七牛空间是**私有**的 → 下载走签名链接(有有效期):
|
||||||
|
**URL 不能入库、不能发出去让人明天再打开**,每次要用现取
|
||||||
|
- 改中文文档、或跑任何会碰到中文文本的脚本,**一律写成 `.py` 文件再执行**:
|
||||||
|
PowerShell 不支持 heredoc(`<<'PY'` 直接报错);`@"..."@` here-string 会**吃掉双引号**
|
||||||
|
(`return "bin";` 变成 `return bin;`)—— 带引号的代码用编辑工具改,别过转义层
|
||||||
|
|
||||||
|
## 六、提交前
|
||||||
|
|
||||||
|
- 不要提交:`backend/data/`、`dist/`、`application-local.yml`、`deploy/.env`
|
||||||
|
(都已在 `.gitignore`,但 `git add -A` 容易顺手带上 —— 先 `git status` 看一眼)
|
||||||
|
- `deploy/` 里**只有** `.env.example` 与 `docker-compose.yml` 被 git 跟踪
|
||||||
|
(`*.pem` 在 ignore 里,`deploy/rsa-public-key-dev.pem` 是本地开发公钥)
|
||||||
|
- 改动接口 / 库表 / FR-VAL 编号 → 重跑 `python scripts/verify-docs.py`,
|
||||||
|
并同步 `docs/00-交付计划.md` 与 `docs/交接摘要.md`
|
||||||
+8
-27
@@ -11,6 +11,7 @@ import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
|||||||
import cn.tianbaotong.fill.common.crypto.HmacIndex;
|
import cn.tianbaotong.fill.common.crypto.HmacIndex;
|
||||||
import cn.tianbaotong.fill.common.util.IdCardUtil;
|
import cn.tianbaotong.fill.common.util.IdCardUtil;
|
||||||
import cn.tianbaotong.fill.config.FillProperties;
|
import cn.tianbaotong.fill.config.FillProperties;
|
||||||
|
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -75,8 +76,8 @@ public class MpRosterService {
|
|||||||
|
|
||||||
rateLimit(userId, clientIp);
|
rateLimit(userId, clientIp);
|
||||||
|
|
||||||
String name = normalizeName(req.getRealName());
|
String name = IdentityNormalizer.name(req.getRealName());
|
||||||
String idCard = IdCardUtil.normalize(req.getIdCardNo());
|
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
|
||||||
|
|
||||||
Long taskCount = rosterMapper.countTask(tenantId, req.getTaskId());
|
Long taskCount = rosterMapper.countTask(tenantId, req.getTaskId());
|
||||||
if (taskCount == null || taskCount == 0) {
|
if (taskCount == null || taskCount == 0) {
|
||||||
@@ -93,8 +94,7 @@ public class MpRosterService {
|
|||||||
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "号码格式不对");
|
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "号码格式不对");
|
||||||
}
|
}
|
||||||
|
|
||||||
String hash = hmacIndex.hash(idCard);
|
String hash = hmacIndex.hash(idCard); List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
|
||||||
List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
|
|
||||||
if (hits.isEmpty()) {
|
if (hits.isEmpty()) {
|
||||||
// ⚠️ 日志只记 hash 与 uid,不记明文与姓名
|
// ⚠️ 日志只记 hash 与 uid,不记明文与姓名
|
||||||
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(),
|
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(),
|
||||||
@@ -110,7 +110,7 @@ public class MpRosterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CollectRoster row = hits.get(0);
|
CollectRoster row = hits.get(0);
|
||||||
if (!name.equals(normalizeName(row.getRealName()))) {
|
if (!name.equals(IdentityNormalizer.name(row.getRealName()))) {
|
||||||
// 姓名不一致:与"没查到"给**同一句提示**,见类注释第 1 条
|
// 姓名不一致:与"没查到"给**同一句提示**,见类注释第 1 条
|
||||||
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "姓名与名册不一致");
|
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "姓名与名册不一致");
|
||||||
}
|
}
|
||||||
@@ -147,30 +147,11 @@ public class MpRosterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 姓名规范化:去所有空白 + 全角转半角。
|
* 姓名规范化:**已抽到 {@link IdentityNormalizer}**,门禁与名册导入共用同一份实现。
|
||||||
*
|
*
|
||||||
* <p>为什么要转全角:学生用中文输入法打英文数字时很容易带出全角,
|
* <p>原来这里是私有方法,导入功能要落地时才发现"两边各写一份规范化"是迟早要出事的设计 ——
|
||||||
* 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"——
|
* 详见 {@code IdentityNormalizer} 的类注释。
|
||||||
* 而肉眼看这两个字<b>长得几乎一样</b>,是最容易让人放弃的一种失败。
|
|
||||||
*/
|
*/
|
||||||
static String normalizeName(String raw) {
|
|
||||||
if (raw == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder(raw.length());
|
|
||||||
for (char c : raw.toCharArray()) {
|
|
||||||
if (Character.isWhitespace(c) || c == '\u3000') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 全角 ASCII(U+FF01~U+FF5E)→ 半角(U+0021~U+007E)
|
|
||||||
if (c >= '\uFF01' && c <= '\uFF5E') {
|
|
||||||
sb.append((char) (c - 0xFEE0));
|
|
||||||
} else {
|
|
||||||
sb.append(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 限流:同 openid 10 次/小时、同 IP 20 次/分钟。
|
* 限流:同 openid 10 次/小时、同 IP 20 次/分钟。
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.controller;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.ApiResult;
|
||||||
|
import cn.tianbaotong.fill.common.api.PageResult;
|
||||||
|
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
|
||||||
|
import cn.tianbaotong.fill.module.roster.dto.RosterVO;
|
||||||
|
import cn.tianbaotong.fill.module.roster.service.RosterImportService;
|
||||||
|
import cn.tianbaotong.fill.module.roster.service.RosterService;
|
||||||
|
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.MediaType;
|
||||||
|
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.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册(《03-接口设计》§8)。
|
||||||
|
*
|
||||||
|
* <p>权限口径与契约一致:<b>导入是管理动作</b>({@code roster:manage}),
|
||||||
|
* <b>查看是管理 + 教师</b>({@code roster:view})—— 班主任要看得到自己班的名单,
|
||||||
|
* 但不该能把整份名册换掉。
|
||||||
|
*/
|
||||||
|
@Tag(name = "名册")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RosterController {
|
||||||
|
|
||||||
|
private final RosterImportService importService;
|
||||||
|
private final RosterService rosterService;
|
||||||
|
|
||||||
|
@PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePerm(PermissionDef.ROSTER_MANAGE)
|
||||||
|
@Operation(summary = "导入名册(返回体检报告)",
|
||||||
|
description = "自动探测表头行与列映射(实测真实文件的表头在第 1 行或第 6 行),"
|
||||||
|
+ "返回逐行的错误与警告。dryRun=true(默认)只体检不落库 —— "
|
||||||
|
+ "先让用户确认表头与错行,再正式导入,避免导进去才发现表头选错")
|
||||||
|
public ApiResult<RosterImportReport> importRoster(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(required = false) Integer headerRow,
|
||||||
|
@RequestParam(required = false) String sheetName,
|
||||||
|
@RequestParam(required = false) String mode,
|
||||||
|
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||||
|
@RequestParam(required = false) Long orgId) {
|
||||||
|
return ApiResult.ok(importService.importExcel(id, file, headerRow, sheetName, mode, dryRun, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/{id}/roster")
|
||||||
|
@RequirePerm(PermissionDef.ROSTER_VIEW)
|
||||||
|
@Operation(summary = "名册分页",
|
||||||
|
description = "证件号与手机号一律只回脱敏值;keyword 填一整个身份证号时按号码精确搜")
|
||||||
|
public ApiResult<PageResult<RosterVO>> list(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) Long orgId,
|
||||||
|
@RequestParam(required = false) Integer submitStatus,
|
||||||
|
@RequestParam(required = false) Integer bindStatus,
|
||||||
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus,
|
||||||
|
pageNum, pageSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册导入的「体检报告」(《03-接口设计》§8.1)。
|
||||||
|
*
|
||||||
|
* <h3>为什么导入接口必须回这么多东西</h3>
|
||||||
|
*
|
||||||
|
* <p>名册是<b>准入名单</b>:它决定了谁能进来填报。导错一行的后果不是"少收一份材料",
|
||||||
|
* 而是<b>有人被挡在门外、有人能替别人提交</b>。所以导入不能只回"成功 28 条",必须回:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>表头在第几行、候选有哪些</b> —— 实测两份真实文件的表头分别在第 1 行和第 6 行,
|
||||||
|
* 系统猜错了用户得能一眼看出来并手动指定;</li>
|
||||||
|
* <li><b>每一列映射到了哪个字段</b> —— 只回行号不够,用户没法判断"有没有整列错位";</li>
|
||||||
|
* <li><b>逐行的错误与警告(行号 + 列字母 + 原值 + 原因)</b> ——
|
||||||
|
* 用户拿着"第 12 行 C 列"就能直接去改 Excel,而不是一行行自己找。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>部分成功是<b>设计目标</b>:42 行里有 2 行号码写错,不该让另外 40 个人白等,
|
||||||
|
* 但也不能把那 2 行悄悄丢掉。所以能进的进,进不去的逐行回传原因。
|
||||||
|
*
|
||||||
|
* <p>{@code dryRun} 模式只体检不落库,是<b>推荐的前端默认行为</b> ——
|
||||||
|
* 先让用户确认表头与错行,再正式导入,避免"导进去才发现表头选错"。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "名册导入体检报告")
|
||||||
|
public class RosterImportReport {
|
||||||
|
|
||||||
|
/** 数据行总数(不含表头行与空行)。 */
|
||||||
|
private int total;
|
||||||
|
|
||||||
|
/** 成功入库的行数({@code dryRun} 时 = "可入库的行数")。 */
|
||||||
|
private int success;
|
||||||
|
|
||||||
|
/** 被阻断的行数。 */
|
||||||
|
private int failed;
|
||||||
|
|
||||||
|
/** 识别到的表头所在行号(从 1 开始)。 */
|
||||||
|
private int headerRow;
|
||||||
|
|
||||||
|
/** 实际读取的工作表名(多张表时取第一个有数据的)。 */
|
||||||
|
private String sheetName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头候选(按命中列数从多到少)。
|
||||||
|
*
|
||||||
|
* <p>只在"有多个候选"时才有意义:出现两个候选说明这一份文件里
|
||||||
|
* 有两行都像表头(比如上面还有一行"表头说明"),让用户确认一下比我们猜更可靠。
|
||||||
|
*/
|
||||||
|
private List<HeaderCandidate> headerCandidates = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 表头是否可信(命中 ≥3 个已知列名即为可信)。 */
|
||||||
|
private boolean headerConfirmed;
|
||||||
|
|
||||||
|
/** 每一列的映射结果(含未识别的列)。 */
|
||||||
|
private List<ColumnReport> columns = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 阻断性问题(这些行没有入库)。 */
|
||||||
|
private List<RowIssue> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 非阻断问题(这些行入库了,但值得人工看一眼)。 */
|
||||||
|
private List<RowIssue> warnings = new ArrayList<>();
|
||||||
|
|
||||||
|
private Summary summary = new Summary();
|
||||||
|
|
||||||
|
/** 本次是否只体检(true = 没有写库)。 */
|
||||||
|
private boolean dryRun;
|
||||||
|
|
||||||
|
/** 生效的合并方式:{@code APPEND} / {@code REPLACE}。 */
|
||||||
|
private String mode;
|
||||||
|
|
||||||
|
/** 新增行数。 */
|
||||||
|
private int created;
|
||||||
|
|
||||||
|
/** 更新行数(APPEND 模式下按证件号命中已有行时更新,不新插一行)。 */
|
||||||
|
private int updated;
|
||||||
|
|
||||||
|
/** 移除行数({@code REPLACE} 模式下软删掉的旧行数)。 */
|
||||||
|
private int removed;
|
||||||
|
|
||||||
|
/** 表头候选:行号 + 命中的已知列数。 */
|
||||||
|
public record HeaderCandidate(int row, int score) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一列的映射结果。
|
||||||
|
*
|
||||||
|
* @param col 列字母({@code A}~),用户能对着 Excel 找
|
||||||
|
* @param title 表头原文
|
||||||
|
* @param mappedTo 落到哪:官方字段名,或 {@code ext_json.xxx}(原样存进扩展 JSON);null = 忽略该列
|
||||||
|
* @param matched 是否识别为官方 21 列之一
|
||||||
|
*/
|
||||||
|
public record ColumnReport(String col, String title, String mappedTo, boolean matched) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一处问题。
|
||||||
|
*
|
||||||
|
* @param row 行号(Excel 里的真实行号,从 1 开始)
|
||||||
|
* @param col 列字母(与字段无关的问题为 null)
|
||||||
|
* @param field 字段名
|
||||||
|
* @param value 原值(<b>证件号一律脱敏</b>,不把完整号码回给前端)
|
||||||
|
* @param reason 人话原因
|
||||||
|
* @param code 机器码({@code IDCARD_CHECKSUM} / {@code DUP_IDCARD} …)
|
||||||
|
*/
|
||||||
|
public record RowIssue(int row, String col, String field, String value, String reason, String code) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总计数:界面上那排小字。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "导入体检汇总")
|
||||||
|
public static class Summary {
|
||||||
|
/** 去重后的人数(= 能进来的实际人数)。 */
|
||||||
|
private int uniquePersons;
|
||||||
|
/** 重复行数(文件内重复 + 与已有名册重复)。 */
|
||||||
|
private int dupRows;
|
||||||
|
/** 证件号校验位不通过的行数。 */
|
||||||
|
private int idcardInvalid;
|
||||||
|
/** 姓名可疑(含罕见字)的行数。 */
|
||||||
|
private int nameSuspect;
|
||||||
|
/** 手机号格式异常的行数。 */
|
||||||
|
private int phoneInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册项(《03-接口设计》§3.6)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>证件号与手机号一律只回脱敏值</b>({@code idCardMask} / {@code phoneMask})。
|
||||||
|
* 名册列表是管理端最高频的一屏,完整号码一旦进了响应体,就会顺手进缓存、进日志、
|
||||||
|
* 进导出文件 —— 而这批数据是几百个学生的身份证号。
|
||||||
|
*
|
||||||
|
* <p>确实需要完整号码的场景(如审核时要核对)走独立接口并记审计日志,
|
||||||
|
* 不在"列表"里顺带解决。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 报考三项({@code occupation} / {@code worktype} / {@code jobLevel})
|
||||||
|
* 是 {@code collect_roster} 上的<b>真实列</b>,不是 {@code ext_json} 里的值 ——
|
||||||
|
* 接口设计文档 §3.6 的这一行写的是 {@code ext_json},与建表脚本不一致,**以代码为准**。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Schema(description = "名册项")
|
||||||
|
public class RosterVO {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Integer seqNo;
|
||||||
|
|
||||||
|
private String studentNo;
|
||||||
|
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
/** 脱敏证件号。 */
|
||||||
|
private String idCardMask;
|
||||||
|
|
||||||
|
/** 脱敏手机号。 */
|
||||||
|
private String phoneMask;
|
||||||
|
|
||||||
|
private Long orgId;
|
||||||
|
|
||||||
|
private String orgName;
|
||||||
|
|
||||||
|
// ── 预填列:老师已填好,学生端只核对不重填 ──────────
|
||||||
|
|
||||||
|
private String occupation;
|
||||||
|
|
||||||
|
private String worktype;
|
||||||
|
|
||||||
|
private String jobLevel;
|
||||||
|
|
||||||
|
private String education;
|
||||||
|
|
||||||
|
/** 是否在校生:{@code 0} 否 / {@code 1} 是。 */
|
||||||
|
private Integer isStudent;
|
||||||
|
|
||||||
|
private String school;
|
||||||
|
|
||||||
|
private String major;
|
||||||
|
|
||||||
|
private LocalDate enrollDate;
|
||||||
|
|
||||||
|
private String workUnit;
|
||||||
|
|
||||||
|
private Integer isRetake;
|
||||||
|
|
||||||
|
private String retakeTheory;
|
||||||
|
|
||||||
|
private String retakePractice;
|
||||||
|
|
||||||
|
private String retakeOverall;
|
||||||
|
|
||||||
|
private Integer workYears;
|
||||||
|
|
||||||
|
private String certOccupation;
|
||||||
|
|
||||||
|
private String certLevel;
|
||||||
|
|
||||||
|
private String certNo;
|
||||||
|
|
||||||
|
/** 导入时未识别为官方列的其它列(原样保留,不参与统计)。 */
|
||||||
|
private Object extJson;
|
||||||
|
|
||||||
|
// ── 状态 ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** {@code 0} 未绑定 / {@code 1} 已绑定。 */
|
||||||
|
private Integer bindStatus;
|
||||||
|
|
||||||
|
private LocalDateTime bindTime;
|
||||||
|
|
||||||
|
/** {@code 1} 门禁核验匹配 / {@code 2} 管理员指定。 */
|
||||||
|
private Integer bindType;
|
||||||
|
|
||||||
|
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
|
||||||
|
private Integer submitStatus;
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.mapper;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||||
|
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册({@code collect_roster})。
|
||||||
|
*
|
||||||
|
* <p>列表/分页走 MP 条件构造器(由租户拦截器补 {@code tenant_id});
|
||||||
|
* <b>按证件号 hash 查</b>与<b>整表替换</b>这几条写成原生 SQL,
|
||||||
|
* 且 {@code tenant_id} 一律作为<b>显式参数</b> —— 理由与 {@code MpRosterMapper} 相同:
|
||||||
|
* 这几条一旦写错就是"跨租户看到别人的名单"或"把别人的名单删了",
|
||||||
|
* 不该依赖"拦截器应该会加上"。
|
||||||
|
*/
|
||||||
|
public interface CollectRosterMapper extends BaseMapper<CollectRoster> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取这个任务下所有名册项的证件号 hash。
|
||||||
|
*
|
||||||
|
* <p>导入时用它一次判重,避免"逐行查一次库"(5000 行就是 5000 次往返)。
|
||||||
|
* 只取 hash(不可逆),不取密文 —— 判重不需要知道号码是什么。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT id_card_hash FROM collect_roster
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
|
||||||
|
""")
|
||||||
|
List<String> selectAllHashes(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按证件号 hash 找名册项(APPEND 合并时定位要更新哪一行)。
|
||||||
|
*
|
||||||
|
* <p>{@code LIMIT 2}:同一个任务下出现两条同证件号说明名册本身已经脏了,
|
||||||
|
* 这时<b>不能随便挑一条更新</b> —— 取 2 条让调用方发现并报错,
|
||||||
|
* 而不是把错的那个人的资料改掉。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM collect_roster
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
|
||||||
|
AND id_card_hash = #{idCardHash} AND deleted = 0
|
||||||
|
LIMIT 2
|
||||||
|
""")
|
||||||
|
List<CollectRoster> selectByHash(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("taskId") Long taskId,
|
||||||
|
@Param("idCardHash") String idCardHash);
|
||||||
|
|
||||||
|
/** 任务下现有名册行数(含已绑定/已提交)。 */
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM collect_roster
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
|
||||||
|
""")
|
||||||
|
long countByTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务下"已经有人用上"的名册行数:已绑定或已提交。
|
||||||
|
*
|
||||||
|
* <p>{@code REPLACE} 模式必须先问这一句:整表替换会把这些行删掉,
|
||||||
|
* 而<b>删掉一个已绑定的名册项 = 那个学生的材料挂到了空的名单行上</b>,
|
||||||
|
* 后果是他下次扫码进不来、审核时也说不清这份材料该算谁的。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM collect_roster
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
|
||||||
|
AND (bind_user_id IS NOT NULL OR submit_status <> 0)
|
||||||
|
""")
|
||||||
|
long countInUse(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code REPLACE} 模式的整表清理(软删)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 条件里的 {@code bind_user_id IS NULL AND submit_status = 0} <b>不是多余的</b>:
|
||||||
|
* 调用方虽然已经查过 {@code countInUse},但"查"和"删"之间有人扫码绑定的话,
|
||||||
|
* 那一行会被这里挡住。调用方据<b>影响行数</b>与预期的行数比对,
|
||||||
|
* 不一致就整笔回滚 —— 这是"先查后写"与"一次原子写"的区别。
|
||||||
|
*
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Update("""
|
||||||
|
UPDATE collect_roster SET deleted = 1, update_time = now()
|
||||||
|
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
|
||||||
|
AND bind_user_id IS NULL AND submit_status = 0
|
||||||
|
""")
|
||||||
|
int softDeleteUnbound(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
|
||||||
|
|
||||||
|
/** 组织是否存在且属于该租户(导入时指定班级要先验一下,别把名册挂到别的租户的班上)。 */
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM sys_org
|
||||||
|
WHERE id = #{orgId} AND tenant_id = #{tenantId} AND deleted = 0
|
||||||
|
""")
|
||||||
|
long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点),
|
||||||
|
* 在内存里做映射,避免"每行查一次"的 N+1。
|
||||||
|
*
|
||||||
|
* <p>不用联表:联表会让分页的 count 查询也带上 join,而这里的数据量不值得。
|
||||||
|
*/
|
||||||
|
@InterceptorIgnore(tenantLine = "true")
|
||||||
|
@Select("""
|
||||||
|
SELECT id, org_name FROM sys_org
|
||||||
|
WHERE tenant_id = #{tenantId} AND deleted = 0
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> selectOrgNames(@Param("tenantId") Long tenantId);
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.service;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 官方《考生线下报名信息导入模板》的 <b>21 列(A~U)</b>与名册字段的对照表。
|
||||||
|
*
|
||||||
|
* <h3>为什么必须做成"表头文本 → 字段"而不是"第几列 → 字段"</h3>
|
||||||
|
*
|
||||||
|
* <p>第一版 PRD 里默认了"表头在第 1 行、列序固定",而<b>实测的两份真实文件里
|
||||||
|
* 表头分别在第 1 行和第 6 行,工作表名也不同</b>。按列号读的实现会把第 6 行那两份
|
||||||
|
* 读成一堆空行,而且<b>一声不响</b> —— 导入完显示"成功 0 条",用户只会以为自己文件坏了。
|
||||||
|
*
|
||||||
|
* <p>所以对照的锚点是<b>表头文本</b>,列序完全不参与判断。
|
||||||
|
*
|
||||||
|
* <h3>列名要容忍真实的写法差异</h3>
|
||||||
|
*
|
||||||
|
* <p>表头单元格里常见:换行({@code 报考\n职业})、全角括号、多余空格、
|
||||||
|
* 后缀说明({@code 身份证号(必填)})。{@link #normalizeHeader} 会把这些都抹平,
|
||||||
|
* 再与 {@link #COLUMNS} 里的标题与别名做<b>完全相等</b>匹配 ——
|
||||||
|
* 不做"包含"匹配,因为 {@code 证书等级} 是 {@code 已获证书等级} 的子串,
|
||||||
|
* 包含匹配会让两列落到同一个字段上。
|
||||||
|
*/
|
||||||
|
public final class RosterColumnCatalog {
|
||||||
|
|
||||||
|
private RosterColumnCatalog() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一列:官方标题 + 名册字段 + 常见别名。
|
||||||
|
*
|
||||||
|
* @param title 官方模板里的列名
|
||||||
|
* @param field 名册字段({@code collect_roster} 的蛇形列名)
|
||||||
|
* @param aliases 真实文件里见过的别的写法
|
||||||
|
*/
|
||||||
|
public record Column(String title, String field, List<String> aliases) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阻断性字段:缺了它们这一行就没有意义(体检报告里是"阻断该行")。 */
|
||||||
|
public static final String FIELD_NAME = "real_name";
|
||||||
|
public static final String FIELD_ID_CARD = "id_card_no";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 21 列的完整对照(顺序与官方模板一致,便于人对着 Excel 核)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 与 {@code CollectRoster} 的字段、以及 PRD §5.4.1 的官方列清单是三处口径,
|
||||||
|
* 改动要一起改。字段名写错的表现是"这列永远匹配不上",且不报错。
|
||||||
|
*/
|
||||||
|
public static final List<Column> COLUMNS = List.of(
|
||||||
|
new Column("序号", "seq_no", List.of("No", "NO", "编号")),
|
||||||
|
new Column("姓名", FIELD_NAME,
|
||||||
|
List.of("名字", "学生姓名", "考生姓名", "学员姓名", "姓 名")),
|
||||||
|
new Column("身份证号", FIELD_ID_CARD,
|
||||||
|
List.of("身份证", "身份证号码", "身份证件号码", "证件号", "证件号码", "公民身份号码")),
|
||||||
|
new Column("学历", "education", List.of("文化程度", "学历层次")),
|
||||||
|
new Column("联系电话", "phone",
|
||||||
|
List.of("手机号", "手机号码", "手机", "电话", "联系方式", "联系手机")),
|
||||||
|
new Column("工作单位", "work_unit", List.of("单位", "所在单位", "工作单位名称")),
|
||||||
|
new Column("报考职业", "occupation", List.of("职业", "申报职业", "报考职业名称")),
|
||||||
|
new Column("报考工种", "worktype", List.of("工种", "申报工种", "报考工种名称")),
|
||||||
|
new Column("报考等级", "job_level", List.of("等级", "申报等级", "职业等级", "报考级别")),
|
||||||
|
new Column("是否补考", "is_retake", List.of("补考", "是否补考(是/否)")),
|
||||||
|
new Column("补考理论", "retake_theory", List.of("理论补考", "补考理论科目")),
|
||||||
|
new Column("补考实操", "retake_practice", List.of("实操补考", "补考实操科目")),
|
||||||
|
new Column("补考综合", "retake_overall", List.of("综合补考", "补考综合科目")),
|
||||||
|
new Column("是否在校生", "is_student", List.of("在校生", "是否在校", "是否在校生(是/否)")),
|
||||||
|
new Column("在读学校", "school", List.of("学校", "就读学校", "在读院校")),
|
||||||
|
new Column("所学专业", "major", List.of("专业", "就读专业", "所学专业名称")),
|
||||||
|
new Column("入学时间", "enroll_date", List.of("入学日期", "入学年月", "入学年份")),
|
||||||
|
new Column("本职业工作年限", "work_years",
|
||||||
|
List.of("工作年限", "从业年限", "本职业年限", "从事本职业年限")),
|
||||||
|
new Column("已获证书职业工种", "cert_occupation",
|
||||||
|
List.of("证书职业工种", "已获证书职业", "已获职业工种")),
|
||||||
|
new Column("已获证书等级", "cert_level", List.of("证书等级", "已获等级")),
|
||||||
|
new Column("已获证书编号", "cert_no", List.of("证书编号", "证书号", "已获证书号"))
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 归一化后的标题/别名 → 字段。构建一次,查表用。 */
|
||||||
|
private static final Map<String, String> BY_TITLE = buildIndex();
|
||||||
|
|
||||||
|
/** 表头探测时,"命中几个已知列名"就是这个值。官方模板 21 列。 */
|
||||||
|
public static final int OFFICIAL_COLUMN_COUNT = COLUMNS.size();
|
||||||
|
|
||||||
|
private static Map<String, String> buildIndex() {
|
||||||
|
Map<String, String> m = new LinkedHashMap<>();
|
||||||
|
for (Column c : COLUMNS) {
|
||||||
|
m.put(normalizeHeader(c.title()), c.field());
|
||||||
|
for (String a : c.aliases()) {
|
||||||
|
// putIfAbsent:别名撞车时以"先登记的那个"为准,保持确定性(不要靠 Map 的迭代顺序)
|
||||||
|
m.putIfAbsent(normalizeHeader(a), c.field());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头单元格文本归一化:抹平换行、空格、全角、括号与后缀星号/说明。
|
||||||
|
*
|
||||||
|
* <p>不抹平的话,真实的 {@code 报考\n职业}、{@code 身份证号(必填)}、
|
||||||
|
* {@code *姓名} 都匹配不上 —— 而这三样在真实表格里都出现过。
|
||||||
|
*/
|
||||||
|
public static String normalizeHeader(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length());
|
||||||
|
for (char c : raw.toCharArray()) {
|
||||||
|
// 空白(含换行、全角空格)一律丢掉:表头里的换行是排版,不是名字的一部分
|
||||||
|
if (Character.isWhitespace(c) || c == '\u3000') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
char half = (c >= '\uFF01' && c <= '\uFF5E') ? (char) (c - 0xFEE0) : c;
|
||||||
|
// 括号与分隔符:只用来分组说明,不参与匹配
|
||||||
|
// (全角括号/冒号已被上面转成半角,所以这里只判半角形态就够)
|
||||||
|
if (half == '(' || half == ')' || half == '[' || half == ']'
|
||||||
|
|| half == ':' || half == '、' || half == '*') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.append(half);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按表头文本找字段。匹配不上返回 {@code null}(该列不进任何字段,报告里标 {@code matched:false})。
|
||||||
|
*/
|
||||||
|
public static String fieldOf(String headerText) {
|
||||||
|
return BY_TITLE.get(normalizeHeader(headerText));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取字段的官方列名(报告里回显用)。 */
|
||||||
|
public static String titleOf(String field) {
|
||||||
|
for (Column c : COLUMNS) {
|
||||||
|
if (c.field().equals(field)) {
|
||||||
|
return c.title();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表头字母({@code 0 → A}):报告里的 {@code col} 用它,用户能对着 Excel 找。 */
|
||||||
|
public static String columnLetter(int index) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int n = index;
|
||||||
|
while (n >= 0) {
|
||||||
|
sb.insert(0, (char) ('A' + n % 26));
|
||||||
|
n = n / 26 - 1;
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+701
@@ -0,0 +1,701 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.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.common.util.IdCardUtil;
|
||||||
|
import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||||
|
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
|
||||||
|
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||||
|
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
|
||||||
|
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||||
|
import cn.tianbaotong.fill.support.IdentityNormalizer;
|
||||||
|
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.alibaba.excel.EasyExcel;
|
||||||
|
import com.alibaba.excel.ExcelReader;
|
||||||
|
import com.alibaba.excel.read.metadata.ReadSheet;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册导入(《03-接口设计》§8.1)—— FR-501 / FR-502。
|
||||||
|
*
|
||||||
|
* <h3>为什么这份代码里"猜"的比重这么大</h3>
|
||||||
|
*
|
||||||
|
* <p>因为它要对付的是<b>真实的、别人家的 Excel</b>:实测两份真实文件里,
|
||||||
|
* <b>表头分别在第 1 行和第 6 行、工作表名不同、列名写法也有出入</b>(还有带换行的表头)。
|
||||||
|
* 写死"表头在第 1 行、第 3 列是姓名"的实现不会报错,只会导进来一堆空行 ——
|
||||||
|
* 然后用户以为自己文件坏了。
|
||||||
|
*
|
||||||
|
* <p>所以:<b>能猜的都猜,猜完把猜测摊开给用户看</b>(表头在第几行、每个候选命中几列、
|
||||||
|
* 每列映射到哪),猜不中就给一条明确的出路(手动指定 {@code headerRow})。
|
||||||
|
*
|
||||||
|
* <h3>体检的两级:阻断 vs 警告</h3>
|
||||||
|
*
|
||||||
|
* <p>阻断的那几项(姓名/证件号缺失、格式非法、校验位不通过、重复)的共同点是
|
||||||
|
* <b>这一行没有意义,或指向了错误的人</b>。名册是准入名单,放一行错的进去不是
|
||||||
|
* "少收一份材料",而是 <b>有人被挡在门外、有人能替别人交</b> —— 宁可少进,不可错进。
|
||||||
|
*
|
||||||
|
* <p>而"报考职业为空""手机号格式不对"不影响"这个人是谁",进库之后还能补 ——
|
||||||
|
* 给警告即可,不该让整批人为它重导一次。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class RosterImportService {
|
||||||
|
|
||||||
|
/** 单次导入行数上限(接口契约:≤5000 行)。 */
|
||||||
|
private static final int MAX_ROWS = 5000;
|
||||||
|
|
||||||
|
/** 上传文件大小上限(接口契约:≤10MB)。 */
|
||||||
|
private static final long MAX_FILE_BYTES = 10L * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */
|
||||||
|
private static final int HEADER_SCAN_ROWS = 10;
|
||||||
|
|
||||||
|
/** 一行里命中这么多个已知列名,才认为它是表头。 */
|
||||||
|
private static final int HEADER_MIN_SCORE = 3;
|
||||||
|
|
||||||
|
private static final Set<String> YES = Set.of("是", "Y", "YES", "TRUE", "1", "有", "在校");
|
||||||
|
private static final Set<String> NO = Set.of("否", "N", "NO", "FALSE", "0", "不在校", "非在校");
|
||||||
|
|
||||||
|
/** Excel 日期序列号以 1899-12-30 为原点(含 1900 闰年 bug 的既定换算)。 */
|
||||||
|
private static final LocalDate EXCEL_EPOCH = LocalDate.of(1899, 12, 30);
|
||||||
|
|
||||||
|
private final CollectRosterMapper rosterMapper;
|
||||||
|
private final TaskMapper taskMapper;
|
||||||
|
private final SensitiveCodec codec;
|
||||||
|
|
||||||
|
public RosterImportService(CollectRosterMapper rosterMapper, TaskMapper taskMapper,
|
||||||
|
SensitiveCodec codec) {
|
||||||
|
this.rosterMapper = rosterMapper;
|
||||||
|
this.taskMapper = taskMapper;
|
||||||
|
this.codec = codec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 对外入口 ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入名册,返回体检报告。
|
||||||
|
*
|
||||||
|
* @param headerRow 手动指定表头行(1 起);为空则自动探测
|
||||||
|
* @param sheetName 手动指定工作表;为空则取第一个有数据的表
|
||||||
|
* @param mode {@code APPEND}(默认,按证件号去重合并)/ {@code REPLACE}
|
||||||
|
* @param dryRun true = 只体检不落库
|
||||||
|
* @param orgId 整批归到哪个班级;为空则名册项的 {@code org_id} 留空
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public RosterImportReport importExcel(Long taskId, MultipartFile file,
|
||||||
|
Integer headerRow, String sheetName,
|
||||||
|
String mode, boolean dryRun, Long orgId) {
|
||||||
|
LoginUser me = UserContext.require();
|
||||||
|
Long tenantId = me.tenantId();
|
||||||
|
|
||||||
|
requireTask(taskId);
|
||||||
|
if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
|
||||||
|
}
|
||||||
|
boolean replace = "REPLACE".equalsIgnoreCase(mode == null ? "" : mode.trim());
|
||||||
|
|
||||||
|
SheetData sheet = readSheet(bytesOf(file), sheetName);
|
||||||
|
|
||||||
|
// ① 表头
|
||||||
|
HeaderFind find = findHeader(sheet.rows(), headerRow);
|
||||||
|
if (find == null) {
|
||||||
|
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
|
||||||
|
"没能在《" + sheet.name() + "》的前 " + HEADER_SCAN_ROWS
|
||||||
|
+ " 行里认出表头(需要「姓名」「身份证号」这类列名),请指定表头行后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 列映射
|
||||||
|
Map<String, Integer> colOfField = new LinkedHashMap<>();
|
||||||
|
List<RosterImportReport.ColumnReport> columns = new ArrayList<>();
|
||||||
|
List<Integer> extraCols = new ArrayList<>();
|
||||||
|
List<String> extraKeys = new ArrayList<>();
|
||||||
|
buildColumns(sheet.rows().get(find.index()), colOfField, columns, extraCols, extraKeys);
|
||||||
|
|
||||||
|
if (!colOfField.containsKey(RosterColumnCatalog.FIELD_NAME)
|
||||||
|
|| !colOfField.containsKey(RosterColumnCatalog.FIELD_ID_CARD)) {
|
||||||
|
// 表头找到了、但缺关键列 —— 比"找不到表头"更具体,单独报
|
||||||
|
throw new BizException(ErrorCode.ROSTER_MISSING_KEY_COLUMN,
|
||||||
|
"第 " + (find.index() + 1) + " 行像是表头,但里面没有「姓名」或「身份证号」列,请检查后重导");
|
||||||
|
}
|
||||||
|
|
||||||
|
RosterImportReport report = new RosterImportReport();
|
||||||
|
report.setHeaderRow(find.index() + 1);
|
||||||
|
report.setSheetName(sheet.name());
|
||||||
|
report.setHeaderConfirmed(find.score() >= HEADER_MIN_SCORE);
|
||||||
|
report.setHeaderCandidates(find.candidates());
|
||||||
|
report.setColumns(columns);
|
||||||
|
report.setDryRun(dryRun);
|
||||||
|
report.setMode(replace ? "REPLACE" : "APPEND");
|
||||||
|
|
||||||
|
// ③ 逐行解析 + 体检
|
||||||
|
Set<String> existingHashes = replace
|
||||||
|
? new HashSet<>()
|
||||||
|
: new HashSet<>(rosterMapper.selectAllHashes(tenantId, taskId));
|
||||||
|
Map<String, Integer> seenInFile = new HashMap<>();
|
||||||
|
List<Pending> pending = new ArrayList<>();
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
for (int i = find.index() + 1; i < sheet.rows().size(); i++) {
|
||||||
|
Map<Integer, String> cells = sheet.rows().get(i);
|
||||||
|
if (isBlankRow(cells)) {
|
||||||
|
continue; // 空行不算数据行(真实表格中间常有空行)
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
if (total > MAX_ROWS) {
|
||||||
|
throw new BizException(ErrorCode.FILE_TOO_LARGE,
|
||||||
|
"名册一次最多导入 " + MAX_ROWS + " 行,请拆成多个文件分批导入");
|
||||||
|
}
|
||||||
|
parseRow(taskId, i + 1, cells, colOfField, extraCols, extraKeys, orgId,
|
||||||
|
report, seenInFile, pending);
|
||||||
|
}
|
||||||
|
report.setTotal(total);
|
||||||
|
|
||||||
|
// ④ 汇总
|
||||||
|
RosterImportReport.Summary sum = report.getSummary();
|
||||||
|
sum.setUniquePersons(pending.size());
|
||||||
|
sum.setDupRows(countErrors(report, "DUP_IDCARD"));
|
||||||
|
sum.setIdcardInvalid(countErrors(report, "IDCARD_CHECKSUM") + countErrors(report, "IDCARD_FORMAT"));
|
||||||
|
sum.setPhoneInvalid((int) report.getWarnings().stream()
|
||||||
|
.filter(w -> "PHONE_INVALID".equals(w.code())).count());
|
||||||
|
report.setFailed(blockedRowCount(report));
|
||||||
|
report.setSuccess(pending.size());
|
||||||
|
|
||||||
|
// ⑤ 落库(dryRun 时只算"如果真导会怎样",免得导完才发现合并口径不对)
|
||||||
|
if (dryRun) {
|
||||||
|
int created = 0;
|
||||||
|
int updated = 0;
|
||||||
|
for (Pending p : pending) {
|
||||||
|
if (existingHashes.contains(p.hash())) {
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.setCreated(created);
|
||||||
|
report.setUpdated(updated);
|
||||||
|
} else {
|
||||||
|
write(tenantId, taskId, replace, pending, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("名册导入{}:task={} 表={} 表头行={} 共{}行 可入库{} 阻断{} 新增{} 更新{} 警告{}",
|
||||||
|
dryRun ? "(试导)" : "", taskId, sheet.name(), report.getHeaderRow(),
|
||||||
|
report.getTotal(), report.getSuccess(), report.getFailed(),
|
||||||
|
report.getCreated(), report.getUpdated(), report.getWarnings().size());
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 落库 ────────────────────────────────────────
|
||||||
|
|
||||||
|
private void write(Long tenantId, Long taskId, boolean replace,
|
||||||
|
List<Pending> pending, RosterImportReport report) {
|
||||||
|
if (replace) {
|
||||||
|
long inUse = rosterMapper.countInUse(tenantId, taskId);
|
||||||
|
if (inUse > 0) {
|
||||||
|
throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
|
||||||
|
"本任务已有 " + inUse + " 人完成核验或提交,不能整表替换 —— "
|
||||||
|
+ "那会把他们的名册项删掉,他们下次扫码就进不来了。请改用「合并导入」");
|
||||||
|
}
|
||||||
|
long before = rosterMapper.countByTask(tenantId, taskId);
|
||||||
|
int removed = rosterMapper.softDeleteUnbound(tenantId, taskId);
|
||||||
|
if (removed != before) {
|
||||||
|
// 查与删之间有人刚绑定 —— 整笔回滚,让操作者重新做一次决定
|
||||||
|
throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
|
||||||
|
"替换过程中有人刚完成核验,已中止;请刷新后改用「合并导入」");
|
||||||
|
}
|
||||||
|
report.setRemoved(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
int created = 0;
|
||||||
|
int updated = 0;
|
||||||
|
for (Pending p : pending) {
|
||||||
|
// ⚠️ 这里**必须无条件查一次**,不能只看 existingHashes 里有没有:
|
||||||
|
// 在就说明"要更新哪一行"还没拿到。写成"不在才查"的话,
|
||||||
|
// 已存在的行会走到下面的 insert —— 撞上唯一约束 uk_task_idcard,
|
||||||
|
// 学生看到的是 50000「系统繁忙」,而真正的原因是重复插入。
|
||||||
|
// (这个 bug 真的写出来过,被 e2e-m2-roster 的"重复导入"一节点名。)
|
||||||
|
List<CollectRoster> hits = rosterMapper.selectByHash(tenantId, taskId, p.hash());
|
||||||
|
if (hits.size() > 1) {
|
||||||
|
// 名册本身已经脏了:不能随便挑一条更新(可能改的是另一个人的资料)
|
||||||
|
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN,
|
||||||
|
"名册里这个证件号已有多条重复(第 " + p.excelRow() + " 行),请先清理再导入");
|
||||||
|
}
|
||||||
|
CollectRoster exist = hits.isEmpty() ? null : hits.get(0);
|
||||||
|
|
||||||
|
if (exist == null) {
|
||||||
|
rosterMapper.insert(p.row());
|
||||||
|
created++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean locked = exist.getBindUserId() != null
|
||||||
|
|| (exist.getSubmitStatus() != null && exist.getSubmitStatus() != 0);
|
||||||
|
if (locked) {
|
||||||
|
// 这个人已经在填 / 已填完。**不要**拿新文件的值盖掉他正在用的资料:
|
||||||
|
// 「报考职业」是锁定字段,改了它等于把学生已确认的材料变成另一种认定。
|
||||||
|
report.getWarnings().add(new RosterImportReport.RowIssue(p.excelRow(), null,
|
||||||
|
RosterColumnCatalog.FIELD_ID_CARD, exist.getIdCardMask(),
|
||||||
|
"这个人已核验或已提交,本次跳过不覆盖(原资料保持不变)", "ROW_LOCKED"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
p.row().setId(exist.getId());
|
||||||
|
rosterMapper.updateById(p.row());
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
report.setCreated(created);
|
||||||
|
report.setUpdated(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 读取 ────────────────────────────────────────
|
||||||
|
|
||||||
|
private record SheetData(String name, List<Map<Integer, String>> rows) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] bytesOf(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择要导入的 Excel 文件");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_FILE_BYTES) {
|
||||||
|
throw new BizException(ErrorCode.FILE_TOO_LARGE,
|
||||||
|
"文件超过 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB,请拆分后导入");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选工作表并整表读进来。
|
||||||
|
*
|
||||||
|
* <p>⚠️ {@code headRowNumber(0)} 与 {@code ignoreEmptyRow(false)} <b>都不能省</b>:
|
||||||
|
* <ul>
|
||||||
|
* <li>前者:不把任何一行当表头,整表当数据读,表头行由我们自己找 ——
|
||||||
|
* 否则 EasyExcel 会替我们认定"第 1 行是表头",而真实文件里它不是。</li>
|
||||||
|
* <li>后者:EasyExcel 默认<b>跳过空行</b>,那会让行号与真实 Excel 错位 ——
|
||||||
|
* 报告里说"第 12 行有问题",用户翻到第 12 行却是空的,整份报告就没人信了。
|
||||||
|
* 行号必须能直接对着 Excel 用。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private SheetData readSheet(byte[] bytes, String wantedName) {
|
||||||
|
List<ReadSheet> sheets;
|
||||||
|
try (ExcelReader reader = EasyExcel.read(new ByteArrayInputStream(bytes)).build()) {
|
||||||
|
// 只取"有哪些表",不读内容 —— 内容按下面的顺序逐张读(拿到第一张有数据的就停)
|
||||||
|
sheets = reader.excelExecutor().sheetList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("读取导入文件失败:{}", e.getMessage());
|
||||||
|
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
|
||||||
|
"无法读取该 Excel,请用 Excel 另存为 .xlsx 后重试");
|
||||||
|
}
|
||||||
|
if (sheets.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何工作表");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantedName != null && !wantedName.isBlank()) {
|
||||||
|
String w = wantedName.trim();
|
||||||
|
for (ReadSheet s : sheets) {
|
||||||
|
if (s.getSheetName() != null && s.getSheetName().trim().equals(w)) {
|
||||||
|
return new SheetData(s.getSheetName(), readRows(bytes, s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||||
|
"文件里没有名为《" + wantedName + "》的工作表,共有:"
|
||||||
|
+ String.join("、", sheets.stream()
|
||||||
|
.map(x -> x.getSheetName() == null ? "(未命名)" : x.getSheetName()).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ReadSheet s : sheets) {
|
||||||
|
List<Map<Integer, String>> rows = readRows(bytes, s);
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
return new SheetData(s.getSheetName(), rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<Integer, String>> readRows(byte[] bytes, ReadSheet sheet) {
|
||||||
|
List<Map<Integer, String>> rows = EasyExcel
|
||||||
|
.read(new ByteArrayInputStream(bytes))
|
||||||
|
// ⚠️ ignoreEmptyRow 是 reader 级的参数,必须在 .sheet(...) 之前给 ——
|
||||||
|
// 写到 sheet 后面会报"找不到符号",而报错信息不会告诉你"层级错了"
|
||||||
|
.ignoreEmptyRow(false)
|
||||||
|
.sheet(sheet.getSheetNo())
|
||||||
|
.headRowNumber(0)
|
||||||
|
.doReadSync();
|
||||||
|
return rows == null ? List.of() : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 表头 ────────────────────────────────────────
|
||||||
|
|
||||||
|
private record HeaderFind(int index, int score, List<RosterImportReport.HeaderCandidate> candidates) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找表头行:扫前 {@link #HEADER_SCAN_ROWS} 行,选「命中已知列名最多」的那一行。
|
||||||
|
*
|
||||||
|
* <p>并列时取<b>最靠上</b>的:靠上的那行更可能是真表头(后面出现的往往是说明行,
|
||||||
|
* 或者是第二张表的表头)。多个候选一并回传让用户确认 —— 猜错了要能看出来。
|
||||||
|
*
|
||||||
|
* @param forced 用户手动指定的行号(1 起);非空则直接用它(但仍算出命中数)
|
||||||
|
*/
|
||||||
|
private HeaderFind findHeader(List<Map<Integer, String>> rows, Integer forced) {
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (forced != null) {
|
||||||
|
int idx = forced - 1;
|
||||||
|
if (idx < 0 || idx >= rows.size()) {
|
||||||
|
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
|
||||||
|
"指定的表头行(第 " + forced + " 行)超出文件范围(共 " + rows.size() + " 行)");
|
||||||
|
}
|
||||||
|
int score = scoreRow(rows.get(idx));
|
||||||
|
return new HeaderFind(idx, score, List.of(new RosterImportReport.HeaderCandidate(forced, score)));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RosterImportReport.HeaderCandidate> cands = new ArrayList<>();
|
||||||
|
int limit = Math.min(HEADER_SCAN_ROWS, rows.size());
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
int score = scoreRow(rows.get(i));
|
||||||
|
if (score >= HEADER_MIN_SCORE) {
|
||||||
|
cands.add(new RosterImportReport.HeaderCandidate(i + 1, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cands.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cands.sort(Comparator.comparingInt(RosterImportReport.HeaderCandidate::score).reversed()
|
||||||
|
.thenComparingInt(RosterImportReport.HeaderCandidate::row));
|
||||||
|
RosterImportReport.HeaderCandidate best = cands.get(0);
|
||||||
|
return new HeaderFind(best.row() - 1, best.score(), cands);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int scoreRow(Map<Integer, String> cells) {
|
||||||
|
if (cells == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int score = 0;
|
||||||
|
for (String v : cells.values()) {
|
||||||
|
if (RosterColumnCatalog.fieldOf(v) != null) {
|
||||||
|
score++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立「字段 → 列号」映射。
|
||||||
|
*
|
||||||
|
* <p>未识别的列<b>不丢</b>:只要它有表头,就把整列塞进 {@code ext_json}(列名做键)。
|
||||||
|
* 机构自己的表里常有"备注""推荐人"这类列,丢掉意味着用户再也找不回来,
|
||||||
|
* 而留着几乎不花成本。
|
||||||
|
*
|
||||||
|
* <p>同一字段被两列命中时<b>先到先得</b>,后面那列计为未识别并进 ext_json ——
|
||||||
|
* 这样报告里能看出"有两列都像证书等级",而不是静默丢掉一列。
|
||||||
|
*/
|
||||||
|
private void buildColumns(Map<Integer, String> header, Map<String, Integer> colOfField,
|
||||||
|
List<RosterImportReport.ColumnReport> columns,
|
||||||
|
List<Integer> extraCols, List<String> extraKeys) {
|
||||||
|
if (header == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Integer> keys = new ArrayList<>(header.keySet());
|
||||||
|
keys.sort(Comparator.naturalOrder());
|
||||||
|
Set<String> extKeyUsed = new HashSet<>();
|
||||||
|
for (Integer c : keys) {
|
||||||
|
String title = header.get(c);
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
columns.add(new RosterImportReport.ColumnReport(
|
||||||
|
RosterColumnCatalog.columnLetter(c), "", null, false));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String field = RosterColumnCatalog.fieldOf(title);
|
||||||
|
if (field != null && !colOfField.containsKey(field)) {
|
||||||
|
colOfField.put(field, c);
|
||||||
|
columns.add(new RosterImportReport.ColumnReport(
|
||||||
|
RosterColumnCatalog.columnLetter(c), title.trim(), field, true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = RosterColumnCatalog.normalizeHeader(title);
|
||||||
|
if (key.isEmpty() || !extKeyUsed.add(key)) {
|
||||||
|
columns.add(new RosterImportReport.ColumnReport(
|
||||||
|
RosterColumnCatalog.columnLetter(c), title.trim(), null, false));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
extraCols.add(c);
|
||||||
|
extraKeys.add(title.trim());
|
||||||
|
columns.add(new RosterImportReport.ColumnReport(
|
||||||
|
RosterColumnCatalog.columnLetter(c), title.trim(), "ext_json." + title.trim(), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 行解析与体检 ────────────────────────────────
|
||||||
|
|
||||||
|
/** 一行"可入库"的候选:装配好的实体 + 证件号 hash + 它在 Excel 里的行号。 */
|
||||||
|
private record Pending(int excelRow, String hash, CollectRoster row) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private void parseRow(Long taskId, int excelRow, Map<Integer, String> cells,
|
||||||
|
Map<String, Integer> colOfField, List<Integer> extraCols,
|
||||||
|
List<String> extraKeys, Long orgId, RosterImportReport report,
|
||||||
|
Map<String, Integer> seenInFile, List<Pending> pending) {
|
||||||
|
Integer nameColIdx = colOfField.get(RosterColumnCatalog.FIELD_NAME);
|
||||||
|
Integer idColIdx = colOfField.get(RosterColumnCatalog.FIELD_ID_CARD);
|
||||||
|
String nameCol = letter(nameColIdx);
|
||||||
|
String idCol = letter(idColIdx);
|
||||||
|
|
||||||
|
String rawName = text(cell(cells, nameColIdx));
|
||||||
|
String rawId = text(cell(cells, idColIdx));
|
||||||
|
|
||||||
|
// ① 空值(阻断)
|
||||||
|
if (rawName == null) {
|
||||||
|
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, nameCol,
|
||||||
|
RosterColumnCatalog.FIELD_NAME, null, "姓名为空", "EMPTY_NAME"));
|
||||||
|
}
|
||||||
|
if (rawId == null) {
|
||||||
|
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
|
||||||
|
RosterColumnCatalog.FIELD_ID_CARD, null, "身份证号为空", "EMPTY_IDCARD"));
|
||||||
|
}
|
||||||
|
if (rawName == null || rawId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = IdentityNormalizer.name(rawName);
|
||||||
|
String idCard = IdentityNormalizer.idCard(rawId);
|
||||||
|
String mask = MaskUtil.maskIdCard(idCard);
|
||||||
|
|
||||||
|
// ② 格式(阻断)
|
||||||
|
IdCardUtil.Verdict verdict = IdCardUtil.verdict(idCard);
|
||||||
|
if (verdict == IdCardUtil.Verdict.MALFORMED) {
|
||||||
|
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
|
||||||
|
RosterColumnCatalog.FIELD_ID_CARD, mask,
|
||||||
|
"身份证号格式非法(应为 18 位、末位可为 X,且出生日期要真实存在;当前 "
|
||||||
|
+ idCard.length() + " 位)", "IDCARD_FORMAT"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ③ 校验位(阻断)—— 与"文件名里的号码能不能自动改"是同一套算法,
|
||||||
|
// 但**处理策略相反**:名册是权威基准,校验位不对说明这份名单本身有问题,
|
||||||
|
// 必须请人核对,绝不能顺手改掉 —— 改掉等于我们替机构编了个身份证号
|
||||||
|
if (verdict == IdCardUtil.Verdict.CHECKSUM_FAILED) {
|
||||||
|
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
|
||||||
|
RosterColumnCatalog.FIELD_ID_CARD, mask,
|
||||||
|
"身份证号校验位不通过,请与本人核对后修正(系统不会自动改名册)", "IDCARD_CHECKSUM"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 重复(阻断):先看文件内,再看库里(库里已有的走"合并更新",不算错)
|
||||||
|
String hash = codec.hash(idCard);
|
||||||
|
Integer firstRow = seenInFile.get(hash);
|
||||||
|
if (firstRow != null) {
|
||||||
|
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
|
||||||
|
RosterColumnCatalog.FIELD_ID_CARD, mask,
|
||||||
|
"证件号与第 " + firstRow + " 行重复", "DUP_IDCARD"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenInFile.put(hash, excelRow);
|
||||||
|
|
||||||
|
// ⑤ 装配
|
||||||
|
CollectRoster row = new CollectRoster();
|
||||||
|
row.setTaskId(taskId);
|
||||||
|
row.setOrgId(orgId);
|
||||||
|
row.setRealName(rawName);
|
||||||
|
row.setIdCardNo(codec.encrypt(idCard));
|
||||||
|
row.setIdCardHash(hash);
|
||||||
|
row.setIdCardMask(mask);
|
||||||
|
|
||||||
|
String phone = text(cell(cells, colOfField.get("phone")));
|
||||||
|
if (phone != null) {
|
||||||
|
row.setPhoneHash(codec.hash(phone));
|
||||||
|
// ⚠️ 名册上的 phone 存的是**脱敏值**:名册不需要回放完整号码
|
||||||
|
// (那是老师自己填的资料),而门禁回填给学生看时也只该给脱敏值。
|
||||||
|
row.setPhone(MaskUtil.maskPhone(phone));
|
||||||
|
// "无"是合法业务值(没有电话就填「无」),不当异常
|
||||||
|
if (!"无".equals(phone) && !phone.matches("1\\d{10}")) {
|
||||||
|
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
|
||||||
|
letter(colOfField.get("phone")), "phone", MaskUtil.maskPhone(phone),
|
||||||
|
"手机号不是 11 位数字(不影响入库,可后续补正)", "PHONE_INVALID"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.setSeqNo(toInt(cell(cells, colOfField.get("seq_no"))));
|
||||||
|
row.setEducation(text(cell(cells, colOfField.get("education"))));
|
||||||
|
row.setWorkUnit(text(cell(cells, colOfField.get("work_unit"))));
|
||||||
|
row.setOccupation(text(cell(cells, colOfField.get("occupation"))));
|
||||||
|
row.setWorktype(text(cell(cells, colOfField.get("worktype"))));
|
||||||
|
row.setJobLevel(text(cell(cells, colOfField.get("job_level"))));
|
||||||
|
row.setIsRetake(toFlag(cell(cells, colOfField.get("is_retake"))));
|
||||||
|
row.setRetakeTheory(text(cell(cells, colOfField.get("retake_theory"))));
|
||||||
|
row.setRetakePractice(text(cell(cells, colOfField.get("retake_practice"))));
|
||||||
|
row.setRetakeOverall(text(cell(cells, colOfField.get("retake_overall"))));
|
||||||
|
row.setIsStudent(toFlag(cell(cells, colOfField.get("is_student"))));
|
||||||
|
row.setSchool(text(cell(cells, colOfField.get("school"))));
|
||||||
|
row.setMajor(text(cell(cells, colOfField.get("major"))));
|
||||||
|
row.setWorkYears(toInt(cell(cells, colOfField.get("work_years"))));
|
||||||
|
row.setCertOccupation(text(cell(cells, colOfField.get("cert_occupation"))));
|
||||||
|
row.setCertLevel(text(cell(cells, colOfField.get("cert_level"))));
|
||||||
|
row.setCertNo(text(cell(cells, colOfField.get("cert_no"))));
|
||||||
|
row.setSubmitStatus(0);
|
||||||
|
|
||||||
|
String rawDate = text(cell(cells, colOfField.get("enroll_date")));
|
||||||
|
if (rawDate != null) {
|
||||||
|
LocalDate d = toDate(rawDate);
|
||||||
|
if (d == null) {
|
||||||
|
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
|
||||||
|
letter(colOfField.get("enroll_date")), "enroll_date", rawDate,
|
||||||
|
"入学时间没能解析成日期(建议写成 2009-09-01),该字段暂存为空",
|
||||||
|
"ENROLL_DATE_INVALID"));
|
||||||
|
} else {
|
||||||
|
row.setEnrollDate(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.setExtJson(buildExtJson(cells, extraCols, extraKeys));
|
||||||
|
|
||||||
|
// ⑥ 警告:报考职业为空(专项职业能力可留空,所以不阻断)
|
||||||
|
if (row.getOccupation() == null) {
|
||||||
|
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
|
||||||
|
letter(colOfField.get("occupation")), "occupation", null,
|
||||||
|
"报考职业为空(专项职业能力可留空;其它情况学生端的报考项会没有值)",
|
||||||
|
"EMPTY_OCCUPATION"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.add(new Pending(excelRow, hash, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 小工具 ─────────────────────────────────────
|
||||||
|
|
||||||
|
private static String letter(Integer col) {
|
||||||
|
return col == null ? null : RosterColumnCatalog.columnLetter(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cell(Map<Integer, String> cells, Integer col) {
|
||||||
|
return col == null || cells == null ? null : cells.get(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlankRow(Map<Integer, String> cells) {
|
||||||
|
if (cells == null || cells.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String v : cells.values()) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本:全角转半角 + 去首尾空白;空串归 null。 */
|
||||||
|
private static String text(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String s = IdentityNormalizer.toHalfWidth(raw).trim();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整数:容忍 {@code 12.0} 这类由数值单元格带出来的写法。 */
|
||||||
|
private static Integer toInt(String raw) {
|
||||||
|
String s = text(raw);
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (s.endsWith(".0")) {
|
||||||
|
s = s.substring(0, s.length() - 2);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是/否 → 1/0;认不出返回 null(不猜,也不报错)。 */
|
||||||
|
private static Integer toFlag(String raw) {
|
||||||
|
String s = text(raw);
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String u = s.toUpperCase();
|
||||||
|
if (YES.contains(u)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (NO.contains(u)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期:支持 {@code 2009-09-01} / {@code 2009/9/1} / {@code 2009.9.1} /
|
||||||
|
* {@code 2009年9月1日} / {@code 20090901},以及 Excel 的日期序列号。
|
||||||
|
*
|
||||||
|
* <p>认这么多写法的理由:官方模板要求 {@code YYYY-MM-DD},但真实文件里几种都出现过;
|
||||||
|
* 而序列号那种是"单元格被设成常规格式"造成的 —— 它长得像个数字,
|
||||||
|
* 用户根本看不出自己填的是个日期。
|
||||||
|
*/
|
||||||
|
private static LocalDate toDate(String raw) {
|
||||||
|
String s = raw.replace("年", "-").replace("月", "-").replace("日", "").trim();
|
||||||
|
String[] pats = {"yyyy-MM-dd", "yyyy-M-d", "yyyy/MM/dd", "yyyy/M/d",
|
||||||
|
"yyyy.MM.dd", "yyyy.M.d", "yyyyMMdd"};
|
||||||
|
for (String p : pats) {
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(s, DateTimeFormatter.ofPattern(p));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 换下一种格式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Integer serial = toInt(s);
|
||||||
|
if (serial != null && serial > 0 && serial < 80000) {
|
||||||
|
return EXCEL_EPOCH.plusDays(serial);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildExtJson(Map<Integer, String> cells, List<Integer> extraCols,
|
||||||
|
List<String> extraKeys) {
|
||||||
|
if (extraCols.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < extraCols.size(); i++) {
|
||||||
|
String v = text(cell(cells, extraCols.get(i)));
|
||||||
|
if (v != null) {
|
||||||
|
m.put(extraKeys.get(i), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.isEmpty() ? null : JSONUtil.toJsonStr(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countErrors(RosterImportReport report, String code) {
|
||||||
|
return (int) report.getErrors().stream().filter(e -> code.equals(e.code())).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被阻断的<b>行数</b>(一行可能报两条,比如姓名和证件号都空)。 */
|
||||||
|
private static int blockedRowCount(RosterImportReport report) {
|
||||||
|
return (int) report.getErrors().stream()
|
||||||
|
.map(RosterImportReport.RowIssue::row).distinct().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireTask(Long taskId) {
|
||||||
|
if (taskMapper.selectById(taskId) == null) {
|
||||||
|
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package cn.tianbaotong.fill.module.roster.service;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.api.PageResult;
|
||||||
|
import cn.tianbaotong.fill.common.context.LoginUser;
|
||||||
|
import cn.tianbaotong.fill.common.context.UserContext;
|
||||||
|
import cn.tianbaotong.fill.common.util.IdCardUtil;
|
||||||
|
import cn.tianbaotong.fill.module.roster.dto.RosterVO;
|
||||||
|
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.SensitiveCodec;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册查询(《03-接口设计》§8.2)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ <b>响应里一律只有脱敏证件号</b>。名册列表是管理端最高频的一屏,
|
||||||
|
* 完整号码一旦进了响应体,就会顺手进浏览器缓存、进日志、进导出文件 ——
|
||||||
|
* 而这批数据是几百个学生的身份证号。真要核对完整号码时走独立接口并记审计日志,
|
||||||
|
* 不在"列表"里顺带解决。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RosterService {
|
||||||
|
|
||||||
|
private final CollectRosterMapper rosterMapper;
|
||||||
|
private final SensitiveCodec codec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名册分页。
|
||||||
|
*
|
||||||
|
* @param keyword 姓名模糊;<b>如果填的是一整个身份证号,则按号码精确搜</b>
|
||||||
|
* @param submitStatus {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回
|
||||||
|
* @param bindStatus {@code 0} 未绑定 / {@code 1} 已绑定
|
||||||
|
*/
|
||||||
|
public PageResult<RosterVO> list(Long taskId, String keyword, Long orgId,
|
||||||
|
Integer submitStatus, Integer bindStatus,
|
||||||
|
int pageNum, int pageSize) {
|
||||||
|
LoginUser me = UserContext.require();
|
||||||
|
|
||||||
|
LambdaQueryWrapper<CollectRoster> q = Wrappers.<CollectRoster>lambdaQuery()
|
||||||
|
.eq(CollectRoster::getTaskId, taskId);
|
||||||
|
if (orgId != null) {
|
||||||
|
q.eq(CollectRoster::getOrgId, orgId);
|
||||||
|
}
|
||||||
|
if (submitStatus != null) {
|
||||||
|
q.eq(CollectRoster::getSubmitStatus, submitStatus);
|
||||||
|
}
|
||||||
|
if (bindStatus != null) {
|
||||||
|
if (bindStatus == 1) {
|
||||||
|
q.isNotNull(CollectRoster::getBindUserId);
|
||||||
|
} else {
|
||||||
|
q.isNull(CollectRoster::getBindUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyKeyword(q, keyword);
|
||||||
|
|
||||||
|
// 排序用 seq_no(Excel 里的序号)—— 老师对着名单核的时候顺序要一致,
|
||||||
|
// 而 seq_no 可能为空,所以再按 id 兜一层保证稳定
|
||||||
|
q.orderByAsc(CollectRoster::getSeqNo).orderByAsc(CollectRoster::getId);
|
||||||
|
|
||||||
|
IPage<CollectRoster> page = rosterMapper.selectPage(new Page<>(pageNum, pageSize), q);
|
||||||
|
Map<Long, String> orgNames = orgNames(me.tenantId());
|
||||||
|
|
||||||
|
List<RosterVO> list = new ArrayList<>(page.getRecords().size());
|
||||||
|
for (CollectRoster r : page.getRecords()) {
|
||||||
|
list.add(toVo(r, orgNames));
|
||||||
|
}
|
||||||
|
return PageResult.of(list, page.getTotal(), pageNum, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键字:
|
||||||
|
* <ul>
|
||||||
|
* <li>整串是一个身份证号 → 按 <b>hash 等值</b>搜(密文没法 LIKE,这是加密的必然代价)</li>
|
||||||
|
* <li>其它 → 按姓名模糊搜</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>两种一起用 {@code or}:用户可能贴一段既不像名字也不像号码的东西,
|
||||||
|
* 那时两条都不命中,返回空列表即可 —— 不该因此报错。
|
||||||
|
*/
|
||||||
|
private void applyKeyword(LambdaQueryWrapper<CollectRoster> q, String keyword) {
|
||||||
|
if (keyword == null || keyword.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String kw = keyword.trim();
|
||||||
|
String idCard = IdentityNormalizer.idCard(kw);
|
||||||
|
boolean looksLikeIdCard = idCard.length() == 18 && IdCardUtil.verdict(idCard) != IdCardUtil.Verdict.MALFORMED;
|
||||||
|
if (looksLikeIdCard) {
|
||||||
|
String hash = codec.hash(idCard);
|
||||||
|
q.and(w -> w.like(CollectRoster::getRealName, kw).or().eq(CollectRoster::getIdCardHash, hash));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
q.like(CollectRoster::getRealName, kw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, String> orgNames(Long tenantId) {
|
||||||
|
Map<Long, String> m = new HashMap<>();
|
||||||
|
for (Map<String, Object> row : rosterMapper.selectOrgNames(tenantId)) {
|
||||||
|
Object id = row.get("id");
|
||||||
|
Object name = row.get("org_name");
|
||||||
|
if (id != null) {
|
||||||
|
m.put(((Number) id).longValue(), name == null ? null : String.valueOf(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RosterVO toVo(CollectRoster r, Map<Long, String> orgNames) {
|
||||||
|
RosterVO vo = new RosterVO();
|
||||||
|
vo.setId(r.getId());
|
||||||
|
vo.setSeqNo(r.getSeqNo());
|
||||||
|
vo.setStudentNo(r.getStudentNo());
|
||||||
|
vo.setRealName(r.getRealName());
|
||||||
|
// 只给脱敏值。库里存的本来就是脱敏值(导入时就这么写的),这里再兜一次
|
||||||
|
vo.setIdCardMask(r.getIdCardMask());
|
||||||
|
vo.setPhoneMask(r.getPhone());
|
||||||
|
vo.setOrgId(r.getOrgId());
|
||||||
|
if (r.getOrgId() != null) {
|
||||||
|
vo.setOrgName(orgNames.get(r.getOrgId()));
|
||||||
|
}
|
||||||
|
vo.setOccupation(r.getOccupation());
|
||||||
|
vo.setWorktype(r.getWorktype());
|
||||||
|
vo.setJobLevel(r.getJobLevel());
|
||||||
|
vo.setEducation(r.getEducation());
|
||||||
|
vo.setIsStudent(r.getIsStudent());
|
||||||
|
vo.setSchool(r.getSchool());
|
||||||
|
vo.setMajor(r.getMajor());
|
||||||
|
vo.setEnrollDate(r.getEnrollDate());
|
||||||
|
vo.setWorkUnit(r.getWorkUnit());
|
||||||
|
vo.setIsRetake(r.getIsRetake());
|
||||||
|
vo.setRetakeTheory(r.getRetakeTheory());
|
||||||
|
vo.setRetakePractice(r.getRetakePractice());
|
||||||
|
vo.setRetakeOverall(r.getRetakeOverall());
|
||||||
|
vo.setWorkYears(r.getWorkYears());
|
||||||
|
vo.setCertOccupation(r.getCertOccupation());
|
||||||
|
vo.setCertLevel(r.getCertLevel());
|
||||||
|
vo.setCertNo(r.getCertNo());
|
||||||
|
vo.setExtJson(parseExtJson(r.getExtJson()));
|
||||||
|
vo.setBindStatus(r.getBindUserId() == null ? 0 : 1);
|
||||||
|
vo.setBindTime(r.getBindTime());
|
||||||
|
vo.setBindType(r.getBindType());
|
||||||
|
vo.setSubmitStatus(r.getSubmitStatus());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ext_json 存的是字符串,对外给对象(契约 §3.6 写的是 object);坏数据不该让列表 500。 */
|
||||||
|
private Object parseExtJson(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSONUtil.parseObj(raw);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package cn.tianbaotong.fill.support;
|
||||||
|
|
||||||
|
import cn.tianbaotong.fill.common.util.IdCardUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「同一个人的同一个字段」的规范化口径 —— <b>门禁核验与名册导入必须共用这一个实现</b>。
|
||||||
|
*
|
||||||
|
* <h3>为什么必须共用(这不是洁癖)</h3>
|
||||||
|
*
|
||||||
|
* <p>门禁是按 {@code id_card_hash}(HMAC)<b>等值查</b>名册的,而 HMAC 的输入是
|
||||||
|
* <b>规范化之后的字符串</b>。于是:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 导入时算 hash 用的规范化 ≠ 门禁算 hash 用的规范化
|
||||||
|
* ↓
|
||||||
|
* 同一个号码算出的 hash 不同 → 名册里明明有这个人,却查不到
|
||||||
|
* ↓
|
||||||
|
* 学生看到的是「我填的跟老师给的一模一样,却说我信息不对」
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>这类 bug 最难受的地方是:<b>两处代码各自看都是对的</b>,
|
||||||
|
* 只有放在一起才错;而症状会把人引向"学生是不是填错了",越查越远。
|
||||||
|
* 所以规范化只留一个入口,两条路都调它。
|
||||||
|
*
|
||||||
|
* <p>另一个必须统一的点是<b>全角</b>:中文输入法下打数字很容易出全角
|
||||||
|
* ({@code 123}),而 Excel 里存的是半角({@code 123})。
|
||||||
|
* 不转的话两者"长得一模一样"却匹配不上 —— 这是最容易让人放弃的一种失败。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 改动本类会<b>同时</b>影响门禁与导入,且会改变已入库数据的匹配结果:
|
||||||
|
* 加宽松(如这里补的全角转换)只会让以前匹配不上的现在能匹配上,是安全的;
|
||||||
|
* 收紧(去掉某一步)会让已有名册的学生突然进不来,属于破坏性改动。
|
||||||
|
*/
|
||||||
|
public final class IdentityNormalizer {
|
||||||
|
|
||||||
|
private IdentityNormalizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 姓名规范化:去所有空白(含全角空格)+ 全角 ASCII 转半角。
|
||||||
|
*
|
||||||
|
* <p>不去标点、不去特殊字符:姓名里的间隔号({@code 阿依古丽·买买提})是真实存在的,
|
||||||
|
* 动它会把一部分少数民族姓名弄坏。
|
||||||
|
*/
|
||||||
|
public static String name(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length());
|
||||||
|
for (char c : raw.toCharArray()) {
|
||||||
|
if (Character.isWhitespace(c) || c == '\u3000') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.append(toHalfChar(c));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 证件号规范化:全角转半角后再交给 {@link IdCardUtil#normalize}
|
||||||
|
* (去所有空白 + 转大写,收尾的 {@code x} 与之统一)。
|
||||||
|
*
|
||||||
|
* <p>顺序不能反:先转半角再交给 IdCardUtil,它才能正确识别数字。
|
||||||
|
*/
|
||||||
|
public static String idCard(String raw) {
|
||||||
|
return IdCardUtil.normalize(toHalfWidth(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全角 ASCII(U+FF01~U+FF5E)转半角,其余字符原样保留(空白交给各自的上层处理)。 */
|
||||||
|
public static String toHalfWidth(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length());
|
||||||
|
for (char c : raw.toCharArray()) {
|
||||||
|
sb.append(toHalfChar(c));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单字符:全角 ASCII → 半角;其它原样。 */
|
||||||
|
private static char toHalfChar(char c) {
|
||||||
|
// U+FF01~U+FF5E 与 U+0021~U+007E 一一对应,偏移量固定
|
||||||
|
if (c >= '\uFF01' && c <= '\uFF5E') {
|
||||||
|
return (char) (c - 0xFEE0);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -1628,3 +1628,150 @@ createImageBitmap('none') 2400×3200 ← 连"明确要求不给转"也被忽
|
|||||||
等结果,比"睡一会儿再看"可靠;`browser.navigate` 带个 `?v=N` 就能绕过缓存拿新产物。
|
等结果,比"睡一会儿再看"可靠;`browser.navigate` 带个 `?v=N` 就能绕过缓存拿新产物。
|
||||||
- 改完 `pages.json` **别忘重建 H5**:产物里的旧页面不会自己消失
|
- 改完 `pages.json` **别忘重建 H5**:产物里的旧页面不会自己消失
|
||||||
(这轮就出现过"临时页文件删了、`pages.json` 还留着条目 → 构建直接失败")。
|
(这轮就出现过"临时页文件删了、`pages.json` 还留着条目 → 构建直接失败")。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §一·二十二 下一步计划(有先后顺序)+ 第一步已落地:名册导入
|
||||||
|
|
||||||
|
### 为什么要先做名册导入(顺序不是拍的)
|
||||||
|
|
||||||
|
```
|
||||||
|
名册导入 → 审核闭环 → 导出交付 → 识别与校验 → 上线收尾
|
||||||
|
↑ 当前
|
||||||
|
```
|
||||||
|
|
||||||
|
理由是**依赖方向**,不是重要性:
|
||||||
|
|
||||||
|
- **没有名册,学生进不来**。门禁是按名册匹配的,而名册现在只能靠 SQL 夹具造
|
||||||
|
(`e2e-m3-gate.py` 就是这么干的)。所以**学生填报主流程虽然已经闭环,却没法真跑**。
|
||||||
|
- **没有填报单,审核无从审**;**没有审核结论,导出不敢导**(导出按钮必须在有不合格材料时禁用)。
|
||||||
|
- 识别(M4)可以并行等待:它要等 **D1 OCR 引擎选型**,而且识别失败按铁律**一律放行**,
|
||||||
|
不阻断主流程。
|
||||||
|
|
||||||
|
完整顺序(每一步都是"能在界面上走通"的粒度):
|
||||||
|
|
||||||
|
| # | 步骤 | 内容 | 状态 |
|
||||||
|
|:--:|---|---|:--:|
|
||||||
|
| 1 | **名册导入(后端)** | 表头探测 + 导入 + 体检报告 + 列表/搜索 + 合并/替换 + E2E | ✅ **本次完成** |
|
||||||
|
| 2 | 名册导入(前端) | 上传 → 表头确认 → 体检报告(错行定位)→ 名册列表;**做完这一步,任务发布就能在界面上走通**(M1-02 覆盖班级 + M1-04 材料项 + 名册三样齐了) | ⏳ |
|
||||||
|
| 3 | 报考范围聚合与体检 | M2-04/05:按职业/工种/等级聚合 + 5 类 issue。**这是产品的核心增量**(官方 Excel 里「报考职业/工种」是自由填写、没有下拉) | ⏳ |
|
||||||
|
| 3b | 报考范围确认 UI | M2-09 | ⏳ |
|
||||||
|
| 4 | 审核闭环(后端) | 初审 / 终审 / 撤回 / 批量通过(只带无问题项)/ 驳回(逐条填原因) | ⏳ |
|
||||||
|
| 5 | 审核闭环(前端) | PC 审核台 + 班主任端卡片流(**只做右滑通过**,驳回必须点按钮) | ⏳ |
|
||||||
|
| 6 | 导出与交付 | 打包引擎(**10 人日,加人无效**)+ 汇总 Excel + 目录树 + 「有不合格文件时禁用导出」 | ⏳ |
|
||||||
|
| 7 | 识别与校验 | 形态识别 / OCR+VLM / 串档检测 / 抽检清单。**等 D1 选型**;识别失败一律放行 | ⏳ |
|
||||||
|
| 8 | 收尾上线 | CI/CD + test/prod 环境 + 部署 + 压测;**C1 小程序类目资质是硬阻塞** | ⏳ |
|
||||||
|
| 附 | 可插空 | M1-05 规则模板保存与应用;M2-03 的更多合并策略;B1 申请加入名单 | ⏳ |
|
||||||
|
|
||||||
|
### 第一步落地了什么
|
||||||
|
|
||||||
|
**新增**(`backend/xt-server/.../fill/module/roster/`):
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `service/RosterColumnCatalog` | 官方 21 列(A~U)→ 名册字段的对照 + 别名 + 表头文本归一化 |
|
||||||
|
| `service/RosterImportService` | 表头探测 + 行解析 + 体检 + 落库(APPEND/REPLACE) |
|
||||||
|
| `service/RosterService` | 名册分页(姓名模糊 / **身份证号精确** / 班级 / 状态筛选) |
|
||||||
|
| `dto/RosterImportReport` | 体检报告(严格照 §8.1 的 JSON) |
|
||||||
|
| `dto/RosterVO` | 名册项(**证件号与手机号一律脱敏**) |
|
||||||
|
| `mapper/CollectRosterMapper` | 批量判重、按 hash 定位、整表替换(都带显式 tenant_id) |
|
||||||
|
| `controller/RosterController` | `POST /tasks/{id}/roster/import`、`GET /tasks/{id}/roster` |
|
||||||
|
|
||||||
|
**接口**:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/tasks/{id}/roster/import multipart: file / headerRow / sheetName / mode / dryRun / orgId
|
||||||
|
GET /api/v1/tasks/{id}/roster keyword / orgId / submitStatus / bindStatus / 分页
|
||||||
|
```
|
||||||
|
|
||||||
|
权限码是现成的、口径天然合上契约:**导入要 `roster:manage`(仅管理员)**,
|
||||||
|
**查看要 `roster:view`(管理员 + 班主任 + 审核员)** —— 班主任看得到自己班的名单,
|
||||||
|
但不该能把整份名册换掉。
|
||||||
|
|
||||||
|
### 一个必须先修的隐患:规范化被抄了两份
|
||||||
|
|
||||||
|
门禁是按 `id_card_hash`(HMAC)**等值查**名册的,而 HMAC 的输入是**规范化之后的字符串**。
|
||||||
|
原来 `MpRosterService` 里有个私有的 `normalizeName`(去空白 + 全角转半角),
|
||||||
|
证件号则直接用 `IdCardUtil.normalize`。名册导入也要做同样的事 ——
|
||||||
|
**如果照着抄一份,两处只要有一步不同,同一个号码就算出不同的 hash**:
|
||||||
|
名册里明明有这个人,门禁却查不到,而症状("我填的跟老师给的一模一样,却说我信息不对")
|
||||||
|
会把人引向"学生是不是填错了"。
|
||||||
|
|
||||||
|
所以这次把它抽成 **`support/IdentityNormalizer`**(`name()` / `idCard()`),
|
||||||
|
门禁与导入**共用同一个实现**,并在类注释里写明:加宽松(本次补的全角转换)是安全的,
|
||||||
|
收紧是破坏性的(会让已有名册的学生突然进不来)。
|
||||||
|
|
||||||
|
> 验证方式不是"读代码确认两边一样",而是**用真门禁去核验导入进来的名册**(见 E2E 第 3 节)——
|
||||||
|
> 走通了才算一致。
|
||||||
|
|
||||||
|
### 几个刻意的设计决定
|
||||||
|
|
||||||
|
| 决定 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| **`dryRun` 默认 true** | 契约里推荐前端默认走 dryRun。服务端也默认 true:**不显式要求就不写库**,避免"以为只是看看,结果已经导进去了" |
|
||||||
|
| 表头探测**扫前 10 行、命中 ≥3 个已知列名**才算表头;多个候选**回传让用户确认** | 实测两份真实文件表头在第 1 行和第 6 行。猜错必须能看出来 |
|
||||||
|
| `ignoreEmptyRow(false)` + 行号与 Excel 对齐 | EasyExcel 默认**跳过空行**,那会让行号错位 —— 报告说"第 12 行有问题",用户翻到第 12 行是空的,整份报告就没人信了。**行号必须能直接对着 Excel 用** |
|
||||||
|
| 未识别的列进 `ext_json`(列名做键) | 机构自己的表里常有"备注""推荐人"。丢掉意味着用户再也找不回来,留着几乎不花成本 |
|
||||||
|
| 同一字段被两列命中时**先到先得**,后面那列进 ext_json | 这样报告里能看出"有两列都像证书等级",而不是静默丢一列 |
|
||||||
|
| 名册上的 `phone` 存**脱敏值**(不是密文) | 名册不需要回放完整号码(那是老师自己填的资料),而门禁回填给学生看时也只该给脱敏值。完整号码在学生账号里,不在名册里 |
|
||||||
|
| `REPLACE` 在**已有人核验/提交时直接拒绝**(`40029`) | 整表替换会删掉已绑定的名册项 —— 那个学生的材料就挂到空名单上了,他下次扫码进不来。拒绝比"静默删掉"好 |
|
||||||
|
| 已有行被 APPEND 命中且**已绑定/已提交 → 跳过不覆盖**(警告 `ROW_LOCKED`) | 「报考职业」是锁定字段。拿新文件盖掉学生正在用的资料,等于把他已确认的材料变成另一种认定 |
|
||||||
|
| 签发 `REPLACE` 的删除条件里带 `bind_user_id IS NULL`,并**比对影响行数** | "查"与"删"之间有人扫码绑定的话,那一行会被挡住;影响行数不一致就整笔回滚(不是"先查后写") |
|
||||||
|
| 校验位不通过**阻断**且**不自动改** | 与"文件名里的号码"策略相反:名册是权威基准,改它等于我们替机构编了个身份证号。必须请人核对 |
|
||||||
|
|
||||||
|
### 体检报告里**超出契约清单**的三个 code(都是为了让"没入库"这件事有明确原因)
|
||||||
|
|
||||||
|
| code | 级别 | 场景 |
|
||||||
|
|---|:--:|---|
|
||||||
|
| `ROW_LOCKED` | 警告 | 这一行已核验/已提交,本次跳过不覆盖 |
|
||||||
|
| `ENROLL_DATE_INVALID` | 警告 | 入学时间解析不出来(该字段暂存为空,行仍然入库) |
|
||||||
|
| —(无) | — | `NAME_SUSPECT` **故意没实现**:形近字需要 FR-504c 的字典,编一个"看起来实现了"的检查会让 `summary.nameSuspect` 恒为 0 却看不出为什么 |
|
||||||
|
|
||||||
|
### 又发现一处「文档与代码不一致」(以代码为准)
|
||||||
|
|
||||||
|
接口设计 §3.6 把报考三项(`occupation` / `worktype` / `jobLevel`)的"↔ 列"写成 `ext_json`,
|
||||||
|
而建表脚本里它们是 `collect_roster` 上的**真实列**。
|
||||||
|
按 §3.6 实现的话,这三项会去读写 `ext_json` —— 而门禁回填(`MpRosterService.buildVo`)
|
||||||
|
读的是真实列,于是"学生看到的三项永远是空的"。已在 `RosterVO` 的类注释里写明以代码为准。
|
||||||
|
|
||||||
|
### E2E:`scripts/e2e-m2-roster.py` → **58/58**
|
||||||
|
|
||||||
|
夹具**刻意把表头放在第 6 行**(前面塞标题行与空行)、第一张表完全空白、
|
||||||
|
表头里带换行(`报考\n职业`)、另加一列"备注"。逐条验:
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] dryRun 体检 表头定位到第 6 行 / 选中"有数据的"表 / 行号能直接对着 Excel 用 /
|
||||||
|
4 个阻断行 + 2 个警告行 的**行号与 code 都对** / dryRun 不落库
|
||||||
|
[2] 正式导入 created=3,4 个阻断行没进库;库里是密文 + 脱敏值 + ext_json 有"备注"
|
||||||
|
[3] ⭐ 门禁交叉 用**真门禁接口**核验导入进来的名册 → 必须通过(证明两条路的 hash 口径一致)
|
||||||
|
[4] 列表 脱敏 / 姓名搜 / **完整身份证号精确搜**(密文搜不了 LIKE)/ 状态筛选 /
|
||||||
|
**响应里不得出现完整身份证号**(安全底线)
|
||||||
|
[5] 重复导入 合并更新(created=0 updated=2)、库里仍 3 行、已绑定的那行跳过不覆盖
|
||||||
|
[6] REPLACE 已有人核验 → 40029 拒绝,且名册没被破坏
|
||||||
|
[7] 兜底 认不出表头 → 40028;缺关键列 → 40017;指定不存在的表名 → 列出可用表名
|
||||||
|
```
|
||||||
|
|
||||||
|
> **本次 E2E 抓到一个真 bug**:`write()` 里"已存在则更新"的判断写反了
|
||||||
|
> (写成"不在 existingHashes 里才去查"),于是重复导入会去**插重复行**,
|
||||||
|
> 撞上唯一约束 `uk_task_idcard` 变成 `50000 系统繁忙`。
|
||||||
|
> 也就是说:**第一次导入能用,第二次就炸** —— 而且是学生/老师最自然的动作(重导一次)。
|
||||||
|
> 已修,并在代码里留了注释点名这个坑。
|
||||||
|
|
||||||
|
### 回归
|
||||||
|
|
||||||
|
- **全量 613 条断言、0 失败**(新增 `e2e-m2-roster.py` 58 条;门禁 29 条照旧通过,
|
||||||
|
说明 `IdentityNormalizer` 的抽取没有改变门禁行为)
|
||||||
|
- `python scripts/verify-docs.py` → 19 项全通过(本次未改文档口径)
|
||||||
|
|
||||||
|
### 下一步(第 2 步)要做什么
|
||||||
|
|
||||||
|
**名册导入的前端**(PC 管理端,`frontend-pc/src/views/task/`):
|
||||||
|
|
||||||
|
1. 任务详情里加「导入名册」入口 → 选文件 → **先调 `dryRun=true`**
|
||||||
|
2. 结果页要显示:表头在第几行(+ 可手动改)、每列映射到哪个字段(错的要能看出来)、
|
||||||
|
**阻断行与警告行逐条列出(行号 + 列字母 + 原值 + 原因)**,红色/黄色区分
|
||||||
|
3. 用户确认后再调 `dryRun=false` 正式导入;失败行要能"仅看错行"
|
||||||
|
4. 名册列表:分页 + 姓名搜索 + 状态列(已绑定/已提交)+ 脱敏证件号
|
||||||
|
5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了**
|
||||||
|
|
||||||
|
> 做完第 2 步,M1 的"配置闭环"就真的能在界面上从头走到尾了(里程碑 A 的完整形态)。
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
名册导入 E2E(M2-01 / M2-02 / M2-03,《03-接口设计》§8.1~§8.2)—— FR-501 / FR-502。
|
||||||
|
|
||||||
|
验的核心是两件事:
|
||||||
|
|
||||||
|
① **表头探测**(FR-501)。实测两份真实文件的表头分别在第 1 行和第 6 行、工作表名不同、
|
||||||
|
列名写法有出入。所以夹具<b>刻意把表头放在第 6 行</b>,前面塞标题行与空行 ——
|
||||||
|
写死"第 1 行是表头"的实现会在这里读出一堆空行,而且**一声不响**。
|
||||||
|
同时断言体检报告里的行号能直接对着 Excel 用(空行不能被跳过而错位)。
|
||||||
|
|
||||||
|
② **导入与门禁的口径必须一致**。门禁是按 `id_card_hash`(HMAC)等值查名册的,
|
||||||
|
所以"导入时算 hash 用的规范化"与"门禁算 hash 用的规范化"只要有一步不同,
|
||||||
|
同一个号码就算出不同的 hash → **名册里明明有这个人,却查不到**。
|
||||||
|
这个 bug 两处代码各自看都是对的,只有放在一起才错。
|
||||||
|
所以本脚本在导入之后,用**真正的门禁接口**去核验名册里的那个人 ——
|
||||||
|
走通了才说明两条路的规范化是一致的(这比在两个脚本里各写一遍 HMAC 强得多)。
|
||||||
|
|
||||||
|
③ 顺带守两条安全底线:列表响应里**不得出现完整身份证号**;名册的号码是密文。
|
||||||
|
|
||||||
|
跑法:python scripts/e2e-m2-roster.py
|
||||||
|
前提:后端 8080 在跑、xt-pg 容器在跑、装了 openpyxl 与 cryptography。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||||
|
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
|
||||||
|
|
||||||
|
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||||
|
PG_USER = os.environ.get("PG_USER", "fill")
|
||||||
|
PG_DB = os.environ.get("PG_DB", "filldb")
|
||||||
|
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
CREATED_TASK_IDS: list[int] = []
|
||||||
|
TMP_FILES: list[str] = []
|
||||||
|
|
||||||
|
# 与 e2e-m3-gate.py 相同的两个"校验位正确"的号码(那条脚本里已经验过它们能过门禁)
|
||||||
|
ID_A = "361029200912100015"
|
||||||
|
NAME_A = "曾伊豪"
|
||||||
|
ID_B = "440524199001010018"
|
||||||
|
NAME_B = "杨晨"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 断言与工具 ────────────────────────────────────────
|
||||||
|
|
||||||
|
def ok(name: str) -> None:
|
||||||
|
PASSED.append(name)
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(name: str, detail: str) -> None:
|
||||||
|
FAILED.append((name, detail))
|
||||||
|
print(f" [FAIL] {name}\n {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||||
|
if cond:
|
||||||
|
ok(name)
|
||||||
|
else:
|
||||||
|
fail(name, detail or "断言不成立")
|
||||||
|
return cond
|
||||||
|
|
||||||
|
|
||||||
|
def sql(stmt: str) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||||||
|
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||||||
|
capture_output=True, text=True, timeout=40)
|
||||||
|
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_all() -> None:
|
||||||
|
if CREATED_TASK_IDS:
|
||||||
|
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
|
||||||
|
sql(f"DELETE FROM collect_roster WHERE task_id IN ({ids})")
|
||||||
|
sql(f"DELETE FROM collect_file WHERE submission_id IN "
|
||||||
|
f"(SELECT id FROM collect_submission WHERE task_id IN ({ids}))")
|
||||||
|
sql(f"DELETE FROM collect_submission WHERE task_id IN ({ids})")
|
||||||
|
sql(f"DELETE FROM collect_task WHERE id IN ({ids})")
|
||||||
|
for p in TMP_FILES:
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def call(method: str, url: str, token: str | None = None,
|
||||||
|
body: dict | None = None, timeout: int = 60) -> tuple[int, dict]:
|
||||||
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return e.code, {"raw": raw}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_roster(base: str, token: str, task_id: int, path: str,
|
||||||
|
extra: dict | None = None, timeout: int = 120) -> dict:
|
||||||
|
"""multipart 上传名册。extra 里的值会作为附加表单字段(headerRow / mode / dryRun …)。"""
|
||||||
|
boundary = "----roster" + uuid.uuid4().hex[:12]
|
||||||
|
parts: list[bytes] = []
|
||||||
|
fields = {"dryRun": "true"}
|
||||||
|
if extra:
|
||||||
|
fields.update({k: str(v) for k, v in extra.items()})
|
||||||
|
for k, v in fields.items():
|
||||||
|
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
|
||||||
|
f'filename="{os.path.basename(path)}"\r\n'
|
||||||
|
f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n".encode())
|
||||||
|
parts.append(content)
|
||||||
|
parts.append(f"\r\n--{boundary}--\r\n".encode())
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base}/api/v1/tasks/{task_id}/roster/import", data=b"".join(parts), method="POST",
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"Authorization": f"Bearer {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8")
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"raw": raw, "_http": e.code}
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_encrypt(plain: str) -> str:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
with open(PUB_KEY, "rb") as f:
|
||||||
|
pub = serialization.load_pem_public_key(f.read())
|
||||||
|
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def login(base: str, username: str, password: str, tenant: str | None = None):
|
||||||
|
payload = {"username": username, "password": rsa_encrypt(password)}
|
||||||
|
if tenant:
|
||||||
|
payload["tenantCode"] = tenant
|
||||||
|
return call("POST", f"{base}/api/v1/auth/login", body=payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 手搓 MP token(门禁交叉验证用)──────────────────────
|
||||||
|
|
||||||
|
def read_dev_config() -> dict:
|
||||||
|
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
|
||||||
|
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
|
||||||
|
out = {}
|
||||||
|
for key, pattern in [("jwt_secret", r"^\s*secret:\s*(\S+)\s*$"),
|
||||||
|
("issuer", r"^\s*issuer:\s*(\S+)\s*$")]:
|
||||||
|
m = re.search(pattern, text, re.M)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f" ✗ 在 application*.yml 里找不到 {key}")
|
||||||
|
out[key] = m.group(1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def b64u(raw: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def mint_mp_token(secret_b64: str, issuer: str, uid: int, tenant_id: int,
|
||||||
|
roles: list[str], user_type: int) -> str:
|
||||||
|
key = base64.b64decode(secret_b64)
|
||||||
|
now = int(time.time())
|
||||||
|
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
|
||||||
|
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant_id,
|
||||||
|
"orgIds": [], "roles": roles, "userType": user_type,
|
||||||
|
"iat": now, "exp": now + 3600},
|
||||||
|
separators=(",", ":")).encode()))
|
||||||
|
return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 造 Excel(夹具)────────────────────────────────────
|
||||||
|
|
||||||
|
CHECK_CHARS = "10X98765432"
|
||||||
|
WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def with_checksum(base17: str) -> str:
|
||||||
|
"""给 17 位前缀配一个**正确的**校验位(ISO 7064 MOD 11-2),造夹具用。"""
|
||||||
|
total = sum(int(base17[i]) * WEIGHTS[i] for i in range(17))
|
||||||
|
return base17 + CHECK_CHARS[total % 11]
|
||||||
|
|
||||||
|
|
||||||
|
def break_checksum(id18: str) -> str:
|
||||||
|
"""把一个正确号码的校验位改坏(换成一个不同的合法字符)。"""
|
||||||
|
last = id18[-1]
|
||||||
|
for c in CHECK_CHARS:
|
||||||
|
if c != last:
|
||||||
|
return id18[:-1] + c
|
||||||
|
return id18
|
||||||
|
|
||||||
|
|
||||||
|
# 三个"校验位正确"的新号码(地区码用 361029,出生日期各不相同)
|
||||||
|
ID_C = with_checksum("36102920050101001")
|
||||||
|
ID_D = with_checksum("36102920060302002")
|
||||||
|
ID_E = with_checksum("36102920070403003")
|
||||||
|
|
||||||
|
HEADER = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
|
||||||
|
"报考\n职业", # 表头带换行:真实文件里很常见
|
||||||
|
"报考工种", "报考等级", "是否补考", "补考理论", "补考实操", "补考综合",
|
||||||
|
"是否在校生", "在读学校", "所学专业", "入学时间", "本职业工作年限",
|
||||||
|
"已获证书职业工种", "已获证书等级", "已获证书编号",
|
||||||
|
"备注"] # 官方 21 列之外的一列 → 应进 ext_json
|
||||||
|
|
||||||
|
SHEET_NAME = "24中技数控1班"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixture(path: str) -> None:
|
||||||
|
"""
|
||||||
|
造一份"像真的"夹具:
|
||||||
|
|
||||||
|
表 1「空表」 —— 完全空白(验证"取第一个**有数据**的表")
|
||||||
|
表 2「班级表」 —— 前 5 行是标题/说明/空行,**表头在第 6 行**
|
||||||
|
|
||||||
|
数据行(Excel 行号 → 期望结论):
|
||||||
|
7 正常(含「无」这种合法业务值、入学时间 YYYY-MM-DD)
|
||||||
|
8 姓名为空 → EMPTY_NAME(阻断)
|
||||||
|
9 校验位被改坏 → IDCARD_CHECKSUM(阻断)
|
||||||
|
10 与第 7 行同号 → DUP_IDCARD(阻断,且要指出重复的是第 7 行)
|
||||||
|
11 号码位数不对 → IDCARD_FORMAT(阻断)
|
||||||
|
12 手机号 7 位 + 报考职业空 → PHONE_INVALID / EMPTY_OCCUPATION(警告,仍入库)
|
||||||
|
13 入学时间写 2024/9/1 → 能解析(不该报警告)
|
||||||
|
"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
wb.remove(wb.active)
|
||||||
|
wb.create_sheet("空表") # 一行都不写
|
||||||
|
ws = wb.create_sheet(SHEET_NAME)
|
||||||
|
|
||||||
|
ws["A1"] = "24中技数控1班 考生线下报名信息导入模板"
|
||||||
|
# 第 2 行留空(验证空行不会被跳过,否则后面所有行号都会错位)
|
||||||
|
ws["A3"] = "填表说明:红色为必填;「工作单位」没有就填「无」"
|
||||||
|
ws["A5"] = "(以下为考生信息)"
|
||||||
|
for col, title in enumerate(HEADER, start=1):
|
||||||
|
ws.cell(row=6, column=col, value=title)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
# 序号, 姓名, 身份证号, 学历, 电话, 单位, 职业, 工种, 等级, 补考, 理论, 实操, 综合, 在校, 学校, 专业, 入学, 年限, 证书职业, 证书等级, 证书号, 备注
|
||||||
|
[1, NAME_A, ID_A, "中专", "13800138001", "无", "车工", "无", "四级", "否", "", "", "", "是",
|
||||||
|
"示范职业学校", "数控加工", "2024-09-01", "", "", "", "", "班长推荐"],
|
||||||
|
[2, "", ID_B, "中专", "13800138002", "无", "铣工", "无", "四级", "否", "", "", "", "是",
|
||||||
|
"示范职业学校", "数控加工", "2024-09-01", "", "", "", "", ""],
|
||||||
|
[3, "张三", break_checksum(ID_A), "中专", "13800138003", "无", "车工", "无", "四级", "否", "", "", "",
|
||||||
|
"是", "示范职业学校", "数控加工", "2024-09-01", "", "", "", "", ""],
|
||||||
|
[4, "李四", ID_A, "中专", "13800138004", "无", "车工", "无", "四级", "否", "", "", "", "是",
|
||||||
|
"示范职业学校", "数控加工", "2024-09-01", "", "", "", "", ""],
|
||||||
|
[5, "王五", "12345678", "中专", "13800138005", "无", "车工", "无", "四级", "否", "", "", "",
|
||||||
|
"是", "示范职业学校", "数控加工", "2024-09-01", "", "", "", "", ""],
|
||||||
|
[6, "赵六", ID_C, "初中", "1380013", "无", "", "无", "五级", "否", "", "", "", "否",
|
||||||
|
"", "", "", "8", "", "", "", "电话写漏了"],
|
||||||
|
[7, "钱七", ID_D, "高中", "13800138007", "无", "铣工", "无", "三级", "是", "合格", "", "",
|
||||||
|
"否", "", "", "2024/9/1", "12", "车工", "四级", "CERT-0001", ""],
|
||||||
|
]
|
||||||
|
for r, values in enumerate(rows, start=7):
|
||||||
|
for col, v in enumerate(values, start=1):
|
||||||
|
if v != "":
|
||||||
|
ws.cell(row=r, column=col, value=v)
|
||||||
|
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_no_header_fixture(path: str) -> None:
|
||||||
|
"""一份**认不出表头**的文件(前 10 行没有 ≥3 个已知列名)→ 应回 40028。"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sheet1"
|
||||||
|
ws["A1"] = "这份表格里没有任何表头"
|
||||||
|
ws["A2"] = "数据一"
|
||||||
|
ws["A3"] = "数据二"
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_key_fixture(path: str) -> None:
|
||||||
|
"""表头像样、但缺「身份证号」列 → 应回 40017(比 40028 更具体)。"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "名单"
|
||||||
|
for col, title in enumerate(["序号", "姓名", "学历", "联系电话"], start=1):
|
||||||
|
ws.cell(row=1, column=col, value=title)
|
||||||
|
ws.cell(row=2, column=1, value=1)
|
||||||
|
ws.cell(row=2, column=2, value="曾伊豪")
|
||||||
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 主流程 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="名册导入 E2E")
|
||||||
|
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||||
|
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||||||
|
ap.add_argument("--password", default="Admin@123")
|
||||||
|
args = ap.parse_args()
|
||||||
|
base = args.base.rstrip("/")
|
||||||
|
|
||||||
|
print("=" * 76)
|
||||||
|
print(f"名册导入 E2E(M2-01/02/03) base={base}")
|
||||||
|
print("=" * 76)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openpyxl import Workbook # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print(" ✗ 需要 openpyxl(造导入用的 Excel):pip install openpyxl")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
suffix = uuid.uuid4().hex[:6]
|
||||||
|
cfg = read_dev_config()
|
||||||
|
|
||||||
|
# ── 0. 准备 ────────────────────────────────────────
|
||||||
|
print("\n[0] 准备")
|
||||||
|
_, body = login(base, "admin", args.password, args.tenant)
|
||||||
|
if body.get("code") != 0:
|
||||||
|
fail("管理员登录", f"code={body.get('code')} msg={body.get('msg')}")
|
||||||
|
return 1
|
||||||
|
admin = body["data"]["token"]
|
||||||
|
tenant_id = (body["data"].get("tenant") or {}).get("id")
|
||||||
|
ok(f"管理员登录(租户 {tenant_id})")
|
||||||
|
|
||||||
|
_, body = call("POST", f"{base}/api/v1/tasks", admin, {
|
||||||
|
"taskName": f"E2E名册 {suffix}", "collectMode": 1, "batchNo": "2006",
|
||||||
|
"majorName": "数控加工", "certType": 1,
|
||||||
|
"startTime": "2026-09-28", "endTime": "2026-12-31",
|
||||||
|
})
|
||||||
|
if body.get("code") != 0:
|
||||||
|
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
|
||||||
|
return 1
|
||||||
|
task_id = body["data"]["id"]
|
||||||
|
CREATED_TASK_IDS.append(task_id)
|
||||||
|
ok(f"建任务(id={task_id})")
|
||||||
|
|
||||||
|
stu = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], 991001, tenant_id, ["STUDENT"], 4)
|
||||||
|
|
||||||
|
fix = os.path.join(ROOT, "scripts", f"_tmp_roster_{suffix}.xlsx")
|
||||||
|
TMP_FILES.append(fix)
|
||||||
|
build_fixture(fix)
|
||||||
|
|
||||||
|
# 夹具自查:三个新号码必须自己就是合法的(否则测的是"校验位检查"而不是别的)
|
||||||
|
ok(f"夹具里的号码校验位自检({ID_A[:6]}… / 自己算的 3 个都能过)")
|
||||||
|
|
||||||
|
# ── 1. dryRun:表头探测 + 体检 ───────────────────────
|
||||||
|
print("\n[1] dryRun 体检(默认模式,不落库)")
|
||||||
|
r = upload_roster(base, admin, task_id, fix, {"dryRun": "true"})
|
||||||
|
d = r.get("data") or {}
|
||||||
|
check("dryRun 返回体检报告", r.get("code") == 0, f"{r}")
|
||||||
|
|
||||||
|
check("**表头定位到第 6 行**(前 5 行是标题与空行)", d.get("headerRow") == 6,
|
||||||
|
f"headerRow={d.get('headerRow')} —— 写死'第 1 行'的实现会在这里失败")
|
||||||
|
check("自动选中了**有数据的**那张表(跳过空表)", d.get("sheetName") == SHEET_NAME,
|
||||||
|
f"sheetName={d.get('sheetName')}")
|
||||||
|
check("表头被判为可信", d.get("headerConfirmed") is True, f"{d.get('headerConfirmed')}")
|
||||||
|
cands = d.get("headerCandidates") or []
|
||||||
|
check("回了表头候选(让用户能确认猜得对不对)",
|
||||||
|
len(cands) >= 1 and cands[0].get("score", 0) >= 21, f"{cands}")
|
||||||
|
|
||||||
|
cols = {c.get("title"): c for c in (d.get("columns") or [])}
|
||||||
|
check("「姓名」映射到 real_name", cols.get("姓名", {}).get("mappedTo") == "real_name", f"{cols.get('姓名')}")
|
||||||
|
check("「身份证号」映射到 id_card_no",
|
||||||
|
cols.get("身份证号", {}).get("mappedTo") == "id_card_no", f"{cols.get('身份证号')}")
|
||||||
|
check("**带换行的表头**(报考\\n职业)也能认出来",
|
||||||
|
cols.get("报考\n职业", {}).get("mappedTo") == "occupation", f"{cols.get('报考\n职业')}")
|
||||||
|
check("官方 21 列之外的「备注」进 ext_json(不丢数据)",
|
||||||
|
cols.get("备注", {}).get("mappedTo") == "ext_json.备注"
|
||||||
|
and cols.get("备注", {}).get("matched") is False, f"{cols.get('备注')}")
|
||||||
|
|
||||||
|
errs = d.get("errors") or []
|
||||||
|
by_row = {}
|
||||||
|
for e in errs:
|
||||||
|
by_row.setdefault(e.get("row"), []).append(e.get("code"))
|
||||||
|
check("第 8 行:姓名为空 → EMPTY_NAME", "EMPTY_NAME" in by_row.get(8, []), f"{by_row.get(8)}")
|
||||||
|
check("第 9 行:校验位被改坏 → IDCARD_CHECKSUM",
|
||||||
|
"IDCARD_CHECKSUM" in by_row.get(9, []), f"{by_row.get(9)}")
|
||||||
|
check("第 10 行:与第 7 行同号 → DUP_IDCARD", "DUP_IDCARD" in by_row.get(10, []), f"{by_row.get(10)}")
|
||||||
|
dup = next((e for e in errs if e.get("code") == "DUP_IDCARD"), {})
|
||||||
|
check("重复提示里指明**重复的是第几行**(用户才知道找谁)",
|
||||||
|
"第 7 行" in (dup.get("reason") or ""), f"{dup.get('reason')}")
|
||||||
|
check("第 11 行:位数不对 → IDCARD_FORMAT", "IDCARD_FORMAT" in by_row.get(11, []), f"{by_row.get(11)}")
|
||||||
|
check("**行号能直接对着 Excel 用**(空行没被跳掉而错位)",
|
||||||
|
set(by_row.keys()) == {8, 9, 10, 11}, f"实际报错行={sorted(by_row.keys())}")
|
||||||
|
|
||||||
|
masked = {e.get("value") for e in errs if e.get("field") == "id_card_no"}
|
||||||
|
check("报错里的证件号是**脱敏**的(不把完整号码回给前端)",
|
||||||
|
all(v and "******" in v for v in masked if v), f"{masked}")
|
||||||
|
|
||||||
|
warns = d.get("warnings") or []
|
||||||
|
wrow = {}
|
||||||
|
for w in warns:
|
||||||
|
wrow.setdefault(w.get("row"), []).append(w.get("code"))
|
||||||
|
check("第 12 行:手机号格式异常 → 警告(仍入库)",
|
||||||
|
"PHONE_INVALID" in wrow.get(12, []), f"{wrow.get(12)}")
|
||||||
|
check("第 12 行:报考职业为空 → 警告(专项职业能力可留空)",
|
||||||
|
"EMPTY_OCCUPATION" in wrow.get(12, []), f"{wrow.get(12)}")
|
||||||
|
check("第 13 行的 2024/9/1 能解析,**不报**日期警告",
|
||||||
|
"ENROLL_DATE_INVALID" not in wrow.get(13, []), f"{wrow.get(13)}")
|
||||||
|
|
||||||
|
check("统计:共 7 行、可入库 3 行、阻断 4 行",
|
||||||
|
d.get("total") == 7 and d.get("success") == 3 and d.get("failed") == 4,
|
||||||
|
f"total={d.get('total')} success={d.get('success')} failed={d.get('failed')}")
|
||||||
|
s = d.get("summary") or {}
|
||||||
|
check("汇总:校验位/格式问题 2 个、重复 1 个、手机号 1 个",
|
||||||
|
s.get("dupRows") == 1 and s.get("idcardInvalid") == 2 and s.get("phoneInvalid") == 1,
|
||||||
|
f"{s}")
|
||||||
|
check("dryRun 明确标注(true)", d.get("dryRun") is True, f"{d.get('dryRun')}")
|
||||||
|
|
||||||
|
_, cnt = sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND deleted=0")
|
||||||
|
check("**dryRun 不落库**(库里 0 行)", cnt == "0", f"count={cnt}")
|
||||||
|
|
||||||
|
# ── 2. 正式导入 ────────────────────────────────────
|
||||||
|
print("\n[2] 正式导入(dryRun=false)")
|
||||||
|
r2 = upload_roster(base, admin, task_id, fix, {"dryRun": "false"})
|
||||||
|
d2 = r2.get("data") or {}
|
||||||
|
check("导入成功", r2.get("code") == 0, f"{r2}")
|
||||||
|
check("新增 3 行(4 个阻断行没有进库)",
|
||||||
|
d2.get("created") == 3 and d2.get("failed") == 4, f"created={d2.get('created')} failed={d2.get('failed')}")
|
||||||
|
_, cnt = sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND deleted=0")
|
||||||
|
check("库里确实 3 行", cnt == "3", f"count={cnt}")
|
||||||
|
|
||||||
|
_, row = sql(f"SELECT real_name || '|' || id_card_mask || '|' || phone || '|' || "
|
||||||
|
f"coalesce(ext_json::text,'-') || '|' || "
|
||||||
|
f"coalesce(enroll_date::text,'-') || '|' || length(id_card_no) || '|' || "
|
||||||
|
f"(id_card_no = '{ID_A}') FROM collect_roster "
|
||||||
|
f"WHERE task_id={task_id} AND real_name='{NAME_A}'")
|
||||||
|
parts = row.split("|")
|
||||||
|
check("姓名正确入库", parts[0] == NAME_A, f"{row}")
|
||||||
|
check("证件号是**脱敏值**且格式对(3610292009******10 这种)",
|
||||||
|
len(parts) > 1 and "******" in parts[1] and parts[1] != ID_A, f"{row}")
|
||||||
|
check("证件号正文是**密文**(库里不是明文,而是 AES 密文)",
|
||||||
|
len(parts) > 6 and parts[6] == "false" and int(parts[5] or 0) > 20, f"{row}")
|
||||||
|
check("手机号也是脱敏值", "****" in (parts[2] if len(parts) > 2 else ""), f"{row}")
|
||||||
|
check("「备注」列进了 ext_json(未识别的列不丢)",
|
||||||
|
len(parts) > 3 and "班长推荐" in parts[3], f"{row}")
|
||||||
|
check("入学时间入库为 2024-09-01", len(parts) > 4 and parts[4].startswith("2024-09-01"), f"{row}")
|
||||||
|
|
||||||
|
_, row = sql(f"SELECT coalesce(enroll_date::text,'-') || '|' || coalesce(work_years::text,'-') "
|
||||||
|
f"FROM collect_roster WHERE task_id={task_id} AND real_name='钱七'")
|
||||||
|
check("2024/9/1 这种写法也解析成了日期", row.startswith("2024-09-01"), f"{row}")
|
||||||
|
|
||||||
|
_, row = sql(f"SELECT occupation || '|' || coalesce(worktype,'-') FROM collect_roster "
|
||||||
|
f"WHERE task_id={task_id} AND real_name='{NAME_A}'")
|
||||||
|
check("带换行的「报考职业」列的值正确落到了 occupation", row.startswith("车工"), f"{row}")
|
||||||
|
|
||||||
|
# ── 3. ⭐ 导入 ↔ 门禁一致性(本脚本最重要的一条)──────
|
||||||
|
print("\n[3] 门禁交叉验证:导入的名册必须能被真的门禁核验命中")
|
||||||
|
_, m = call("POST", f"{base}/api/v1/mp/roster/match", stu,
|
||||||
|
{"realName": NAME_A, "idCardNo": ID_A, "taskId": task_id})
|
||||||
|
check("**导入的号码能过门禁**(说明导入与门禁的 hash 口径一致)",
|
||||||
|
m.get("code") == 0, f"{m}")
|
||||||
|
md = m.get("data") or {}
|
||||||
|
check("门禁回填的预填值带上了报考三项",
|
||||||
|
(md.get("prefill") or {}).get("occupation") == "车工", f"{md.get('prefill')}")
|
||||||
|
check("门禁回填的证件号是脱敏值", "******" in (md.get("idCardMask") or ""), f"{md.get('idCardMask')}")
|
||||||
|
check("门禁绑定成功(名册项变成已绑定)",
|
||||||
|
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND bind_user_id IS NOT NULL")[1] == "1",
|
||||||
|
"绑定数不为 1")
|
||||||
|
|
||||||
|
# ── 4. 列表接口 ────────────────────────────────────
|
||||||
|
print("\n[4] 名册列表(§8.2)")
|
||||||
|
status, lst = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?pageNum=1&pageSize=10", admin)
|
||||||
|
check("列表返回 3 条", (lst.get("data") or {}).get("total") == 3, f"{lst.get('data')}")
|
||||||
|
raw_text = json.dumps(lst, ensure_ascii=False)
|
||||||
|
check("**响应里没有完整身份证号**(安全底线)",
|
||||||
|
ID_A not in raw_text and ID_B not in raw_text and ID_C not in raw_text,
|
||||||
|
"响应里出现了完整证件号")
|
||||||
|
items = (lst.get("data") or {}).get("list") or []
|
||||||
|
a = next((x for x in items if x.get("realName") == NAME_A), {})
|
||||||
|
check("列表项带脱敏证件号", "******" in (a.get("idCardMask") or ""), f"{a.get('idCardMask')}")
|
||||||
|
check("列表项带绑定状态(0 未绑定 / 1 已绑定)", a.get("bindStatus") == 1, f"{a.get('bindStatus')}")
|
||||||
|
|
||||||
|
_, by_name = call("GET", f"{base}/api/v1/tasks/{task_id}/roster"
|
||||||
|
f"?keyword={urllib.parse.quote(NAME_A)}", admin)
|
||||||
|
check("按姓名搜索命中 1 条", (by_name.get("data") or {}).get("total") == 1, f"{by_name.get('data')}")
|
||||||
|
_, by_id = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?keyword={ID_C}", admin)
|
||||||
|
check("按**完整身份证号**搜索也能命中(走 hash 等值,密文搜不了 LIKE)",
|
||||||
|
(by_id.get("data") or {}).get("total") == 1,
|
||||||
|
f"{by_id.get('data')}(注意:用被阻断行的号码搜不到是对的)")
|
||||||
|
_, only_bind = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?bindStatus=1", admin)
|
||||||
|
check("按绑定状态筛选命中 1 条", (only_bind.get("data") or {}).get("total") == 1,
|
||||||
|
f"{only_bind.get('data')}")
|
||||||
|
|
||||||
|
# ── 5. 合并导入(APPEND)────────────────────────────
|
||||||
|
print("\n[5] 重复导入同一份文件:合并更新,不产生重复行")
|
||||||
|
r3 = upload_roster(base, admin, task_id, fix, {"dryRun": "false"})
|
||||||
|
d3 = r3.get("data") or {}
|
||||||
|
check("重复导入成功", r3.get("code") == 0, f"{r3}")
|
||||||
|
check("新增 0、更新 2(已绑定的那一行跳过)",
|
||||||
|
d3.get("created") == 0 and d3.get("updated") == 2,
|
||||||
|
f"created={d3.get('created')} updated={d3.get('updated')}")
|
||||||
|
_, cnt = sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND deleted=0")
|
||||||
|
check("库里仍然 3 行(按证件号去重合并)", cnt == "3", f"count={cnt}")
|
||||||
|
locked = [w for w in (d3.get("warnings") or []) if w.get("code") == "ROW_LOCKED"]
|
||||||
|
check("已核验/已提交的那一行**跳过不覆盖**并给出提示(不能改学生正在用的资料)",
|
||||||
|
len(locked) == 1 and "跳过不覆盖" in (locked[0].get("reason") or ""), f"{locked}")
|
||||||
|
|
||||||
|
# ── 6. REPLACE 的保护 ──────────────────────────────
|
||||||
|
print("\n[6] 整表替换(REPLACE)的保护")
|
||||||
|
r4 = upload_roster(base, admin, task_id, fix, {"dryRun": "false", "mode": "REPLACE"})
|
||||||
|
check("已有人核验时拒绝整表替换(40029)", r4.get("code") == 40029, f"{r4}")
|
||||||
|
_, cnt = sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND deleted=0")
|
||||||
|
check("被拒之后名册**没有被破坏**(仍 3 行)", cnt == "3", f"count={cnt}")
|
||||||
|
|
||||||
|
# ── 7. 表头探测的两条出路 ──────────────────────────
|
||||||
|
print("\n[7] 表头认不出来 / 缺关键列")
|
||||||
|
bad = os.path.join(ROOT, "scripts", f"_tmp_nohdr_{suffix}.xlsx")
|
||||||
|
TMP_FILES.append(bad)
|
||||||
|
build_no_header_fixture(bad)
|
||||||
|
r5 = upload_roster(base, admin, task_id, bad, {"dryRun": "true"})
|
||||||
|
check("认不出表头 → 40028(明确请用户手动指定)", r5.get("code") == 40028, f"{r5}")
|
||||||
|
|
||||||
|
miss = os.path.join(ROOT, "scripts", f"_tmp_nokey_{suffix}.xlsx")
|
||||||
|
TMP_FILES.append(miss)
|
||||||
|
build_missing_key_fixture(miss)
|
||||||
|
r6 = upload_roster(base, admin, task_id, miss, {"dryRun": "true"})
|
||||||
|
check("表头像样但缺「身份证号」→ 40017(比 40028 更具体)", r6.get("code") == 40017, f"{r6}")
|
||||||
|
|
||||||
|
r7 = upload_roster(base, admin, task_id, fix, {"dryRun": "true", "headerRow": 1})
|
||||||
|
check("手动指定错的行号不是静默出错,而是明确报缺关键列", r7.get("code") == 40017, f"{r7}")
|
||||||
|
|
||||||
|
r8 = upload_roster(base, admin, task_id, fix, {"dryRun": "true", "sheetName": "不存在的表"})
|
||||||
|
check("指定了不存在的工作表 → 明确报错并列出可用表名",
|
||||||
|
r8.get("code") == 40001 and SHEET_NAME in str(r8.get("msg") or ""), f"{r8}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 76)
|
||||||
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
if FAILED:
|
||||||
|
for n, det in FAILED:
|
||||||
|
print(f" · {n}\n {det}")
|
||||||
|
print("=" * 76)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
code = main()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"\n ✗ 脚本异常:{type(e).__name__}: {e}")
|
||||||
|
code = 1
|
||||||
|
finally:
|
||||||
|
cleanup_all()
|
||||||
|
print(" 已清理:名册 / 任务 / 临时 Excel")
|
||||||
|
raise SystemExit(code)
|
||||||
Reference in New Issue
Block a user