PDEPARTS 废弃 + 头像同步修复 + 用户查询回归:递归 CTE 上溯

导读

6/22 暴露的 PDEPARTS 下线问题,今天给出完整修复:废弃所有依赖 PDEPARTS 的解析,改用部门 UUID 沿 sys_organization.parent_id 递归上溯查 sys_park_organization 桥表(MySQL 8 CTE)。一个 commit 改了 8 个文件、+167/-33,把昨天识别出的 4 个 Bug 全部修完。同时顺手把头像同步 bug(setFacePic 误写为 setFacePicAuditStatus)修了,并补充 face_pic='2' 历史脏数据的处理策略。

🎧 文章导读

🎵 背景音乐

前言

6/22 链路测试报告里挖出来的 4 个 Bug,今天一次性收尾。核心是 PDEPARTS 替代方案:用递归 CTE 沿部门 UUID 上溯查桥表,这是 MySQL 8 才支持的语法(项目里其他 SQL 都很”老派”,没想到会用到 CTE)。

递归 CTE 上溯
图1:递归 CTE 上溯

一、核心修复:废弃 PDEPARTS,改用部门 UUID 上溯

方案

1
2
3
4
5
6
7
8
9
10
11
WITH RECURSIVE org_chain AS (
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

改动文件

文件 改动
UserSyncServiceImpl.java ADD/UPDATE 分支:getCompanyIdFromPDeparts()findParkByOrgWalkUp(getDepartment());部门列表从 PDEPARTS 数组改为单值构造
ParkOrganizationServiceImpl.java 新增 findParkByOrgWalkUp 实现
ParkOrganizationMapper.xml 新增 CTE 递归 SQL
IParkOrganizationService.java 接口定义

数据依赖

sys_user_organization.org_id 从公司级改为存用户直接部门 UUID。桥表 sys_park_organization 需要有公司级 org_id → park_id 映射(广蓄/清蓄已补)。

防环机制

AND c.depth < 20防环保险——理论上 sys_organization.parent_id 应该是有向无环树,但万一数据脏了(人为乱改 parent_id)会无限递归。20 层足够业务穿透(公司级通常 3-5 层)。

二、回归修复:用户查询接口

PDEPARTS 修复后 sys_user_organization.org_id 语义变更(公司级→部门级),导致两个用户查询接口返回 0 条。

Bug 1:/user/pagingOrgAndParklistByOrgIdsAndParkId

  • 现象:带 orgId+parkId 搜用户返回 0 条
  • 根因:SQL 通过 uo.org_id = po.org_id JOIN 桥表,部门级 ≠ 公司级 → JOIN 失效
  • 修复(UserMapper.xml):去掉桥表 JOIN,sys_user_park 直连 sys_user_organization

Bug 2:/user/pagelistByConditionExt

  • 现象:带 orgId+parkId 搜用户返回 0 条,只搜 parkId 能搜到 138 条
  • 根因uo.org_id = #{orgId} 精确匹配部门级 ≠ 公司级
  • 修复(UserMapperExt.xml):orgId 过滤改用 urp.org_id(sys_user_park 公司级),urp 子查询加 MIN(org_id)

组织树修复
图2:组织树修复

三、组织树修复

Bug 3:selectCurParkOrgTree 返回空

  • 现象:广蓄园区组织树返回 []
  • 根因:递归从 parentId="" 起步,UUID 组织树根节点 parent_id=NULLequalsIgnoreCase(null, "") = false → 根进不来
  • 修复(OrganizationServiceImpl.java):loadCurParkTreeloadOrganizationTree 的匹配条件兼容 null 和 “” 两种根写法

额外改进

  • selectOrganizationTree 强制 delete_flag='0'(原来不过滤)
  • getCompanyList 改为两级树(一级公司 + 二级子节点)
  • saveCompanyBind 去掉 orgIds 非空校验

头像同步策略表
图3:头像同步策略表

四、钉钉头像同步

问题 1:setFacePic 误写为 setFacePicAuditStatus

UserSyncServiceImpl.java 第 129 行 bug:

1
2
3
4
5
// 错误代码(bug)
userDTO.setFacePic(FACE_CHANGE_AUDIT_STATUS_REFUSE); // ← 应该是 setFacePicAuditStatus

// 正确代码(修复后)
userDTO.setFacePicAuditStatus(FACE_CHANGE_AUDIT_STATUS_REFUSE);

导致所有钉钉同步用户 face_pic = '2'(这是 audit_status 的”拒绝”状态,不是 face_pic 的值)。

问题 2:Dataphin 返回的 avatar 完全没用

PROFILEPHOTOURL(avatar 字段)包含钉钉头像 URL,但同步代码完全没用这个字段。

修复策略

用户类型 face_pic 现状 同步后行为
新用户 下载钉钉头像 → upload aided OSS → /fs/hikFacePic/...
老用户(脏值"2" "2" 有头像→/fs/...;无头像→清空
老用户(空值) 空/NULL 不回填(避免批量下载几千个头像占事务)
老用户(正常/fs/... 正常路径 不覆盖

关键设计

  • uploadDingAvatar 整段 try/catch,Feign 超时 / aided 宕机不影响同步事务
  • shouldBackfillFacePic 第一版只匹配脏值 "2",空值留给后续离线任务

为什么不同步回填空值

如果对所有空值用户都触发头像下载:

  • 15000 用户 × 头像下载(每张 ~2MB)= 30GB 网络流量
  • 单个事务内长时间 IO 阻塞
  • 失败重试成本高

主动放弃空值回填——这是和 pass_user 字段同步同样的折中思路:高频场景做,低频场景留给后续离线任务。

五、其他发现(未修复,记录备查)

问题 说明
sys_user_organization.id 列长不够 varchar(64) → UUID 时代 36+36=72 > 64,生产需 ALTER TABLE sys_user_organization MODIFY COLUMN id varchar(128)
桥表数据 广蓄/清蓄需 INSERT UUID 映射(生产 UUID 和 dev 不同,需先查)
dev OSS 未配置 config.enabled: false,需在 bootstrap-dev.yml 加 oss 配置(provider:local, bucketName:Windows 路径)
face_pic='2' 历史脏数据 ~160 条(全软删),5 条未删的全是测试账号

六、验证结果

  • mock 端到端测试:admin 侧 sys_user + sys_user_organization + sys_user_park ✅,through 侧 pass_user dist_sync_status=PENDING ✅
  • /user/page MCP 验证:带 orgId+parkId 改前 0 条 → 改后 3 条 ✅
  • /user/pagingOrgAndPark MCP 验证:改前 0 条 → 改后 3 条 ✅
  • selectCurParkOrgTree 递归 null 兼容:修复后返回树结构 ✅
  • Java 8 编译通过 ✅

提交概览

提交: f78fbe0be (8 files, +167 -33)
分支: 618zh

类别 文件数 行数
核心修复(PDEPARTS) 4 +98
回归修复(查询接口) 2 +22
组织树修复 1 +15
头像同步修复 1 +27
其他杂项 0 +5

经验总结

递归 CTE 的实战价值

MySQL 8 的 WITH RECURSIVE 解决了”沿树结构上溯直到命中条件”这类问题,比 Java 里写递归函数简洁 100 倍:

1
2
3
4
5
6
7
8
9
// Java 递归方案(伪代码)
Organization org = findOrgById(deptOrgId);
while (org != null && org.getParentId() != null) {
ParkOrganizationDTO bind = findParkByOrg(org.getOrgId());
if (bind != null) return bind;
org = findOrgById(org.getParentId());
}
return null;
// 缺点:N 次 SQL,无法在事务里优化
1
2
3
4
-- SQL CTE 方案
WITH RECURSIVE org_chain AS (...)
SELECT ... LIMIT 1;
-- 优点:1 次 SQL,DB 优化器能并行

BaseResultMap 错映射是 MyBatis-Plus 隐形坑

第 2 个回归 bug 的根因:UserParkMapper.xmlBaseResultMaporg_idpark_id 都映射到 parkId 字段。没有配置 mapUnderscoreToCamelCase,裸 resultType 也不行。最终采用显式 AS 别名的方式绕过:

1
2
3
4
5
6
7
8
<select id="listActiveByUserIds" resultType="...UserParkDTO">
SELECT user_id AS userId,
org_id AS orgId, <!-- 显式别名,绕开 BaseResultMap 错映射 -->
park_id AS parkId
FROM sys_user_park
WHERE user_id IN (...)
AND delete_flag = '0'
</select>

教训:MyBatis-Plus 的 BaseResultMap 自动生成不是万能的,手写 resultType 时必须显式 AS 别名。

主动放弃低频场景是工程纪律

头像回填同步只覆盖”脏值 "2"“和”新用户”,空值留给离线任务

  • 高频场景(新用户入库):立即做
  • 低频场景(存量空值回填):数据迁移期单独跑

这与之前 pass_user 字段同步”换部门放弃”的思路一致:折中方案的纪律是分清高频/低频,专注高频场景

数据迁移期要列详细清单

5 个未修复的发现都是”未来生产要处理”的:

  • sys_user_organization.id 列长不够——生产环境必须 ALTER
  • 桥表数据(广蓄/清蓄 UUID)——需要从生产 dump 提取
  • dev OSS 配置——本地开发才能验证头像链路
  • face_pic='2' 历史脏数据——需要在迁移脚本里清理

这些不能”先上线再说”——必须先列出清单,每条 owner + 截止日期。

关联

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