Files
starRiverProperty/docs/superpowers/specs/2026-05-01-org-id-policy-fix-design.md
hpd840321 7b2bd307f1 Initial commit: reorganized source tree
- backend/: 13 Maven modules (cw-elevator-application, cloudwalk-cloud, intelligent-cwoscomponent, ninca-crk, etc.)
- frontend/: 4 Vue projects (elevator-front, cwos-portal, alarm-front, front_acs) + decompiled + scripts
- scripts/: build, test-env, tools (Docker Compose, service templates, API parity)
- docs/: AGENTS.md, superpowers specs, architecture docs
- .gitignore: standard Java/Maven exclusions

Moved from legacy maven-*/ root layout to backend/ organized structure.
2026-05-09 09:56:45 +08:00

14 KiB
Raw Permalink Blame History

租户访客楼层策略 — org_id 粒度修复设计

文档性质:技术设计说明(方案 Aorg_id 替换 business_id
设计日期2026-05-01
状态:待评审
关联分支:(待创建)
关联 Specdocs/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.javaaddVisitor 方法

关键改动:

旧: 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 为空 被访人 floorListUC-01)或 调用方 floorIdsUC-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 7626053328F 不在 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 7626053399F 不在 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_idorg_id
TenantVisitorFloorPolicyMapper.java 修改 方法签名
TenantVisitorFloorPolicyDao.java 修改 接口方法
TenantVisitorFloorPolicyDaoImpl.java 修改 调用新 Mapper 方法
TenantVisitorFloorPolicyDto.java 修改 新增 orgId 字段
PersonRuleServiceImpl.java 修改 addVisitor + 新增辅助方法

本文档为 org_id 粒度修复的技术设计;实施以代码评审与安全评审结论为准。