「不要做任何加工」:会议室同步钉钉原始数据表落地记

导读

上级要看会议室同步时钉钉返回的完整原始数据——不做任何加工。需求一句话就说完了,真正的功夫在五个关键决策上,其中一个是对”自作聪明”方案的否决。落表之后还有个意外发现:钉钉返回的会议室比业务表多出 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 之外提取 groupIdgroupName 等常用列方便查询——被上级一句话否掉:**”你这样子放给他,做了你自己的加工”**。上级要的是”钉钉返回了什么我就看到什么”,任何提取列都是加工,加工就意味着有人要背”加工得对不对”的锅。纯 JSON 原文落库,责任边界干净利落。

教训:**”参照 X 实现”的需求,先对齐参照到什么程度再动手**。方案 B 不是技术错误,是没问清楚就替需求方做了主。

二、同步链路与落表点位

原有链路是手动触发的一条线:点”同步分组及会议室”按钮 → POST /smart/sync/syncConferenceInfoFromDingByUser,然后串行两步:

会议室同步链路(原有逻辑 + 本次落表点位)
syncGroup() → 钉钉 /v1.0/rooms/groupLists → 11 个分组
   ├─【落表点位】saveDingGroupRaw():deleteAll → 逐条 add   ← 本次新增
   └─ 业务转换 → conference_group
睡 3 秒(给钉钉接口喘息)
syncRoom() → 钉钉 /v1.0/rooms/roomLists(分页,每页 100)→ 158 个会议室
   ├─【落表点位】saveDingRoomRaw():deleteAll → 逐条 add    ← 本次新增
   └─ 业务转换 → conference_room

落表点位的选择是这次改动里唯一有讲究的地方:**必须在”拿到钉钉返回后、业务转换前”**。转换之后的数据已经被裁剪过滤过,失去了快照意义;只有这个位置能保证落库的字节就是钉钉返回的字节。

同步链路:按钮触发,两路原始数据分别落两张快照表
图 1:一次按钮触发,分组与会议室两路返回在业务转换前先原样落快照表

三、表结构与实现:快照表三件套

两张表结构完全一样,只有 3 列:

V20260904.16.45__create_ding_raw_tables.sql
`id`        varchar(64) NOT NULL COMMENT '主键'
`raw_json`  text        COMMENT '钉钉接口返回的原始完整JSON'
`sync_time` datetime    DEFAULT NULL COMMENT '本次同步写入时间'

实现清单:SysDingRoomGroupRaw / SysDingRoomRaw 实体(service-model 层)、两个 Mapper + 手写 XML(add / deleteAll / listByCondition)、Flyway 迁移(building_smart 库),最后 ConferenceFacadeServiceImpl 注入两个 mapper,新增两个私有方法:

ConferenceFacadeServiceImpl 保存方法骨架
private void saveDingRoomRaw(List<JsonNode> rooms) {
    Date syncTime = new Date();            // 同一批共用同一个 syncTime
    sysDingRoomRawMapper.deleteAll();      // 先清空:只保留最新快照
    for (JsonNode room : rooms) {
        SysDingRoomRaw raw = new SysDingRoomRaw();
        raw.setId(UUID.randomUUID().toString().replace("-", "")); // 手动设主键,见踩坑 2
        raw.setRawJson(room.toString());   // 原文,不加工
        raw.setSyncTime(syncTime);
        sysDingRoomRawMapper.add(raw);
    }
}

同一批共用同一个 syncTime 是个刻意的细节:它让”某一次同步的完整快照”可以用一个 WHERE sync_time = ? 完整捞出来,快照的”同一时刻”语义就保住了。

四、意外发现:钉钉说有 158 间,业务表只有 100 间

落库后顺手比对,发现一个此前没人注意的事实:钉钉实际返回 158 间会议室,本地业务表 conference_room 只有 100 条。差值主要来自两类:没分组的房间(roomGroup.groupId=0)和”钉钉投屏_xxx”这类虚拟房间(roomStatus=1 停用)——业务转换有自己的过滤逻辑,而 raw 表照单全收。

sys_ding_room_raw 数据样例(截取)
{"corpId":"ding91d6...","roomCapacity":18,
 "roomGroup":{"groupId":14,"groupName":"天二公司","parentId":0},
 "roomLabels":[{"labelId":2,"labelName":"电话"},{"labelId":3,"labelName":"投影仪"}],
 "roomLocation":{"title":"兴义办公楼6楼611会议室"},
 "roomName":"兴义611会议室","roomStatus":2}

158 对 100:原始数据比业务表多出的一截
图 2:raw 表照单全收 158 间,业务表过滤后 100 间——差值是没分组的房间和停用的虚拟房间

这正是原始数据表存在的意义:**业务表回答”系统认为有哪些会议室”,raw 表回答”钉钉说有哪些会议室”**。将来两边对不上时,不用抓包不用重放,SELECT 一下就知道是谁的问题——raw 表是唯一的裁判。

五、踩坑五条

  1. GenericMapper 的 T 必须继承 BaseEntity,否则编译直接报”不在类型变量 T 的范围内”——框架约束,新实体别漏继承。
  2. @TableId(type = IdType.UUID) 不会自动生成主键。名字有误导性,它只是声明主键策略,INSERT 时并不会替你生成值,必须手动 setId(UUID...),否则报 Field 'id' doesn't have a default value
  3. 同步报”缺少园区信息”:测试账号在 sys_user_park 表无记录,token 里 parkId 为空。插一条园区关联后还要重新登录才生效——/user/userParkSwitch 切园区接口返回成功但不刷新 token,这是个容易白等半天的坑。
  4. **打包报 Unable to rename ...jar**:运行中的 smart JVM 锁住了 jar,netstat -ano | findstr 18160 找到 PID 杀掉再打包。
  5. 会议室分页用 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 表是业务表的裁判:数据对不上时,让原始数据自己说话;
  • 低频小数据量场景,诚实记录的理论弱点好过过度设计。

结语

这个需求代码量不大,但它回答了所有同步类功能早晚会被问到的问题——“数据对不上的时候,谁说了算?”留了原始快照,答案就永远是:让数据自己说话。