From fdfe21345a9f4b6302a898ac395193f9704a2243 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 17:00:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(roster):=20=E5=90=8D=E5=86=8C=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=EF=BC=88M2-01/02/03=20=E5=90=8E=E7=AB=AF=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=A1=A8=E5=A4=B4=E6=8E=A2=E6=B5=8B=20+?= =?UTF-8?q?=20=E4=BD=93=E6=A3=80=E6=8A=A5=E5=91=8A=20+=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6/=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计划里排第一的一步。顺序不是拍的,是依赖方向: 没有名册,门禁匹配不到任何人 → 学生进不来;而名册此前只能靠 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/):上传 → 表头确认 → 体检报告 (错行定位、红黄区分)→ 名册列表。做完它,任务发布就能在界面上走通了。 --- AGENTS.md | 147 ++++ .../module/mp/service/MpRosterService.java | 35 +- .../roster/controller/RosterController.java | 71 ++ .../module/roster/dto/RosterImportReport.java | 133 ++++ .../fill/module/roster/dto/RosterVO.java | 99 +++ .../roster/mapper/CollectRosterMapper.java | 116 +++ .../roster/service/RosterColumnCatalog.java | 153 ++++ .../roster/service/RosterImportService.java | 701 ++++++++++++++++++ .../module/roster/service/RosterService.java | 169 +++++ .../fill/support/IdentityNormalizer.java | 88 +++ docs/交接摘要.md | 147 ++++ scripts/e2e-m2-roster.py | 581 +++++++++++++++ 12 files changed, 2413 insertions(+), 27 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterImportReport.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterColumnCatalog.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/support/IdentityNormalizer.java create mode 100644 scripts/e2e-m2-roster.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..547b49a --- /dev/null +++ b/AGENTS.md @@ -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=&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` diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java index d2a5796..1d2842f 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java @@ -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 hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash); + String hash = hmacIndex.hash(idCard); List 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}**,门禁与名册导入共用同一份实现。 * - *

为什么要转全角:学生用中文输入法打英文数字时很容易带出全角, - * 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"—— - * 而肉眼看这两个字长得几乎一样,是最容易让人放弃的一种失败。 + *

原来这里是私有方法,导入功能要落地时才发现"两边各写一份规范化"是迟早要出事的设计 —— + * 详见 {@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 次/分钟。 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java new file mode 100644 index 0000000..aa09c8e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/controller/RosterController.java @@ -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)。 + * + *

权限口径与契约一致:导入是管理动作({@code roster:manage}), + * 查看是管理 + 教师({@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 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> 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)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterImportReport.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterImportReport.java new file mode 100644 index 0000000..16c740b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterImportReport.java @@ -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)。 + * + *

为什么导入接口必须回这么多东西

+ * + *

名册是准入名单:它决定了谁能进来填报。导错一行的后果不是"少收一份材料", + * 而是有人被挡在门外、有人能替别人提交。所以导入不能只回"成功 28 条",必须回: + * + *

    + *
  • 表头在第几行、候选有哪些 —— 实测两份真实文件的表头分别在第 1 行和第 6 行, + * 系统猜错了用户得能一眼看出来并手动指定;
  • + *
  • 每一列映射到了哪个字段 —— 只回行号不够,用户没法判断"有没有整列错位";
  • + *
  • 逐行的错误与警告(行号 + 列字母 + 原值 + 原因) —— + * 用户拿着"第 12 行 C 列"就能直接去改 Excel,而不是一行行自己找。
  • + *
+ * + *

部分成功是设计目标:42 行里有 2 行号码写错,不该让另外 40 个人白等, + * 但也不能把那 2 行悄悄丢掉。所以能进的进,进不去的逐行回传原因。 + * + *

{@code dryRun} 模式只体检不落库,是推荐的前端默认行为 —— + * 先让用户确认表头与错行,再正式导入,避免"导进去才发现表头选错"。 + */ +@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; + + /** + * 表头候选(按命中列数从多到少)。 + * + *

只在"有多个候选"时才有意义:出现两个候选说明这一份文件里 + * 有两行都像表头(比如上面还有一行"表头说明"),让用户确认一下比我们猜更可靠。 + */ + private List headerCandidates = new ArrayList<>(); + + /** 表头是否可信(命中 ≥3 个已知列名即为可信)。 */ + private boolean headerConfirmed; + + /** 每一列的映射结果(含未识别的列)。 */ + private List columns = new ArrayList<>(); + + /** 阻断性问题(这些行没有入库)。 */ + private List errors = new ArrayList<>(); + + /** 非阻断问题(这些行入库了,但值得人工看一眼)。 */ + private List 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 原值(证件号一律脱敏,不把完整号码回给前端) + * @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; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterVO.java new file mode 100644 index 0000000..6c1fd3f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/dto/RosterVO.java @@ -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)。 + * + *

⚠️ 证件号与手机号一律只回脱敏值({@code idCardMask} / {@code phoneMask})。 + * 名册列表是管理端最高频的一屏,完整号码一旦进了响应体,就会顺手进缓存、进日志、 + * 进导出文件 —— 而这批数据是几百个学生的身份证号。 + * + *

确实需要完整号码的场景(如审核时要核对)走独立接口并记审计日志, + * 不在"列表"里顺带解决。 + * + *

⚠️ 报考三项({@code occupation} / {@code worktype} / {@code jobLevel}) + * 是 {@code collect_roster} 上的真实列,不是 {@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java new file mode 100644 index 0000000..de10bab --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/mapper/CollectRosterMapper.java @@ -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})。 + * + *

列表/分页走 MP 条件构造器(由租户拦截器补 {@code tenant_id}); + * 按证件号 hash 查与整表替换这几条写成原生 SQL, + * 且 {@code tenant_id} 一律作为显式参数 —— 理由与 {@code MpRosterMapper} 相同: + * 这几条一旦写错就是"跨租户看到别人的名单"或"把别人的名单删了", + * 不该依赖"拦截器应该会加上"。 + */ +public interface CollectRosterMapper extends BaseMapper { + + /** + * 取这个任务下所有名册项的证件号 hash。 + * + *

导入时用它一次判重,避免"逐行查一次库"(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 selectAllHashes(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId); + + /** + * 按证件号 hash 找名册项(APPEND 合并时定位要更新哪一行)。 + * + *

{@code LIMIT 2}:同一个任务下出现两条同证件号说明名册本身已经脏了, + * 这时不能随便挑一条更新 —— 取 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 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); + + /** + * 任务下"已经有人用上"的名册行数:已绑定或已提交。 + * + *

{@code REPLACE} 模式必须先问这一句:整表替换会把这些行删掉, + * 而删掉一个已绑定的名册项 = 那个学生的材料挂到了空的名单行上, + * 后果是他下次扫码进不来、审核时也说不清这份材料该算谁的。 + */ + @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} 模式的整表清理(软删)。 + * + *

⚠️ 条件里的 {@code bind_user_id IS NULL AND submit_status = 0} 不是多余的: + * 调用方虽然已经查过 {@code countInUse},但"查"和"删"之间有人扫码绑定的话, + * 那一行会被这里挡住。调用方据影响行数与预期的行数比对, + * 不一致就整笔回滚 —— 这是"先查后写"与"一次原子写"的区别。 + * + * @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。 + * + *

不用联表:联表会让分页的 count 查询也带上 join,而这里的数据量不值得。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT id, org_name FROM sys_org + WHERE tenant_id = #{tenantId} AND deleted = 0 + """) + List> selectOrgNames(@Param("tenantId") Long tenantId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterColumnCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterColumnCatalog.java new file mode 100644 index 0000000..65e8007 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterColumnCatalog.java @@ -0,0 +1,153 @@ +package cn.tianbaotong.fill.module.roster.service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 官方《考生线下报名信息导入模板》的 21 列(A~U)与名册字段的对照表。 + * + *

为什么必须做成"表头文本 → 字段"而不是"第几列 → 字段"

+ * + *

第一版 PRD 里默认了"表头在第 1 行、列序固定",而实测的两份真实文件里 + * 表头分别在第 1 行和第 6 行,工作表名也不同。按列号读的实现会把第 6 行那两份 + * 读成一堆空行,而且一声不响 —— 导入完显示"成功 0 条",用户只会以为自己文件坏了。 + * + *

所以对照的锚点是表头文本,列序完全不参与判断。 + * + *

列名要容忍真实的写法差异

+ * + *

表头单元格里常见:换行({@code 报考\n职业})、全角括号、多余空格、 + * 后缀说明({@code 身份证号(必填)})。{@link #normalizeHeader} 会把这些都抹平, + * 再与 {@link #COLUMNS} 里的标题与别名做完全相等匹配 —— + * 不做"包含"匹配,因为 {@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 aliases) { + } + + /** 阻断性字段:缺了它们这一行就没有意义(体检报告里是"阻断该行")。 */ + public static final String FIELD_NAME = "real_name"; + public static final String FIELD_ID_CARD = "id_card_no"; + + /** + * 21 列的完整对照(顺序与官方模板一致,便于人对着 Excel 核)。 + * + *

⚠️ 与 {@code CollectRoster} 的字段、以及 PRD §5.4.1 的官方列清单是三处口径, + * 改动要一起改。字段名写错的表现是"这列永远匹配不上",且不报错。 + */ + public static final List 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 BY_TITLE = buildIndex(); + + /** 表头探测时,"命中几个已知列名"就是这个值。官方模板 21 列。 */ + public static final int OFFICIAL_COLUMN_COUNT = COLUMNS.size(); + + private static Map buildIndex() { + Map 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; + } + + /** + * 表头单元格文本归一化:抹平换行、空格、全角、括号与后缀星号/说明。 + * + *

不抹平的话,真实的 {@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(); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java new file mode 100644 index 0000000..442eeb8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterImportService.java @@ -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。 + * + *

为什么这份代码里"猜"的比重这么大

+ * + *

因为它要对付的是真实的、别人家的 Excel:实测两份真实文件里, + * 表头分别在第 1 行和第 6 行、工作表名不同、列名写法也有出入(还有带换行的表头)。 + * 写死"表头在第 1 行、第 3 列是姓名"的实现不会报错,只会导进来一堆空行 —— + * 然后用户以为自己文件坏了。 + * + *

所以:能猜的都猜,猜完把猜测摊开给用户看(表头在第几行、每个候选命中几列、 + * 每列映射到哪),猜不中就给一条明确的出路(手动指定 {@code headerRow})。 + * + *

体检的两级:阻断 vs 警告

+ * + *

阻断的那几项(姓名/证件号缺失、格式非法、校验位不通过、重复)的共同点是 + * 这一行没有意义,或指向了错误的人。名册是准入名单,放一行错的进去不是 + * "少收一份材料",而是 有人被挡在门外、有人能替别人交 —— 宁可少进,不可错进。 + * + *

而"报考职业为空""手机号格式不对"不影响"这个人是谁",进库之后还能补 —— + * 给警告即可,不该让整批人为它重导一次。 + */ +@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 YES = Set.of("是", "Y", "YES", "TRUE", "1", "有", "在校"); + private static final Set 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 colOfField = new LinkedHashMap<>(); + List columns = new ArrayList<>(); + List extraCols = new ArrayList<>(); + List 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 existingHashes = replace + ? new HashSet<>() + : new HashSet<>(rosterMapper.selectAllHashes(tenantId, taskId)); + Map seenInFile = new HashMap<>(); + List pending = new ArrayList<>(); + int total = 0; + + for (int i = find.index() + 1; i < sheet.rows().size(); i++) { + Map 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, 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 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> 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, "文件读取失败,请重试"); + } + } + + /** + * 选工作表并整表读进来。 + * + *

⚠️ {@code headRowNumber(0)} 与 {@code ignoreEmptyRow(false)} 都不能省: + *

    + *
  • 前者:不把任何一行当表头,整表当数据读,表头行由我们自己找 —— + * 否则 EasyExcel 会替我们认定"第 1 行是表头",而真实文件里它不是。
  • + *
  • 后者:EasyExcel 默认跳过空行,那会让行号与真实 Excel 错位 —— + * 报告里说"第 12 行有问题",用户翻到第 12 行却是空的,整份报告就没人信了。 + * 行号必须能直接对着 Excel 用。
  • + *
+ */ + private SheetData readSheet(byte[] bytes, String wantedName) { + List 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> rows = readRows(bytes, s); + if (!rows.isEmpty()) { + return new SheetData(s.getSheetName(), rows); + } + } + throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何内容"); + } + + private List> readRows(byte[] bytes, ReadSheet sheet) { + List> 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 candidates) { + } + + /** + * 找表头行:扫前 {@link #HEADER_SCAN_ROWS} 行,选「命中已知列名最多」的那一行。 + * + *

并列时取最靠上的:靠上的那行更可能是真表头(后面出现的往往是说明行, + * 或者是第二张表的表头)。多个候选一并回传让用户确认 —— 猜错了要能看出来。 + * + * @param forced 用户手动指定的行号(1 起);非空则直接用它(但仍算出命中数) + */ + private HeaderFind findHeader(List> 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 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 cells) { + if (cells == null) { + return 0; + } + int score = 0; + for (String v : cells.values()) { + if (RosterColumnCatalog.fieldOf(v) != null) { + score++; + } + } + return score; + } + + /** + * 建立「字段 → 列号」映射。 + * + *

未识别的列不丢:只要它有表头,就把整列塞进 {@code ext_json}(列名做键)。 + * 机构自己的表里常有"备注""推荐人"这类列,丢掉意味着用户再也找不回来, + * 而留着几乎不花成本。 + * + *

同一字段被两列命中时先到先得,后面那列计为未识别并进 ext_json —— + * 这样报告里能看出"有两列都像证书等级",而不是静默丢掉一列。 + */ + private void buildColumns(Map header, Map colOfField, + List columns, + List extraCols, List extraKeys) { + if (header == null) { + return; + } + List keys = new ArrayList<>(header.keySet()); + keys.sort(Comparator.naturalOrder()); + Set 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 cells, + Map colOfField, List extraCols, + List extraKeys, Long orgId, RosterImportReport report, + Map seenInFile, List 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 cells, Integer col) { + return col == null || cells == null ? null : cells.get(col); + } + + private static boolean isBlankRow(Map 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 的日期序列号。 + * + *

认这么多写法的理由:官方模板要求 {@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 cells, List extraCols, + List extraKeys) { + if (extraCols.isEmpty()) { + return null; + } + Map 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(); + } + + /** 被阻断的行数(一行可能报两条,比如姓名和证件号都空)。 */ + 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, "任务不存在或已删除"); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterService.java new file mode 100644 index 0000000..bdf4a4f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/service/RosterService.java @@ -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)。 + * + *

⚠️ 响应里一律只有脱敏证件号。名册列表是管理端最高频的一屏, + * 完整号码一旦进了响应体,就会顺手进浏览器缓存、进日志、进导出文件 —— + * 而这批数据是几百个学生的身份证号。真要核对完整号码时走独立接口并记审计日志, + * 不在"列表"里顺带解决。 + */ +@Service +@RequiredArgsConstructor +public class RosterService { + + private final CollectRosterMapper rosterMapper; + private final SensitiveCodec codec; + + /** + * 名册分页。 + * + * @param keyword 姓名模糊;如果填的是一整个身份证号,则按号码精确搜 + * @param submitStatus {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回 + * @param bindStatus {@code 0} 未绑定 / {@code 1} 已绑定 + */ + public PageResult list(Long taskId, String keyword, Long orgId, + Integer submitStatus, Integer bindStatus, + int pageNum, int pageSize) { + LoginUser me = UserContext.require(); + + LambdaQueryWrapper q = Wrappers.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 page = rosterMapper.selectPage(new Page<>(pageNum, pageSize), q); + Map orgNames = orgNames(me.tenantId()); + + List list = new ArrayList<>(page.getRecords().size()); + for (CollectRoster r : page.getRecords()) { + list.add(toVo(r, orgNames)); + } + return PageResult.of(list, page.getTotal(), pageNum, pageSize); + } + + /** + * 关键字: + *

    + *
  • 整串是一个身份证号 → 按 hash 等值搜(密文没法 LIKE,这是加密的必然代价)
  • + *
  • 其它 → 按姓名模糊搜
  • + *
+ * + *

两种一起用 {@code or}:用户可能贴一段既不像名字也不像号码的东西, + * 那时两条都不命中,返回空列表即可 —— 不该因此报错。 + */ + private void applyKeyword(LambdaQueryWrapper 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 orgNames(Long tenantId) { + Map m = new HashMap<>(); + for (Map 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 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; + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/IdentityNormalizer.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/IdentityNormalizer.java new file mode 100644 index 0000000..211bd54 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/IdentityNormalizer.java @@ -0,0 +1,88 @@ +package cn.tianbaotong.fill.support; + +import cn.tianbaotong.fill.common.util.IdCardUtil; + +/** + * 「同一个人的同一个字段」的规范化口径 —— 门禁核验与名册导入必须共用这一个实现。 + * + *

为什么必须共用(这不是洁癖)

+ * + *

门禁是按 {@code id_card_hash}(HMAC)等值查名册的,而 HMAC 的输入是 + * 规范化之后的字符串。于是: + * + *

+ *   导入时算 hash 用的规范化  ≠  门禁算 hash 用的规范化
+ *        ↓
+ *   同一个号码算出的 hash 不同 → 名册里明明有这个人,却查不到
+ *        ↓
+ *   学生看到的是「我填的跟老师给的一模一样,却说我信息不对」
+ * 
+ * + *

这类 bug 最难受的地方是:两处代码各自看都是对的, + * 只有放在一起才错;而症状会把人引向"学生是不是填错了",越查越远。 + * 所以规范化只留一个入口,两条路都调它。 + * + *

另一个必须统一的点是全角:中文输入法下打数字很容易出全角 + * ({@code 123}),而 Excel 里存的是半角({@code 123})。 + * 不转的话两者"长得一模一样"却匹配不上 —— 这是最容易让人放弃的一种失败。 + * + *

⚠️ 改动本类会同时影响门禁与导入,且会改变已入库数据的匹配结果: + * 加宽松(如这里补的全角转换)只会让以前匹配不上的现在能匹配上,是安全的; + * 收紧(去掉某一步)会让已有名册的学生突然进不来,属于破坏性改动。 + */ +public final class IdentityNormalizer { + + private IdentityNormalizer() { + } + + /** + * 姓名规范化:去所有空白(含全角空格)+ 全角 ASCII 转半角。 + * + *

不去标点、不去特殊字符:姓名里的间隔号({@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} 与之统一)。 + * + *

顺序不能反:先转半角再交给 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; + } +} diff --git a/docs/交接摘要.md b/docs/交接摘要.md index af8b4ac..fb147f8 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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 的完整形态)。 diff --git a/scripts/e2e-m2-roster.py b/scripts/e2e-m2-roster.py new file mode 100644 index 0000000..d908eeb --- /dev/null +++ b/scripts/e2e-m2-roster.py @@ -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 行、工作表名不同、 + 列名写法有出入。所以夹具刻意把表头放在第 6 行,前面塞标题行与空行 —— + 写死"第 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)