「不要做任何加工」:会议室同步钉钉原始数据表落地记
导读
上级要看会议室同步时钉钉返回的完整原始数据——不做任何加工。需求一句话就说完了,真正的功夫在五个关键决策上,其中一个是对”自作聪明”方案的否决。落表之后还有个意外发现:钉钉返回的会议室比业务表多出 58 间,而原始快照表成了唯一的裁判。
前言
9 月 5 日的任务一句话说得清:参照 admin 库已有的 sys_ding_user_raw(钉钉用户原始数据表,34 个字段原样落列、每次同步全量覆盖),给会议室同步也落两张原始数据表。有现成样板在前,代码量不大(最终 8 个文件 294 行),但”参照现有实现”这五个字,恰恰是最容易翻车的地方——参照到什么程度,得先对齐。
一、需求对齐:五个决策,其中一个是否决
动手之前先把五个问题问清楚了,这是这一天最值的投入:
| 决策点 | 结论 | 理由 |
|---|---|---|
| 放哪个库 | smart 库 | 会议室是 smart 服务的领域,不放 admin |
| 字段粒度 | 纯 JSON 原文,不加提取列 | 见下面的否决故事 |
| 存储粒度 | 按行存,每个对象一行 | 分组 11 行、会议室 158 行,而不是整个 HTTP 响应存一行 |
| 覆盖策略 | 先清空再全量写入 | 只保留最新快照,与 sys_ding_user_raw 语义一致 |
| 本期范围 | 只做分组 + 会议室两张 | 预订同步(syncDingReservation)不做 |
最值得记录的是第二个决策。最初的方案 B 想在 raw_json 之外提取 groupId、groupName 等常用列方便查询——被上级一句话否掉:**”你这样子放给他,做了你自己的加工”**。上级要的是”钉钉返回了什么我就看到什么”,任何提取列都是加工,加工就意味着有人要背”加工得对不对”的锅。纯 JSON 原文落库,责任边界干净利落。
教训:**”参照 X 实现”的需求,先对齐参照到什么程度再动手**。方案 B 不是技术错误,是没问清楚就替需求方做了主。
二、同步链路与落表点位
原有链路是手动触发的一条线:点”同步分组及会议室”按钮 → POST /smart/sync/syncConferenceInfoFromDingByUser,然后串行两步:
落表点位的选择是这次改动里唯一有讲究的地方:**必须在”拿到钉钉返回后、业务转换前”**。转换之后的数据已经被裁剪过滤过,失去了快照意义;只有这个位置能保证落库的字节就是钉钉返回的字节。

图 1:一次按钮触发,分组与会议室两路返回在业务转换前先原样落快照表
三、表结构与实现:快照表三件套
两张表结构完全一样,只有 3 列:
实现清单:SysDingRoomGroupRaw / SysDingRoomRaw 实体(service-model 层)、两个 Mapper + 手写 XML(add / deleteAll / listByCondition)、Flyway 迁移(building_smart 库),最后 ConferenceFacadeServiceImpl 注入两个 mapper,新增两个私有方法:
同一批共用同一个 syncTime 是个刻意的细节:它让”某一次同步的完整快照”可以用一个 WHERE sync_time = ? 完整捞出来,快照的”同一时刻”语义就保住了。
四、意外发现:钉钉说有 158 间,业务表只有 100 间
落库后顺手比对,发现一个此前没人注意的事实:钉钉实际返回 158 间会议室,本地业务表 conference_room 只有 100 条。差值主要来自两类:没分组的房间(roomGroup.groupId=0)和”钉钉投屏_xxx”这类虚拟房间(roomStatus=1 停用)——业务转换有自己的过滤逻辑,而 raw 表照单全收。

图 2:raw 表照单全收 158 间,业务表过滤后 100 间——差值是没分组的房间和停用的虚拟房间
这正是原始数据表存在的意义:**业务表回答”系统认为有哪些会议室”,raw 表回答”钉钉说有哪些会议室”**。将来两边对不上时,不用抓包不用重放,SELECT 一下就知道是谁的问题——raw 表是唯一的裁判。
五、踩坑五条
- GenericMapper 的 T 必须继承 BaseEntity,否则编译直接报”不在类型变量 T 的范围内”——框架约束,新实体别漏继承。
@TableId(type = IdType.UUID)不会自动生成主键。名字有误导性,它只是声明主键策略,INSERT 时并不会替你生成值,必须手动setId(UUID...),否则报Field 'id' doesn't have a default value。- 同步报”缺少园区信息”:测试账号在
sys_user_park表无记录,token 里 parkId 为空。插一条园区关联后还要重新登录才生效——/user/userParkSwitch切园区接口返回成功但不刷新 token,这是个容易白等半天的坑。 - **打包报
Unable to rename ...jar**:运行中的 smart JVM 锁住了 jar,netstat -ano | findstr 18160找到 PID 杀掉再打包。 - 会议室分页用
nextToken,代码里已有重复页保护(nextToken相同即断言失败)——翻旧代码时发现的,顺手记一笔。
六、性能评估与两个诚实记录的弱点
169 次单行 INSERT(11 + 158),本地约 0.5~1 秒,相对整个同步约 13 秒的耗时占比很小;手动按钮触发,频率极低。两个理论弱点评估后暂不处理:
- 逐行插入未批量:会议室到千级规模、或同步改成定时任务时,再改
foreach批量 INSERT; deleteAll+ 插入没包事务(私有方法上@Transactional自调用不生效):半写状态会被下一次同步自愈——快照表不留历史,风险可接受。
“暂不处理”也是设计决策的一部分:把风险和自愈路径写下来,比假装没有风险强。验证环节:本地建表 + 重启 smart + 真实账号调同步接口,分组 11 条、会议室 158 条全部落库成功,业务表数据不翻倍;mvn -pl service-provider/smart-service -am test BUILD SUCCESS;推送前还顺手解决了与同事 TRACE 日志提交的一处同行冲突,两边都保留。
经验总结
- “参照 X 实现”先对齐参照粒度——被否的方案 B 是没问清楚就替需求方做了主;
- 原始数据落表的位置只有一处:拿到返回后、业务转换前;
- 快照表三件套:
raw_json原文 + 同批同值sync_time+ 全量覆盖; - raw 表是业务表的裁判:数据对不上时,让原始数据自己说话;
- 低频小数据量场景,诚实记录的理论弱点好过过度设计。
结语
这个需求代码量不大,但它回答了所有同步类功能早晚会被问到的问题——“数据对不上的时候,谁说了算?”留了原始快照,答案就永远是:让数据自己说话。