给员工 ID 算出审批人:负责人反查的角色化地基
BPM 审批流要跑起来,地基不是流程图,而是一个看起来朴素的问题:给一个员工 ID,算出这张单该批给谁。这次改造把原先”部门管理员”的散装机制整体角色化,落地两个负责人反查接口。中间有三个决策值得展开:主部门锚点的回填、递归 CTE 的”最近一级优先”、以及同步绑定与手动绑定的免疫隔离。

图 1:从员工的主部门出发,沿组织树向上找最近一级绑定了负责人的节点。
两个新角色,两种绑定方式
| 角色 | 绑定来源 | 用途 |
|---|---|---|
| 部门负责人 | 钉钉同步按职位字典关键词自动绑/解 + 允许手动补绑 | 监控菜单权限 + BPM 审批人 |
| 办公室负责人 | 纯手动绑定,同步不碰 | BPM 审批人 |
两个角色都是系统内置不可删,各配固定 UUID 由迁移脚本种入。旧角色下的菜单权限平移到新角色,旧角色走脚本软删退休。
级联解绑,只剔”自动绑”的
上级定的业务规则是:只有通过同步进去成为负责人的,职位变动后才从角色里剔除;手动添加的不动。实现上零表结构改动:同步绑定的行标 create_id='system',级联解绑的 SQL 带 AND create_id='system' 只删带标行。边缘情况也自洽——同一人既有自动绑又被手动绑过,解绑只删自动那条,他还是负责人。
绑定用差量而不是全删全插:先查已绑集合,命中且未绑才插、不再命中才解。旧方案每轮全删全插,面对十万级用户的全量同步,每轮把绑定表整表重写一遍——写入放大不可接受。同步类任务的第一原则:不变的行,一行都不应该被重写。
is_primary 锚点:不做这一步,反查全是空的
反查的第一步是”员工的主部门”。但改之前,用户-部门关系表的 is_primary 列只有前端手动编辑用户时才写,钉钉同步建关系时从不写——几乎全部真实用户没有主部门,反查 SQL 写得再漂亮也是查空。
修复分两半:
- 同步补写:同步链路的建关系点补上主部门标记。安全前提是新的数据模型下钉钉一个用户就一个部门,唯一关系即主部门,不用猜;
- 存量回填:迁移脚本只回填”只有一个有效关系”的用户(十三万余行),多关系用户留给 UI 路径自己管。
回填后实测:真实用户 100% 有且只有一个主部门,剩下几千条”多关系无主”是孤儿数据(用户在主表里根本不存在),记录在案、没动。
做依赖存量数据的新功能时,先验证数据前提,再写查询逻辑。”关系表里主部门列为空”这种地基级缺口,不会在任何报错里暴露——它只是安静地让所有查询返回空。
两条递归 CTE:同向上爬,不同停法
MySQL 8 的递归公共表表达式(WITH RECURSIVE)让”沿组织树向上找”不再需要应用层循环查库。两条反查 SQL 都是向上爬,但停止条件不同。
部门负责人:从主部门起沿 parent_id 向上,WHERE lvl = MIN(lvl) 最近一级优先——本级有负责人就停,不把上级的也返回。语义是”离我最近的那级负责人管我”。
办公室负责人:先向上爬到”parent 是根”的节点(即二级公司),再向下递归整棵子树找绑定了办公室负责人角色的部门——子树内任何层级绑的都算。语义是”整个公司大片区共用一个办公室负责人”。
两个语义差异用同一套递归骨架表达,区别只在锚点和终止条件。配套的两个防御也值得一记:
- 读侧 DISTINCT 兜底:角色绑定表存在历史重复绑定(同一人同角色多行),软删表又加不了唯一索引——解绑后物理行还在,唯一索引会堵死重新绑定。查询侧去重是代价最小的方案;
- 安全列白名单:用户表的通用结果映射里带了密码和盐值字段,实体上没有任何序列化屏蔽。反查 SQL 只输出
user_id, user_name, real_name, position四列,并配了一条契约测试守着——谁以后改成u.*,测试当场红。
发布时序:顺序错了会出事
这次改造的发布顺序是功能正确性的一部分:
1 | 发布代码 → 立刻跑一次全量钉钉同步(空窗期负责人角色没人,反查全空) |
另一个运营层面的坑:办公室负责人没有任何自动来源,发布后如果没人去绑,反查永远为空。技术上线和职责数据就位,是两条都要排期的线。
参考资料
- MySQL 8.0 Reference: WITH (Common Table Expressions)(递归 CTE 语法与限制)
- MySQL 8.0 Reference: 递归 CTE 遍历层次数据
- 钉钉开放平台:通讯录管理(部门与用户同步的数据模型)