# 租户访客楼层策略 — 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 ```sql -- 新增 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` ```xml ``` ### 3.2 DAO 接口 **文件**:`TenantVisitorFloorPolicyDao.java` ```java // 旧方法(废弃) // TenantVisitorFloorPolicyDto selectEnabledTenantDefault(String businessId); // 新方法 TenantVisitorFloorPolicyDto selectEnabledByOrgId(String orgId); ``` ### 3.3 DTO **文件**:`TenantVisitorFloorPolicyDto.java` ```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 解析日志升级 ```java } 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 辅助方法 ```java /** 按 org_id 查找策略,遍历 organizationIds 取第一个命中 */ private TenantVisitorFloorPolicyDto findPolicyByOrgIds(List 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 allow = parseAllowZoneIds(p.getAllowZoneIds()); if (!CollectionUtils.isEmpty(allow)) return p; } } return null; } /** * 二选一:有策略用 allow,无策略用 fallbackFloors。 * 约束:allow 必须是 hostFloors 的子集,否则拒绝。 */ private List resolveEffectiveFloors( List fallbackFloors, List hostFloors, TenantVisitorFloorPolicyDto policy, String personId) { if (policy == null) return fallbackFloors; List allow = parseAllowZoneIds(policy.getAllowZoneIds()); if (CollectionUtils.isEmpty(allow)) return fallbackFloors; // 安全校验:allow 中每个值必须在 hostFloors 中存在 Set hostSet = new HashSet<>(hostFloors); List 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 模板 ```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(),'-',''), '', 'INTERSECT_ALLOWLIST', '[""]', 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 粒度修复的技术设计;实施以代码评审与安全评审结论为准。*