mirror of
https://github.com/hpd840321/starRiverProperty.git
synced 2026-06-09 08:20:31 +08:00
8b15445328
Former-commit-id: 1de24b7eb79676d1aba9d799a58c5a753290cf52
14 KiB
14 KiB
租户访客楼层策略 — org_id 粒度修复设计
文档性质:技术设计说明(方案 A:org_id 替换 business_id)
设计日期:2026-05-01
状态:待评审
关联分支:(待创建)
关联 Spec:docs/superpowers/specs/2026-05-01-database-schema-reference-design.md
1. 问题陈述
1.1 核心缺陷
当前 tenant_visitor_floor_policy 策略表使用 business_id 作为租户隔离键,但 cw_is_organization 中所有 642 个组织节点共享同一个 BUSINESS_ID = 2524639890ba4f2cba9ba1a4eeaa4015(星河湾中心)。
后果:整个星河湾中心只能存在一条访客楼层策略,无法为广发基金(仅允许 28F)和另一入驻公司(仅允许 15F)配置不同的策略。
1.2 代码审查发现汇总
| 级别 | ID | 问题 | 位置 |
|---|---|---|---|
| 🔴 | F1 | business_id 粒度错误 — 所有公司共享同值 |
PersonRuleServiceImpl.java:200 |
| 🔴 | F2 | UC-02 完全绕过策略,无安全兜底 | PersonRuleServiceImpl.java:179-180 |
| 🔴 | F3 | PersonResult.getOrganizationIds() 可用但未用于策略匹配 |
PersonRuleServiceImpl.java:194 |
| 🟡 | W1 | zoneService.page 只查第一个楼层,后续盲写 |
PersonRuleServiceImpl.java:222-223 |
| 🟡 | W2 | JSON 解析失败静默降级(warn → 应 error) | PersonRuleServiceImpl.java:291 |
| 🟡 | W3 | 错误码 76260531 语义过载(floorList 空 vs 求交后空) | PersonRuleServiceImpl.java:196,218 |
| 🟢 | O1 | 每次请求查库,无缓存 | PersonRuleServiceImpl.java:200 |
| 🟢 | O2 | image_rule_ref.person_id 访客/员工语义混淆 |
PersonRuleServiceImpl.java:235 |
1.3 方案选择
| 方案 | 描述 | 选择 |
|---|---|---|
| A | org_id 直替 business_id |
✅ 选中 |
| B | 多级策略查找(org_id + business_id 双层) | ❌ 复杂度过高 |
| C | 组织树向上查找 | ❌ 依赖额外 Feign 调用 |
2. 数据模型变更
2.1 DDL
-- 新增 org_id 列
ALTER TABLE tenant_visitor_floor_policy
ADD COLUMN org_id VARCHAR(32) NULL COMMENT '组织节点ID(cw_is_organization.ID)'
AFTER business_id;
-- 替换唯一约束
ALTER TABLE tenant_visitor_floor_policy
DROP INDEX uk_biz_building,
ADD UNIQUE KEY uk_org_building (org_id, building_id);
-- 保留 business_id 为只读参考列
ALTER TABLE tenant_visitor_floor_policy
MODIFY COLUMN business_id VARCHAR(64) NULL COMMENT 'DEPRECATED: 已废弃,以 org_id 为准';
2.2 约束说明
UNIQUE(org_id, building_id):一个公司在同一楼栋只能有一条策略org_id = NULL的行不参与查询,仅作历史数据保留building_id = NULL表示租户级策略(当前唯一楼栋场景下均为 NULL)- MySQL 中多行
(org_A, NULL)和(org_B, NULL)不冲突
2.3 实际数据验证
tenant_visitor_floor_policy 当前: 1 行,building_id = NULL
elevator_device 当前: 126 台设备,全部 building_id = 605560539791228928(星河湾中心)
device_image_store 当前: 1 行,building_id = 605560539791228928
→ 当前只有一个楼栋,building_id 字段尚未启用多楼栋场景
3. 代码变更
3.1 Mapper SQL
文件:TenantVisitorFloorPolicyMapper.xml
<select id="selectEnabledByOrgId" resultType="...TenantVisitorFloorPolicyDto">
SELECT id, org_id AS orgId, policy_type AS policyType,
allow_zone_ids AS allowZoneIds, building_id AS buildingId,
enabled, policy_version AS policyVersion
FROM tenant_visitor_floor_policy
WHERE org_id = #{orgId,jdbcType=VARCHAR}
AND enabled = 1
AND policy_type = 'INTERSECT_ALLOWLIST'
AND (building_id IS NULL OR building_id = '')
ORDER BY updated_at DESC, policy_version DESC
LIMIT 1
</select>
3.2 DAO 接口
文件:TenantVisitorFloorPolicyDao.java
// 旧方法(废弃)
// TenantVisitorFloorPolicyDto selectEnabledTenantDefault(String businessId);
// 新方法
TenantVisitorFloorPolicyDto selectEnabledByOrgId(String orgId);
3.3 DTO
文件:TenantVisitorFloorPolicyDto.java
// 新增字段
private String orgId; // + getter/setter
// businessId 保留,getter/setter 不删(兼容旧序列化)
3.4 Service 核心逻辑
文件:PersonRuleServiceImpl.java — addVisitor 方法
关键改动:
旧: policy = dao.selectEnabledTenantDefault(context.getCompany().getCompanyId());
→ 所有公司返回同一条策略
新: orgIds = personResult.getOrganizationIds();
遍历 orgIds 查策略,取第一个命中
→ 不同公司返回各自策略
完整控制流见 §4。
3.5 W2 修复 — JSON 解析日志升级
} catch (Exception e) {
this.logger.error("allow_zone_ids JSON 无效,策略失效!orgId={} policyId={} raw={}",
orgId, policy.getId(), json, e); // warn → error
return Collections.emptyList();
}
4. 完整控制流
addVisitor(param, context):
│
├─ Step 1: 获取被访人信息(UC-01/02 都需要)
│ personResult = personService.detail(param.personId)
│ hostFloors = personResult.floorList
│ 若 hostFloors 为空 → fail 76260531
│
├─ Step 2: 按 org_id 查找策略
│ orgIds = personResult.organizationIds
│ 遍历 orgIds:
│ policy = dao.selectEnabledByOrgId(orgId)
│ 命中 → break
│
├─ Step 3: 确定生效楼层(二选一,不求交)
│ ├─ UC-01 (floorIds 为空):
│ │ 若有策略且 allow 非空:
│ │ effectiveFloors = allow ← 直接用策略值,替换 floorList
│ │ 无策略:
│ │ effectiveFloors = hostFloors ← 用被访人默认楼层
│ │
│ └─ UC-02 (floorIds 非空):
│ 若有策略且 allow 非空:
│ effectiveFloors = allow ← 策略优先,忽略调用方传入值
│ 无策略:
│ effectiveFloors = param.floorIds ← 用调用方传入值
│ 软校验: floorId 不在 hostFloors 中 → WARN 日志
│
├─ Step 4: 约束校验(allow ⊆ floorList)
│ 若 effectiveFloors 中任何值不在 hostFloors 中 → ERROR 日志 + fail 76260533
│ (策略配置了被访人无权访问的楼层 = 安全风险,拒绝)
│
└─ Step 5: 落库(不变)
zoneService.page → image_rule_ref insert → batchBind → updateGroupPersonRef
4.1 核心语义:二选一,不求交
| 条件 | 生效楼层 |
|---|---|
| 策略存在且 allow 非空 | allow(直接替换,不做交集) |
| 无策略 / enabled=0 / allow 为空 | 被访人 floorList(UC-01)或 调用方 floorIds(UC-02) |
| 维度 | 变更前 | 变更后 |
|---|---|---|
| 策略生效逻辑 | floorList ∩ allow(求交) |
allow 直接替换 floorList |
| UC-02 是否查策略 | ❌ 不查 | ✅ 查(策略优先) |
| allow 含无效楼层 | 交集自动排除 | ERROR 日志 + fail 76260533 |
4.2 辅助方法
/** 按 org_id 查找策略,遍历 organizationIds 取第一个命中 */
private TenantVisitorFloorPolicyDto findPolicyByOrgIds(List<String> orgIds) {
if (CollectionUtils.isEmpty(orgIds)) return null;
for (String orgId : orgIds) {
TenantVisitorFloorPolicyDto p = tenantVisitorFloorPolicyDao.selectEnabledByOrgId(orgId);
if (p != null && p.getEnabled() != null && p.getEnabled() == 1) {
List<String> allow = parseAllowZoneIds(p.getAllowZoneIds());
if (!CollectionUtils.isEmpty(allow)) return p;
}
}
return null;
}
/**
* 二选一:有策略用 allow,无策略用 fallbackFloors。
* 约束:allow 必须是 hostFloors 的子集,否则拒绝。
*/
private List<String> resolveEffectiveFloors(
List<String> fallbackFloors, List<String> hostFloors,
TenantVisitorFloorPolicyDto policy, String personId) {
if (policy == null) return fallbackFloors;
List<String> allow = parseAllowZoneIds(policy.getAllowZoneIds());
if (CollectionUtils.isEmpty(allow)) return fallbackFloors;
// 安全校验:allow 中每个值必须在 hostFloors 中存在
Set<String> hostSet = new HashSet<>(hostFloors);
List<String> unknownAllow = allow.stream()
.filter(a -> !hostSet.contains(a))
.collect(Collectors.toList());
if (!unknownAllow.isEmpty()) {
this.logger.error("策略配置错误:allow 包含不在被访人 floorList 中的 zoneId!"
+ "orgId={} policyId={} personId={} unknownAllow={} hostFloors={}",
policy.getOrgId(), policy.getId(), personId, unknownAllow, hostFloors);
throw new ServiceException("76260533",
"策略配置了被访人无权访问的楼层,请联系管理员");
}
this.logger.info("策略生效 orgId={} policyId={} v={} allow={} hostSize={}",
policy.getOrgId(), policy.getId(), policy.getPolicyVersion(),
allow.size(), hostFloors.size());
return allow; // 直接用 allow,不求交
}
4.3 关键设计约束:allow ⊆ floorList(强制)
策略的 allow_zone_ids 必须是被访人 floorList 的子集。因为生效逻辑是「有策略直接用 allow」,如果 allow 包含被访人无权访问的楼层 → 访客获得越权 → 直接拒绝。
| 场景 | 处理 |
|---|---|
allow = [28F], floorList = [28F, 29F] |
✅ 正常:allow ⊆ floorList,生效 [28F] |
allow = [28F, 99F], floorList = [28F, 29F] |
❌ 配置错误:99F 不在 floorList → ERROR + fail 76260533 |
allow = [28F], floorList = [29F, 30F] |
❌ 配置错误:28F 不在 floorList → ERROR + fail 76260533 |
- 校验在
resolveEffectiveFloors中执行 - 不满足时
throw ServiceException("76260533"),请求失败 - 新增错误码 76260533:「策略配置了被访人无权访问的楼层,请联系管理员」
5. 数据迁移
5.1 执行顺序
Step 1: DDL 上线(表结构变更,不影响行为)
→ ALTER TABLE 加 org_id 列 + 新唯一约束
Step 2: 数据迁移(人工 SQL,按公司逐行执行)
→ 查询 cw_is_organization 获取各公司 org_id
→ UPDATE tenant_visitor_floor_policy SET org_id = ? WHERE ...
Step 3: 发应用包(代码切到 org_id 查询)
→ Mapper SQL: business_id → org_id
→ Service: 取 organizationIds 遍历查策略
Step 4(可选): 废弃 business_id 列
→ MODIFY COLUMN ... COMMENT 'DEPRECATED'
5.2 数据迁移 SQL 模板
-- 1. 列出所有公司级组织节点
SELECT o.ID, o.NAME, o.PARENT_ID
FROM component-organization.cw_is_organization o
WHERE o.BUSINESS_ID = '2524639890ba4f2cba9ba1a4eeaa4015'
AND o.IS_DEL = 0
ORDER BY o.NAME;
-- 2. 为指定公司设置策略
UPDATE cw-elevator-application.tenant_visitor_floor_policy
SET org_id = '<目标公司 org_id>'
WHERE business_id = '2524639890ba4f2cba9ba1a4eeaa4015'
AND org_id IS NULL;
-- 3. 为其他公司新增策略行
INSERT INTO tenant_visitor_floor_policy
(id, org_id, policy_type, allow_zone_ids, building_id, enabled, policy_version, remark, created_at, updated_at)
VALUES
(REPLACE(UUID(),'-',''), '<org_id>', 'INTERSECT_ALLOWLIST', '["<zone_id>"]', NULL, 1, 1, '', UNIX_TIMESTAMP(NOW())*1000, UNIX_TIMESTAMP(NOW())*1000);
5.3 向后兼容
| 场景 | 行为 |
|---|---|
org_id = NULL 的行 |
WHERE org_id = ? 不匹配,等同于无策略 |
旧 business_id 策略未迁移 |
全量 floorList(与现网一致) |
| 新代码 + 旧表结构 | SQL 报错 → 回滚应用包 |
| 应用回滚 | 旧代码仍用 business_id 查询,行为不变 |
6. 测试策略
| ID | 场景 | 期望结果 |
|---|---|---|
| T1 | A公司有策略 allow=[28F],被访人 floorList=[28F,29F],UC-01 | effective=[28F](直接用 allow) |
| T2 | A公司有策略 allow=[28F],被访人 floorList=[29F,30F],UC-01 | fail 76260533(28F 不在 floorList 中) |
| T3 | B公司无策略,被访人 floorList=[15F,16F],UC-01 | effective=[15F,16F](用 floorList) |
| T4 | 被访人属 [A公司, C公司],A有策略 allow=[28F] | 命中A策略→effective=[28F] |
| T5 | 被访人属 [B公司, D公司],均无策略 | effective=floorList 全集 |
| T6 | UC-02 传 [15F],B公司无策略 | effective=[15F](用调用方值) |
| T7 | UC-02 传 [15F],A公司有策略 allow=[28F] | effective=[28F](策略优先,忽略调用方) |
| T8 | 策略 enabled=0 | 等同无策略→用 floorList |
| T9 | allow_zone_ids 为非法 JSON ["28F" |
ERROR 日志 + 等同无策略 |
| T10 | 策略 allow=[28F,99F],floorList=[28F,29F] | fail 76260533(99F 不在 floorList) |
| T11 | 回滚:新 DDL + 旧代码 | 行为不变 |
7. 风险与缓解
| 风险 | 概率 | 缓解 |
|---|---|---|
organizationIds 为空(被访人无组织归属) |
低 | 降级为无策略→全量 floorList |
| DBA 迁移时填错 org_id | 中 | 迁移前 SELECT 确认 NAME 匹配,迁移后抽样验证 |
| 多公司共用一个 org_id(父子组织) | 低 | 当前需求是公司级粒度;未来如需子树继承可升级到方案C |
| UC-02 调用方依赖旧行为(不查策略) | 中 | 发版说明明确标注行为变更;灰度发布 |
8. 文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
docs/sql/tenant_visitor_floor_policy_v2.sql |
新增 | DDL 变更脚本 |
TenantVisitorFloorPolicyMapper.xml |
修改 | business_id → org_id |
TenantVisitorFloorPolicyMapper.java |
修改 | 方法签名 |
TenantVisitorFloorPolicyDao.java |
修改 | 接口方法 |
TenantVisitorFloorPolicyDaoImpl.java |
修改 | 调用新 Mapper 方法 |
TenantVisitorFloorPolicyDto.java |
修改 | 新增 orgId 字段 |
PersonRuleServiceImpl.java |
修改 | addVisitor + 新增辅助方法 |
本文档为 org_id 粒度修复的技术设计;实施以代码评审与安全评审结论为准。