pass_user 死亡覆盖链与 spec 错误判断:从集成测试挖出 421 条脏数据

导读

6/11 实现的”钉钉同步触发 pass_user 同步”功能本身端到端跑通,但暴露出 6/11 spec 文档第 46 行的一个事实错误判断:把 PassUserServiceImpl.synSysUserToPassUser() 里的 setParkId(DEFAULT_PARK_ID) 误判为”无效死代码”。实际是有效过滤。更糟的是下午挖出了真正的死亡覆盖链:admin 端 UserMapperExt.xmllistByConditionExt SQL 没 select urp.park_id,导致 user.getParkId() 永远是 NULL,421 条 pass_user.park_id 全被污染成 NULL。

🎧 文章导读

🎵 背景音乐

前言

集成测试是揭露设计缺陷的最佳工具。这次集成测试同时挖出了三个问题:

  1. spec 文档第 46 行的事实错误判断(setParkId(DEFAULT_PARK_ID) 是死代码 → 实际是有效过滤)
  2. 死亡覆盖链的真正根因(UserMapperExt.xml SELECT 漏列)
  3. 421 条历史脏数据(其中 322 条本应是番禺基地用户)

整个过程是一个标准的”反向归因”案例:表象和根因完全相反。

死亡覆盖链路流程图
图1:pass_user.park_id 死亡覆盖链

第一阶段:集成测试 + spec 错误判断

测试场景 T1:mock 用户没进 pass_user

我准备了一个 mock 用户 MOCK_TEST_PASS_001USERIDDEPARTMENTPDEPARTS 三个字段都填了,调用 POST /sync/mockDingUserSync 后去查 building_through.pass_user 表,发现没有这个用户

第一反应:”Feign 调用失败了” 或 “through-service 那边有 bug”。

但先用 OpenFeign 的日志和 triggerPassUserSync 的返回值(返回的是 affected rows 数)确认了一个关键事实:返回值是 335不是 0。这意味着 synSysUserToPassUser 确实跑了,而且写了 335 个用户——只是没写我的 mock 用户。

反查源码

1
2
3
4
5
6
7
// PassUserServiceImpl.java:442-466
UserDTO userDTO = new UserDTO();
userDTO.setParkId(DictConstants.DEFAULT_PARK_ID); // ← 这行是有效过滤
userDTO.setDeleteFlag(DictConstants.DEL_FLAG_NO);
pagingCondition.setEntity(userDTO);
// ...
Page<UserDTO> userPage = userClient.paging(pagingCondition).getData();

userClient.paging(queryCondition) 走的是 building 内部统一的查询协议,会用 userDTO 的所有非空字段做 WHERE 过滤。所以这一行的含义是:

1
WHERE park_id = '284cd7570daf5d1ca8e121bc025f7a02' AND delete_flag = '0'

——只查”番禺基地”且未删除的用户。

反查 DictConstants.DEFAULT_PARK_ID

1
2
// service-util/.../DictConstants.java:765
public static final String DEFAULT_PARK_ID = "284cd7570daf5d1ca8e121bc025f7a02";

这是硬编码的”番禺基地” 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 同步功能基本正常。但如果:

  1. 未来扩展到非番禺基地的新园区
  2. 钉钉侧新增了非番禺基地的部门/人员
  3. 桥表里有非番禺基地的 org_id

这些用户的 pass_user 记录永远不会出现,会直接影响 /gateway/through/passUser/page 接口的展示完整性。

测试方法学的反面案例

测试通过的 mock 数据

1
2
3
4
5
{
"USERID": "MOCK_TEST_PASS_003",
"DEPARTMENT": "[136602494]",
"PDEPARTS": "[[1, 136602494, 1]]"
}

136602494 在桥表里映射到番禺基地 → sys_user_park.park_id = 284cd757... → 满足 setParkId(DEFAULT_PARK_ID) 过滤 → 写入 pass_user

测试失败的 mock 数据

1
2
3
4
5
{
"USERID": "MOCK_TEST_PASS_001",
"DEPARTMENT": "[59096189]",
"PDEPARTS": "[[1, 59096189, 1]]"
}

59096189 不在桥表(之前测试硬删了)→ sys_user_park.park_id = NULL被过滤

421 条脏数据分布
图2:421 条 NULL park_id 量化结果

第二阶段:死亡覆盖链的真正根因

现象升级

上午只判断了”setParkId(DEFAULT_PARK_ID) 是有效过滤”(事实纠正),下午继续深挖发现真正致命的是 admin 端 mapper 漏 SELECT 列表。

pass_user.page 接口(POST /gateway/through/passUser/page)按 parkId 过滤时,几乎所有数据都查不到(即使不是 NULL 的,parkId 也是错的)。

死亡覆盖链

1
2
3
4
5
6
sys_user 行 → UserMapperExt.xml listByConditionExt
→ LEFT JOIN sys_user_park urp on u.user_id = urp.user_id
→ SELECT u.user_id, u.user_name, ... (漏了 urp.park_id!)
→ UserDTO.parkId = null (BaseResultMap 映射的字段没数据)
→ line 478 passUserDTO.setParkId(user.getParkId()) → null
→ pass_user.park_id = NULL

3 行代码的死亡覆盖链

1
2
3
4
5
6
7
8
9
10
11
12
// PassUserServiceImpl.java:445-478
UserDTO userDTO = new UserDTO();
userDTO.setParkId(DictConstants.DEFAULT_PARK_ID); // line 447: 设请求 parkId=番禺基地
PagingCondition<UserDTO> pagingCondition = new PagingCondition<>();
pagingCondition.setEntity(userDTO);
Page<UserDTO> userPage = userClient.paging(pagingCondition).getData();
// 拿到 322 个番禺用户(userDTO.parkId 参与 WHERE 过滤)
for (UserDTO user : userPage.getRecords()) {
PassUserDTO passUserDTO = new PassUserDTO();
passUserDTO.setParkId(user.getParkId()); // line 478: user.parkId=NULL → 覆盖
// ...
}

**为什么 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
2
3
4
5
6
SELECT pu.real_name, pu.park_id, urp.park_id as expected
FROM building_through.pass_user pu
JOIN building_admin.sys_user u ON pu.sys_user_id = u.user_id
JOIN building_admin.sys_user_park urp ON u.user_id = urp.user_id AND urp.delete_flag = 0
WHERE pu.park_id IS NULL OR pu.park_id != urp.park_id;
-- 命中 421 条,全是 pu.park_id = NULL

**421 条 pass_user.park_id IS NULL**,其中 322 条本应是番禺基地用户(park_id = 284cd757…)。

UserMapperExt SQL 修复对比
图3:UserMapperExt 漏 SELECT 列修复

修复方案

修复 1:让新 sync 的 pass_user 写入正确的 park_id

改动文件 1UserMapperExt.xml

SELECT 加 ,urp.park_id,JOIN 改为子查询解决多园区用户重复问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 修改前 -->
SELECT DISTINCT u.user_id, u.user_name, ...
FROM sys_user u
LEFT JOIN sys_user_park urp on u.user_id = urp.user_id

<!-- 修改后 -->
SELECT DISTINCT u.user_id, u.user_name, ..., urp.park_id
FROM sys_user u
LEFT JOIN (
SELECT user_id, MIN(park_id) as park_id
FROM sys_user_park
GROUP BY user_id
) urp on u.user_id = urp.user_id

[!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 行为一致(保持不过滤是原行为,加了反而引入行为变更)。

改动文件 2PassUserServiceImpl.java

注释掉 line 447 setParkId(DEFAULT_PARK_ID),让扫描覆盖所有园区:

1
2
3
4
5
6
// line 445-448
// 临时注释掉 setParkId:让扫描覆盖所有园区,配合 admin 端 mapper 改动让 pass_user 拿真实 parkId
// userDTO.setParkId(DictConstants.DEFAULT_PARK_ID); <!-- 注释 -->
PagingCondition<UserDTO> pagingCondition = new PagingCondition<>();
UserDTO userDTO = new UserDTO();
userDTO.setDeleteFlag(DictConstants.DEL_FLAG_NO);

修复 2:清理 321 条历史 NULL park_id 数据

[!warning] 增量同步对历史脏数据无效
修复后 synSysUserToPassUser 走增量逻辑:if (!userIds.contains(user.getUserId())) 跳过已有。后果:321 个老 NULL park_id 的番禺用户永远不会被更新。必须单独 UPDATE。

1
2
3
4
5
6
UPDATE pass_user pu
JOIN building_admin.sys_user u ON pu.sys_user_id = u.user_id
JOIN building_admin.sys_user_park urp ON u.user_id = urp.user_id AND urp.delete_flag = 0
SET pu.park_id = urp.park_id
WHERE pu.park_id IS NULL;
-- 预期: 更新 321 条(322 个番禺 - 1 个 MOCK_MIN_001 = 321)

验证证据

修复后跑一次 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