pass_user 死亡覆盖链与 spec 错误判断:从集成测试挖出 421 条脏数据
导读
6/11 实现的”钉钉同步触发 pass_user 同步”功能本身端到端跑通,但暴露出 6/11 spec 文档第 46 行的一个事实错误判断:把
PassUserServiceImpl.synSysUserToPassUser()里的setParkId(DEFAULT_PARK_ID)误判为”无效死代码”。实际是有效过滤。更糟的是下午挖出了真正的死亡覆盖链:admin 端UserMapperExt.xml的listByConditionExtSQL 没 selecturp.park_id列,导致user.getParkId()永远是 NULL,421 条pass_user.park_id全被污染成 NULL。
🎧 文章导读
🎵 背景音乐
前言
集成测试是揭露设计缺陷的最佳工具。这次集成测试同时挖出了三个问题:
- spec 文档第 46 行的事实错误判断(
setParkId(DEFAULT_PARK_ID)是死代码 → 实际是有效过滤) - 死亡覆盖链的真正根因(
UserMapperExt.xmlSELECT 漏列) - 421 条历史脏数据(其中 322 条本应是番禺基地用户)
整个过程是一个标准的”反向归因”案例:表象和根因完全相反。

图1:pass_user.park_id 死亡覆盖链
第一阶段:集成测试 + spec 错误判断
测试场景 T1:mock 用户没进 pass_user
我准备了一个 mock 用户 MOCK_TEST_PASS_001,USERID、DEPARTMENT、PDEPARTS 三个字段都填了,调用 POST /sync/mockDingUserSync 后去查 building_through.pass_user 表,发现没有这个用户。
第一反应:”Feign 调用失败了” 或 “through-service 那边有 bug”。
但先用 OpenFeign 的日志和 triggerPassUserSync 的返回值(返回的是 affected rows 数)确认了一个关键事实:返回值是 335,不是 0。这意味着 synSysUserToPassUser 确实跑了,而且写了 335 个用户——只是没写我的 mock 用户。
反查源码
1 | // PassUserServiceImpl.java:442-466 |
userClient.paging(queryCondition) 走的是 building 内部统一的查询协议,会用 userDTO 的所有非空字段做 WHERE 过滤。所以这一行的含义是:
1 | WHERE park_id = '284cd7570daf5d1ca8e121bc025f7a02' AND delete_flag = '0' |
——只查”番禺基地”且未删除的用户。
反查 DictConstants.DEFAULT_PARK_ID:
1 | // service-util/.../DictConstants.java:765 |
这是硬编码的”番禺基地” park_id。也就是说,所有非番禺基地的钉钉用户,pass_user 同步后仍然不会出现。
spec 错误判断的事实纠正
6/11 写的 spec 文档第 46 行断言 setParkId(DEFAULT_PARK_ID) 是”无效死代码”,这个判断是错的。我反复看了 PassUserServiceImpl.synSysUserToPassUser() 的源码后确认:这一行是有效过滤,它把同步范围限定在”番禺基地”。
业务正确性问题
DEFAULT_PARK_ID 是番禺基地的硬编码。含义:所有非番禺基地的钉钉用户,pass_user 同步后不会出现。
[!warning] 当前生产环境的潜在影响
现有 1161 个 sys_user 钉钉用户,假设大部分 park_id =284cd757...(番禺基地),那 pass_user 同步功能基本正常。但如果:
- 未来扩展到非番禺基地的新园区
- 钉钉侧新增了非番禺基地的部门/人员
- 桥表里有非番禺基地的 org_id
这些用户的 pass_user 记录永远不会出现,会直接影响
/gateway/through/passUser/page接口的展示完整性。
测试方法学的反面案例
测试通过的 mock 数据:
1 | { |
136602494 在桥表里映射到番禺基地 → sys_user_park.park_id = 284cd757... → 满足 setParkId(DEFAULT_PARK_ID) 过滤 → 写入 pass_user。
测试失败的 mock 数据:
1 | { |
59096189 不在桥表(之前测试硬删了)→ sys_user_park.park_id = NULL → 被过滤。

图2:421 条 NULL park_id 量化结果
第二阶段:死亡覆盖链的真正根因
现象升级
上午只判断了”setParkId(DEFAULT_PARK_ID) 是有效过滤”(事实纠正),下午继续深挖发现真正致命的是 admin 端 mapper 漏 SELECT 列表。
pass_user.page 接口(POST /gateway/through/passUser/page)按 parkId 过滤时,几乎所有数据都查不到(即使不是 NULL 的,parkId 也是错的)。
死亡覆盖链
1 | sys_user 行 → UserMapperExt.xml listByConditionExt |
3 行代码的死亡覆盖链:
1 | // PassUserServiceImpl.java:445-478 |
**为什么 line 447 看起来”起作用”但实际”无效”**:
- 修复前
setParkId(DEFAULT_PARK_ID)设的是userDTO.parkId = "284cd757..."✓ - admin-service WHERE 过滤:
urp.park_id = '284cd757...'→ 只返 322 个番禺用户 ✓ - 但 SELECT 没返回 park_id → userDTO.parkId 永远是 NULL ✗
- 322 个番禺用户被 sync 进了 pass_user,但 park_id 全是 NULL ✗
量化脏数据
1 | SELECT pu.real_name, pu.park_id, urp.park_id as expected |
**421 条 pass_user.park_id IS NULL**,其中 322 条本应是番禺基地用户(park_id = 284cd757…)。

图3:UserMapperExt 漏 SELECT 列修复
修复方案
修复 1:让新 sync 的 pass_user 写入正确的 park_id
改动文件 1:UserMapperExt.xml
SELECT 加 ,urp.park_id,JOIN 改为子查询解决多园区用户重复问题:
1 | <!-- 修改前 --> |
[!note] 为什么用子查询而不是直接 LEFT JOIN
同一个 user 可能在多个园区(sys_user_park一对多),直接 LEFT JOIN 会让用户被 DISTINCT 出去后任一园区的 park_id 都可能漏选。子查询MIN(park_id) GROUP BY user_id保证每个 user 拿到一个确定的 park_id 值。
**故意不加 WHERE delete_flag='0'**,与原 LEFT JOIN 行为一致(保持不过滤是原行为,加了反而引入行为变更)。
改动文件 2:PassUserServiceImpl.java
注释掉 line 447 setParkId(DEFAULT_PARK_ID),让扫描覆盖所有园区:
1 | // line 445-448 |
修复 2:清理 321 条历史 NULL park_id 数据
[!warning] 增量同步对历史脏数据无效
修复后synSysUserToPassUser走增量逻辑:if (!userIds.contains(user.getUserId()))跳过已有。后果:321 个老 NULL park_id 的番禺用户永远不会被更新。必须单独 UPDATE。
1 | UPDATE pass_user pu |
验证证据
修复后跑一次 synSysUserToPassUser,268 条新 pass_user 完美匹配 sys_user_park.park_id:
| pass_user.real_name | pass_user.park_id | sys_user_park.park_id | 匹配 |
|---|---|---|---|
| 李泓希 | 59258e4f… | 59258e4f… | ✅ |
| 陈启操 | a69dd3d6… | a69dd3d6… | ✅ |
| 消防巡检卡 | a69dd3d6… | a69dd3d6… | ✅ |
Page 接口验证(parkId=番禺基地 过滤):
- 修复前:
totalCount=0(pass_user.park_id 全是 NULL,过滤后空集) - 修复后:
totalCount=2(MOCK_MIN_001 + MOCK_PANYU_001)
4 个踩坑总结
坑 1:line 478 setParkId(user.getParkId()) 永远会覆盖 line 447
表面上 setParkId(DEFAULT_PARK_ID) 像是”死代码”——其实判断方向反了:
- 错判断:line 447 是死代码,没生效
- 真实:line 447 有效(WHERE 过滤返 322 个番禺用户),但 line 478 用 NULL 覆盖了 park_id 字段
- 后果:322 个番禺 user 进了 pass_user,但 park_id 是 NULL
教训:判断”代码无效”前必须先 trace 完整的 setter → 调用 → mapper → 落库链路。
坑 2:spec/plan 阶段误判 BaseResultMap 缺 park_id 映射
- spec 写”改 2 个文件 4 处”,但 plan 写了”改 3 个文件”(多了 UserMapper.xml)
- 实际查证 BaseResultMap line 30-31 已经有
<result column="park_id" property="parkId" jdbcType="VARCHAR"/> - 教训:写 plan 前先
grep实际文件,不要凭印象判断
坑 3:增量同步对历史脏数据无效
- 修复前 sync 写了 421 条 NULL park_id 的 pass_user
- 修复后
synSysUserToPassUser走增量逻辑,跳过已有 - 后果:321 个老 NULL park_id 的番禺 user 永远不会被更新
- 教训:所有”增量同步”逻辑对老数据无效,必须单独 UPDATE 或 DELETE 重建
坑 4:MyBatis 子查询行为一致性
- 新子查询
LEFT JOIN (SELECT user_id, MIN(park_id) FROM sys_user_park GROUP BY user_id) urp - 故意不加
WHERE delete_flag='0',与原LEFT JOIN sys_user_park urp(不过滤)行为一致 - 原则:修复不要引入额外行为变更
排查方法论
- 返回值优先:
synSysUserToPassUser返回 335 直接告诉我是过滤问题(写入了 335 个番禺用户,只是 mock 用户没在里面)。永远先看返回值再下结论 - 完整 trace:从 controller → service → mapper → generic XML,每一层都要追到。这次 line 478 的 NULL 来自 6/10 的 mapper 改动,跨 3 天的代码层才暴露
- 脏数据量化:发现 bug 后第一件事是量化脏数据量(”421 条 NULL,其中 322 条是番禺”)。这决定了修复优先级和清理工作量
代码规范
- “看起来无效”和”无效”是两回事。遇到可疑代码,必须追到底——读 caller 的实现才能判断”它真的没用”还是”它就是过滤条件”
- spec 写下”无效死代码”前应该能引用具体证据(哪行覆盖、哪行忽略)。6/11 spec 没给证据就下了结论,6/12 集成测试推翻
- 写代码、读代码、写 spec、读 spec,每一个环节都可能引入错误。6/11 我同时是实现者和 spec 写作者,缺乏第二视角。建议以后 spec 写完、实现开始前,让另一个人过一遍 spec 的事实性判断
测试覆盖
- 集成测试的”端到端”必须真的”端到端”——从 mock 入口到最终落库表,每一张表都查一遍。这次只查了
pass_user没查sys_user_park,导致没发现 mapper 的 SELECT 漏列 - 测试场景要包含”数据正确性”:除了”有没有写”,还要查”写得对不对”(pass_user.park_id 是不是等于 sys_user_park.park_id)
业务影响
[!note] 影响范围
修复后所有新写入的 pass_user.park_id 都是正确的。但历史 321 条 NULL 数据需要单独清理(在运维窗口统一处理)。在清理完成前,/gateway/through/passUser/page按 parkId 过滤会少返回 321 个番禺用户。
待办事项
- 清理 321 条历史 NULL park_id 数据(运维窗口统一处理)
- 验证 Page 接口的完整性:在历史数据清理后,重新跑一次
parkId=番禺基地过滤,确认 totalCount 包含 322 条 - 修改 spec 文档第 46 行(必改):把”无效死代码”改成”有效过滤 + 行为约束”
- 新提 issue/spec:”pass_user 同步支持多园区”——调研其他园区 pass_user 是怎么同步的,再决定是否去掉
setParkId(DEFAULT_PARK_ID)这一行
关键代码位置
service-provider/admin-service/src/main/resources/mapper/UserMapperExt.xml:7-37— SELECT + JOIN 改子查询service-provider/through-service/src/main/java/cn/csg/building/through/service/impl/PassUserServiceImpl.java:447— 注释掉 setParkId