摘要: 考勤管理员补录一条漏卡,表面上只是新增一个时间点,实际却要完成可信身份覆盖、批量原子写入、跨日重算范围计算、分析任务去重、任务落库和 Kafka 实时通知。恢复这条链路时最反直觉的发现是:补录时间为凌晨 05:12,并不意味着只重算当天;系统把分析开始时间向前扩一天,以便重新判断它究竟属于前一天夜班的下班卡,还是当天的上班卡。本文同时复盘一个常见误区:afterCommit 能防止回滚后误发消息,却不能消除数据库与 Kafka 的双写窗口。
目录
-
背景与问题 2. 目标与约束 3. 系统架构 4. 核心设计
-
数据模型与 ERD 6. 数据流与执行流程 7. 关键实现 8. 方案取舍
-
Troubleshooting / RCA 10. 验证过程 11. Lessons Learned 12. 后续优化与总结
1. 背景与问题
人工补卡通常发生在三种场景:考勤机漏采、员工忘记打卡、跨日班次的末卡没有正确进入系统。一条补录记录不仅影响原始打卡明细,还可能改变缺卡、迟到、早退、实际工时、加班和月度汇总。
本案例的一次请求包含两个时间点:前一天 20:58 和次日 05:12。它们必须同时成功或同时失败;写入成功后还要分别触发分析,因为两个时间点可能落入不同的班次窗口。
真正的工程问题由此变成:
- 如何防止客户端伪造租户和操作人;
- 如何保证多个打卡时间原子写入;
- 如何让数据库回滚时绝不发送重算消息;
- 如何覆盖凌晨跨日卡的正确归属;
- 数据库提交后 Kafka 失败又该如何恢复。
2. 目标与约束
| 目标/约束 | 工程含义 |
|---|---|
| 可信身份 | 租户和操作人来自服务端安全上下文,覆盖请求体中的同名值。 |
| 批量原子性 | 逗号分隔的 N 个时间点使用原 Mapper 批量 INSERT,并由同一个 MySQL 事务包裹。 |
| 最小持久化契约 | 人工补录只保存人员、时间、备注及审计字段;设备、定位、图片等 DTO 字段不落库。 |
| 提交后副作用 | 分析任务和 Kafka 必须在打卡事务 commit 后触发,回滚时保持为 0。 |
| 跨日覆盖 | 每个打卡的重算开始日取该日期前一天,结束时间保留打卡时间。 |
| 原 SQL 复用 | 保留部署包中的打卡、分析任务 MyBatis XML,Java 不重新拼写 SQL。 |
| 非 XA 边界 | MySQL 和 Kafka 不能靠 afterCommit 达成绝对原子性,必须明确部分成功窗口。 |
3. 系统架构

图 1 批量写事务、afterCommit 和分析副作用的边界
入口刻意做成窄 Controller:只发布人工补录一个功能,不顺带开放同一旧 Controller 中的人员档案、规则分配和删除等接口。运行时还要求写权限、生产数据源和 Kafka 外发门禁全部成立。
本流程没有使用 Redis 锁。分析任务通过数据库中的待处理任务做去重;把旁路 Redis 误写进架构图,会制造"验证过分布式锁"的假象。
4. 核心设计
4.1 一次请求为什么可以包含多个时间点
旧接口把多个打卡时间放在一个逗号分隔字符串中。恢复实现没有逐条执行多次 INSERT,而是先组装 N 条记录,再调用原 XML 的批量 INSERT。这样第二行发生约束或触发器错误时,整个 statement 失败,外层事务随之回滚。
4.2 为什么凌晨卡要从前一天开始重算
自然日并不等于考勤日。一个 20:00---次日 05:00 的夜班,05:12 这张卡很可能是前一天班次的下班卡。若只重算 10 月 31 日,系统看不到 10 月 30 日的排班起点,可能把它当成当天异常上班卡。
因此每个补录事件的分析范围为:
start = date(recorded_at) - 1 day
end = recorded_at
真正归属仍由下游排班、工作时间和打卡选择算法决定;"减一天"只是扩大候选窗口,不是直接把所有凌晨卡归到昨天。
4.3 为什么只保存四组业务字段
请求 DTO 同时被多个打卡入口复用,能表达设备号、经纬度、网络、运营商、图片和地址。但部署行为显示,人工补录只复制租户、人员、时间、备注,再由服务端生成来源、有效性、UUID、审计人与有效期。
这是一条重要边界:DTO 能表达什么,不等于当前接口承诺持久化什么。把全部字段"顺手"映射进去,会让人工补录伪装成设备原始打卡,污染风控和来源统计。
5. 数据模型与 ERD

图 2 打卡、分析任务、排班和汇总之间的业务关系
| 示例表 | 职责 | 关键业务键 | 写入阶段 |
|---|---|---|---|
hr_attendance_punch |
原始打卡时间、来源、有效性和备注 | uuid;tenant + person + recorded_at | 主事务批量写入 |
hr_analysis_job |
保存待执行的重算范围、来源和状态 | tenant + person + start + end + source | commit 后逐事件去重写入 |
hr_work_schedule |
提供人员在考勤日的班次和跨日窗口 | tenant + person + work_date | 下游重算只读 |
hr_attendance_summary |
保存迟到、早退、缺卡和工时等结果 | tenant + person + period | 下游分析更新 |
数据库关系以业务键为主,不应假设存在物理外键。验证时应让两个虚拟租户复用同一个 person_id,才能暴露任何遗漏 tenant_id 的查询或写入。
6. 数据流与执行流程
6.1 脱敏请求示例
POST /api/attendance/manual-punch
X-Tenant-Id: 1001
X-Operator-Id: 9001
{
"person_id": 20001,
"recorded_at": "2026-10-30 20:58:00,2026-10-31 05:12:00",
"remark": "夜班漏卡人工补录",
"device_id": "CLIENT-SENT-BUT-IGNORED",
"longitude": "CLIENT-SENT-BUT-IGNORED"
}
示例中 device_id 和 longitude 虽然能被 DTO 接收,但人工补录业务不会保存它们。租户和操作人也不从 JSON 接受,而是使用服务端可信上下文。
6.2 成功时序

图 3 两个补录时间点的批量写与提交后逐事件处理
**1 身份覆盖。**Controller 用安全上下文中的 tenant_id 覆盖请求值,并使用可信 operator_id 写审计字段。
**2 最小投影。**拆分时间字符串,为每个时间点生成 UUID、来源、有效性、有效期和审计时间。
**3 批量写入。**复用原 MyBatis XML 一次写入所有行,影响行数大于零才视为保存成功。
**4 注册回调。**事务尚未提交时只登记 afterCommit,不立即写任务或发 Kafka。
**5 提交后去重。**对每个时间点查询相同租户、人员、时间范围、来源且状态为待处理的任务。
**6 实时触发。**不存在重复任务时生成任务 UUID、插入任务,再发送带同一 job UUID 的 Kafka 消息。
7. 关键实现
7.1 等价伪代码
saveManualPunch(trustedTenant, trustedOperator, request):
begin MySQL transaction
rows = []
for timestamp in split(request.recorded_at, ","):
rows += Punch(
uuid = nextId(),
tenant = trustedTenant,
person = request.person_id,
recordedAt = timestamp,
source = MANUAL,
valid = true,
remark = request.remark,
creator = trustedOperator
)
# device/location/image fields intentionally remain null
originalMapper.batchInsert(rows)
for row in rows:
registerAfterCommit(() -> {
start = date(row.recordedAt) - 1 day
if no pending job(start, row.recordedAt, MANUAL):
jobId = insertAnalysisJob(start, row.recordedAt)
sendKafka(jobId, start, row.recordedAt)
})
commit MySQL transaction
7.2 afterCommit 解决了什么
如果在 INSERT 之后、COMMIT 之前就发 Kafka,第二行失败时数据库会回滚,但消息已被下游消费。下游将重算一条根本不存在的打卡。afterCommit 把外部副作用延迟到提交完成,消除了这类"回滚后幽灵消息"。
7.3 afterCommit 没有解决什么
一旦 COMMIT 完成,打卡已经成为事实。随后分析任务 INSERT 失败、Kafka 超时或进程崩溃,都无法让原事务复活并回滚。因此当前语义是"打卡优先落库,分析尽力触发",而不是 MySQL 与 Kafka 的全局原子提交。
**额外观察:**恢复出的兼容编排会捕获分析任务写入异常,并继续尝试发送实时消息。这保留了部署行为,但可能产生"Kafka 有 job_id,任务表没有对应行"的状态。该兼容点应通过对账或 Outbox 演进收口,而不能仅靠重试 HTTP 请求。
7.4 为什么保留原 Mapper XML
原批量 XML 保存了 28 列字段顺序、类型处理和批量 foreach 语义;分析任务 XML 还编码了来源、状态 TypeHandler 与去重查询。重新在 Java 中拼 SQL 会扩大等价性风险,因此恢复层只负责字段转换、事务和守卫。
8. 方案取舍
| 候选方案 | 优点 | 代价与结论 |
|---|---|---|
| 逐条 INSERT | 实现直观 | 往返多,若事务遗漏会部分成功;未采用。 |
| 原 XML 批量 INSERT | 语义忠实,statement 失败整体回滚 | 错误定位到具体行较难;采用。 |
| 事务内直接发 Kafka | 响应前已触发消息 | 数据库回滚时可能产生幽灵消息;拒绝。 |
| afterCommit 发送 | 数据库回滚时副作用为 0 | 提交后失败无法反向回滚;当前采用。 |
| 事务型 Outbox | 打卡与待发事件在同一事务持久化,可重试可审计 | 需要新增表、投递器和清理策略;建议演进。 |
| Kafka 事务 + 数据库 XA | 理论上更强原子性 | 遗留栈复杂、运维代价高;本次未采用。 |
9. Troubleshooting 与根因分析
Symptoms
首版契约测试认为客户端传入的设备号、经纬度、地址和图片都应写入数据库;实际恢复实现只保存了人员、时间和备注,测试失败。
Investigation
- 检查请求 DTO,确认它确实包含完整设备与定位字段;
- 继续追踪具体业务方法,而不是从 DTO 反推持久化行为;
- 逐字段对照部署字节码中的对象 builder 和原 Mapper 入参;
- 发现人工补录路径只复制租户、人员、时间、备注;
- 设备字段属于设备打卡、移动打卡等其他入口的共享 DTO 能力。
Root Cause
根因是把"传输对象的字段全集"误当成"单个接口的持久化契约"。共享 DTO 为多个入口服务,而每个业务方法只消费其中一部分。若按 DTO 全字段映射,反而会偏离部署行为,并让人工记录携带伪设备证据。
Resolution
- 建立显式最小投影,只传租户、人员、时间和备注;
- 来源、有效性、UUID、审计字段和有效期全部由服务端生成;
- 契约测试明确断言设备、定位、图片字段保持空值;
- 保留原 Mapper XML,不通过"列存在"推导"业务允许写入"。
Verification
**验证结果:**隔离 MySQL 使用生产 DDL、零生产业务行和最小 DML 账号。触发器强制第二条打卡失败时,整个批次回滚,分析任务与 Kafka 消息均为 0;正常双租户请求共写入 3 条最小打卡、3 条去重任务和 3 条真实消息。可信身份覆盖、同人员号租户隔离、设备/地址不落库、缺上下文拒绝和兄弟路由隔离均通过。
5 Whys:提交后双写窗口

图 4 afterCommit 的价值与不可消除的非 XA 边界
Lessons Learned
- 不要从 DTO 字段数量推导业务持久化范围;必须追踪方法级字段使用。
- afterCommit 是时序控制,不是分布式事务。
- 故障注入要覆盖批量中间行、commit 前回滚和 commit 后断网三种位置。
- 真正的验证要同时检查数据库行、任务行、消息数量和租户隔离。
10. 验证过程与证据分级
| 证据层级 | 已验证内容 | 仍不能证明 |
|---|---|---|
| L1 静态/制品证据 | 字段投影、原 XML、前一天重算范围、afterCommit 调用顺序 | 真实网络与 Broker 故障行为 |
| L2 单元/装配测试 | 多时间拆分、来源与审计字段、条件装配、Kafka 门禁 | 真实 MySQL 批量失败语义 |
| L3 隔离联合验证 | 生产 DDL、最小权限、批量回滚、双租户、任务和真实 Kafka 消息 | 生产流量、生产 Broker 主从和长时间运行 |
| L4 故障演练待增强 | 已覆盖 INSERT 中间行失败 | commit 后进程崩溃、Kafka 超时、任务成功消息失败及重放 |
| L5 灰度 | 应核对单所有者、消息积压、任务/消息差异和无感回滚 | 完成前不得宣称整容器可替换 |
11. Lessons Learned
- **考勤日不是自然日。**跨日班次必须扩大查询与重算窗口,再由排班规则决定归属。
- 批量原子性要做失败注入。"代码有事务注解"不如让第二行真实失败更有证明力。
- **来源字段是业务证据。**人工补录不能伪装成设备采集,设备字段应保持为空。
- **提交后回调只解决一个方向。**它防止回滚后误发,但无法撤销已提交的数据。
- **任务表与消息要能对账。**相同 job_id 应能串起 HTTP、任务、Kafka 和下游结果。
- **窄发布优于复活整个旧 Controller。**只开放已经验证的路由,减少二开恢复的爆炸半径。
12. 后续优化与总结
- 引入事务型 Outbox:主事务同时写打卡与待发事件,独立投递器可靠发送 Kafka;
- 给 Outbox 和任务增加唯一业务键,支持幂等重试与安全重放;
- 增加"打卡数---任务数---消息数---完成数"对账和差异告警;
- 演练 commit 后 kill -9、Kafka 超时、任务写成功但消息失败等故障点;
- 把跨日窗口、时区和夏令时边界固化为参数化契约测试;
- 为人工补录记录增加审批依据和不可抵赖审计,而不是扩写设备字段。
这条流程说明,可靠的考勤补录远不止一条 INSERT。它既是原始数据修正,也是一次派生数据重算的起点。正确设计必须分清可信字段、最小持久化契约、本地事务和外部副作用,并坦诚面对非 XA 系统中的部分成功窗口。
最可迁移的实践是:主事务内只做能够原子完成的数据库事实;外部副作用延迟到提交后;再用 Outbox、幂等、对账和补偿把提交后的可靠性补齐。这样才能让一次看似简单的补卡,变成可解释、可验证、可恢复的工程流程。
**证据声明:**本文根据一套私有化 HR 系统的部署制品还原代码、原始 MyBatis XML、事务装配、隔离 MySQL 与 Kafka 验证整理。真实系统名、路由、表前缀、Topic、域名、IP、租户号、人员号和账号凭据均已替换为示例值;已验证事实、静态观察和仍待演练的提交后故障窗口分别标注。
生成日期:2026-10-31 | 单文件 HTML,4 张中文 PNG 以 Base64 内嵌,可直接用浏览器打开后复制到 CSDN 富文本编辑器。