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:
2026-09-29 17:00:21 +08:00
parent 66e4ccade9
commit fdfe21345a
12 changed files with 2413 additions and 27 deletions
+147
View File
@@ -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`
@@ -11,6 +11,7 @@ import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.common.crypto.HmacIndex;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -75,8 +76,8 @@ public class MpRosterService {
rateLimit(userId, clientIp);
String name = normalizeName(req.getRealName());
String idCard = IdCardUtil.normalize(req.getIdCardNo());
String name = IdentityNormalizer.name(req.getRealName());
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
Long taskCount = rosterMapper.countTask(tenantId, req.getTaskId());
if (taskCount == null || taskCount == 0) {
@@ -93,8 +94,7 @@ public class MpRosterService {
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "号码格式不对");
}
String hash = hmacIndex.hash(idCard);
List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
String hash = hmacIndex.hash(idCard); List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
if (hits.isEmpty()) {
// ⚠️ 日志只记 hash 与 uid,不记明文与姓名
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(),
@@ -110,7 +110,7 @@ public class MpRosterService {
}
CollectRoster row = hits.get(0);
if (!name.equals(normalizeName(row.getRealName()))) {
if (!name.equals(IdentityNormalizer.name(row.getRealName()))) {
// 姓名不一致:与"没查到"给**同一句提示**,见类注释第 1 条
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "姓名与名册不一致");
}
@@ -147,30 +147,11 @@ public class MpRosterService {
}
/**
* 姓名规范化:去所有空白 + 全角转半角。
* 姓名规范化:**已抽到 {@link IdentityNormalizer}**,门禁与名册导入共用同一份实现。
*
* <p>为什么要转全角:学生用中文输入法打英文数字时很容易带出全角,
* 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"——
* 而肉眼看这两个字<b>长得几乎一样</b>,是最容易让人放弃的一种失败。
* <p>原来这里是私有方法,导入功能要落地时才发现"两边各写一份规范化"是迟早要出事的设计 ——
* 详见 {@code IdentityNormalizer} 的类注释。
*/
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 次/分钟。
@@ -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));
}
}
@@ -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;
}
@@ -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);
}
@@ -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();
}
}
@@ -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, "任务不存在或已删除");
}
}
}
@@ -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
View File
@@ -1628,3 +1628,150 @@ createImageBitmap('none') 2400×3200 ← 连"明确要求不给转"也被忽
等结果,比"睡一会儿再看"可靠;`browser.navigate` 带个 `?v=N` 就能绕过缓存拿新产物。
- 改完 `pages.json` **别忘重建 H5**:产物里的旧页面不会自己消失
(这轮就出现过"临时页文件删了、`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 的完整形态)。
+581
View File
@@ -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)