Files
starRiverProperty/docs/superpowers/specs/2026-05-09-guangfa-visitor-floor-design.md
T
hpd840321 42c4a9fd6b docs: mark elevator-side tenant policy SQL as deprecated, add guangfa visitor floor design
- Deprecate elevator-side tenant_visitor_floor_policy SQL files
  (V2 queries only component-organization library)
- Add guangfa 28F visitor floor design spec (table-driven approach A)
- Add complete database ER diagram (14 DBs, 537 tables)
- Add implementation plan for guangfa visitor floor policy
- Code walkthrough docs for visitor floor policy analysis
2026-05-09 23:56:12 +08:00

26 KiB

广发基金访客默认28F — 架构设计文档

日期: 2026-05-09 状态: 评审通过 (方案A: 表驱动, 单库维护) 读者: 架构师 / 技术评审


目录

  1. 架构决策摘要
  2. 数据结构关系 — 完整 ER 图
  3. 业务数据流 — 访客邀约+派梯全链路
  4. 方案A实现详解
  5. 方案合理性论证
  6. 附录: 关键表DDL

1. 架构决策摘要

决策项 选择 理由
策略存储 tenant_visitor_floor_policy 表 (component-organization 库) V2 已实现,代码就绪
策略语义 替代 (Replacement),非求交 (Intersection) 产品需求: 策略启用时整表替换 floorList
隔离键 org_id (组织节点) 多租户共享同一 business_id, 需组织级隔离
策略生效点 ImgPersonServiceImpl.detail() L643-648 邀约页初始化 + UC-01 派梯兜底
数据库维护 仅 component-organization V2 电梯侧不查询此表
代码变更 0 行 UPDATE 1 条 SQL

本质认知

广发基金 28F 策略不是一个新需求 — 它是当前 tenant_visitor_floor_policy 表驱动架构的已有实例。当前生产库中已存在该策略行(enabled=1),且 ImgPersonServiceImpl.detail() 的 L643-648 已经在执行策略替代逻辑。扩展至 20 层是纯数据变更


2. 数据结构关系 — 完整 ER 图

2.1 涉及三库的核心表

┌── component-organization ───────────────────────────────────────────┐
│                                                                       │
│  cw_is_person                     cw_is_label                         │
│  ├─ ID (PK)                       ├─ ID (PK)                          │
│  ├─ BUSINESS_ID (FK→portal)       ├─ NAME ("访客")                    │
│  ├─ NAME                          ├─ CODE ("1")                       │
│  ├─ DEFAULT_FLOOR (zone_id)       └──────────────────┐                │
│  └─ CHOOSE_FLOOR (zone_id list)                      │                │
│         │                                             │                │
│         ├── cw_is_person_label_ref ───────────────────┘                │
│         │    ├─ PERSON_ID (FK→person)                                  │
│         │    └─ LABEL_ID  (FK→label)                                   │
│         │                                                              │
│         ├── cw_is_person_organization_ref                              │
│         │    ├─ PERSON_ID (FK→person)                                  │
│         │    └─ ORG_ID    (FK→organization) ──────────┐               │
│         │                                              │               │
│  cw_is_organization                                    │               │
│  ├─ ID (PK)  ◄────────────────────────────────────────┘               │
│  ├─ NAME  ("[28-38F]广发基金管理有限公司")                             │
│  ├─ PARENT_ID (FK→自身, 树形)                                          │
│  ├─ BUSINESS_ID (FK→portal)                                           │
│  └─ IS_VALID                                                          │
│         │                                                              │
│         ├── org_floor                                                  │
│         │    ├─ org_id (FK→organization)                               │
│         │    └─ zone_id (逻辑FK→code_elevator_area)                    │
│         │                                                              │
│         └── tenant_visitor_floor_policy  ★ 策略表                      │
│              ├─ id = 'gf_vstr_policy_guangfa_fund_001x'               │
│              ├─ org_id (FK→organization, UNIQUE)                       │
│              ├─ allow_zone_ids (JSON数组 zone_id列表)                   │
│              ├─ enabled (0/1)                                          │
│              └─ policy_version (自增)                                   │
└───────────────────────────────────────────────────────────────────────┘
                              │
                              │ zone_id (逻辑外键, 非数据库约束)
                              ▼
┌── cw-elevator-application ───────────────────────────────────────────┐
│                                                                       │
│  code_elevator_area                                                   │
│  ├─ zone_id (PK)  ◄──── 所有 zone_id 的权威来源                       │
│  ├─ code (如 0x1C=28F)                                               │
│  ├─ parent_id (楼栋 building_id)                                      │
│  └─ is_first (是否首层)                                               │
│                                                                       │
│  image_rule_ref  ★ 通行规则                                           │
│  ├─ person_id (逻辑FK→cw_is_person)                                   │
│  ├─ include_labels (逻辑FK→cw_is_label, 单值)                         │
│  ├─ include_organizations (逻辑FK→cw_is_organization, 单值)           │
│  ├─ zone_id (FK→code_elevator_area)                                   │
│  ├─ zone_name                                                        │
│  ├─ parent_rule (FK→it_acs_pass_rule)                                 │
│  └─ is_default (0/1)                                                  │
│                                                                       │
│  it_acs_pass_rule                                                     │
│  ├─ ID (PK)  ◄─── image_rule_ref.parent_rule                         │
│  ├─ ZONE_ID (FK→code_elevator_area)                                   │
│  └─ IMAGE_STORE_ID (FK→cwos_portal.cw_ag_image_store)                │
└───────────────────────────────────────────────────────────────────────┘
                              │
                              │ zone_id (逻辑外键, Feign查询)
                              ▼
┌── ninca_common ──────────────────────────────────────────────────────┐
│                                                                       │
│  cw_qz_zone  ★ zone 主数据                                           │
│  ├─ ID (zone_id) ◄──── Feign: ZoneFeignClient.findZonelist()         │
│  ├─ NAME ("28F")                                                     │
│  ├─ PARENT_ID (楼栋/上级区域)                                         │
│  ├─ BUSINESS_ID                                                       │
│  └─ LEVEL                                                            │
└───────────────────────────────────────────────────────────────────────┘

2.2 关键实体关系

关系 类型 说明
Person → Organization N:N cw_is_person_organization_ref 关联表
Person → Label N:N cw_is_person_label_ref 关联表
Organization → Floor (org_floor) 1:N 组织可关联多个楼层
Organization → Policy (tenant_visitor_floor_policy) 1:1 uk_org_building 唯一约束
Policy → Floor (allow_zone_ids) 1:N JSON 数组存储
image_rule_ref → Person/Zone N:1 每条规则绑定一个人员+楼层
image_rule_ref → PassRule N:1 parent_rule 指向默认规则模板

2.3 广发基金当前数据快照

-- cw_is_organization (广发基金组织节点)
ID      = '488b8ad049bb43408a6fbcc50bcb89ac'
NAME    = '[28-38F]广发基金管理有限公司'
BUSINESS_ID = '2524639890ba4f2cba9ba1a4eeaa4015'  -- 星河湾中心

-- tenant_visitor_floor_policy (当前策略)
id              = 'gf_vstr_policy_guangfa_fund_001x'
org_id          = '488b8ad049bb43408a6fbcc50bcb89ac'
allow_zone_ids  = '["605560545117995008"]'          -- 仅 28F
enabled         = 1
policy_version  = 1

-- code_elevator_area (28F zone)
zone_id    = '605560545117995008'
code       = 0x1C
parent_id  = '605560539791228928'                   -- 星河湾中心楼栋

3. 业务数据流 — 访客邀约+派梯全链路

3.1 核心业务前提

一个关键事实: 广发基金和物业公司共享同一个 BUSINESS_ID = 2524639890ba4f2cba9ba1a4eeaa4015 (星河湾中心)。这意味着:

  • 不能使用 business_id 做策略隔离
  • 必须使用 org_id 做细粒度隔离
  • tenant_visitor_floor_policy.uk_org_building(org_id, building_id) 保证了每个组织节点唯一一条策略

3.2 完整数据流 (6 步骤)

═══════════════════════════════════════════════════════════════════════════
  Step 1: 前端打开访客邀约页 — 选择被访人(广发员工)
═══════════════════════════════════════════════════════════════════════════
  
  前端 → POST /component/person/detail { id: "广发员工personId" }
    │
    ├─ DB: cw_is_person WHERE ID = personId
    │    → NAME, PHONE, DEFAULT_FLOOR, CHOOSE_FLOOR
    │
    ├─ DB: cw_is_person_organization_ref WHERE PERSON_ID = personId
    │    → orgIds = ['488b8ad...']  (广发基金)
    │
    ├─ DB: cw_is_person_label_ref WHERE PERSON_ID = personId
    │    → labelIds = ['someLabelId', ...]
    │
    ├─ Feign → elevator: POST /elevator/passRule/image
    │    { personId, includeLabels, includeOrganizations }
    │    │
    │    ├─ DB: image_rule_ref
    │    │    SELECT DISTINCT zone_id,zone_name
    │    │    WHERE person_id=X AND person_delete=0
    │    │       OR include_labels IN (...)
    │    │       OR include_organizations IN (...)
    │    │    → 返回被访人所有可访问楼层 (如: 28F,...,38F 共11层)
    │    │
    │    └─ 返回: [{zone_id:"28F",zone_name:"28F"}, ...]
    │
    ├─ ★ 策略替代 (ImgPersonServiceImpl.detail() L643-648):
    │    DB: tenant_visitor_floor_policy
    │      SELECT * WHERE org_id='488b8ad...' AND enabled=1
    │      → allow_zone_ids = ["28F_zone_id"]
    │
    │    floorList = ["28F_zone_id"]    ← 替换! 不再是11层
    │    zoneNames = "28F"
    │
    └─ 返回: PersonResult { floorList: ["28F_zone_id"], floorNames: "28F" }

  前端收到 floorList = [28F] → 渲染: ☑ 28F (唯一可选楼层)

═══════════════════════════════════════════════════════════════════════════
  Step 2: 用户填写访客信息 + 确认楼层 → 提交
═══════════════════════════════════════════════════════════════════════════

  前端 → POST /elevator/person/add/visitor
    { personId: "广发员工", visitorId: "新访客",
      floorIds: ["28F_zone_id"], begVisitorTime, endVisitorTime }
    │
    ├─ Feign → component-org: POST /component/person/detail (回查)
    │    → floorList = ["28F_zone_id"]  (策略已生效)
    │
    ├─ UC-02 判定: param.floorIds 非空
    │    effective = ["28F_zone_id"]
    │
    ├─ DB: image_rule_ref
    │    SELECT * WHERE zone_id='28F' AND is_default=1
    │    → 获取默认规则模板 (parent_rule, name, zoneName)
    │
    ├─ DB: image_rule_ref INSERT
    │    (person_id=visitorId, zone_id=28F, parent_rule=defaultRule.id)
    │
    ├─ Feign → component-org: POST /component/imagestore/person/batchBind
    │    (personId=visitorId, imageStoreId, expiry dates)
    │    → DB: cw_is_group_person_ref INSERT
    │
    └─ 返回: success

═══════════════════════════════════════════════════════════════════════════
  Step 3: 访客到达 — 人脸识别 → 自动派梯
═══════════════════════════════════════════════════════════════════════════

  设备识别 → DB: it_acs_recog_record INSERT
    { PERSON_ID: visitorId, PERSON_LABEL_IDS: "1", ... }
    │
    ├─ MQTT 判定: PERSON_LABEL_IDS.contains("1") → isVisitor = true
    │
    ├─ DB: image_rule_ref WHERE person_id = visitorId
    │    → zone_id = 28F
    │
    └─ 派梯到 28F

3.3 策略替代的精确代码位置

// ImgPersonServiceImpl.detail()
// Line 620-626: 调用 elevator 获取被访人原始楼层
AcsPassRuleImageForm acsPassRuleImageForm = new AcsPassRuleImageForm();
acsPassRuleImageForm.setPersonId(param.getId());
acsPassRuleImageForm.setIncludeOrganizations(result.getOrganizationIds());
acsPassRuleImageForm.setIncludeLabels(result.getLabelIds());
CloudwalkResult<List<AcsPassRuleImageResultDto>> images =
    this.elevatorFeignClient.listByImageId(acsPassRuleImageForm);
// → 返回被访人所有楼层 (标签+组织+个人关联的 zone)

// Line 632-641: 组装原始 floorList (全量)
for (AcsPassRuleImageResultDto dto : images.getData()) {
    floorList.add(dto.getZoneId());
}

// ★ Line 643-648: 策略替代 — 就是这个位置
Optional<List<String>> replacementFloors =
    this.tenantVisitorFloorPolicyService.replacementZoneIdsIfPolicyActive(
        result.getOrganizationIds());
if (replacementFloors.isPresent()) {
    floorList = new ArrayList<>(replacementFloors.get());  // 完全替换!
    zoneNames = buildCommaSeparatedFloorNames(businessId, floorList);
}

// Line 650-651: 写入 PersonResult
result.setFloorList(floorList);

3.4 策略查询链

TenantVisitorFloorPolicyService.replacementZoneIdsIfPolicyActive(orgIds)
  │
  ├─ 遍历 orgIds, 对每个 orgId:
  │
  └─ TenantVisitorFloorPolicyMapper.selectEnabledByOrgId(orgId)
       │
       └─ SQL:
            SELECT * FROM tenant_visitor_floor_policy
            WHERE org_id = #{orgId}
              AND enabled = 1
              AND (building_id IS NULL OR building_id = '')
            LIMIT 1
       │
       └─ 返回第一行命中 → 解析 allow_zone_ids JSON → 返回 zone_id 列表

  ↓ 如果任意 orgId 命中策略 → 返回 Optional.of(allow_zone_ids)
  ↓ 如果所有 orgId 都未命中 → 返回 Optional.empty()
       → ImgPersonServiceImpl.detail() 保留 listByImageId 原始结果

3.5 listByPage (访客列表) 的并行路径

listByPage(isVisitor=true) 的策略处理 (L326-333):
  
  P1: tenantVisitorFloorPolicyService.replacementZoneIdsIfPolicyActive(orgIds)
      ├─ 命中 → 直接使用 allow_zone_ids 构建 floorInfoList
      │         设置 defaultChooseFloor = allow_zone_ids[0]
      │         设置 isAcrossDay = 0
      │         跳过 P2 (包括跳过 40F/6F 硬编码块)
      │
      └─ 未命中 → P2: listByImageId + XHW硬编码块 (L336-371)
                    ├─ orgIds.contains(xhwId) → 40F
                    └─ else → 6F

40F/6F 硬编码与策略表的关系: 策略命中时,硬编码逻辑被完全跳过。两者互斥,不冲突。


4. 方案A实现详解

4.1 实现步骤

# 步骤 操作 数据库
1 确认 zone_id 列表 SELECT zone_id,zone_name,code FROM cw-elevator-app.code_elevator_area WHERE parent_id='605560539791228928' AND zone_id BETWEEN ? AND ? elevator
2 更新策略 UPDATE component-org.tenant_visitor_floor_policy SET allow_zone_ids='["z1",...,"z20"]' WHERE id='gf_vstr_policy_guangfa_fund_001x' component-org
3 更新种子SQL 修改 organization_tenant_visitor_floor_policy_init_tenants.sql 代码仓库
4 验证 curl addVisitor + 检查 image_rule_ref 写入行数

4.2 为什么只需要修改 component-organization 库

V2 架构中的策略查询链:

  elevator-app
    PersonRuleServiceImpl.addVisitor()
      → personService.detail()  [Feign调用]
        → component-org: ImgPersonServiceImpl.detail()
          → tenantVisitorFloorPolicyService  [本地调用]
            → TenantVisitorFloorPolicyMapper [本地调用]
              → SQL: SELECT FROM tenant_visitor_floor_policy  ← component-org 库

  电梯侧完全不参与策略查询。V1 时代的电梯侧 tenant_visitor_floor_policy 表
  已成为死数据。确认:
  - V2 电梯 Java 源码中无 TenantVisitorFloorPolicy* 引用
  - V2 电梯 MyBatis Mapper 中无该表查询
  - PersonRuleServiceImpl.addVisitor() 注释明确: "电梯侧不再读策略表"

4.3 数据一致性保证

保证机制 说明
唯一约束 uk_org_building(org_id, building_id) — 确保每个组织只有一条策略
版本追踪 policy_version 字段自增 — 每次变更可追溯
回滚即时 UPDATE enabled=0 或 UPDATE allow_zone_ids — 下个 detail() 调用即刻生效
幂等变更 使用固定 id 的 INSERT ON DUPLICATE KEY UPDATE — 重复执行安全

5. 方案合理性论证

5.1 为什么是表驱动而非硬编码

论证维度 表驱动 (A) 硬编码 (B/C)
当前代码就绪 L643-648 已存在策略替代块 需要新增 @Value + if/else
现有实例 广发 28F 已作为表行存在 (enabled=1) 需要新增代码路径
扩展性 INSERT 一行 = 新租户策略 每次 + @Value + 代码分支
运行时变更 UPDATE SQL, 即时生效 改配置 + 重启服务
多 zone 支持 JSON 数组原生支持 长字符串, 难以维护
组织级隔离 org_id 精确定位 if/else 链, 易出错
与 40F/6F 关系 互斥 (策略先于硬编码) 冲突 (两套逻辑并排)
数据库一致性 单库 (component-org) 无数据库参与

5.2 为什么不需要修改电梯侧

V2 架构将策略职责完全收敛到 component-organization:

                    V1 (旧)                    V2 (新)
                  ┌──────────┐              ┌──────────┐
  策略存储         │ 双库维护  │      →       │ 单库维护  │
                  ├──────────┤              ├──────────┤
  策略查询         │ elevator  │      →       │ org组件   │
                  ├──────────┤              ├──────────┤
  addVisitor语义   │ 求交(∩)  │      →       │ 替代(=)   │
                  ├──────────┤              ├──────────┤
  listByPage语义   │ 无策略    │      →       │ 策略覆盖  │
                  └──────────┘              └──────────┘

V2 的设计决策已在代码中体现 (L643-648 替代语义, addVisitor "此处不做 ∩" 注释)。方案A是对这个既有设计的延续,而非引入新的设计模式。

5.3 为什么 org_id 是合适的隔离键

问题: BUSINESS_ID=2524639890ba4f2cba9ba1a4eeaa4015 被多个租户共享
        ├─ 广发基金 (28-38F)
        ├─ 星河湾物业管理 (全楼)
        ├─ 康怡健
        ├─ 大石
        └─ ... (642 个组织节点)

如果按 business_id 隔离 → 一个策略影响所有租户 → 不可行
如果按 org_id 隔离    → 每个组织独立策略 → 精确控制

tenant_visitor_floor_policyuk_org_building(org_id, building_id) 唯一约束正是为此设计。

5.4 为什么 20 层扩展只需 1 条 SQL

扩展前 (当前生产):
  allow_zone_ids = '["605560545117995008"]'     ← 1 个 zone (28F)
  → detail() floorList = [28F]
  → addVisitor UC-01 effective = [28F]
  → image_rule_ref INSERT 1 行

扩展后:
  allow_zone_ids = '["28F_id","29F_id",...,"47F_id"]'  ← 20 个 zone
  → detail() floorList = [28F,...,47F]
  → addVisitor UC-01 effective = [28F,...,47F]
  → image_rule_ref INSERT 20 行 (循环, 单次批量操作)

引擎层面无任何变化: parseAllowZoneIds() 已支持任意数量,
replacementZoneIdsIfPolicyActive() 已遍历 orgIds 并返回列表,
detail() L647 已用 new ArrayList<>(replacementFloors.get()) 接收。

5.5 当前架构的已知缺陷 (与方案A无关)

缺陷 位置 影响 是否本方案引入
addVisitor 无 floorId 校验 PersonRuleServiceImpl L222 调用方可传任意 floorId 已有
UC-02 不做子集检查 PersonRuleServiceImpl L187-191 绕过策略限制 已有
getDefaultByZoneId 无 null 检查 PersonRuleServiceImpl L222-227 NPE → 76260530 已有
40F/6F 仍为硬编码 ImgPersonServiceImpl L354-370 技术债 已有

以上缺陷在方案A、B、C 中均存在,非本方案引入。


6. 附录: 关键表DDL

6.1 tenant_visitor_floor_policy (策略表)

CREATE TABLE `tenant_visitor_floor_policy` (
  `id` varchar(32) NOT NULL COMMENT '主键',
  `business_id` varchar(64) DEFAULT NULL COMMENT 'DEPRECATED',
  `org_id` varchar(32) DEFAULT NULL COMMENT '组织节点ID (隔离键)',
  `policy_type` varchar(32) NOT NULL DEFAULT 'INTERSECT_ALLOWLIST',
  `allow_zone_ids` text COMMENT 'JSON数组 zoneId列表',
  `building_id` varchar(64) DEFAULT NULL,
  `enabled` tinyint(1) NOT NULL DEFAULT '1',
  `policy_version` bigint(20) NOT NULL DEFAULT '1',
  `remark` varchar(256) DEFAULT NULL,
  `created_at` bigint(20) DEFAULT NULL,
  `updated_at` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_building` (`org_id`,`building_id`),
  KEY `idx_org_enabled` (`org_id`,`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6.2 image_rule_ref (通行规则 — 访客权限落地表)

CREATE TABLE `image_rule_ref` (
  `id` varchar(32) NOT NULL,
  `zone_id` varchar(64) NOT NULL COMMENT '楼层zone_id',
  `zone_name` varchar(64) DEFAULT NULL,
  `name` varchar(64) DEFAULT NULL COMMENT '规则名',
  `person_id` varchar(64) DEFAULT NULL COMMENT '人员id',
  `include_labels` varchar(64) DEFAULT NULL COMMENT '标签ID(单值)',
  `include_organizations` varchar(64) DEFAULT NULL COMMENT '组织ID(单值)',
  `is_default` tinyint(1) DEFAULT '0' COMMENT '默认规则标记',
  `parent_rule` varchar(64) DEFAULT NULL COMMENT '归属规则id',
  `person_delete` tinyint(1) DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `image_rule_ref_include_labels_IDX` (`include_labels`),
  KEY `image_rule_ref_include_organizations_IDX` (`include_organizations`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

6.3 cw_is_organization (组织架构)

CREATE TABLE `cw_is_organization` (
  `ID` varchar(32) NOT NULL,
  `NAME` varchar(60) DEFAULT NULL COMMENT '如"[28-38F]广发基金管理有限公司"',
  `PARENT_ID` varchar(32) DEFAULT NULL COMMENT '树形结构',
  `BUSINESS_ID` varchar(32) DEFAULT NULL,
  `IS_DEL` smallint(2) DEFAULT NULL,
  `IS_VALID` int(2) DEFAULT NULL COMMENT '0禁用 1启用',
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

6.4 code_elevator_area (zone 编码)

CREATE TABLE `code_elevator_area` (
  `zone_id` varchar(64) NOT NULL COMMENT '电梯编码',
  `code` varchar(64) NOT NULL COMMENT '地区编码(如0x1C=28F)',
  `parent_id` varchar(64) DEFAULT NULL COMMENT '楼栋id',
  `is_first` tinyint(4) DEFAULT NULL,
  PRIMARY KEY (`zone_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

总结

广发基金访客默认 28F (及后续 20 层扩展) 的合理实现方案是:

  1. 利用已有的 tenant_visitor_floor_policy 表驱动架构
  2. 更新 allow_zone_ids 字段为目标 zone 列表
  3. 不改 任何 Java 代码 (L643-648 策略替代块已就绪)
  4. 只维护 component-organization 库 (V2 电梯侧不参与)
  5. org_id 做隔离键 (多租户共享同一 business_id)

架构评审关键点: 这不是引入新模式, 而是对 V2 既有表驱动设计的延续使用