钉钉同步写 sys_user_park + 园区绑定一级公司:从功能到 OGNL 经典踩坑
导读
今天一口气做了 2 个 feature + 1 个构建命令摸清:钉钉同步补
sys_user_park占位记录、园区绑定一级公司(顶级 org + 互斥)、admin-service 打包 fat jar。feature 走的是 spec-first + subagent-driven,3 轮 spec review + 3 轮 code review。但 06-11 集成测试第一次就跑出 500——saveCompanyBind调updateParkOrganization时,generic mapper 对 Date 字段 OGNL 求值崩了。**1 行 fix:删掉多余的WebExtUtils.iniCreate(model);**。这条经验已经写进全局 CLAUDE.md。
🎧 文章导读
🎵 背景音乐
前言
业务方反馈”钉钉同步过来的用户进不了门”——门禁/人脸下发找不到匹配的用户归属。handleDingUserSync() 写 sys_user 和 sys_user_organization 没问题,但没写 sys_user_park(用户-园区关系)。同时 sys_park_organization 桥表历史脏数据严重(顶级公司 + 子部门混着绑),业务方要求只配一级公司且一个公司只能被一个园区绑。
两个 feature 同根问题,一起做完刚好闭环。spec 提前写好(docs/superpowers/specs/2026-06-10-ding-user-sync-user-park-design.md、docs/superpowers/specs/2026-06-11-park-company-bind-design.md),3 轮 spec review 把模糊地带全聊清楚,再交给 subagent 执行。

图1:南网权限模型四级关系
Feature 1:钉钉同步写 sys_user_park
业务背景
南网项目的权限模型:用户 → sys_user_park 关联 → 园区 → 一级公司 → 子部门 → 数据权限。
钉钉是中台用户来源的主渠道。UserSyncServiceImpl.handleDingUserSync() 每天定时把钉钉通讯录里的员工同步进 sys_user 和 sys_user_organization,但**一直没有写 sys_user_park**。结果:
- 园区级门禁权限下发找不到匹配的用户归属
- 园区消息推送按园区过滤时直接被忽略
- 园区级统计(
SELECT ... FROM sys_user WHERE park_id = ?)永远是空
设计思路
核心数据关系
1 | 人 → 部门 → 公司 → 园区 |
钉钉返回的 pDeparts 是 JSON 字符串,形如 [[叶子部门id, 公司id, 序号]]。第一组 [0] 是叶子部门,第二组 [1] 是公司 id。**园区维度只认”公司”**——因为一个公司对应一个园区(项目里的强假设),所以我们要的不是叶子部门,而是 pDeparts[0][1]。
为什么不走”先全量同步再后处理”
考虑过两个备选:
- 方案 A:
handleDingUserSync()只写sys_user,园区关系走单独定时任务从sys_user_organization反推——这条路不通,链路长且依赖组织树完整性 - 方案 B:
handleDingUserSync()里同步写sys_user_park——采用。一个事务里把三张表都写好,下游门禁/人脸/统计全部能直接读到园区归属
桥表查不到时怎么办
公司 id 到 park_id 的映射走 sys_park_organization 桥表。但桥表里不一定有(比如新注册的公司还没建园区):
- 跳过:不写
sys_user_park记录,依赖下游业务容忍空park_id——否决,下游会真的出问题 - 写占位记录(
park_id = NULL):保留用户,运维通过WHERE park_id IS NULL找到待补登——采用。运维可观测性比”静默丢数据”重要
关键实现
1. 解析钉钉 pDeparts 拿公司 id
在 DataPhinUserResp 内嵌类 DataPhinUser 上加了一个 getCompanyIdFromPDeparts() 纯函数:
1 | // DataPhinUserResp.java:127-153 |
几个关键决定:
- **
@JsonIgnore**:不参与 JSON 序列化/反序列化,避免被框架误判 - 异常吞掉返回 null:钉钉的
pDeparts格式千奇百怪,任何一个解析失败都不应该让整个同步任务挂掉 size() < 2防御:钉钉的pDeparts在某些边界情况下只有[[叶子部门id]]一项(公司 id 缺失),直接get(1)会IndexOutOfBoundsException- 三段判空:
pDeparts字符串、解析后的 list、第一组 list,分别对应三个层级的失败
2. 索引对齐:构造一个同源的 Map
这是改动里最容易踩坑的地方:
1 | // UserSyncServiceImpl.java:71-76 |
直觉上要拿 DataPhinUser 的字段(比如 getCompanyIdFromPDeparts())就该写 comingDingUsers.get(i)——但 toAddUsers 已经被 filter 过了,索引完全错位了。
隐藏的强约束:Map 的 filter 条件必须与 toAddUsers 严格一致。否则 toAddUsers 里有 X,但 dingUserByDingId 里没有 X,循环里 dingUser = dingUserByDingId.get(addUser.getDingUserId()) 就拿到 null,然后 continue 跳过——静默少写 sys_user_park 记录。
3. 写 sys_user_park 占位记录
1 | // UserSyncServiceImpl.java:193-224(简化版) |
两个 continue 兜底:第一处防御 Map filter 漂移,第二处防御 pDeparts 解析失败。
测试与验证
- 单元测试:
getCompanyIdFromPDeparts()13/13 通过,覆盖空值、格式错误、嵌套层级、null元素、数字/字符串 id - 编译验证:
mvn -pl service-provider/admin-service -am clean package -DskipTestsBUILD SUCCESS - Code review:3 轮 spec review + 3 轮 code quality review,每轮都有实质调整
风险与后续
[!warning] R1 整方法回滚
@Transactional(rollbackFor = Exception.class),单条addSelective抛异常会回滚整次同步。短期不修,长期拆成”按用户独立事务”。
[!warning] R6 filter 漂移
Map filter 和toAddUsersfilter 必须同步改,否则静默少写sys_user_park。当前靠 code review 人工对照,建议提取公共 filter 方法。

图2:钉钉同步写 sys_user_park
Feature 2:园区绑定一级公司
业务背景
南网项目权限模型的中间一环”园区↔组织”绑得乱七八糟:
- 园区
番禺基地绑了 5 个 org,其中 3 个是二级公司(钉钉同步过来的子部门) - 同一个 org(
110007195)被两个不同园区同时绑了 - 还有 1116 条
sys_user_park记录挂着这些混乱绑定
业务方要求:园区维度只能配置一级公司(org_type IS NULL 且 parent_id 为空),并且一个公司只能被一个园区绑。
设计思考
新建表 vs 复用旧表
第一反应是新建一张 sys_park_top_company,但现实是:
- 现有
sys_park_organization已经有selectParkOrganizationList、updateParkOrganization这些现成的批量查/upsert 方法 ParkOrganizationServiceImpl已经注入了ParkMapper,复用等于 0 行 mapper 新增- 历史子级绑定的 1116 条
sys_user_park记录如果不动,新建表 = 旧数据继续生效 + 新功能并行,反而更乱
最终选了复用旧表 + 加过滤 SQL 的方案。新功能只读/写顶级公司,历史子级绑定作为只读数据保留。
互斥校验的位置
互斥校验放在 service 层而不是 SQL 层:
- MySQL 没合适的唯一索引能表达”org_id 全局唯一”
- 数据库级约束会带 N 个失败回滚,报错信息对前端不友好
- 业务校验可以一次性收集所有冲突
关键实现
1. 顶级公司查询 SQL
1 | <!-- OrganizationMapperExt.xml --> |
parent_id = '' 是平台约定,parent_id IS NULL 是数据兜底——实测历史数据里有 1 条 NULL。
2. 互斥校验:service 层 2 次批量查
最大忌讳是 for 循环里逐个查 park 名称——请求 10 个 orgId 就 10 次 SQL。
1 | // 第 1 次:批量查这批 orgId 的现有绑定 |
无论前端传 1 个还是 100 个 orgId,DB 查询次数稳定在 2-3 次。
集成测试:MyBatis OGNL Date 坑
[!bug] 经典踩坑
集成测试调用POST /parkOrganization/saveCompanyBind,直接 500:
1
2
3
4
5
6 java.lang.IllegalArgumentException: invalid comparison: java.lang.String and java.util.Date
at org.apache.ibatis.ognl.OgnlOps.compareWithConversion(OgnlOps.java:97)
at org.apache.ibatis.ognl.ASTNotEq.getValueBody(ASTNotEq.java:53)
...
at ParkOrganizationServiceImpl.updateParkOrganization:62
at ParkOrganizationServiceImpl.saveCompanyBind:239
根因
1 | // saveCompanyBind(错误版本) |
updateParkOrganization 内部会调一个通用 mapper 方法 baseMapper.listByCondition(model),XML 是generic的——对 DTO 的所有字段都做 <if test="xxx != null and xxx != ''"> 动态拼接:
1 | <if test="createTime != null and createTime != ''"> <!-- Date 跟 '' 比 --> |
OGNL 在评估 createTime != '' 时,Date 和 String 类型不一致抛错。MyBatis-Plus 这套 generic mapper 写法在 DTO 字段都是 String/Integer 时没问题,只要混入 Date 字段就翻车。
修法
**删掉那行 WebExtUtils.iniCreate(model);**。
1 | // saveCompanyBind(修复后) |
updateParkOrganization 内部对自己构造的 inner DTO 自己会调 iniCreate,外层传进来的 model 根本不需要再初始化一次。这是**典型的”老代码 copy-paste 没清干净”**。
教训
[!warning] 三个复盘要点
Plan 阶段直接抄了 N+1 时代用过的
iniCreate(model),没意识到重构后updateParkOrganization已经自己处理。外层iniCreate既是多余也是有害(设了 Date 字段触发 OGNL 报错)。Plan review 时应当模拟调用链跑一遍,而不只是光看代码对不对。我在 review 阶段自己提了”
@NotEmpty注解可能 dead code”等小问题,但漏看了这一行——因为我没有顺着saveCompanyBind → updateParkOrganization → listByCondition → XML这条链跑一遍调用流。generic listByCondition 接收任意 model 时,不要设置 Date 字段。这是平台层 generic mapper 写法的固有脆弱性——所有字段都用
!= ''校验,Date 类型天然不兼容。
补救 commit f14cd9be4 fix: remove redundant iniCreate call causing Date OGNL comparison error 是个 1 行 fix——这种 1 行 fix 越短,说明根因越简单,也越说明 plan review 阶段本应该抓到。
4 个集成测试场景全过
| 场景 | 输入 | 预期 | 实际 |
|---|---|---|---|
| 1. 首次绑定 | park=番禺基地, orgIds=[110007195] | 200 OK,DB 写入 | ✅ 通过 |
| 2. 互斥拒绝 | park_A 已绑 110007195,park_B 再绑 | 200 OK + 错误信息 | ✅ 通过 |
| 3. 空 orgIds | park=X, orgIds=[] | 拒绝 ARGS_ILLEGAL | ✅ 通过 |
| 4. 替换绑定 | park_A 已绑 110007195,换绑 136602494 | upsert 成功 | ✅ 通过 |

图3:saveCompanyBind 集成测试 500
构建命令:JDK 8 + Maven -am
最终命令(可直接复制)
1 | cd "/e/nanwang/smart-park-cloud - no jar" |
为什么必须用 JDK 8
1 | [ERROR] /E:/nanwang/smart-park-cloud - no jar/service-util/src/main/java/cn/csg/building/admin/utils/QrCodeByIdTypeUtils.java:[12,16] 找不到符号 |
sun.misc.BASE64Encoder 是 Sun 内部类,JDK 9+ 模块化后被移除。环境 PATH 默认指向 JDK 17,但项目源码按 JDK 8 写的。
为什么必须用 -am
1 | [ERROR] Could not find artifact cn.csg.building:service-util:jar:1.0.0-SNAPSHOT |
service-model 依赖 service-util,但 service-util 是项目内部模块(不会发布到公网)。单跑时 Maven 不知道要从源码构建。**-am(also-make)**:自动把 admin-service 依赖链上的所有模块按顺序构建。
产物位置
1 | E:\nanwang\smart-park-cloud - no jar\service-provider\admin-service\target\admin-service-1.0.0-SNAPSHOT.jar |
约 104 MB(Spring Boot fat jar),10 个模块全过,耗时 ~1.5 分钟。
经验总结
Spec-first + subagent-driven 跑通
3 轮 spec review + 3 轮 code review,每轮都有实质调整:
- Spec review 第 1 轮:明确”顶级公司 =
parent_id='' AND org_type IS NULL“的判定 - Spec review 第 2 轮:决定不删历史子级绑定——业务侧没拍板前不动脏数据
- Code review 第 1 轮:subagent 自己抓到
selectParkOrganizationList是不是真有List<String>入参 - Code review 第 2 轮:抓住”互斥校验用 2 次批量查而不是 N+1”
每轮 review 成本 ~15 分钟,但抓到的问题如果流入集成测试阶段,单次 debug 至少 30 分钟。
Plan review 缺一次”调用链跑读”
OGNL 那个坑暴露的最深层问题是:plan review 阶段我只看代码片段,没有顺着调用链把 saveCompanyBind → updateParkOrganization → listByCondition → generic XML 跑一遍。
[!danger] 教训登记
- [2026-06-11] Rule correction: Plan review 阶段必须 trace 一次 controller → service → mapper 调用链(预防 generic mapper 写法的 OGNL Date 坑)
后续 TODO
- 业务侧拍板历史子级绑定是清理还是迁移
- 互斥并发量监控,达到阈值时加
SELECT FOR UPDATE - 调研是否要复用 generic mapper 的
listByCondition—— 改成<if test="createTime != null">形式
关联
- [[2026-06-11 工作记录]]
- [[钉钉同步写sys_user_park/功能设计与实现]]
- [[园区绑定一级公司/功能设计与实现]]
- [[2026-06-11 admin-service构建命令记录]]