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 | 中台拉数据 → admin 写本地(sys_user / sys_user_organization / sys_user_park) |
这条链路上任一环节数据缺失,下游就会拿到空值,最终表现为”园区收不到人员”。
本次问题正是链路最上游的数据模型发生了变更——中台 Dataphin 的 h_user 表下线了 PDEPARTS 字段,同时 department 字段从数字 JSON 数组格式(如 [494216765])变成了 UUID 字符串(如 e6a63c82-80bc-4a75-8d71-67675cfcf5ec)。

图1:Dataphin 数据流断链
排查思路
从现象入手:trace 整条同步链路
用户最初报告的现象是”园区写不进 pass_user”。从终端往回追溯,pass_user 是 through 服务写的,入口是 PassUserClient.synSysUserToPassUser() 这个 Feign 调用。
关键教训:本地 mock 数据的 PDEPARTS 是手工构造的、格式正确,所以本地测试看起来都正常——这埋下了后面一个重要教训。
查真实库找证据: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 | DataPhinUser(userId=$:LWCP_v1:$4G18hbvCSH4n5IRfVMkxGoF3Ti+7WKR7, |
再去查 DataPhinClientImpl.queryDingUser() 的”全字段探测”日志——它调 setReturnFields(null) 探测 Dataphin h_user 表所有可用列,返回的 metaColumns 完整列表里根本没有 PDEPARTS:
1 | PROFILEPHOTOURL, EXTATTR, DINGTALKUNIONID, STATE, EMAIL, HOMEPHONE, ENTRYDATE, |
没有 PDEPARTS。也就是说,字段在源表里被删了,不是代码忘了请求。这个证据把根因坐实。
根因分析
[!danger] 根因描述
中台 Dataphinh_user源表下线了PDEPARTS字段,同时department字段格式从数字 JSON 数组变为 UUID 字符串。所有依赖这两个字段解析公司归属和部门挂靠的 Java 逻辑(getCompanyIdFromPDeparts、getDepartmentAsList)静默返回 null/空集合,导致 sys_user_park 和 sys_user_organization 全部缺失,下游 pass_user 拿不到 parkId,整条下发链路断裂。
DataPhinUserResp.java:135-153 的 getCompanyIdFromPDeparts(),在 pDeparts 为空时直接返回 null:
1 | public String getCompanyIdFromPDeparts() { |
链路从此处开始崩塌:
1 | sys_user 没有 sys_user_park 关联 |
次要 Bug:synSysUserToPassUser ADD 分支漏设 distSyncStatus
1 | // PassUserServiceImpl.java:471-488(ADD 分支,漏了一行) |
对比同方法 createTempPassUser() 第 184-186 行是有这个状态的——唯独 ADD 分支漏了。
[!info] 为什么是 null 而不是其他值
pass_user.dist_sync_status列在仓库里没有任何 ALTER 迁移脚本,列定义DEFAULT = null、IS_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 的排查弯路
排查过程中走了两次弯路,值得记录。一开始怀疑两个地方:
——实际synSysUserToPassUser里phoneEncryptToHex(user.getPhone())二次加密EncryptionUtils.phoneEncryptToHex是幂等的(只在length==11时加密,已加密的 64 位 hex 原样返回),不会二次加密——idCardSubTail(null, 4)NPEidCardSubTail内部StringUtils.isEmpty(null)为 true 直接返回 null,不会 NPE
[!warning] 教训
下结论前先读 utils 的实现,不要靠方法名望文生义。这两个误报各占去半小时排查时间。
修复方案
PDEPARTS 相关(主因)
- **废弃
getCompanyIdFromPDeparts()**——字段没了,方法彻底作废 - 改用 PARENTID UUID 定位组织——新 schema 里
department(映射自 PARENTID)是父级组织节点 UUID。要从这个 UUID 出发,沿sys_organization.parent_id往上找公司级节点 - 重建
sys_park_organization桥表——现在存的是老数字 id(59096189、136602494),新 UUID 对不上,查 park_id 永远查不到
synSysUserToPassUser ADD 分支(次要 Bug)
补一行:
1 | passUserDTO.setDistSyncStatus(PaasDistSyncStatus.PENDING.getStatus()); |
并刷存量 7 条 null 状态数据:
1 | UPDATE pass_user |
真实格式链路测试报告
字段映射变更(已适配生产)
| 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)PDEPARTS:null(生产不再返回)- 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 |

图3:4 个 Bug 表格
4 个新 Bug
Bug#1:增量更新取消删除标记失效
- 位置:
UserSyncServiceImpl.java:146-180(toUpdateUsersstream 内的 map 块) - 症状:本次同步后所有已存在用户被软删(
delete_flag=1),T9/T10 查询totalCount=0 - 根因:第 161 行
existDingUser.setDeleteFlag(NO)只修改了existDingUserMapvalue 的引用对象,但新构造的updateUserDTO(真正写入 DB 的对象)未设置deleteFlag字段 - 影响范围:所有走 update 分支的已存在钉钉用户(含真实员工),本次 5297 个真实用户被一并错误软删
修复方向:在 updateUser DTO 中显式 setDeleteFlag(DEL_FLAG_NO);或确认 listByCondition 返回的对象与 existDingUserMap value 是否同一引用,并据此调整 list 的 peek 时机。
Bug#2:园区链路仍依赖 PDEPARTS
- 位置:
UserSyncServiceImpl.java:228, 297, 307 - 症状:
PDEPARTS=null时getCompanyIdFromPDeparts()返回null,整个园区写/更新/跨园区跳过 - 根因:真实生产数据不再返回
PDEPARTS,公司 ID 失去来源
修复方向:
- A.
DataPhinUser新增companyId字段(直接来自中台) - B. 通过
PARENTID→sys_organization表反查公司 - C. 保持现状但与业务确认新数据源
Bug#3:pass_user 链路同样依赖 PDEPARTS
与 Bug#2 同一根因,修复与 Bug#2 一并处理。
Bug#4(设计层):部门 vs 公司数据源不一致
- 症状:
sys_user_organization走PARENTID(新代码已适配 UUID),但sys_user_park/pass_user仍走PDEPARTS。两边数据格式(UUID vs 数字 ID)和值完全不一致 - 建议:统一公司获取逻辑,所有关联表用同一来源
软删恢复
测试造成的影响:5297 个真实用户被错误软删(删前 Ding 用户 5301)
执行恢复 SQL:
1 | UPDATE sys_user SET delete_flag='0' WHERE user_from='Ding' AND delete_flag='1'; |
用户同步→园区下发 完整链路(改动后)
1 | Dataphin API (DataPhinUser department: UUID) |
findParkByOrgWalkUp 递归 CTE
1 | WITH RECURSIVE org_chain AS ( |
桥表语义
sys_park_organization(桥表)里配的是 “公司级 org_id ↔ park_id” 的映射。”公司”= 桥表里配了的那个组织节点,它可以是根、可以是电厂级、也可以是部门级 —— 取决于你在 sys_park_organization 里配了哪一层。
[!example] 三种配法
- 桥表配”广蓄电厂 → 广蓄园区” → 广蓄电厂及其下面所有人都归广蓄园区
- 桥表配”南方电网总部 → 某园区” → 整棵树的人都归那个园区
- 两个都配了 → CTE 取最近的(depth 小的)
经验总结
排查方法论
- 从终端现象往回追溯链路,每一环用真实数据(生产 dump、MCP 查库、API 响应样本)验证,不要只在本地复现。本地 mock 是最大的盲区
- 下结论前先读 utils 的实现,不要靠方法名望文生义
- 排查起点永远先看返回值(affected rows、日志)——
synSysUserToPassUser返回 335 立刻说明是过滤问题
代码规范
- 依赖外部数据字段的解析方法,字段缺失时应该打 WARN 日志而不是静默返回 null——
getCompanyIdFromPDeparts和getDepartmentAsList都是静默失败的,导致问题难以被监控发现 - 数据模型层变更应该有契约测试或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字段下线/问题分析与修复]]
- [[用户同步园区下发完整链路]]