一次干净的「放宽约束」重构:让公司楼层配置跨园区

公司默认可见楼层配置(/orgFloorConfig/{orgId} 查询、POST /orgFloorConfig 保存)最初的语义是「限当前园区」——超管在 A 园区,只能给 A 内的公司配 A 的楼层。本次把它放宽为「跨园区绑定」:一个公司可以同时绑多个园区的楼层。改动只动 6 个文件 +70/-23,但牵住了查询、保存、校验三个「按当前园区」硬编码点的同步改造,是放宽约束类重构的一个干净样本。

🎧 文章导读

🎵 背景音乐

一、需求背景:为什么这条线现在才动

这条线一共四个 commit,本次是收尾:

commit 主题
8e0bd588d 按公司隔离摄像头可见范围:建 security_camera_org_floor 表,getCameraTree 加可见性过滤
9c7e71ac6 新增公司默认可见楼层配置查询/保存接口
657502014 楼层配置返回每楼层摄像头数(floorCameraCounts
334cd811d 支持跨园区绑定(本次)

前三个 commit 把「按公司 + 按楼层」的可见性闭环跑通了,但落表和查询都隐含一个假设:「公司所在园区 = 楼层所在园区」,代码里这个假设被硬编码成 baseUser.getParkId()

业务侧的真实场景把假设戳破了:集团超管管着多个园区,一家安保公司可能驻扎在 A 园区,但需要看到 B 园区某栋楼的摄像头(典型的集团统管安保指挥场景)。原来的配置页对这类需求直接瘫痪——超管切到 A 园区,找不到 B 的楼层可勾;切到 B 园区,又没法给 A 的公司改归属。

所以本次改动的本质,不是「加新功能」,而是「把一个写死的隐含假设显式化、然后放开」。

flowchart LR subgraph "园区 A" Co["公司 X
(归属园区 A)"] end subgraph "园区 B" F1["楼层 1"] F2["楼层 2"] F3["楼层 3"] end Co -.超管在 A 配置.-> F1 Co -.超管在 A 配置.-> F2 Co -.超管在 A 配置.-> F3 style Co fill:#fff4e1,stroke:#e6a23c,stroke-width:2px style F1 fill:#e1f5ff,stroke:#3a8fb7 style F2 fill:#e1f5ff,stroke:#3a8fb7 style F3 fill:#e1f5ff,stroke:#3a8fb7

图 1:超管在 A 园区,给属于 A 的公司 X 配置 B 园区的楼层

二、关键决策:怎么把单园区语义松绑到跨园区

放宽语义最容易踩的坑不是「想不到新方案」,而是「漏改硬编码点」。一个「按当前园区」约束在三个地方都有硬编码:查询、保存、校验。任何一处没跟着松绑,就会出现「能查不能存」或「能存不能查」的不一致——这类 bug 在多园区系统里最难发现,因为只有跨园区配置时才暴露,单园区回归测试全绿。

本次的设计不是「想一个新方案」,而是「把现有的三处约束对齐到同一个新语义」。新语义是:

公司仍必须属于当前园区(超管在 A 只能配 A 的公司),但楼层可以来自任意园区。

这种「公司单园区 + 楼层跨园区」的非对称是有意保留的——公司归属是组织架构决定的,不该被超管随手改;楼层是可见范围配置,本就该灵活。

flowchart TB Org["公司归属
单园区"] Park["当前园区"] F1["本园区楼层"] F2["园区 B 楼层"] F3["园区 C 楼层"] Org === Park Park --- F1 Park -.跨园区配置.-> F2 Park -.跨园区配置.-> F3 style Org fill:#ffeaa7,stroke:#d63031,stroke-width:2px style Park fill:#ffeaa7,stroke:#d63031,stroke-width:2px style F1 fill:#dfe6e9,stroke:#636e72,stroke-width:2px style F2 fill:#74b9ff,stroke:#0984e3,color:#fff style F3 fill:#74b9ff,stroke:#0984e3,color:#fff

图 2:公司归属约束保留,楼层绑定放开

[!WARNING]
这条非对称需要产品最终确认。「超管在 A 园区给属于 A 的公司 X 绑定 B 园区的楼层」目前是允许的。如果后续产品要求「公司归属园区必须和楼层园区一致」,那现在这套逻辑要回退。代码上两种走向都容易改,但语义要先定死。

三、跨服务数据聚合:按数据自带的 parkId 分组

一旦楼层跨园区,就面临「要拉多个园区的空间和摄像头」这个跨服务聚合问题。楼层存在 admin 库的 space_info,摄像头在 security 库。

最朴素的方案是跨库 JOIN——直接被否定,admin 和 security 是两个独立的微服务库,这套架构里就没有跨库 JOIN 的先例。

采用的方案是按数据自带的维度分组,循环调用单园区接口,合并结果SpaceInfoDTO 本身就带 parkId 字段,先拿到本次要绑定的所有楼层,按 parkId 分组,对每个园区分别调 spaceInfoClient.getSpaceInfo(parkId) 拉空间、调 baseMapper.listByCondition(cameraQuery) 拉摄像头,最后合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 按楼层自带的 parkId 分组(LinkedHashMap 保序)
Map<String, List<String>> floorIdsByPark = groupFloorIdsByPark(floors);

for (String parkId : floorIdsByPark.keySet()) {
// 每个园区分别拉空间
BaseResult<List<SpaceInfoDTO>> spaceResult = spaceInfoClient.getSpaceInfo(parkId);
spaces.addAll(spaceResult.getData());
// 每个园区分别拉摄像头
CameraInfoDTO cameraQuery = new CameraInfoDTO();
cameraQuery.setParkId(parkId);
cameraQuery.setDeleteFlag(DictConstants.DEL_FLAG_NO);
cameras.addAll(baseMapper.listByCondition(cameraQuery));
}

这避开了跨库 JOIN,代价是多次 RPC——但配置页是低频接口,一个公司绑定的园区数也就个位数,完全可以接受。LinkedHashMap 在分组时保序,保证园区渲染顺序稳定,不被 HashMap 的哈希序打乱。

四、实现:6 个文件,三处核心改动

核心文件 service-provider/security-service/.../service/impl/CameraInfoServiceImpl.java,三处改动必须对齐:

1. 查询:getOrgFloorConfig

旧逻辑里查询、统计都绑死当前园区。新逻辑的关键转变是不再假设所有楼层同园区——先按 orgId 跨园区查所有绑定楼层,再按每个楼层自带的 parkId 分组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 旧:查/统计都绑死当前园区
List<String> floorSpaceIds = baseMapper.getFloorSpaceIds(
baseUser.getParkId(), Collections.singleton(orgId));

// 新:只按 orgId 查(跨园区),再分园区统计
List<String> floorSpaceIds = baseMapper.getOrgFloorSpaceIds(orgId);
List<SpaceInfoDTO> floors = getFloors(floorSpaceIds);
List<SpaceInfoDTO> spaces = new ArrayList<>();
List<CameraInfoDTO> cameras = new ArrayList<>();
for (String parkId : groupFloorIdsByPark(floors).keySet()) {
BaseResult<List<SpaceInfoDTO>> spaceResult = spaceInfoClient.getSpaceInfo(parkId);
// ... 分园区累加 spaces / cameras
}
result.setFloorCameraCounts(countCamerasByFloor(cameras, spaces, floorSpaceIds));

2. 保存:saveOrgFloorConfig

旧:删除/插入都带 parkId,强制楼层属于当前园区。新:按 orgId 跨园区全删 + 按园区分组分别插入:

1
2
3
4
5
6
7
8
9
10
11
// 旧:单园区删 + 单园区插
assertFloorsBelongToPark(floorSpaceIds, baseUser.getParkId());
baseMapper.deleteOrgFloors(baseUser.getParkId(), model.getOrgId());
baseMapper.batchInsertOrgFloors(baseUser.getParkId(), model.getOrgId(), floorSpaceIds, ...);

// 新:跨园区删 + 分园区插
Map<String, List<String>> floorIdsByPark = groupFloorIdsByPark(getFloors(floorSpaceIds));
baseMapper.deleteAllOrgFloors(model.getOrgId()); // 跨园区全删(只按 orgId)
for (Map.Entry<String, List<String>> entry : floorIdsByPark.entrySet()) {
baseMapper.batchInsertOrgFloors(entry.getKey(), model.getOrgId(), entry.getValue(), ...);
}

3. 校验:assertFloorsBelongToPark → getFloors

校验语义从「楼层必须属于当前园区」放宽为「楼层有效 + 必须带 parkId」:

1
2
3
4
5
6
7
// 旧:强制属于当前园区
Assert.isTrue(validFloorIds.containsAll(floorSpaceIds), "包含不属于当前园区的楼层");

// 新:只校验有效性和园区信息完整性,不再限制哪个园区
Assert.isTrue(validFloorIds.containsAll(floorSpaceIds), "包含无效楼层");
Assert.isTrue(floors.stream().allMatch(space -> StringUtils.isNotBlank(space.getParkId())),
"楼层缺少园区信息");

配套改动(4 个文件)

  • SpaceInfoClient(admin-api):新增 Feign GET /getSpaceInfo/{parkId},供 security 侧分园区拉空间
  • SpaceInfoServiceImpl.selectSpaceInfoByIds:加 deleteFlag=DEL_FLAG_NO 过滤软删空间,并改调 baseMapper.listByCondition(让 deleteFlag 生效)
  • CameraInfoMapper + CameraInfoMapperExt.xml:新增 getOrgFloorSpaceIds(orgId)deleteAllOrgFloors(orgId),分别只按 orgId 查 / 删
  • CameraInfoServiceImplTest:新增 floorConfigGroupsFloorsByTheirOwnPark 验证按 parkId 正确分组;mapper 契约测试加 deleteAllOrgFloors 断言

权限守卫(未变,但关键)

两个入口共用 getConfigAdmin(),强制超管才能操作:

1
Assert.isTrue(isSuperAdmin(baseUser.getUserId()), "无权限管理公司摄像头楼层配置");

controller 层无 @PreAuthorize,权限完全靠这一行 service 守卫——这意味着放开到非超管时只需改这一行。

五、经验总结

1. 放宽约束类重构的 checklist

碰到「从 X 放宽到非 X」的改动,按这个 checklist 走:

  1. 找出所有「X 假设」的硬编码点——grep 当前模块里所有写死的位置,本次查出查询、删除、校验三处
  2. 逐一定义新语义——每个硬编码点放宽后的新约束是什么?必须显式写出来,不能模糊
  3. 同步改、一起提——不要拆成多个 PR,部分放宽会留下「能 X 不能 Y」的不一致
  4. 非对称语义要产品确认——本次「公司单园区 + 楼层跨园区」的不对称是设计选择,不是 bug

2. 跨服务数据聚合的通用模式

当需要跨多个同构子域聚合数据,而下游接口只支持单子域查询时,通用做法是:

按数据自带的子域维度分组 → 循环调用单子域接口 → 合并结果。

这避开了跨库 JOIN,代价是多次 RPC。低频接口(N 个子域个位数)完全可接受;高频接口要重新评估,可考虑预聚合或 ES 反查。

3. 死代码的 surgical 处理

旧的 getFloorSpaceIds(parkId, ...)deleteOrgFloors(parkId, ...) 两个 mapper 方法在本次改动后已无调用方。按 surgical 原则没删,只记录——它们离本次改动很近,删之前要确认所有调用方(mapper 方法可能被 service 反射调用、或后续 commit 引用)。后续若连续 2-3 个 sprint 仍无引用,再删不迟。


本次改动落地 6 个文件 +70/-23,单测覆盖新增分组逻辑,mapper 契约测试加 deleteAllOrgFloors 断言。无跨服务侵入、无表结构变更、无 API 路径变更,向后兼容。