Files
starRiverProperty/docs/superpowers/specs/2026-05-01-org-id-policy-fix-design.md
T
反编译工作区 8b15445328 feat: add service config templates and extraction script
Former-commit-id: 1de24b7eb79676d1aba9d799a58c5a753290cf52
2026-05-01 19:38:01 +08:00

366 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 租户访客楼层策略 — org_id 粒度修复设计
**文档性质**:技术设计说明(方案 Aorg_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
<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`
```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<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 模板
```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 粒度修复的技术设计;实施以代码评审与安全评审结论为准。*