feat(roster): 名册导入(M2-01/02/03 后端)—— 表头探测 + 体检报告 + 合并/替换

计划里排第一的一步。顺序不是拍的,是依赖方向:
没有名册,门禁匹配不到任何人 → 学生进不来;而名册此前只能靠 SQL 夹具造。
(做了这一步,下一步的前端一上,任务发布就能在界面上走通。)

一、新增(module/roster/)
   RosterColumnCatalog   官方 21 列(A~U) ↔ 名册字段 对照 + 别名 + 表头文本归一化
   RosterImportService   表头探测 + 行解析 + 体检 + 落库(APPEND / REPLACE)
   RosterService         分页(姓名模糊 / 身份证号精确 / 班级 / 状态)
   RosterImportReport    体检报告(严格照 §8.1 的 JSON)
   RosterVO              名册项(证件号与手机号一律脱敏)
   CollectRosterMapper   批量判重 / 按 hash 定位 / 整表替换(都带显式 tenant_id)
   RosterController      POST /tasks/{id}/roster/import、GET /tasks/{id}/roster
   权限码现成且口径天然合上契约:导入 roster:manage(仅管理员),
   查看 roster:view(管理员+班主任+审核员)

二、先修了一个隐患:规范化被抄成两份就会出事
   门禁是按 id_card_hash(HMAC)等值查名册的,而 HMAC 的输入是**规范化后**的字符串。
   原来 normalizeName 是 MpRosterService 的私有方法,导入再抄一份的话,
   两处只要有一步不同 → 同一号码算出不同 hash → **名册里明明有这个人却查不到**,
   而症状会把人引向"学生是不是填错了"。已抽成 support/IdentityNormalizer
   (name/idCard),门禁与导入共用;并注明:加宽松(本次补的全角转换)安全,
   收紧是破坏性的(已有名册的学生会突然进不来)。
   验证不是"读代码比对",而是**用真门禁去核验导入进来的名册**(E2E 第 3 节)。

三、几个刻意的决定
   · dryRun 默认 true —— 不显式要求就不写库
   · ignoreEmptyRow(false) + 行号与 Excel 对齐 —— EasyExcel 默认跳过空行会让行号错位,
     报告说"第 12 行有问题"而用户翻到第 12 行是空的,整份报告就没人信了
   · 未识别的列进 ext_json(列名做键)—— "备注""推荐人"这类列不该被丢掉
   · 名册上的 phone 存脱敏值(不是密文):名册不需要回放完整号码
   · REPLACE 在已有人核验/提交时直接拒绝(40029)—— 否则那个学生的材料
     会挂到空名单上,他下次扫码进不来
   · APPEND 命中已绑定/已提交的行 → 跳过不覆盖(ROW_LOCKED):
     「报考职业」是锁定字段,盖掉等于把学生已确认的材料变成另一种认定
   · 校验位不通过**阻断且不自动改** —— 与"文件名里的号码"策略相反:
     名册是权威基准,改它等于我们替机构编了个身份证号

四、E2E:scripts/e2e-m2-roster.py → 58/58
   夹具刻意把**表头放在第 6 行**、第一张表全空白、表头带换行、另加一列"备注"。
   逐条验:4 个阻断行 + 2 个警告行的**行号与 code 都对**、dryRun 不落库、
   密文/脱敏值/ext_json、**完整身份证号能精确搜**(密文搜不了 LIKE)、
   响应里不得出现完整证件号、重复导入合并不新增、REPLACE 被拒后名册没被破坏、
   40028/40017 两条兜底出路。
   ⭐ 第 3 节用**真门禁接口**核验导入的名册 —— 导入与门禁的 hash 口径一致才算过。

   **本次 E2E 抓到一个真 bug**:write() 里"已存在则更新"的判断写反了
   (写成"不在 existingHashes 里才去查"),于是重复导入会插重复行,
   撞唯一约束 uk_task_idcard → 50000。也就是"第一次能用、第二次就炸",
   而重导一次正是最自然的动作。已修 + 在代码里点名这个坑。

五、又发现一处文档与代码不一致(以代码为准)
   接口设计 §3.6 把报考三项的"↔列"写成 ext_json,而建表脚本里它们是真实列。
   按 §3.6 实现的话,门禁回填(读真实列)会永远给学生空值。
   已在 RosterVO 类注释写明。

六、回归
   全量 613 条断言、0 失败(新增 58;门禁 29 照旧通过 → 规范化抽取没改变门禁行为)
   verify-docs.py 19 项全通过

七、下一步(第 2 步)
   名册导入前端(frontend-pc/src/views/task/):上传 → 表头确认 → 体检报告
   (错行定位、红黄区分)→ 名册列表。做完它,任务发布就能在界面上走通了。
This commit is contained in:
2026-09-29 17:00:21 +08:00
parent 66e4ccade9
commit fdfe21345a
12 changed files with 2413 additions and 27 deletions
@@ -11,6 +11,7 @@ import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.common.crypto.HmacIndex;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -75,8 +76,8 @@ public class MpRosterService {
rateLimit(userId, clientIp);
String name = normalizeName(req.getRealName());
String idCard = IdCardUtil.normalize(req.getIdCardNo());
String name = IdentityNormalizer.name(req.getRealName());
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
Long taskCount = rosterMapper.countTask(tenantId, req.getTaskId());
if (taskCount == null || taskCount == 0) {
@@ -93,8 +94,7 @@ public class MpRosterService {
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "号码格式不对");
}
String hash = hmacIndex.hash(idCard);
List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
String hash = hmacIndex.hash(idCard); List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
if (hits.isEmpty()) {
// ⚠️ 日志只记 hash 与 uid,不记明文与姓名
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(),
@@ -110,7 +110,7 @@ public class MpRosterService {
}
CollectRoster row = hits.get(0);
if (!name.equals(normalizeName(row.getRealName()))) {
if (!name.equals(IdentityNormalizer.name(row.getRealName()))) {
// 姓名不一致:与"没查到"给**同一句提示**,见类注释第 1 条
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "姓名与名册不一致");
}
@@ -147,30 +147,11 @@ public class MpRosterService {
}
/**
* 姓名规范化:去所有空白 + 全角转半角。
* 姓名规范化:**已抽到 {@link IdentityNormalizer}**,门禁与名册导入共用同一份实现。
*
* <p>为什么要转全角:学生用中文输入法打英文数字时很容易带出全角,
* 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"——
* 而肉眼看这两个字<b>长得几乎一样</b>,是最容易让人放弃的一种失败。
* <p>原来这里是私有方法,导入功能要落地时才发现"两边各写一份规范化"是迟早要出事的设计 ——
* 详见 {@code IdentityNormalizer} 的类注释。
*/
static String normalizeName(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
if (Character.isWhitespace(c) || c == '\u3000') {
continue;
}
// 全角 ASCII(U+FF01~U+FF5E)→ 半角(U+0021~U+007E)
if (c >= '\uFF01' && c <= '\uFF5E') {
sb.append((char) (c - 0xFEE0));
} else {
sb.append(c);
}
}
return sb.toString();
}
/**
* 限流:同 openid 10 次/小时、同 IP 20 次/分钟。
@@ -0,0 +1,71 @@
package cn.tianbaotong.fill.module.roster.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
import cn.tianbaotong.fill.module.roster.dto.RosterVO;
import cn.tianbaotong.fill.module.roster.service.RosterImportService;
import cn.tianbaotong.fill.module.roster.service.RosterService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 名册(《03-接口设计》§8)。
*
* <p>权限口径与契约一致:<b>导入是管理动作</b>({@code roster:manage}),
* <b>查看是管理 + 教师</b>({@code roster:view})—— 班主任要看得到自己班的名单,
* 但不该能把整份名册换掉。
*/
@Tag(name = "名册")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class RosterController {
private final RosterImportService importService;
private final RosterService rosterService;
@PostMapping(value = "/tasks/{id}/roster/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.ROSTER_MANAGE)
@Operation(summary = "导入名册(返回体检报告)",
description = "自动探测表头行与列映射(实测真实文件的表头在第 1 行或第 6 行),"
+ "返回逐行的错误与警告。dryRun=true(默认)只体检不落库 —— "
+ "先让用户确认表头与错行,再正式导入,避免导进去才发现表头选错")
public ApiResult<RosterImportReport> importRoster(
@PathVariable Long id,
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) Integer headerRow,
@RequestParam(required = false) String sheetName,
@RequestParam(required = false) String mode,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(required = false) Long orgId) {
return ApiResult.ok(importService.importExcel(id, file, headerRow, sheetName, mode, dryRun, orgId));
}
@GetMapping("/tasks/{id}/roster")
@RequirePerm(PermissionDef.ROSTER_VIEW)
@Operation(summary = "名册分页",
description = "证件号与手机号一律只回脱敏值;keyword 填一整个身份证号时按号码精确搜")
public ApiResult<PageResult<RosterVO>> list(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) Integer submitStatus,
@RequestParam(required = false) Integer bindStatus,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return ApiResult.ok(rosterService.list(id, keyword, orgId, submitStatus, bindStatus,
pageNum, pageSize));
}
}
@@ -0,0 +1,133 @@
package cn.tianbaotong.fill.module.roster.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 名册导入的「体检报告」(《03-接口设计》§8.1)。
*
* <h3>为什么导入接口必须回这么多东西</h3>
*
* <p>名册是<b>准入名单</b>:它决定了谁能进来填报。导错一行的后果不是"少收一份材料",
* 而是<b>有人被挡在门外、有人能替别人提交</b>。所以导入不能只回"成功 28 条",必须回:
*
* <ul>
* <li><b>表头在第几行、候选有哪些</b> —— 实测两份真实文件的表头分别在第 1 行和第 6 行,
* 系统猜错了用户得能一眼看出来并手动指定;</li>
* <li><b>每一列映射到了哪个字段</b> —— 只回行号不够,用户没法判断"有没有整列错位";</li>
* <li><b>逐行的错误与警告(行号 + 列字母 + 原值 + 原因)</b> ——
* 用户拿着"第 12 行 C 列"就能直接去改 Excel,而不是一行行自己找。</li>
* </ul>
*
* <p>部分成功是<b>设计目标</b>:42 行里有 2 行号码写错,不该让另外 40 个人白等,
* 但也不能把那 2 行悄悄丢掉。所以能进的进,进不去的逐行回传原因。
*
* <p>{@code dryRun} 模式只体检不落库,是<b>推荐的前端默认行为</b> ——
* 先让用户确认表头与错行,再正式导入,避免"导进去才发现表头选错"。
*/
@Getter
@Setter
@Schema(description = "名册导入体检报告")
public class RosterImportReport {
/** 数据行总数(不含表头行与空行)。 */
private int total;
/** 成功入库的行数({@code dryRun} 时 = "可入库的行数")。 */
private int success;
/** 被阻断的行数。 */
private int failed;
/** 识别到的表头所在行号(从 1 开始)。 */
private int headerRow;
/** 实际读取的工作表名(多张表时取第一个有数据的)。 */
private String sheetName;
/**
* 表头候选(按命中列数从多到少)。
*
* <p>只在"有多个候选"时才有意义:出现两个候选说明这一份文件里
* 有两行都像表头(比如上面还有一行"表头说明"),让用户确认一下比我们猜更可靠。
*/
private List<HeaderCandidate> headerCandidates = new ArrayList<>();
/** 表头是否可信(命中 ≥3 个已知列名即为可信)。 */
private boolean headerConfirmed;
/** 每一列的映射结果(含未识别的列)。 */
private List<ColumnReport> columns = new ArrayList<>();
/** 阻断性问题(这些行没有入库)。 */
private List<RowIssue> errors = new ArrayList<>();
/** 非阻断问题(这些行入库了,但值得人工看一眼)。 */
private List<RowIssue> warnings = new ArrayList<>();
private Summary summary = new Summary();
/** 本次是否只体检(true = 没有写库)。 */
private boolean dryRun;
/** 生效的合并方式:{@code APPEND} / {@code REPLACE}。 */
private String mode;
/** 新增行数。 */
private int created;
/** 更新行数(APPEND 模式下按证件号命中已有行时更新,不新插一行)。 */
private int updated;
/** 移除行数({@code REPLACE} 模式下软删掉的旧行数)。 */
private int removed;
/** 表头候选:行号 + 命中的已知列数。 */
public record HeaderCandidate(int row, int score) {
}
/**
* 一列的映射结果。
*
* @param col 列字母({@code A}~),用户能对着 Excel 找
* @param title 表头原文
* @param mappedTo 落到哪:官方字段名,或 {@code ext_json.xxx}(原样存进扩展 JSON);null = 忽略该列
* @param matched 是否识别为官方 21 列之一
*/
public record ColumnReport(String col, String title, String mappedTo, boolean matched) {
}
/**
* 一处问题。
*
* @param row 行号(Excel 里的真实行号,从 1 开始)
* @param col 列字母(与字段无关的问题为 null)
* @param field 字段名
* @param value 原值(<b>证件号一律脱敏</b>,不把完整号码回给前端)
* @param reason 人话原因
* @param code 机器码({@code IDCARD_CHECKSUM} / {@code DUP_IDCARD} …)
*/
public record RowIssue(int row, String col, String field, String value, String reason, String code) {
}
/** 汇总计数:界面上那排小字。 */
@Getter
@Setter
@Schema(description = "导入体检汇总")
public static class Summary {
/** 去重后的人数(= 能进来的实际人数)。 */
private int uniquePersons;
/** 重复行数(文件内重复 + 与已有名册重复)。 */
private int dupRows;
/** 证件号校验位不通过的行数。 */
private int idcardInvalid;
/** 姓名可疑(含罕见字)的行数。 */
private int nameSuspect;
/** 手机号格式异常的行数。 */
private int phoneInvalid;
}
}
@@ -0,0 +1,99 @@
package cn.tianbaotong.fill.module.roster.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 名册项(《03-接口设计》§3.6)。
*
* <p>⚠️ <b>证件号与手机号一律只回脱敏值</b>({@code idCardMask} / {@code phoneMask})。
* 名册列表是管理端最高频的一屏,完整号码一旦进了响应体,就会顺手进缓存、进日志、
* 进导出文件 —— 而这批数据是几百个学生的身份证号。
*
* <p>确实需要完整号码的场景(如审核时要核对)走独立接口并记审计日志,
* 不在"列表"里顺带解决。
*
* <p>⚠️ 报考三项({@code occupation} / {@code worktype} / {@code jobLevel})
* 是 {@code collect_roster} 上的<b>真实列</b>,不是 {@code ext_json} 里的值 ——
* 接口设计文档 §3.6 的这一行写的是 {@code ext_json},与建表脚本不一致,**以代码为准**。
*/
@Getter
@Setter
@Schema(description = "名册项")
public class RosterVO {
private Long id;
private Integer seqNo;
private String studentNo;
private String realName;
/** 脱敏证件号。 */
private String idCardMask;
/** 脱敏手机号。 */
private String phoneMask;
private Long orgId;
private String orgName;
// ── 预填列:老师已填好,学生端只核对不重填 ──────────
private String occupation;
private String worktype;
private String jobLevel;
private String education;
/** 是否在校生:{@code 0} 否 / {@code 1} 是。 */
private Integer isStudent;
private String school;
private String major;
private LocalDate enrollDate;
private String workUnit;
private Integer isRetake;
private String retakeTheory;
private String retakePractice;
private String retakeOverall;
private Integer workYears;
private String certOccupation;
private String certLevel;
private String certNo;
/** 导入时未识别为官方列的其它列(原样保留,不参与统计)。 */
private Object extJson;
// ── 状态 ──────────────────────────────────────
/** {@code 0} 未绑定 / {@code 1} 已绑定。 */
private Integer bindStatus;
private LocalDateTime bindTime;
/** {@code 1} 门禁核验匹配 / {@code 2} 管理员指定。 */
private Integer bindType;
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
private Integer submitStatus;
}
@@ -0,0 +1,116 @@
package cn.tianbaotong.fill.module.roster.mapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* 名册({@code collect_roster})。
*
* <p>列表/分页走 MP 条件构造器(由租户拦截器补 {@code tenant_id});
* <b>按证件号 hash 查</b>与<b>整表替换</b>这几条写成原生 SQL,
* 且 {@code tenant_id} 一律作为<b>显式参数</b> —— 理由与 {@code MpRosterMapper} 相同:
* 这几条一旦写错就是"跨租户看到别人的名单"或"把别人的名单删了",
* 不该依赖"拦截器应该会加上"。
*/
public interface CollectRosterMapper extends BaseMapper<CollectRoster> {
/**
* 取这个任务下所有名册项的证件号 hash。
*
* <p>导入时用它一次判重,避免"逐行查一次库"(5000 行就是 5000 次往返)。
* 只取 hash(不可逆),不取密文 —— 判重不需要知道号码是什么。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT id_card_hash FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
""")
List<String> selectAllHashes(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* 按证件号 hash 找名册项(APPEND 合并时定位要更新哪一行)。
*
* <p>{@code LIMIT 2}:同一个任务下出现两条同证件号说明名册本身已经脏了,
* 这时<b>不能随便挑一条更新</b> —— 取 2 条让调用方发现并报错,
* 而不是把错的那个人的资料改掉。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND id_card_hash = #{idCardHash} AND deleted = 0
LIMIT 2
""")
List<CollectRoster> selectByHash(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("idCardHash") String idCardHash);
/** 任务下现有名册行数(含已绑定/已提交)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
""")
long countByTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* 任务下"已经有人用上"的名册行数:已绑定或已提交。
*
* <p>{@code REPLACE} 模式必须先问这一句:整表替换会把这些行删掉,
* 而<b>删掉一个已绑定的名册项 = 那个学生的材料挂到了空的名单行上</b>,
* 后果是他下次扫码进不来、审核时也说不清这份材料该算谁的。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
AND (bind_user_id IS NOT NULL OR submit_status <> 0)
""")
long countInUse(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/**
* {@code REPLACE} 模式的整表清理(软删)。
*
* <p>⚠️ 条件里的 {@code bind_user_id IS NULL AND submit_status = 0} <b>不是多余的</b>:
* 调用方虽然已经查过 {@code countInUse},但"查"和"删"之间有人扫码绑定的话,
* 那一行会被这里挡住。调用方据<b>影响行数</b>与预期的行数比对,
* 不一致就整笔回滚 —— 这是"先查后写"与"一次原子写"的区别。
*
* @return 影响行数
*/
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE collect_roster SET deleted = 1, update_time = now()
WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND deleted = 0
AND bind_user_id IS NULL AND submit_status = 0
""")
int softDeleteUnbound(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
/** 组织是否存在且属于该租户(导入时指定班级要先验一下,别把名册挂到别的租户的班上)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM sys_org
WHERE id = #{orgId} AND tenant_id = #{tenantId} AND deleted = 0
""")
long countOrg(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
/**
* 名册列表要显示班级名 —— 一次取全租户的组织(一所学校几十个节点),
* 在内存里做映射,避免"每行查一次"的 N+1。
*
* <p>不用联表:联表会让分页的 count 查询也带上 join,而这里的数据量不值得。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT id, org_name FROM sys_org
WHERE tenant_id = #{tenantId} AND deleted = 0
""")
List<Map<String, Object>> selectOrgNames(@Param("tenantId") Long tenantId);
}
@@ -0,0 +1,153 @@
package cn.tianbaotong.fill.module.roster.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 官方《考生线下报名信息导入模板》的 <b>21 列(A~U)</b>与名册字段的对照表。
*
* <h3>为什么必须做成"表头文本 → 字段"而不是"第几列 → 字段"</h3>
*
* <p>第一版 PRD 里默认了"表头在第 1 行、列序固定",而<b>实测的两份真实文件里
* 表头分别在第 1 行和第 6 行,工作表名也不同</b>。按列号读的实现会把第 6 行那两份
* 读成一堆空行,而且<b>一声不响</b> —— 导入完显示"成功 0 条",用户只会以为自己文件坏了。
*
* <p>所以对照的锚点是<b>表头文本</b>,列序完全不参与判断。
*
* <h3>列名要容忍真实的写法差异</h3>
*
* <p>表头单元格里常见:换行({@code 报考\n职业})、全角括号、多余空格、
* 后缀说明({@code 身份证号(必填)})。{@link #normalizeHeader} 会把这些都抹平,
* 再与 {@link #COLUMNS} 里的标题与别名做<b>完全相等</b>匹配 ——
* 不做"包含"匹配,因为 {@code 证书等级} 是 {@code 已获证书等级} 的子串,
* 包含匹配会让两列落到同一个字段上。
*/
public final class RosterColumnCatalog {
private RosterColumnCatalog() {
}
/**
* 一列:官方标题 + 名册字段 + 常见别名。
*
* @param title 官方模板里的列名
* @param field 名册字段({@code collect_roster} 的蛇形列名)
* @param aliases 真实文件里见过的别的写法
*/
public record Column(String title, String field, List<String> aliases) {
}
/** 阻断性字段:缺了它们这一行就没有意义(体检报告里是"阻断该行")。 */
public static final String FIELD_NAME = "real_name";
public static final String FIELD_ID_CARD = "id_card_no";
/**
* 21 列的完整对照(顺序与官方模板一致,便于人对着 Excel 核)。
*
* <p>⚠️ 与 {@code CollectRoster} 的字段、以及 PRD §5.4.1 的官方列清单是三处口径,
* 改动要一起改。字段名写错的表现是"这列永远匹配不上",且不报错。
*/
public static final List<Column> COLUMNS = List.of(
new Column("序号", "seq_no", List.of("No", "NO", "编号")),
new Column("姓名", FIELD_NAME,
List.of("名字", "学生姓名", "考生姓名", "学员姓名", "姓 名")),
new Column("身份证号", FIELD_ID_CARD,
List.of("身份证", "身份证号码", "身份证件号码", "证件号", "证件号码", "公民身份号码")),
new Column("学历", "education", List.of("文化程度", "学历层次")),
new Column("联系电话", "phone",
List.of("手机号", "手机号码", "手机", "电话", "联系方式", "联系手机")),
new Column("工作单位", "work_unit", List.of("单位", "所在单位", "工作单位名称")),
new Column("报考职业", "occupation", List.of("职业", "申报职业", "报考职业名称")),
new Column("报考工种", "worktype", List.of("工种", "申报工种", "报考工种名称")),
new Column("报考等级", "job_level", List.of("等级", "申报等级", "职业等级", "报考级别")),
new Column("是否补考", "is_retake", List.of("补考", "是否补考(是/否)")),
new Column("补考理论", "retake_theory", List.of("理论补考", "补考理论科目")),
new Column("补考实操", "retake_practice", List.of("实操补考", "补考实操科目")),
new Column("补考综合", "retake_overall", List.of("综合补考", "补考综合科目")),
new Column("是否在校生", "is_student", List.of("在校生", "是否在校", "是否在校生(是/否)")),
new Column("在读学校", "school", List.of("学校", "就读学校", "在读院校")),
new Column("所学专业", "major", List.of("专业", "就读专业", "所学专业名称")),
new Column("入学时间", "enroll_date", List.of("入学日期", "入学年月", "入学年份")),
new Column("本职业工作年限", "work_years",
List.of("工作年限", "从业年限", "本职业年限", "从事本职业年限")),
new Column("已获证书职业工种", "cert_occupation",
List.of("证书职业工种", "已获证书职业", "已获职业工种")),
new Column("已获证书等级", "cert_level", List.of("证书等级", "已获等级")),
new Column("已获证书编号", "cert_no", List.of("证书编号", "证书号", "已获证书号"))
);
/** 归一化后的标题/别名 → 字段。构建一次,查表用。 */
private static final Map<String, String> BY_TITLE = buildIndex();
/** 表头探测时,"命中几个已知列名"就是这个值。官方模板 21 列。 */
public static final int OFFICIAL_COLUMN_COUNT = COLUMNS.size();
private static Map<String, String> buildIndex() {
Map<String, String> m = new LinkedHashMap<>();
for (Column c : COLUMNS) {
m.put(normalizeHeader(c.title()), c.field());
for (String a : c.aliases()) {
// putIfAbsent:别名撞车时以"先登记的那个"为准,保持确定性(不要靠 Map 的迭代顺序)
m.putIfAbsent(normalizeHeader(a), c.field());
}
}
return m;
}
/**
* 表头单元格文本归一化:抹平换行、空格、全角、括号与后缀星号/说明。
*
* <p>不抹平的话,真实的 {@code 报考\n职业}、{@code 身份证号(必填)}、
* {@code *姓名} 都匹配不上 —— 而这三样在真实表格里都出现过。
*/
public static String normalizeHeader(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
// 空白(含换行、全角空格)一律丢掉:表头里的换行是排版,不是名字的一部分
if (Character.isWhitespace(c) || c == '\u3000') {
continue;
}
char half = (c >= '\uFF01' && c <= '\uFF5E') ? (char) (c - 0xFEE0) : c;
// 括号与分隔符:只用来分组说明,不参与匹配
// (全角括号/冒号已被上面转成半角,所以这里只判半角形态就够)
if (half == '(' || half == ')' || half == '[' || half == ']'
|| half == ':' || half == '、' || half == '*') {
continue;
}
sb.append(half);
}
return sb.toString();
}
/**
* 按表头文本找字段。匹配不上返回 {@code null}(该列不进任何字段,报告里标 {@code matched:false})。
*/
public static String fieldOf(String headerText) {
return BY_TITLE.get(normalizeHeader(headerText));
}
/** 取字段的官方列名(报告里回显用)。 */
public static String titleOf(String field) {
for (Column c : COLUMNS) {
if (c.field().equals(field)) {
return c.title();
}
}
return field;
}
/** 列表头字母({@code 0 → A}):报告里的 {@code col} 用它,用户能对着 Excel 找。 */
public static String columnLetter(int index) {
StringBuilder sb = new StringBuilder();
int n = index;
while (n >= 0) {
sb.insert(0, (char) ('A' + n % 26));
n = n / 26 - 1;
}
return sb.toString();
}
}
@@ -0,0 +1,701 @@
package cn.tianbaotong.fill.module.roster.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.hutool.json.JSONUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelReader;
import com.alibaba.excel.read.metadata.ReadSheet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 名册导入(《03-接口设计》§8.1)—— FR-501 / FR-502。
*
* <h3>为什么这份代码里"猜"的比重这么大</h3>
*
* <p>因为它要对付的是<b>真实的、别人家的 Excel</b>:实测两份真实文件里,
* <b>表头分别在第 1 行和第 6 行、工作表名不同、列名写法也有出入</b>(还有带换行的表头)。
* 写死"表头在第 1 行、第 3 列是姓名"的实现不会报错,只会导进来一堆空行 ——
* 然后用户以为自己文件坏了。
*
* <p>所以:<b>能猜的都猜,猜完把猜测摊开给用户看</b>(表头在第几行、每个候选命中几列、
* 每列映射到哪),猜不中就给一条明确的出路(手动指定 {@code headerRow})。
*
* <h3>体检的两级:阻断 vs 警告</h3>
*
* <p>阻断的那几项(姓名/证件号缺失、格式非法、校验位不通过、重复)的共同点是
* <b>这一行没有意义,或指向了错误的人</b>。名册是准入名单,放一行错的进去不是
* "少收一份材料",而是 <b>有人被挡在门外、有人能替别人交</b> —— 宁可少进,不可错进。
*
* <p>而"报考职业为空""手机号格式不对"不影响"这个人是谁",进库之后还能补 ——
* 给警告即可,不该让整批人为它重导一次。
*/
@Slf4j
@Service
public class RosterImportService {
/** 单次导入行数上限(接口契约:≤5000 行)。 */
private static final int MAX_ROWS = 5000;
/** 上传文件大小上限(接口契约:≤10MB)。 */
private static final long MAX_FILE_BYTES = 10L * 1024 * 1024;
/** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */
private static final int HEADER_SCAN_ROWS = 10;
/** 一行里命中这么多个已知列名,才认为它是表头。 */
private static final int HEADER_MIN_SCORE = 3;
private static final Set<String> YES = Set.of("是", "Y", "YES", "TRUE", "1", "有", "在校");
private static final Set<String> NO = Set.of("否", "N", "NO", "FALSE", "0", "不在校", "非在校");
/** Excel 日期序列号以 1899-12-30 为原点(含 1900 闰年 bug 的既定换算)。 */
private static final LocalDate EXCEL_EPOCH = LocalDate.of(1899, 12, 30);
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final SensitiveCodec codec;
public RosterImportService(CollectRosterMapper rosterMapper, TaskMapper taskMapper,
SensitiveCodec codec) {
this.rosterMapper = rosterMapper;
this.taskMapper = taskMapper;
this.codec = codec;
}
// ── 对外入口 ────────────────────────────────────
/**
* 导入名册,返回体检报告。
*
* @param headerRow 手动指定表头行(1 起);为空则自动探测
* @param sheetName 手动指定工作表;为空则取第一个有数据的表
* @param mode {@code APPEND}(默认,按证件号去重合并)/ {@code REPLACE}
* @param dryRun true = 只体检不落库
* @param orgId 整批归到哪个班级;为空则名册项的 {@code org_id} 留空
*/
@Transactional(rollbackFor = Exception.class)
public RosterImportReport importExcel(Long taskId, MultipartFile file,
Integer headerRow, String sheetName,
String mode, boolean dryRun, Long orgId) {
LoginUser me = UserContext.require();
Long tenantId = me.tenantId();
requireTask(taskId);
if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
}
boolean replace = "REPLACE".equalsIgnoreCase(mode == null ? "" : mode.trim());
SheetData sheet = readSheet(bytesOf(file), sheetName);
// ① 表头
HeaderFind find = findHeader(sheet.rows(), headerRow);
if (find == null) {
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
"没能在《" + sheet.name() + "》的前 " + HEADER_SCAN_ROWS
+ " 行里认出表头(需要「姓名」「身份证号」这类列名),请指定表头行后重试");
}
// ② 列映射
Map<String, Integer> colOfField = new LinkedHashMap<>();
List<RosterImportReport.ColumnReport> columns = new ArrayList<>();
List<Integer> extraCols = new ArrayList<>();
List<String> extraKeys = new ArrayList<>();
buildColumns(sheet.rows().get(find.index()), colOfField, columns, extraCols, extraKeys);
if (!colOfField.containsKey(RosterColumnCatalog.FIELD_NAME)
|| !colOfField.containsKey(RosterColumnCatalog.FIELD_ID_CARD)) {
// 表头找到了、但缺关键列 —— 比"找不到表头"更具体,单独报
throw new BizException(ErrorCode.ROSTER_MISSING_KEY_COLUMN,
"第 " + (find.index() + 1) + " 行像是表头,但里面没有「姓名」或「身份证号」列,请检查后重导");
}
RosterImportReport report = new RosterImportReport();
report.setHeaderRow(find.index() + 1);
report.setSheetName(sheet.name());
report.setHeaderConfirmed(find.score() >= HEADER_MIN_SCORE);
report.setHeaderCandidates(find.candidates());
report.setColumns(columns);
report.setDryRun(dryRun);
report.setMode(replace ? "REPLACE" : "APPEND");
// ③ 逐行解析 + 体检
Set<String> existingHashes = replace
? new HashSet<>()
: new HashSet<>(rosterMapper.selectAllHashes(tenantId, taskId));
Map<String, Integer> seenInFile = new HashMap<>();
List<Pending> pending = new ArrayList<>();
int total = 0;
for (int i = find.index() + 1; i < sheet.rows().size(); i++) {
Map<Integer, String> cells = sheet.rows().get(i);
if (isBlankRow(cells)) {
continue; // 空行不算数据行(真实表格中间常有空行)
}
total++;
if (total > MAX_ROWS) {
throw new BizException(ErrorCode.FILE_TOO_LARGE,
"名册一次最多导入 " + MAX_ROWS + " 行,请拆成多个文件分批导入");
}
parseRow(taskId, i + 1, cells, colOfField, extraCols, extraKeys, orgId,
report, seenInFile, pending);
}
report.setTotal(total);
// ④ 汇总
RosterImportReport.Summary sum = report.getSummary();
sum.setUniquePersons(pending.size());
sum.setDupRows(countErrors(report, "DUP_IDCARD"));
sum.setIdcardInvalid(countErrors(report, "IDCARD_CHECKSUM") + countErrors(report, "IDCARD_FORMAT"));
sum.setPhoneInvalid((int) report.getWarnings().stream()
.filter(w -> "PHONE_INVALID".equals(w.code())).count());
report.setFailed(blockedRowCount(report));
report.setSuccess(pending.size());
// ⑤ 落库(dryRun 时只算"如果真导会怎样",免得导完才发现合并口径不对)
if (dryRun) {
int created = 0;
int updated = 0;
for (Pending p : pending) {
if (existingHashes.contains(p.hash())) {
updated++;
} else {
created++;
}
}
report.setCreated(created);
report.setUpdated(updated);
} else {
write(tenantId, taskId, replace, pending, report);
}
log.info("名册导入{}:task={} 表={} 表头行={} 共{}行 可入库{} 阻断{} 新增{} 更新{} 警告{}",
dryRun ? "(试导)" : "", taskId, sheet.name(), report.getHeaderRow(),
report.getTotal(), report.getSuccess(), report.getFailed(),
report.getCreated(), report.getUpdated(), report.getWarnings().size());
return report;
}
// ── 落库 ────────────────────────────────────────
private void write(Long tenantId, Long taskId, boolean replace,
List<Pending> pending, RosterImportReport report) {
if (replace) {
long inUse = rosterMapper.countInUse(tenantId, taskId);
if (inUse > 0) {
throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
"本任务已有 " + inUse + " 人完成核验或提交,不能整表替换 —— "
+ "那会把他们的名册项删掉,他们下次扫码就进不来了。请改用「合并导入」");
}
long before = rosterMapper.countByTask(tenantId, taskId);
int removed = rosterMapper.softDeleteUnbound(tenantId, taskId);
if (removed != before) {
// 查与删之间有人刚绑定 —— 整笔回滚,让操作者重新做一次决定
throw new BizException(ErrorCode.ROSTER_ITEM_IN_USE,
"替换过程中有人刚完成核验,已中止;请刷新后改用「合并导入」");
}
report.setRemoved(removed);
}
int created = 0;
int updated = 0;
for (Pending p : pending) {
// ⚠️ 这里**必须无条件查一次**,不能只看 existingHashes 里有没有:
// 在就说明"要更新哪一行"还没拿到。写成"不在才查"的话,
// 已存在的行会走到下面的 insert —— 撞上唯一约束 uk_task_idcard,
// 学生看到的是 50000「系统繁忙」,而真正的原因是重复插入。
// (这个 bug 真的写出来过,被 e2e-m2-roster 的"重复导入"一节点名。)
List<CollectRoster> hits = rosterMapper.selectByHash(tenantId, taskId, p.hash());
if (hits.size() > 1) {
// 名册本身已经脏了:不能随便挑一条更新(可能改的是另一个人的资料)
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN,
"名册里这个证件号已有多条重复(第 " + p.excelRow() + " 行),请先清理再导入");
}
CollectRoster exist = hits.isEmpty() ? null : hits.get(0);
if (exist == null) {
rosterMapper.insert(p.row());
created++;
continue;
}
boolean locked = exist.getBindUserId() != null
|| (exist.getSubmitStatus() != null && exist.getSubmitStatus() != 0);
if (locked) {
// 这个人已经在填 / 已填完。**不要**拿新文件的值盖掉他正在用的资料:
// 「报考职业」是锁定字段,改了它等于把学生已确认的材料变成另一种认定。
report.getWarnings().add(new RosterImportReport.RowIssue(p.excelRow(), null,
RosterColumnCatalog.FIELD_ID_CARD, exist.getIdCardMask(),
"这个人已核验或已提交,本次跳过不覆盖(原资料保持不变)", "ROW_LOCKED"));
continue;
}
p.row().setId(exist.getId());
rosterMapper.updateById(p.row());
updated++;
}
report.setCreated(created);
report.setUpdated(updated);
}
// ── 读取 ────────────────────────────────────────
private record SheetData(String name, List<Map<Integer, String>> rows) {
}
private byte[] bytesOf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择要导入的 Excel 文件");
}
if (file.getSize() > MAX_FILE_BYTES) {
throw new BizException(ErrorCode.FILE_TOO_LARGE,
"文件超过 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB,请拆分后导入");
}
try {
return file.getBytes();
} catch (IOException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件读取失败,请重试");
}
}
/**
* 选工作表并整表读进来。
*
* <p>⚠️ {@code headRowNumber(0)} 与 {@code ignoreEmptyRow(false)} <b>都不能省</b>:
* <ul>
* <li>前者:不把任何一行当表头,整表当数据读,表头行由我们自己找 ——
* 否则 EasyExcel 会替我们认定"第 1 行是表头",而真实文件里它不是。</li>
* <li>后者:EasyExcel 默认<b>跳过空行</b>,那会让行号与真实 Excel 错位 ——
* 报告里说"第 12 行有问题",用户翻到第 12 行却是空的,整份报告就没人信了。
* 行号必须能直接对着 Excel 用。</li>
* </ul>
*/
private SheetData readSheet(byte[] bytes, String wantedName) {
List<ReadSheet> sheets;
try (ExcelReader reader = EasyExcel.read(new ByteArrayInputStream(bytes)).build()) {
// 只取"有哪些表",不读内容 —— 内容按下面的顺序逐张读(拿到第一张有数据的就停)
sheets = reader.excelExecutor().sheetList();
} catch (Exception e) {
log.warn("读取导入文件失败:{}", e.getMessage());
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
"无法读取该 Excel,请用 Excel 另存为 .xlsx 后重试");
}
if (sheets.isEmpty()) {
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何工作表");
}
if (wantedName != null && !wantedName.isBlank()) {
String w = wantedName.trim();
for (ReadSheet s : sheets) {
if (s.getSheetName() != null && s.getSheetName().trim().equals(w)) {
return new SheetData(s.getSheetName(), readRows(bytes, s));
}
}
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"文件里没有名为《" + wantedName + "》的工作表,共有:"
+ String.join("、", sheets.stream()
.map(x -> x.getSheetName() == null ? "(未命名)" : x.getSheetName()).toList()));
}
for (ReadSheet s : sheets) {
List<Map<Integer, String>> rows = readRows(bytes, s);
if (!rows.isEmpty()) {
return new SheetData(s.getSheetName(), rows);
}
}
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND, "文件里没有读到任何内容");
}
private List<Map<Integer, String>> readRows(byte[] bytes, ReadSheet sheet) {
List<Map<Integer, String>> rows = EasyExcel
.read(new ByteArrayInputStream(bytes))
// ⚠️ ignoreEmptyRow 是 reader 级的参数,必须在 .sheet(...) 之前给 ——
// 写到 sheet 后面会报"找不到符号",而报错信息不会告诉你"层级错了"
.ignoreEmptyRow(false)
.sheet(sheet.getSheetNo())
.headRowNumber(0)
.doReadSync();
return rows == null ? List.of() : rows;
}
// ── 表头 ────────────────────────────────────────
private record HeaderFind(int index, int score, List<RosterImportReport.HeaderCandidate> candidates) {
}
/**
* 找表头行:扫前 {@link #HEADER_SCAN_ROWS} 行,选「命中已知列名最多」的那一行。
*
* <p>并列时取<b>最靠上</b>的:靠上的那行更可能是真表头(后面出现的往往是说明行,
* 或者是第二张表的表头)。多个候选一并回传让用户确认 —— 猜错了要能看出来。
*
* @param forced 用户手动指定的行号(1 起);非空则直接用它(但仍算出命中数)
*/
private HeaderFind findHeader(List<Map<Integer, String>> rows, Integer forced) {
if (rows == null || rows.isEmpty()) {
return null;
}
if (forced != null) {
int idx = forced - 1;
if (idx < 0 || idx >= rows.size()) {
throw new BizException(ErrorCode.HEADER_ROW_NOT_FOUND,
"指定的表头行(第 " + forced + " 行)超出文件范围(共 " + rows.size() + " 行)");
}
int score = scoreRow(rows.get(idx));
return new HeaderFind(idx, score, List.of(new RosterImportReport.HeaderCandidate(forced, score)));
}
List<RosterImportReport.HeaderCandidate> cands = new ArrayList<>();
int limit = Math.min(HEADER_SCAN_ROWS, rows.size());
for (int i = 0; i < limit; i++) {
int score = scoreRow(rows.get(i));
if (score >= HEADER_MIN_SCORE) {
cands.add(new RosterImportReport.HeaderCandidate(i + 1, score));
}
}
if (cands.isEmpty()) {
return null;
}
cands.sort(Comparator.comparingInt(RosterImportReport.HeaderCandidate::score).reversed()
.thenComparingInt(RosterImportReport.HeaderCandidate::row));
RosterImportReport.HeaderCandidate best = cands.get(0);
return new HeaderFind(best.row() - 1, best.score(), cands);
}
private int scoreRow(Map<Integer, String> cells) {
if (cells == null) {
return 0;
}
int score = 0;
for (String v : cells.values()) {
if (RosterColumnCatalog.fieldOf(v) != null) {
score++;
}
}
return score;
}
/**
* 建立「字段 → 列号」映射。
*
* <p>未识别的列<b>不丢</b>:只要它有表头,就把整列塞进 {@code ext_json}(列名做键)。
* 机构自己的表里常有"备注""推荐人"这类列,丢掉意味着用户再也找不回来,
* 而留着几乎不花成本。
*
* <p>同一字段被两列命中时<b>先到先得</b>,后面那列计为未识别并进 ext_json ——
* 这样报告里能看出"有两列都像证书等级",而不是静默丢掉一列。
*/
private void buildColumns(Map<Integer, String> header, Map<String, Integer> colOfField,
List<RosterImportReport.ColumnReport> columns,
List<Integer> extraCols, List<String> extraKeys) {
if (header == null) {
return;
}
List<Integer> keys = new ArrayList<>(header.keySet());
keys.sort(Comparator.naturalOrder());
Set<String> extKeyUsed = new HashSet<>();
for (Integer c : keys) {
String title = header.get(c);
if (title == null || title.isBlank()) {
columns.add(new RosterImportReport.ColumnReport(
RosterColumnCatalog.columnLetter(c), "", null, false));
continue;
}
String field = RosterColumnCatalog.fieldOf(title);
if (field != null && !colOfField.containsKey(field)) {
colOfField.put(field, c);
columns.add(new RosterImportReport.ColumnReport(
RosterColumnCatalog.columnLetter(c), title.trim(), field, true));
continue;
}
String key = RosterColumnCatalog.normalizeHeader(title);
if (key.isEmpty() || !extKeyUsed.add(key)) {
columns.add(new RosterImportReport.ColumnReport(
RosterColumnCatalog.columnLetter(c), title.trim(), null, false));
continue;
}
extraCols.add(c);
extraKeys.add(title.trim());
columns.add(new RosterImportReport.ColumnReport(
RosterColumnCatalog.columnLetter(c), title.trim(), "ext_json." + title.trim(), false));
}
}
// ── 行解析与体检 ────────────────────────────────
/** 一行"可入库"的候选:装配好的实体 + 证件号 hash + 它在 Excel 里的行号。 */
private record Pending(int excelRow, String hash, CollectRoster row) {
}
private void parseRow(Long taskId, int excelRow, Map<Integer, String> cells,
Map<String, Integer> colOfField, List<Integer> extraCols,
List<String> extraKeys, Long orgId, RosterImportReport report,
Map<String, Integer> seenInFile, List<Pending> pending) {
Integer nameColIdx = colOfField.get(RosterColumnCatalog.FIELD_NAME);
Integer idColIdx = colOfField.get(RosterColumnCatalog.FIELD_ID_CARD);
String nameCol = letter(nameColIdx);
String idCol = letter(idColIdx);
String rawName = text(cell(cells, nameColIdx));
String rawId = text(cell(cells, idColIdx));
// ① 空值(阻断)
if (rawName == null) {
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, nameCol,
RosterColumnCatalog.FIELD_NAME, null, "姓名为空", "EMPTY_NAME"));
}
if (rawId == null) {
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
RosterColumnCatalog.FIELD_ID_CARD, null, "身份证号为空", "EMPTY_IDCARD"));
}
if (rawName == null || rawId == null) {
return;
}
String name = IdentityNormalizer.name(rawName);
String idCard = IdentityNormalizer.idCard(rawId);
String mask = MaskUtil.maskIdCard(idCard);
// ② 格式(阻断)
IdCardUtil.Verdict verdict = IdCardUtil.verdict(idCard);
if (verdict == IdCardUtil.Verdict.MALFORMED) {
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
RosterColumnCatalog.FIELD_ID_CARD, mask,
"身份证号格式非法(应为 18 位、末位可为 X,且出生日期要真实存在;当前 "
+ idCard.length() + " 位)", "IDCARD_FORMAT"));
return;
}
// ③ 校验位(阻断)—— 与"文件名里的号码能不能自动改"是同一套算法,
// 但**处理策略相反**:名册是权威基准,校验位不对说明这份名单本身有问题,
// 必须请人核对,绝不能顺手改掉 —— 改掉等于我们替机构编了个身份证号
if (verdict == IdCardUtil.Verdict.CHECKSUM_FAILED) {
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
RosterColumnCatalog.FIELD_ID_CARD, mask,
"身份证号校验位不通过,请与本人核对后修正(系统不会自动改名册)", "IDCARD_CHECKSUM"));
return;
}
// ④ 重复(阻断):先看文件内,再看库里(库里已有的走"合并更新",不算错)
String hash = codec.hash(idCard);
Integer firstRow = seenInFile.get(hash);
if (firstRow != null) {
report.getErrors().add(new RosterImportReport.RowIssue(excelRow, idCol,
RosterColumnCatalog.FIELD_ID_CARD, mask,
"证件号与第 " + firstRow + " 行重复", "DUP_IDCARD"));
return;
}
seenInFile.put(hash, excelRow);
// ⑤ 装配
CollectRoster row = new CollectRoster();
row.setTaskId(taskId);
row.setOrgId(orgId);
row.setRealName(rawName);
row.setIdCardNo(codec.encrypt(idCard));
row.setIdCardHash(hash);
row.setIdCardMask(mask);
String phone = text(cell(cells, colOfField.get("phone")));
if (phone != null) {
row.setPhoneHash(codec.hash(phone));
// ⚠️ 名册上的 phone 存的是**脱敏值**:名册不需要回放完整号码
// (那是老师自己填的资料),而门禁回填给学生看时也只该给脱敏值。
row.setPhone(MaskUtil.maskPhone(phone));
// "无"是合法业务值(没有电话就填「无」),不当异常
if (!"无".equals(phone) && !phone.matches("1\\d{10}")) {
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
letter(colOfField.get("phone")), "phone", MaskUtil.maskPhone(phone),
"手机号不是 11 位数字(不影响入库,可后续补正)", "PHONE_INVALID"));
}
}
row.setSeqNo(toInt(cell(cells, colOfField.get("seq_no"))));
row.setEducation(text(cell(cells, colOfField.get("education"))));
row.setWorkUnit(text(cell(cells, colOfField.get("work_unit"))));
row.setOccupation(text(cell(cells, colOfField.get("occupation"))));
row.setWorktype(text(cell(cells, colOfField.get("worktype"))));
row.setJobLevel(text(cell(cells, colOfField.get("job_level"))));
row.setIsRetake(toFlag(cell(cells, colOfField.get("is_retake"))));
row.setRetakeTheory(text(cell(cells, colOfField.get("retake_theory"))));
row.setRetakePractice(text(cell(cells, colOfField.get("retake_practice"))));
row.setRetakeOverall(text(cell(cells, colOfField.get("retake_overall"))));
row.setIsStudent(toFlag(cell(cells, colOfField.get("is_student"))));
row.setSchool(text(cell(cells, colOfField.get("school"))));
row.setMajor(text(cell(cells, colOfField.get("major"))));
row.setWorkYears(toInt(cell(cells, colOfField.get("work_years"))));
row.setCertOccupation(text(cell(cells, colOfField.get("cert_occupation"))));
row.setCertLevel(text(cell(cells, colOfField.get("cert_level"))));
row.setCertNo(text(cell(cells, colOfField.get("cert_no"))));
row.setSubmitStatus(0);
String rawDate = text(cell(cells, colOfField.get("enroll_date")));
if (rawDate != null) {
LocalDate d = toDate(rawDate);
if (d == null) {
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
letter(colOfField.get("enroll_date")), "enroll_date", rawDate,
"入学时间没能解析成日期(建议写成 2009-09-01),该字段暂存为空",
"ENROLL_DATE_INVALID"));
} else {
row.setEnrollDate(d);
}
}
row.setExtJson(buildExtJson(cells, extraCols, extraKeys));
// ⑥ 警告:报考职业为空(专项职业能力可留空,所以不阻断)
if (row.getOccupation() == null) {
report.getWarnings().add(new RosterImportReport.RowIssue(excelRow,
letter(colOfField.get("occupation")), "occupation", null,
"报考职业为空(专项职业能力可留空;其它情况学生端的报考项会没有值)",
"EMPTY_OCCUPATION"));
}
pending.add(new Pending(excelRow, hash, row));
}
// ── 小工具 ─────────────────────────────────────
private static String letter(Integer col) {
return col == null ? null : RosterColumnCatalog.columnLetter(col);
}
private static String cell(Map<Integer, String> cells, Integer col) {
return col == null || cells == null ? null : cells.get(col);
}
private static boolean isBlankRow(Map<Integer, String> cells) {
if (cells == null || cells.isEmpty()) {
return true;
}
for (String v : cells.values()) {
if (v != null && !v.isBlank()) {
return false;
}
}
return true;
}
/** 文本:全角转半角 + 去首尾空白;空串归 null。 */
private static String text(String raw) {
if (raw == null) {
return null;
}
String s = IdentityNormalizer.toHalfWidth(raw).trim();
return s.isEmpty() ? null : s;
}
/** 整数:容忍 {@code 12.0} 这类由数值单元格带出来的写法。 */
private static Integer toInt(String raw) {
String s = text(raw);
if (s == null) {
return null;
}
if (s.endsWith(".0")) {
s = s.substring(0, s.length() - 2);
}
try {
return Integer.valueOf(s);
} catch (NumberFormatException e) {
return null;
}
}
/** 是/否 → 1/0;认不出返回 null(不猜,也不报错)。 */
private static Integer toFlag(String raw) {
String s = text(raw);
if (s == null) {
return null;
}
String u = s.toUpperCase();
if (YES.contains(u)) {
return 1;
}
if (NO.contains(u)) {
return 0;
}
return null;
}
/**
* 日期:支持 {@code 2009-09-01} / {@code 2009/9/1} / {@code 2009.9.1} /
* {@code 2009年9月1日} / {@code 20090901},以及 Excel 的日期序列号。
*
* <p>认这么多写法的理由:官方模板要求 {@code YYYY-MM-DD},但真实文件里几种都出现过;
* 而序列号那种是"单元格被设成常规格式"造成的 —— 它长得像个数字,
* 用户根本看不出自己填的是个日期。
*/
private static LocalDate toDate(String raw) {
String s = raw.replace("年", "-").replace("月", "-").replace("日", "").trim();
String[] pats = {"yyyy-MM-dd", "yyyy-M-d", "yyyy/MM/dd", "yyyy/M/d",
"yyyy.MM.dd", "yyyy.M.d", "yyyyMMdd"};
for (String p : pats) {
try {
return LocalDate.parse(s, DateTimeFormatter.ofPattern(p));
} catch (Exception ignored) {
// 换下一种格式
}
}
Integer serial = toInt(s);
if (serial != null && serial > 0 && serial < 80000) {
return EXCEL_EPOCH.plusDays(serial);
}
return null;
}
private String buildExtJson(Map<Integer, String> cells, List<Integer> extraCols,
List<String> extraKeys) {
if (extraCols.isEmpty()) {
return null;
}
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < extraCols.size(); i++) {
String v = text(cell(cells, extraCols.get(i)));
if (v != null) {
m.put(extraKeys.get(i), v);
}
}
return m.isEmpty() ? null : JSONUtil.toJsonStr(m);
}
private static int countErrors(RosterImportReport report, String code) {
return (int) report.getErrors().stream().filter(e -> code.equals(e.code())).count();
}
/** 被阻断的<b>行数</b>(一行可能报两条,比如姓名和证件号都空)。 */
private static int blockedRowCount(RosterImportReport report) {
return (int) report.getErrors().stream()
.map(RosterImportReport.RowIssue::row).distinct().count();
}
private void requireTask(Long taskId) {
if (taskMapper.selectById(taskId) == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
}
}
@@ -0,0 +1,169 @@
package cn.tianbaotong.fill.module.roster.service;
import cn.tianbaotong.fill.common.api.PageResult;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.module.roster.dto.RosterVO;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 名册查询(《03-接口设计》§8.2)。
*
* <p>⚠️ <b>响应里一律只有脱敏证件号</b>。名册列表是管理端最高频的一屏,
* 完整号码一旦进了响应体,就会顺手进浏览器缓存、进日志、进导出文件 ——
* 而这批数据是几百个学生的身份证号。真要核对完整号码时走独立接口并记审计日志,
* 不在"列表"里顺带解决。
*/
@Service
@RequiredArgsConstructor
public class RosterService {
private final CollectRosterMapper rosterMapper;
private final SensitiveCodec codec;
/**
* 名册分页。
*
* @param keyword 姓名模糊;<b>如果填的是一整个身份证号,则按号码精确搜</b>
* @param submitStatus {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回
* @param bindStatus {@code 0} 未绑定 / {@code 1} 已绑定
*/
public PageResult<RosterVO> list(Long taskId, String keyword, Long orgId,
Integer submitStatus, Integer bindStatus,
int pageNum, int pageSize) {
LoginUser me = UserContext.require();
LambdaQueryWrapper<CollectRoster> q = Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, taskId);
if (orgId != null) {
q.eq(CollectRoster::getOrgId, orgId);
}
if (submitStatus != null) {
q.eq(CollectRoster::getSubmitStatus, submitStatus);
}
if (bindStatus != null) {
if (bindStatus == 1) {
q.isNotNull(CollectRoster::getBindUserId);
} else {
q.isNull(CollectRoster::getBindUserId);
}
}
applyKeyword(q, keyword);
// 排序用 seq_no(Excel 里的序号)—— 老师对着名单核的时候顺序要一致,
// 而 seq_no 可能为空,所以再按 id 兜一层保证稳定
q.orderByAsc(CollectRoster::getSeqNo).orderByAsc(CollectRoster::getId);
IPage<CollectRoster> page = rosterMapper.selectPage(new Page<>(pageNum, pageSize), q);
Map<Long, String> orgNames = orgNames(me.tenantId());
List<RosterVO> list = new ArrayList<>(page.getRecords().size());
for (CollectRoster r : page.getRecords()) {
list.add(toVo(r, orgNames));
}
return PageResult.of(list, page.getTotal(), pageNum, pageSize);
}
/**
* 关键字:
* <ul>
* <li>整串是一个身份证号 → 按 <b>hash 等值</b>搜(密文没法 LIKE,这是加密的必然代价)</li>
* <li>其它 → 按姓名模糊搜</li>
* </ul>
*
* <p>两种一起用 {@code or}:用户可能贴一段既不像名字也不像号码的东西,
* 那时两条都不命中,返回空列表即可 —— 不该因此报错。
*/
private void applyKeyword(LambdaQueryWrapper<CollectRoster> q, String keyword) {
if (keyword == null || keyword.isBlank()) {
return;
}
String kw = keyword.trim();
String idCard = IdentityNormalizer.idCard(kw);
boolean looksLikeIdCard = idCard.length() == 18 && IdCardUtil.verdict(idCard) != IdCardUtil.Verdict.MALFORMED;
if (looksLikeIdCard) {
String hash = codec.hash(idCard);
q.and(w -> w.like(CollectRoster::getRealName, kw).or().eq(CollectRoster::getIdCardHash, hash));
return;
}
q.like(CollectRoster::getRealName, kw);
}
private Map<Long, String> orgNames(Long tenantId) {
Map<Long, String> m = new HashMap<>();
for (Map<String, Object> row : rosterMapper.selectOrgNames(tenantId)) {
Object id = row.get("id");
Object name = row.get("org_name");
if (id != null) {
m.put(((Number) id).longValue(), name == null ? null : String.valueOf(name));
}
}
return m;
}
private RosterVO toVo(CollectRoster r, Map<Long, String> orgNames) {
RosterVO vo = new RosterVO();
vo.setId(r.getId());
vo.setSeqNo(r.getSeqNo());
vo.setStudentNo(r.getStudentNo());
vo.setRealName(r.getRealName());
// 只给脱敏值。库里存的本来就是脱敏值(导入时就这么写的),这里再兜一次
vo.setIdCardMask(r.getIdCardMask());
vo.setPhoneMask(r.getPhone());
vo.setOrgId(r.getOrgId());
if (r.getOrgId() != null) {
vo.setOrgName(orgNames.get(r.getOrgId()));
}
vo.setOccupation(r.getOccupation());
vo.setWorktype(r.getWorktype());
vo.setJobLevel(r.getJobLevel());
vo.setEducation(r.getEducation());
vo.setIsStudent(r.getIsStudent());
vo.setSchool(r.getSchool());
vo.setMajor(r.getMajor());
vo.setEnrollDate(r.getEnrollDate());
vo.setWorkUnit(r.getWorkUnit());
vo.setIsRetake(r.getIsRetake());
vo.setRetakeTheory(r.getRetakeTheory());
vo.setRetakePractice(r.getRetakePractice());
vo.setRetakeOverall(r.getRetakeOverall());
vo.setWorkYears(r.getWorkYears());
vo.setCertOccupation(r.getCertOccupation());
vo.setCertLevel(r.getCertLevel());
vo.setCertNo(r.getCertNo());
vo.setExtJson(parseExtJson(r.getExtJson()));
vo.setBindStatus(r.getBindUserId() == null ? 0 : 1);
vo.setBindTime(r.getBindTime());
vo.setBindType(r.getBindType());
vo.setSubmitStatus(r.getSubmitStatus());
return vo;
}
/** ext_json 存的是字符串,对外给对象(契约 §3.6 写的是 object);坏数据不该让列表 500。 */
private Object parseExtJson(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return JSONUtil.parseObj(raw);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,88 @@
package cn.tianbaotong.fill.support;
import cn.tianbaotong.fill.common.util.IdCardUtil;
/**
* 「同一个人的同一个字段」的规范化口径 —— <b>门禁核验与名册导入必须共用这一个实现</b>。
*
* <h3>为什么必须共用(这不是洁癖)</h3>
*
* <p>门禁是按 {@code id_card_hash}(HMAC)<b>等值查</b>名册的,而 HMAC 的输入是
* <b>规范化之后的字符串</b>。于是:
*
* <pre>
* 导入时算 hash 用的规范化 ≠ 门禁算 hash 用的规范化
* ↓
* 同一个号码算出的 hash 不同 → 名册里明明有这个人,却查不到
* ↓
* 学生看到的是「我填的跟老师给的一模一样,却说我信息不对」
* </pre>
*
* <p>这类 bug 最难受的地方是:<b>两处代码各自看都是对的</b>,
* 只有放在一起才错;而症状会把人引向"学生是不是填错了",越查越远。
* 所以规范化只留一个入口,两条路都调它。
*
* <p>另一个必须统一的点是<b>全角</b>:中文输入法下打数字很容易出全角
* ({@code 123}),而 Excel 里存的是半角({@code 123})。
* 不转的话两者"长得一模一样"却匹配不上 —— 这是最容易让人放弃的一种失败。
*
* <p>⚠️ 改动本类会<b>同时</b>影响门禁与导入,且会改变已入库数据的匹配结果:
* 加宽松(如这里补的全角转换)只会让以前匹配不上的现在能匹配上,是安全的;
* 收紧(去掉某一步)会让已有名册的学生突然进不来,属于破坏性改动。
*/
public final class IdentityNormalizer {
private IdentityNormalizer() {
}
/**
* 姓名规范化:去所有空白(含全角空格)+ 全角 ASCII 转半角。
*
* <p>不去标点、不去特殊字符:姓名里的间隔号({@code 阿依古丽·买买提})是真实存在的,
* 动它会把一部分少数民族姓名弄坏。
*/
public static String name(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
if (Character.isWhitespace(c) || c == '\u3000') {
continue;
}
sb.append(toHalfChar(c));
}
return sb.toString();
}
/**
* 证件号规范化:全角转半角后再交给 {@link IdCardUtil#normalize}
* (去所有空白 + 转大写,收尾的 {@code x} 与之统一)。
*
* <p>顺序不能反:先转半角再交给 IdCardUtil,它才能正确识别数字。
*/
public static String idCard(String raw) {
return IdCardUtil.normalize(toHalfWidth(raw));
}
/** 全角 ASCII(U+FF01~U+FF5E)转半角,其余字符原样保留(空白交给各自的上层处理)。 */
public static String toHalfWidth(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
sb.append(toHalfChar(c));
}
return sb.toString();
}
/** 单字符:全角 ASCII → 半角;其它原样。 */
private static char toHalfChar(char c) {
// U+FF01~U+FF5E 与 U+0021~U+007E 一一对应,偏移量固定
if (c >= '\uFF01' && c <= '\uFF5E') {
return (char) (c - 0xFEE0);
}
return c;
}
}