mirror of
https://github.com/hpd840321/starRiverProperty.git
synced 2026-06-09 08:20:31 +08:00
8b15445328
Former-commit-id: 1de24b7eb79676d1aba9d799a58c5a753290cf52
366 lines
14 KiB
Markdown
366 lines
14 KiB
Markdown
# 租户访客楼层策略 — 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
|
||
<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 粒度修复的技术设计;实施以代码评审与安全评审结论为准。*
|