PDEPARTS 字段下线与钉钉同步真实格式链路测试报告

导读

今天捅了两个马蜂窝:中台 Dataphin 下线了 PDEPARTS 字段,导致所有依赖该字段解析公司归属的 Java 逻辑静默失效(getCompanyIdFromPDeparts / getDepartmentAsList 返回 null),1542 个 Ding 用户 100% 缺 sys_user_park;用真实格式跑 12 个端到端测试用例,又挖出 4 个新 bug(增量更新取消删除标记失效、园区链路仍依赖 PDEPARTS、pass_user 链路同样依赖 PDEPARTS、部门 vs 公司数据源不一致)。修复方案是改用部门 UUID 沿 parent_id 递归上溯查桥表(CTE),以及补回 synSysUserToPassUser ADD 分支漏设的 distSyncStatus。

🎧 文章导读

🎵 背景音乐

前言

钉钉用户同步链路异常:园区侧永远收不到人员数据,门禁通行功能受影响。最初以为是某个 service 的小 bug,深挖才发现是中台数据模型层变更(PDEPARTS 字段下线)导致整条链路静默失效。

这种”上游变更、下游无感”的故障最难排查——没有异常日志,没有错误码,只有”业务不工作”的最终表现。

问题背景

钉钉用户数据从南网中台 Dataphin 流入园区平台,经过四个环节最终下发到各园区:

1
2
3
中台拉数据 → admin 写本地(sys_user / sys_user_organization / sys_user_park)
→ Feign 调 through 补写总部 pass_user
→ 定时任务通过 ROMA 下发园区

这条链路上任一环节数据缺失,下游就会拿到空值,最终表现为”园区收不到人员”。

本次问题正是链路最上游的数据模型发生了变更——中台 Dataphin 的 h_user 表下线了 PDEPARTS 字段,同时 department 字段从数字 JSON 数组格式(如 [494216765])变成了 UUID 字符串(如 e6a63c82-80bc-4a75-8d71-67675cfcf5ec)。

Dataphin 数据流断链
图1:Dataphin 数据流断链

排查思路

从现象入手:trace 整条同步链路

用户最初报告的现象是”园区写不进 pass_user”。从终端往回追溯,pass_user 是 through 服务写的,入口是 PassUserClient.synSysUserToPassUser() 这个 Feign 调用。

关键教训:本地 mock 数据的 PDEPARTS 是手工构造的、格式正确,所以本地测试看起来都正常——这埋下了后面一个重要教训。

查真实库找证据:dist_sync_status 分布异常

dist_sync_status 分布
图2:dist_sync_status 分布

用 MCP 查 building_through 库的 pass_user 表,按 dist_sync_status 分组统计:

  • NONE 454 条(标记为”总部数据无需同步”,正常)
  • ING 236 条(卡在”已下发待 ACK”状态)
  • null 7 条(新创建的 pass_user 完全没有 status)

null 状态这 7 条很可疑——追溯到 PassUserServiceImpl.synSysUserToPassUser() 的 ADD 分支,发现了第二个 Bug:**新增分支漏设 distSyncStatus**。

查 0618 生产 dump:Ding 用户 100% 缺 sys_user_park

继续往上翻:admin 服务的 synSysUserToPassUser 是从 sys_user 关联 sys_user_park 拿 parkId 的。如果 sys_user_park 没写,下游 passUserDTO.parkId 就是 null。

用 Python 解析 E:/nanwang/项目会用压缩包/数据库sql文件/db0618(1).sql 这个 12MB 的生产快照:

1542 个 user_from='Ding' 的真实钉钉用户里,0 个有 sys_user_park 记录(100% 缺失)。只有 182 个(11.8%)有 sys_user_organization 记录——这 11.8% 是老格式时代的存量。

用户提供关键证据:pDeparts=null 的真实响应

转折点来了。用户提供了真实的 Dataphin 响应样本:

1
2
3
4
5
DataPhinUser(userId=$:LWCP_v1:$4G18hbvCSH4n5IRfVMkxGoF3Ti+7WKR7,
openId=wBSKiPJ5gTiPjwcR0gB1xMNQiEiE,
department=e6a63c82-80bc-4a75-8d71-67675cfcf5ec, // UUID
pDeparts=null, // 彻底没了
name=马漫欢, mobile=15602386855, ...)

再去查 DataPhinClientImpl.queryDingUser() 的”全字段探测”日志——它调 setReturnFields(null) 探测 Dataphin h_user 表所有可用列,返回的 metaColumns 完整列表里根本没有 PDEPARTS

1
2
3
PROFILEPHOTOURL, EXTATTR, DINGTALKUNIONID, STATE, EMAIL, HOMEPHONE, ENTRYDATE,
OFFICEPHONE, EMPLOYEENUMBER, OBJECTID, POSITION, ..., PARENTID, PASSWORD,
TITLE, CREATEDTIME, WECHATUSERID, SORTKEY, MOBILE

没有 PDEPARTS。也就是说,字段在源表里被删了,不是代码忘了请求。这个证据把根因坐实。

根因分析

[!danger] 根因描述
中台 Dataphin h_user 源表下线了 PDEPARTS 字段,同时 department 字段格式从数字 JSON 数组变为 UUID 字符串。所有依赖这两个字段解析公司归属和部门挂靠的 Java 逻辑(getCompanyIdFromPDepartsgetDepartmentAsList)静默返回 null/空集合,导致 sys_user_park 和 sys_user_organization 全部缺失,下游 pass_user 拿不到 parkId,整条下发链路断裂。

DataPhinUserResp.java:135-153getCompanyIdFromPDeparts(),在 pDeparts 为空时直接返回 null:

1
2
3
4
5
6
public String getCompanyIdFromPDeparts() {
if (!StringUtils.hasText(this.pDeparts)) {
return null; // ← pDeparts=null,直接返回 null
}
// ...解析 [[dept, company, seq]] 取 [0][1]...
}

链路从此处开始崩塌:

1
2
3
4
5
6
sys_user 没有 sys_user_park 关联
→ listByConditionExt(UserMapperExt.xml 的 LEFT JOIN)查不到 park_id
→ synSysUserToPassUser 写入 passUserDTO.setParkId(null)
→ PassDistSyncer.resolveShouldSync(null) 返回 false
→ 标记 NONE"总部数据无需同步"
→ 永不下发到园区

次要 Bug:synSysUserToPassUser ADD 分支漏设 distSyncStatus

1
2
3
4
5
6
7
// PassUserServiceImpl.java:471-488(ADD 分支,漏了一行)
passUserDTO.setParkId(user.getParkId());
passUserDTO.setOrgId(user.getOrgId());
passUserDTO.setDeleteFlag(DictConstants.DEL_FLAG_NO);
WebExtUtils.iniCreate(passUserDTO);
// ← 这里没有 setDistSyncStatus(PENDING)!
toAdd.add(passUserDTO);

对比同方法 createTempPassUser() 第 184-186 行是有这个状态的——唯独 ADD 分支漏了。

[!info] 为什么是 null 而不是其他值
pass_user.dist_sync_status 列在仓库里没有任何 ALTER 迁移脚本,列定义 DEFAULT = nullIS_NULLABLE=YES——是手动加的列。通用 mapper 的 addSelective 只插非 null 字段,不设就是 null。而 findByDistSyncStatus 查询是 WHERE dist_sync_status = 'PENDING'(PassUserMapper.xml:1378-1381),null 状态永远查不到。

数据库佐证:building_through 库里 7 条 dist_sync_status IS NULL 的记录全是 6/12-6/16 的测试数据。

关于 EncryptionUtils 的排查弯路

排查过程中走了两次弯路,值得记录。一开始怀疑两个地方:

  1. synSysUserToPassUserphoneEncryptToHex(user.getPhone()) 二次加密——实际 EncryptionUtils.phoneEncryptToHex幂等的(只在 length==11 时加密,已加密的 64 位 hex 原样返回),不会二次加密
  2. idCardSubTail(null, 4) NPE——idCardSubTail 内部 StringUtils.isEmpty(null) 为 true 直接返回 null,不会 NPE

[!warning] 教训
下结论前先读 utils 的实现,不要靠方法名望文生义。这两个误报各占去半小时排查时间。

修复方案

PDEPARTS 相关(主因)

  1. **废弃 getCompanyIdFromPDeparts()**——字段没了,方法彻底作废
  2. 改用 PARENTID UUID 定位组织——新 schema 里 department(映射自 PARENTID)是父级组织节点 UUID。要从这个 UUID 出发,沿 sys_organization.parent_id 往上找公司级节点
  3. 重建 sys_park_organization 桥表——现在存的是老数字 id(59096189136602494),新 UUID 对不上,查 park_id 永远查不到

synSysUserToPassUser ADD 分支(次要 Bug)

补一行:

1
passUserDTO.setDistSyncStatus(PaasDistSyncStatus.PENDING.getStatus());

并刷存量 7 条 null 状态数据:

1
2
3
4
UPDATE pass_user
SET dist_sync_status = 'PENDING'
WHERE dist_sync_status IS NULL
AND delete_flag = '0';

真实格式链路测试报告

字段映射变更(已适配生产)

Java 字段 @JsonProperty @JsonProperty
userId USERID DINGID
openId OPENID DINGTALKUNIONID
department DEPARTMENT PARENTID
orgEmail ORGEMAIL EMAIL
avatar AVATAR PROFILEPHOTOURL
userOrder USERORDER SORTKEY
tsUserId TSUSERID OBJECTID

mock 数据格式(真实生产格式)

  • PARENTID:单个 UUID(如 e6a63c82-80bc-4a75-8d71-67675cfcf5ec
  • PDEPARTSnull(生产不再返回)
  • 4 个 mock 用户用不同 UUID 模拟”同部门 → 换部门 → 跨园区”

HTTP 层测试结果

用例 HTTP data 备注
T6 companyList 200 4 条
T7 saveCompanyBind 200 null
T8 互斥 200 FAIL_OPERATE ✅ 业务失败符合预期
T1 同步 200 1
T2 幂等 200 1
T3 重名 200 1
T4-create 200 1
T4-update 换部门 200 1
T12 dirty check 200 1
T5-create 200 1
T5-update 跨园区 200 1
T11 getCurParkOrgTree 200 2 条
T9 parkName/orgName/userName 筛选 200 totalCount:0 ⚠️ Bug#1 副作用

DB 落库(关键发现)

本次同步结果 状态
sys_user 4 个 mock 用户全 delete_flag=1 Bug#1
sys_user_organization 4 条新记录,org_id=PARENTID UUID ✅ 新代码适配
sys_user_park 0 条新记录,全是 06-16 首次测试快照 Bug#2
pass_user 0 条变化,全是 06-16 快照 Bug#3

4 个 Bug 表格
图3:4 个 Bug 表格

4 个新 Bug

Bug#1:增量更新取消删除标记失效

  • 位置UserSyncServiceImpl.java:146-180toUpdateUsers stream 内的 map 块)
  • 症状:本次同步后所有已存在用户被软删(delete_flag=1),T9/T10 查询 totalCount=0
  • 根因:第 161 行 existDingUser.setDeleteFlag(NO) 只修改了 existDingUserMap value 的引用对象,但新构造的 updateUser DTO(真正写入 DB 的对象)未设置 deleteFlag 字段
  • 影响范围:所有走 update 分支的已存在钉钉用户(含真实员工),本次 5297 个真实用户被一并错误软删

修复方向:在 updateUser DTO 中显式 setDeleteFlag(DEL_FLAG_NO);或确认 listByCondition 返回的对象与 existDingUserMap value 是否同一引用,并据此调整 list 的 peek 时机。

Bug#2:园区链路仍依赖 PDEPARTS

  • 位置UserSyncServiceImpl.java:228, 297, 307
  • 症状PDEPARTS=nullgetCompanyIdFromPDeparts() 返回 null,整个园区写/更新/跨园区跳过
  • 根因:真实生产数据不再返回 PDEPARTS,公司 ID 失去来源

修复方向

  • A. DataPhinUser 新增 companyId 字段(直接来自中台)
  • B. 通过 PARENTIDsys_organization 表反查公司
  • C. 保持现状但与业务确认新数据源

Bug#3:pass_user 链路同样依赖 PDEPARTS

与 Bug#2 同一根因,修复与 Bug#2 一并处理

Bug#4(设计层):部门 vs 公司数据源不一致

  • 症状sys_user_organizationPARENTID(新代码已适配 UUID),但 sys_user_park / pass_user 仍走 PDEPARTS。两边数据格式(UUID vs 数字 ID)和值完全不一致
  • 建议:统一公司获取逻辑,所有关联表用同一来源

软删恢复

测试造成的影响:5297 个真实用户被错误软删(删前 Ding 用户 5301)

执行恢复 SQL:

1
2
UPDATE sys_user SET delete_flag='0' WHERE user_from='Ding' AND delete_flag='1';
-- 影响:5301 行(Changed: 5301),全部恢复

用户同步→园区下发 完整链路(改动后)

1
2
3
4
5
6
7
8
9
10
Dataphin API (DataPhinUser department: UUID)
→ handleDingUserSync (admin-service)
→ 写 sys_user
→ 写 sys_user_organization (org_id = 部门 UUID)
→ 写 sys_user_park (org_id = 公司级 org_id, park_id)
↑ 需要 findParkByOrgWalkUp 递归 CTE
→ triggerPassUserSync Feign → synSysUserToPassUser (through-service)
→ 写 pass_user (dist_sync_status=PENDING)
→ PassDistSyncer (90s 定时)
→ ROMA → 园区 pass_user

findParkByOrgWalkUp 递归 CTE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
WITH RECURSIVE org_chain AS (
-- 起点:用户部门 UUID
SELECT org_id, parent_id, 0 AS depth
FROM sys_organization
WHERE org_id = #{deptOrgId} AND delete_flag = '0'
UNION ALL
-- 逐层往上
SELECT o.org_id, o.parent_id, c.depth + 1
FROM sys_organization o
JOIN org_chain c ON o.org_id = c.parent_id AND o.delete_flag = '0'
WHERE c.parent_id IS NOT NULL AND c.parent_id <> '' AND c.depth < 20 -- 防环
)
SELECT po.*
FROM sys_park_organization po
JOIN org_chain c ON po.org_id = c.org_id
WHERE po.delete_flag = '0'
ORDER BY c.depth ASC -- 取最近的命中祖先
LIMIT 1

桥表语义

sys_park_organization(桥表)里配的是 “公司级 org_id ↔ park_id” 的映射。”公司”= 桥表里配了的那个组织节点,它可以是根、可以是电厂级、也可以是部门级 —— 取决于你在 sys_park_organization 里配了哪一层。

[!example] 三种配法

  • 桥表配”广蓄电厂 → 广蓄园区” → 广蓄电厂及其下面所有人都归广蓄园区
  • 桥表配”南方电网总部 → 某园区” → 整棵树的人都归那个园区
  • 两个都配了 → CTE 取最近的(depth 小的)

经验总结

排查方法论

  • 从终端现象往回追溯链路,每一环用真实数据(生产 dump、MCP 查库、API 响应样本)验证,不要只在本地复现。本地 mock 是最大的盲区
  • 下结论前先读 utils 的实现,不要靠方法名望文生义
  • 排查起点永远先看返回值(affected rows、日志)—— synSysUserToPassUser 返回 335 立刻说明是过滤问题

代码规范

  • 依赖外部数据字段的解析方法,字段缺失时应该打 WARN 日志而不是静默返回 null——getCompanyIdFromPDepartsgetDepartmentAsList 都是静默失败的,导致问题难以被监控发现
  • 数据模型层变更应该有契约测试schema 校验,字段下线时让集成测试第一时间失败

测试覆盖

  • 跨服务同步链路应该有端到端集成测试,用接近真实格式的样本数据(包括 UUID 格式的 department),而不只是构造正确格式的 mock
  • 测试场景要包含”数据正确性”——除了”有没有写”,还要查”写得对不对”

待办事项

  • 确认 dept 接口(queryDingDept)返回格式是否能串成组织树,再决定 PDEPARTS 的替代方案
  • 修复 Bug#1(增量更新取消删除标记失效)
  • 修复 Bug#2(园区链路改用 PARENTID)
  • 修复 Bug#3(pass_user 链路同步适配)
  • 解决 Bug#4(部门 vs 公司数据源统一)
  • 重建 sys_park_organization 桥表(新 UUID 映射)

关联

  • [[2026-06-22 工作记录]]
  • [[钉钉同步园区失败-PDEPARTS字段下线/问题分析与修复]]
  • [[用户同步园区下发完整链路]]