# 广发基金访客默认28F — 架构设计文档 **日期**: 2026-05-09 **状态**: 评审通过 (方案A: 表驱动, 单库维护) **读者**: 架构师 / 技术评审 --- ## 目录 1. [架构决策摘要](#1-架构决策摘要) 2. [数据结构关系 — 完整 ER 图](#2-数据结构关系--完整-er-图) 3. [业务数据流 — 访客邀约+派梯全链路](#3-业务数据流--访客邀约派梯全链路) 4. [方案A实现详解](#4-方案a实现详解) 5. [方案合理性论证](#5-方案合理性论证) 6. [附录: 关键表DDL](#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 广发基金当前数据快照 ```sql -- 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 策略替代的精确代码位置 ```java // ImgPersonServiceImpl.detail() // Line 620-626: 调用 elevator 获取被访人原始楼层 AcsPassRuleImageForm acsPassRuleImageForm = new AcsPassRuleImageForm(); acsPassRuleImageForm.setPersonId(param.getId()); acsPassRuleImageForm.setIncludeOrganizations(result.getOrganizationIds()); acsPassRuleImageForm.setIncludeLabels(result.getLabelIds()); CloudwalkResult> images = this.elevatorFeignClient.listByImageId(acsPassRuleImageForm); // → 返回被访人所有楼层 (标签+组织+个人关联的 zone) // Line 632-641: 组装原始 floorList (全量) for (AcsPassRuleImageResultDto dto : images.getData()) { floorList.add(dto.getZoneId()); } // ★ Line 643-648: 策略替代 — 就是这个位置 Optional> 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_policy` 的 `uk_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 (策略表) ```sql 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 (通行规则 — 访客权限落地表) ```sql 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 (组织架构) ```sql 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 编码) ```sql 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 既有表驱动设计的**延续使用**。