一次干净的「放宽约束」重构:让公司楼层配置跨园区
公司默认可见楼层配置(/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 的公司改归属。
所以本次改动的本质,不是「加新功能」,而是「把一个写死的隐含假设显式化、然后放开」。
(归属园区 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 的公司),但楼层可以来自任意园区。
这种「公司单园区 + 楼层跨园区」的非对称是有意保留的——公司归属是组织架构决定的,不该被超管随手改;楼层是可见范围配置,本就该灵活。
单园区"] 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 | // 按楼层自带的 parkId 分组(LinkedHashMap 保序) |
这避开了跨库 JOIN,代价是多次 RPC——但配置页是低频接口,一个公司绑定的园区数也就个位数,完全可以接受。LinkedHashMap 在分组时保序,保证园区渲染顺序稳定,不被 HashMap 的哈希序打乱。
四、实现:6 个文件,三处核心改动
核心文件 service-provider/security-service/.../service/impl/CameraInfoServiceImpl.java,三处改动必须对齐:
1. 查询:getOrgFloorConfig
旧逻辑里查询、统计都绑死当前园区。新逻辑的关键转变是不再假设所有楼层同园区——先按 orgId 跨园区查所有绑定楼层,再按每个楼层自带的 parkId 分组:
1 | // 旧:查/统计都绑死当前园区 |
2. 保存:saveOrgFloorConfig
旧:删除/插入都带 parkId,强制楼层属于当前园区。新:按 orgId 跨园区全删 + 按园区分组分别插入:
1 | // 旧:单园区删 + 单园区插 |
3. 校验:assertFloorsBelongToPark → getFloors
校验语义从「楼层必须属于当前园区」放宽为「楼层有效 + 必须带 parkId」:
1 | // 旧:强制属于当前园区 |
配套改动(4 个文件)
SpaceInfoClient(admin-api):新增 FeignGET /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 走:
- 找出所有「X 假设」的硬编码点——grep 当前模块里所有写死的位置,本次查出查询、删除、校验三处
- 逐一定义新语义——每个硬编码点放宽后的新约束是什么?必须显式写出来,不能模糊
- 同步改、一起提——不要拆成多个 PR,部分放宽会留下「能 X 不能 Y」的不一致
- 非对称语义要产品确认——本次「公司单园区 + 楼层跨园区」的不对称是设计选择,不是 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 路径变更,向后兼容。