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:
+147
@@ -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 的完整形态)。
|
||||
|
||||
Reference in New Issue
Block a user