钉钉同步写 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——saveCompanyBindupdateParkOrganization 时,generic mapper 对 Date 字段 OGNL 求值崩了。**1 行 fix:删掉多余的 WebExtUtils.iniCreate(model);**。这条经验已经写进全局 CLAUDE.md。

🎧 文章导读

🎵 背景音乐

前言

业务方反馈”钉钉同步过来的用户进不了门”——门禁/人脸下发找不到匹配的用户归属。handleDingUserSync()sys_usersys_user_organization 没问题,但没写 sys_user_park(用户-园区关系)。同时 sys_park_organization 桥表历史脏数据严重(顶级公司 + 子部门混着绑),业务方要求只配一级公司一个公司只能被一个园区绑

两个 feature 同根问题,一起做完刚好闭环。spec 提前写好(docs/superpowers/specs/2026-06-10-ding-user-sync-user-park-design.mddocs/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_usersys_user_organization,但**一直没有写 sys_user_park**。结果:

  • 园区级门禁权限下发找不到匹配的用户归属
  • 园区消息推送按园区过滤时直接被忽略
  • 园区级统计(SELECT ... FROM sys_user WHERE park_id = ?)永远是空

设计思路

核心数据关系

1
人 → 部门 → 公司 → 园区

钉钉返回的 pDeparts 是 JSON 字符串,形如 [[叶子部门id, 公司id, 序号]]。第一组 [0] 是叶子部门,第二组 [1] 是公司 id。**园区维度只认”公司”**——因为一个公司对应一个园区(项目里的强假设),所以我们要的不是叶子部门,而是 pDeparts[0][1]

为什么不走”先全量同步再后处理”

考虑过两个备选:

  • 方案 AhandleDingUserSync() 只写 sys_user,园区关系走单独定时任务从 sys_user_organization 反推——这条路不通,链路长且依赖组织树完整性
  • 方案 BhandleDingUserSync() 里同步写 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
2
3
4
5
6
7
8
9
10
11
12
13
// DataPhinUserResp.java:127-153
@JsonIgnore
public String getCompanyIdFromPDeparts() {
if (!StringUtils.hasText(this.pDeparts)) return null;
try {
List<List<Object>> list = JsonUtils.parse(this.pDeparts.trim(), List.class);
if (list == null || list.isEmpty()) return null;
List<Object> firstGroup = list.get(0);
if (firstGroup == null || firstGroup.size() < 2) return null;
Object companyId = firstGroup.get(1);
return companyId == null ? null : String.valueOf(companyId);
} catch (Exception e) { return null; }
}

几个关键决定:

  • **@JsonIgnore**:不参与 JSON 序列化/反序列化,避免被框架误判
  • 异常吞掉返回 null:钉钉的 pDeparts 格式千奇百怪,任何一个解析失败都不应该让整个同步任务挂掉
  • size() < 2 防御:钉钉的 pDeparts 在某些边界情况下只有 [[叶子部门id]] 一项(公司 id 缺失),直接 get(1)IndexOutOfBoundsException
  • 三段判空pDeparts 字符串、解析后的 list、第一组 list,分别对应三个层级的失败

2. 索引对齐:构造一个同源的 Map

这是改动里最容易踩坑的地方:

1
2
3
4
// UserSyncServiceImpl.java:71-76
Map<String, DataPhinUser> dingUserByDingId = comingDingUsers.stream()
.filter(u -> !existDingUserMap.containsKey(u.getUserId())) // 与 toAddUsers 同步
.collect(Collectors.toMap(DataPhinUser::getUserId, Function.identity()));

直觉上要拿 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UserSyncServiceImpl.java:193-224(简化版)
for (UserDTO addUser : toAddUsers) {
DataPhinUser dingUser = dingUserByDingId.get(addUser.getDingUserId());
if (dingUser == null) continue; // Map filter 漂移时的兜底
String companyId = dingUser.getCompanyIdFromPDeparts();
if (companyId == null) continue; // pDeparts 解析失败的兜底

ParkOrganizationDTO q = new ParkOrganizationDTO();
q.setOrgId(companyId);
List<ParkOrganizationDTO> parkOrgList = parkOrganizationService.listByCondition(q);
String parkId = parkOrgList.isEmpty() ? null : parkOrgList.get(0).getParkId();

UserParkDTO dto = new UserParkDTO();
dto.setUserId(addUser.getUserId());
dto.setOrgId(companyId);
dto.setParkId(parkId);
dto.setDeleteFlag("0");
dto.setCreateName("中台数据同步");
userParkService.addSelective(dto);
}

两个 continue 兜底:第一处防御 Map filter 漂移,第二处防御 pDeparts 解析失败。

测试与验证

  • 单元测试getCompanyIdFromPDeparts() 13/13 通过,覆盖空值、格式错误、嵌套层级、null 元素、数字/字符串 id
  • 编译验证mvn -pl service-provider/admin-service -am clean package -DskipTests BUILD SUCCESS
  • Code review:3 轮 spec review + 3 轮 code quality review,每轮都有实质调整

风险与后续

[!warning] R1 整方法回滚
@Transactional(rollbackFor = Exception.class),单条 addSelective 抛异常会回滚整次同步。短期不修,长期拆成”按用户独立事务”。

[!warning] R6 filter 漂移
Map filter 和 toAddUsers filter 必须同步改,否则静默少写 sys_user_park。当前靠 code review 人工对照,建议提取公共 filter 方法。

sys_user_park 写入流程
图2:钉钉同步写 sys_user_park

Feature 2:园区绑定一级公司

业务背景

南网项目权限模型的中间一环”园区↔组织”绑得乱七八糟:

  • 园区 番禺基地 绑了 5 个 org,其中 3 个是二级公司(钉钉同步过来的子部门)
  • 同一个 org(110007195)被两个不同园区同时绑了
  • 还有 1116 条 sys_user_park 记录挂着这些混乱绑定

业务方要求:园区维度只能配置一级公司org_type IS NULLparent_id 为空),并且一个公司只能被一个园区绑

设计思考

新建表 vs 复用旧表

第一反应是新建一张 sys_park_top_company,但现实是:

  • 现有 sys_park_organization 已经有 selectParkOrganizationListupdateParkOrganization 这些现成的批量查/upsert 方法
  • ParkOrganizationServiceImpl 已经注入了 ParkMapper,复用等于 0 行 mapper 新增
  • 历史子级绑定的 1116 条 sys_user_park 记录如果不动,新建表 = 旧数据继续生效 + 新功能并行,反而更乱

最终选了复用旧表 + 加过滤 SQL 的方案。新功能只读/写顶级公司,历史子级绑定作为只读数据保留

互斥校验的位置

互斥校验放在 service 层而不是 SQL 层:

  • MySQL 没合适的唯一索引能表达”org_id 全局唯一”
  • 数据库级约束会带 N 个失败回滚,报错信息对前端不友好
  • 业务校验可以一次性收集所有冲突

关键实现

1. 顶级公司查询 SQL

1
2
3
4
5
6
7
8
9
10
<!-- OrganizationMapperExt.xml -->
<select id="listTopLevelOrgs" resultMap="BaseResultMap">
SELECT org_id, org_name, org_code
FROM sys_organization
WHERE (parent_id = '' OR parent_id IS NULL)
AND org_type IS NULL
AND delete_flag = '0'
AND org_id NOT IN ('TSRY', 'LSRY')
ORDER BY sort
</select>

parent_id = '' 是平台约定,parent_id IS NULL数据兜底——实测历史数据里有 1 条 NULL。

2. 互斥校验:service 层 2 次批量查

最大忌讳是 for 循环里逐个查 park 名称——请求 10 个 orgId 就 10 次 SQL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第 1 次:批量查这批 orgId 的现有绑定
List<ParkOrganizationDTO> existBinds = baseMapper.selectParkOrganizationList(orgIds);

// in-memory 收集冲突(O(n) 遍历,不查 DB)
Map<String, String> conflictOrgToPark = new HashMap<>();
Set<String> conflictParkIds = new HashSet<>();
for (ParkOrganizationDTO bind : existBinds) {
if (!bind.getParkId().equals(req.getParkId())) { // 不是当前 park = 真冲突
conflictOrgToPark.put(bind.getOrgId(), bind.getParkId());
conflictParkIds.add(bind.getParkId());
}
}

if (!conflictOrgToPark.isEmpty()) {
// 第 2 次:批量查冲突 park 的名称(1 次 SQL,不是 N 次)
List<ParkDTO> parks = parkMapper.getParkList(new ArrayList<>(conflictParkIds));
// 第 3 次:批量查冲突 org 的名称(1 次 SQL)
List<OrganizationDTO> orgs = organizationMapper.getOrgList(new ArrayList<>(conflictOrgToPark.keySet()));
throw new BizException("公司 X 已被园区 park_A 绑定...");
}

无论前端传 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
2
3
// saveCompanyBind(错误版本)
WebExtUtils.iniCreate(model); // ← 这行把 model.createTime 设成 Date 类型
this.updateParkOrganization(model);

updateParkOrganization 内部会调一个通用 mapper 方法 baseMapper.listByCondition(model),XML 是generic的——对 DTO 的所有字段都做 <if test="xxx != null and xxx != ''"> 动态拼接:

1
2
3
<if test="createTime != null and createTime != ''">  <!-- Date 跟 '' 比 -->
AND create_time = #{createTime}
</if>

OGNL 在评估 createTime != '' 时,Date 和 String 类型不一致抛错。MyBatis-Plus 这套 generic mapper 写法在 DTO 字段都是 String/Integer 时没问题,只要混入 Date 字段就翻车

修法

**删掉那行 WebExtUtils.iniCreate(model);**。

1
2
// saveCompanyBind(修复后)
this.updateParkOrganization(model);

updateParkOrganization 内部对自己构造的 inner DTO 自己会调 iniCreate,外层传进来的 model 根本不需要再初始化一次。这是**典型的”老代码 copy-paste 没清干净”**。

教训

[!warning] 三个复盘要点

  1. Plan 阶段直接抄了 N+1 时代用过的 iniCreate(model),没意识到重构后 updateParkOrganization 已经自己处理。外层 iniCreate 既是多余也是有害(设了 Date 字段触发 OGNL 报错)。

  2. Plan review 时应当模拟调用链跑一遍,而不只是光看代码对不对。我在 review 阶段自己提了”@NotEmpty 注解可能 dead code”等小问题,但漏看了这一行——因为我没有顺着 saveCompanyBind → updateParkOrganization → listByCondition → XML 这条链跑一遍调用流。

  3. 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 成功 ✅ 通过

MyBatis OGNL Date 报错堆栈
图3:saveCompanyBind 集成测试 500

构建命令:JDK 8 + Maven -am

最终命令(可直接复制)

1
2
3
4
cd "/e/nanwang/smart-park-cloud - no jar"
export JAVA_HOME=/e/jdk
export PATH=$JAVA_HOME/bin:$PATH
mvn -pl service-provider/admin-service -am clean package -DskipTests

为什么必须用 JDK 8

1
2
3
[ERROR] /E:/nanwang/smart-park-cloud - no jar/service-util/src/main/java/cn/csg/building/admin/utils/QrCodeByIdTypeUtils.java:[12,16] 找不到符号
[ERROR] 符号: 类 BASE64Encoder
[ERROR] 位置: 程序包 sun.misc

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构建命令记录]]