# 租户访客楼层策略 org_id 粒度修复 — 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 `tenant_visitor_floor_policy` 的策略键从 `business_id` 改为 `org_id`,实现二选一语义(有策略用 allow,无策略用 floorList),修复 F1/F2/W2 问题。 **Architecture:** DDL 先上线(加列+改约束,不影响行为)→ 代码切换(Mapper/DAO/Service 三层的 business_id → org_id + 二选一逻辑)→ 数据迁移(运维 SQL 填 org_id)。整体改动控制在 7 个文件内,最小风险。 **Tech Stack:** Java 8, Spring Boot, MyBatis, MySQL 5.7 **Spec:** `docs/superpowers/specs/2026-05-01-org-id-policy-fix-design.md` --- ## 前置条件 - [ ] **Step 0: 确认分支与编译环境** ```bash git checkout -b fix/org-id-policy-granularity cd maven-cw-elevator-application && mvn formatter:validate -Dformatter-maven-plugin.version=2.16.0 ``` 期望: formatter 校验通过。 --- ### Task 1: DDL — 策略表结构变更 **Files:** - Create: `docs/sql/tenant_visitor_floor_policy_v2.sql` - [ ] **Step 1: 编写 DDL 脚本** ```sql -- 租户访客楼层策略:org_id 粒度修复 -- 执行顺序:先 DDL → 数据迁移(Task 5)→ 发应用包 -- 回滚:DROP INDEX uk_org_building, DROP COLUMN org_id, ADD UNIQUE KEY uk_biz_building (business_id, building_id) USE `cw-elevator-application`; -- 1. 新增 org_id 列 ALTER TABLE tenant_visitor_floor_policy ADD COLUMN org_id VARCHAR(32) NULL COMMENT '组织节点ID(cw_is_organization.ID)' AFTER business_id; -- 2. 替换唯一约束(business_id → org_id) ALTER TABLE tenant_visitor_floor_policy DROP INDEX uk_biz_building, ADD UNIQUE KEY uk_org_building (org_id, building_id); -- 3. 标记 business_id 为废弃 ALTER TABLE tenant_visitor_floor_policy MODIFY COLUMN business_id VARCHAR(64) NULL COMMENT 'DEPRECATED: 已废弃,以 org_id 为准'; -- 验证 SELECT COLUMN_NAME, COLUMN_KEY, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'cw-elevator-application' AND TABLE_NAME = 'tenant_visitor_floor_policy' ORDER BY ORDINAL_POSITION; ``` - [ ] **Step 2: 在开发库执行 DDL** ```bash mysql -h 192.168.3.12 -P 3307 -u root -p123456 cw-elevator-application < docs/sql/tenant_visitor_floor_policy_v2.sql ``` 期望: 无错误,`org_id` 列存在,`uk_org_building` 索引存在,`uk_biz_building` 已删除。 - [ ] **Step 3: 提交** ```bash git add docs/sql/tenant_visitor_floor_policy_v2.sql git commit -m "feat: add org_id column and uk_org_building constraint to tenant_visitor_floor_policy" ``` --- ### Task 2: DTO — 新增 orgId 字段 **Files:** - Modify: `maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/dto/TenantVisitorFloorPolicyDto.java` - [ ] **Step 1: 添加 orgId 字段 + getter/setter** 在 `businessId` 的 setter 之后插入: ```java // 新增字段 private String orgId; public String getOrgId() { return orgId; } public void setOrgId(String orgId) { this.orgId = orgId; } ``` > 注意:`businessId` 字段保留不删,兼容旧序列化。 - [ ] **Step 2: 验证编译** ```bash cd maven-cw-elevator-application && mvn compile -pl cw-elevator-application-data -am -DskipTests ``` 期望: BUILD SUCCESS。 - [ ] **Step 3: 提交** ```bash git add maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/dto/TenantVisitorFloorPolicyDto.java git commit -m "feat: add orgId field to TenantVisitorFloorPolicyDto" ``` --- ### Task 3: Mapper — SQL 切换 business_id → org_id **Files:** - Modify: `maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/mapper/TenantVisitorFloorPolicyMapper.xml` - Modify: `maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/mapper/TenantVisitorFloorPolicyMapper.java` - [ ] **Step 1: 修改 Mapper XML — WHERE 条件 + 映射** ```xml ``` - [ ] **Step 2: 修改 Mapper 接口** ```java package cn.cloudwalk.elevator.person.mapper; import cn.cloudwalk.elevator.person.dto.TenantVisitorFloorPolicyDto; import org.apache.ibatis.annotations.Param; public interface TenantVisitorFloorPolicyMapper { /** * 按组织节点 ID 查询启用中的 INTERSECT_ALLOWLIST 策略(building_id 为空)。 */ TenantVisitorFloorPolicyDto selectEnabledByOrgId(@Param("orgId") String orgId); // 旧方法(废弃,保留以兼容编译) // TenantVisitorFloorPolicyDto selectEnabledTenantDefault(@Param("businessId") String businessId); } ``` - [ ] **Step 3: 验证编译** ```bash cd maven-cw-elevator-application && mvn compile -pl cw-elevator-application-data -am -DskipTests ``` 期望: BUILD SUCCESS。 - [ ] **Step 4: 提交** ```bash git add maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/mapper/TenantVisitorFloorPolicyMapper.xml git add maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/mapper/TenantVisitorFloorPolicyMapper.java git commit -m "feat: change policy query from business_id to org_id in TenantVisitorFloorPolicyMapper" ``` --- ### Task 4: DAO — 接口与实现切换 **Files:** - Modify: `maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/dao/TenantVisitorFloorPolicyDao.java` - Modify: `maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/impl/TenantVisitorFloorPolicyDaoImpl.java` - [ ] **Step 1: 修改 DAO 接口** ```java package cn.cloudwalk.elevator.person.dao; import cn.cloudwalk.elevator.person.dto.TenantVisitorFloorPolicyDto; public interface TenantVisitorFloorPolicyDao { /** * 按组织节点 ID 查询启用中的 INTERSECT_ALLOWLIST 策略(building_id 为空)。 * * @param orgId 组织节点 ID(cw_is_organization.ID) * @return 无配置时 null */ TenantVisitorFloorPolicyDto selectEnabledByOrgId(String orgId); // 旧方法(废弃) // TenantVisitorFloorPolicyDto selectEnabledTenantDefault(String businessId); } ``` - [ ] **Step 2: 修改 DAO 实现** ```java package cn.cloudwalk.elevator.person.impl; import cn.cloudwalk.elevator.person.dao.TenantVisitorFloorPolicyDao; import cn.cloudwalk.elevator.person.dto.TenantVisitorFloorPolicyDto; import cn.cloudwalk.elevator.person.mapper.TenantVisitorFloorPolicyMapper; import javax.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TenantVisitorFloorPolicyDaoImpl implements TenantVisitorFloorPolicyDao { @Resource private TenantVisitorFloorPolicyMapper tenantVisitorFloorPolicyMapper; @Override public TenantVisitorFloorPolicyDto selectEnabledByOrgId(String orgId) { return this.tenantVisitorFloorPolicyMapper.selectEnabledByOrgId(orgId); } } ``` - [ ] **Step 3: 验证编译** ```bash cd maven-cw-elevator-application && mvn compile -pl cw-elevator-application-data -am -DskipTests ``` 期望: BUILD SUCCESS。 - [ ] **Step 4: 提交** ```bash git add maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/dao/TenantVisitorFloorPolicyDao.java git add maven-cw-elevator-application/cw-elevator-application-data/src/main/java/cn/cloudwalk/elevator/person/impl/TenantVisitorFloorPolicyDaoImpl.java git commit -m "feat: update DAO interface and impl to use org_id query" ``` --- ### Task 5: Service — addVisitor 核心逻辑重写 **Files:** - Modify: `maven-cw-elevator-application/cw-elevator-application-service/src/main/java/cn/cloudwalk/elevator/person/impl/PersonRuleServiceImpl.java` 这是改动最大的文件。分 3 个子步骤。 - [ ] **Step 1: 重写 addVisitor 方法(第 174-275 行)** 完整替换: ```java @CloudwalkParamsValidate public CloudwalkResult addVisitor(AcsPersonAddVisitorParam param, CloudwalkCallContext context) throws ServiceException { this.logger.info("根据被访人添加访客派梯权限开始,AcsPersonAddVisitorParam=[{}], CloudwalkCallContext=[{}]", JSONObject.toJSONString(param), JSONObject.toJSONString(context)); try { // ===== Step 1: 获取被访人信息(UC-01/02 都需要) ===== PersonDetailParam detailParam = new PersonDetailParam(); detailParam.setId(param.getPersonId()); detailParam.setBusinessId(context.getCompany().getCompanyId()); CloudwalkResult detail = this.personService.detail(detailParam, context); if (detail == null || !detail.isSuccess()) { String code = detail != null ? detail.getCode() : "76260531"; String msg = detail != null ? detail.getMessage() : getMessage("76260531"); return CloudwalkResult.fail(code, msg); } PersonResult personResult = (PersonResult) detail.getData(); if (personResult == null) { return CloudwalkResult.fail("76260531", getMessage("76260531")); } List hostFloors = personResult.getFloorList(); if (CollectionUtils.isEmpty(hostFloors)) { return CloudwalkResult.fail("76260531", getMessage("76260531")); } // ===== Step 2: 按 org_id 查找策略 ===== TenantVisitorFloorPolicyDto policy = findPolicyByOrgIds(personResult.getOrganizationIds()); // ===== Step 3: 确定生效楼层(二选一,不求交) ===== List effectiveFloors; boolean callerProvidedFloors = !CollectionUtils.isEmpty(param.getFloorIds()); if (policy != null) { // 有策略:直接用 allow,忽略调用方 floorIds effectiveFloors = resolveEffectiveFloors( callerProvidedFloors ? param.getFloorIds() : hostFloors, hostFloors, policy, param.getPersonId()); } else { // 无策略:用调用方 floorIds 或 hostFloors effectiveFloors = callerProvidedFloors ? param.getFloorIds() : hostFloors; if (callerProvidedFloors) { // UC-02 软校验:记录不在 hostFloors 中的楼层 Set hostSet = new HashSet<>(hostFloors); List outliers = param.getFloorIds().stream() .filter(f -> !hostSet.contains(f)) .collect(Collectors.toList()); if (!outliers.isEmpty()) { this.logger.warn("UC-02 传入非被访人授权楼层 businessId={} personId={} outliers={}", context.getCompany().getCompanyId(), param.getPersonId(), outliers); } } } if (CollectionUtils.isEmpty(effectiveFloors)) { return CloudwalkResult.fail("76260531", getMessage("76260531")); } param.setFloorIds(effectiveFloors); // ===== Step 4: 落库(不变) ===== ZoneQueryParam zoneQueryParam = new ZoneQueryParam(); zoneQueryParam.setId(param.getFloorIds().get(0)); zoneQueryParam.setRowsOfPage(10); zoneQueryParam.setCurrentPage(1); CloudwalkResult> zonePage = this.zoneService.page(zoneQueryParam, context); List zoneResults = (List) ((CloudwalkPageAble) zonePage.getData()).getDatas(); String imageStoreId = this.deviceImageStoreDao.getByBuildingId(((ZoneResult) zoneResults.get(0)).getParentId()); List insertList = new ArrayList<>(); for (String floorId : param.getFloorIds()) { ImageRuleRefResultDto defaultRule = this.imageRuleRefDao.getDefaultByZoneId(floorId); ImageRuleRefAddDto addDto = new ImageRuleRefAddDto(); addDto.setId(genUUID()); addDto.setBusinessId(context.getCompany().getCompanyId()); addDto.setPersonId(param.getVisitorId()); addDto.setParentRule(defaultRule.getId()); addDto.setName(defaultRule.getName()); addDto.setZoneId(defaultRule.getZoneId()); addDto.setZoneName(defaultRule.getZoneName()); addDto.setCreateTime(Long.valueOf(System.currentTimeMillis())); addDto.setLastUpdateTime(Long.valueOf(System.currentTimeMillis())); addDto.setPersonDelete(Integer.valueOf(0)); insertList.add(addDto); } this.logger.info("访客添加派梯权限开始,数据为=[{}]", JSONObject.toJSONString(insertList)); if (!CollectionUtils.isEmpty(insertList)) { this.imageRuleRefDao.insertList(insertList); } ImageStorePersonBindParam imageStorePersonBindParam = new ImageStorePersonBindParam(); imageStorePersonBindParam.setImageStoreId(imageStoreId); imageStorePersonBindParam.setPersonIds(Collections.singletonList(param.getVisitorId())); imageStorePersonBindParam.setNullDateIsLongTerm(Boolean.valueOf(true)); imageStorePersonBindParam.setExpiryBeginDate(param.getBegVisitorTime()); imageStorePersonBindParam.setExpiryEndDate(param.getEndVisitorTime()); this.logger.info("远程调用绑定人员图库开始,imageStorePersonBindParam=[{}], CloudwalkCallContext=[{}]", JSONObject.toJSONString(imageStorePersonBindParam), JSONObject.toJSONString(context)); CloudwalkResult bindResult = this.imageStorePersonService.batchBind(imageStorePersonBindParam, context); if (!bindResult.isSuccess()) { this.logger.error("远程调用绑定人员图库异常,原因:[{}],失败人员id:[{}]", bindResult.getMessage(), param.getVisitorId()); return CloudwalkResult.fail(bindResult.getCode(), bindResult.getMessage()); } UpdateGroupPersonRefParam refParam = new UpdateGroupPersonRefParam(); refParam.setBusinessId(context.getCompany().getCompanyId()); refParam.setPersonIds(Collections.singletonList(param.getVisitorId())); refParam.setImageStoreId(imageStoreId); this.imageStorePersonService.updateGroupPersonRef(refParam, context); } catch (ServiceException e) { throw e; } catch (Exception e) { this.logger.error("根据被访人添加访客派梯权限失败,原因:[{}]", e); throw new ServiceException("76260530", getMessage("76260530")); } return CloudwalkResult.success(Boolean.valueOf(true)); } ``` - [ ] **Step 2: 添加两个新辅助方法 + 修改 W2(JSON 日志升级)** 在 `addVisitor` 方法之后插入: ```java /** * 按 org_id 查找策略,遍历 organizationIds 取第一个命中。 */ private TenantVisitorFloorPolicyDto findPolicyByOrgIds(List orgIds) { if (CollectionUtils.isEmpty(orgIds)) return null; for (String orgId : orgIds) { TenantVisitorFloorPolicyDto p = this.tenantVisitorFloorPolicyDao.selectEnabledByOrgId(orgId); if (p != null && p.getEnabled() != null && p.getEnabled().intValue() == 1) { List allow = parseAllowZoneIds(p.getAllowZoneIds()); if (!CollectionUtils.isEmpty(allow)) return p; } } return null; } /** * 二选一:用 allow 替换 fallbackFloors。 * 约束:allow 必须是 hostFloors 的子集,否则拒绝(76260533)。 */ private List resolveEffectiveFloors( List fallbackFloorsUnused, List hostFloors, TenantVisitorFloorPolicyDto policy, String personId) { List allow = parseAllowZoneIds(policy.getAllowZoneIds()); if (CollectionUtils.isEmpty(allow)) return fallbackFloorsUnused; // 安全校验:allow 中每个值必须在 hostFloors 中存在 Set hostSet = new HashSet<>(hostFloors); List 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={} allowSize={} hostSize={}", policy.getOrgId(), policy.getId(), policy.getPolicyVersion(), allow.size(), hostFloors.size()); return allow; } ``` 同时修改 `parseAllowZoneIds` 的 catch 块(W2 修复): ```java // 旧代码: // this.logger.warn("allow_zone_ids JSON 无效,按无策略处理: {}", e.getMessage()); // 新代码: this.logger.error("allow_zone_ids JSON 无效,策略失效!policyId={} raw={}", "policy.id", json, e); // 注意:此处无法获取 policy.id,改用实际可用字段 ``` > 实际实现时,`parseAllowZoneIds` 不持有 `policyId`,可以在 `resolveEffectiveFloors` 中调用 `parseAllowZoneIds` 之前先做 null 检查,将 ERROR 日志放在调用处: ```java private List resolveEffectiveFloors(...) { String rawJson = policy.getAllowZoneIds(); List allow = parseAllowZoneIds(rawJson); if (CollectionUtils.isEmpty(allow)) { if (!StringUtils.isBlank(rawJson)) { this.logger.error("allow_zone_ids JSON 无效或为空,策略失效!orgId={} policyId={} raw={}", policy.getOrgId(), policy.getId(), rawJson); } return fallbackFloorsUnused; } // ... 后续校验 } ``` - [ ] **Step 3: 删除旧辅助方法 `intersectPreserveHostOrder`(不再需要)** 该方法已被 `resolveEffectiveFloors` 替代,可删除或保留(无调用方即可)。 - [ ] **Step 4: 验证编译** ```bash cd maven-cw-elevator-application && mvn compile -DskipTests ``` 期望: BUILD SUCCESS。 - [ ] **Step 5: 提交** ```bash git add maven-cw-elevator-application/cw-elevator-application-service/src/main/java/cn/cloudwalk/elevator/person/impl/PersonRuleServiceImpl.java git commit -m "feat: rewrite addVisitor with org_id policy lookup and either-or semantics - Replace business_id policy key with org_id from PersonResult.getOrganizationIds() - Change from intersection (floorList ∩ allow) to either-or (policy? allow : floorList) - Add resolveEffectiveFloors with allow ⊆ floorList safety check (76260533) - UC-02 now also checks policy (policy takes precedence over caller floorIds) - Upgrade JSON parse failure log from WARN to ERROR - Remove unused intersectPreserveHostOrder method" ``` --- ### Task 6: 错误码注册(76260533) **Files:** - Check: `maven-cw-elevator-application/cw-elevator-application-starter/src/main/resources/access-control.properties`(或对应的 messages 资源文件) - [ ] **Step 1: 查找错误码资源文件** ```bash grep -rn "76260531\|76260532" --include="*.properties" --include="*.xml" maven-cw-elevator-application/ ``` - [ ] **Step 2: 在对应的 messages 文件中新增** ```properties 76260533=策略配置了被访人无权访问的楼层,请联系管理员 ``` - [ ] **Step 3: 提交** ```bash git add <错误码资源文件路径> git commit -m "feat: add error code 76260533 for policy-host floor mismatch" ``` --- ### Task 7: 数据迁移 SQL **Files:** - Create: `docs/sql/tenant_visitor_floor_policy_migrate_org_id.sql` - [ ] **Step 1: 编写迁移脚本** ```sql -- 租户访客楼层策略:business_id → org_id 数据迁移 -- 前提:DDL(Task 1)已执行 -- 执行方式:人工确认 org_id 对应关系后逐行执行 USE cw-elevator-application; -- 1. 列出所有公司级组织节点(供确认) -- 在 component-organization 库执行: -- 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. 为现有策略行填入 org_id(示例:广发基金) -- 请先确认 NAME 匹配正确 UPDATE tenant_visitor_floor_policy SET org_id = '<广发基金的 org_id>', business_id = NULL -- 可选:标记 business_id 已废弃 WHERE id = 'gf_vstr_policy_guangfa_fund_001x'; -- 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', -- '[""]', NULL, 1, 1, '', UNIX_TIMESTAMP(NOW())*1000, UNIX_TIMESTAMP(NOW())*1000); -- 4. 验证迁移结果 SELECT id, org_id, business_id, policy_type, allow_zone_ids, enabled FROM tenant_visitor_floor_policy ORDER BY org_id; ``` - [ ] **Step 2: 提交** ```bash git add docs/sql/tenant_visitor_floor_policy_migrate_org_id.sql git commit -m "docs: add org_id data migration SQL for tenant_visitor_floor_policy" ``` --- ### Task 8: 构建验证 + 发布准备 - [ ] **Step 1: 全量构建** ```bash cd maven-cw-elevator-application && mvn clean install -DskipTests ``` 期望: BUILD SUCCESS,无编译错误。 - [ ] **Step 2: formatter 校验** ```bash cd maven-cw-elevator-application && mvn formatter:validate -Dformatter-maven-plugin.version=2.16.0 ``` 期望: 无格式化违规。 - [ ] **Step 3: 生成发布包** ```bash bash scripts/release-cw-elevator-application.sh 2.0.10 ``` - [ ] **Step 4: 提交发布包** ```bash git add releases/ git commit -m "release: cw-elevator-application v2.0.10 with org_id policy fix" ``` --- ## 回滚方案 | 步骤 | 操作 | |------|------| | 1. 回滚应用包 | 部署旧版本 JAR(用 `business_id` 查询的代码) | | 2. 回滚 DDL(可选) | `DROP INDEX uk_org_building; ALTER TABLE ... DROP COLUMN org_id; ADD UNIQUE KEY uk_biz_building (business_id, building_id);` | | 3. 恢复数据(可选) | `UPDATE tenant_visitor_floor_policy SET business_id = '252463...' WHERE org_id IS NOT NULL;` | > DDL 回滚不影响旧代码行为(旧代码不查 `org_id` 列)。 --- ## 发布顺序(生产环境) ``` 1. DDL 上线(Task 1) → 表结构变更,不影响线上行为 2. 数据迁移(Task 7) → 运维手工填 org_id 3. 发应用包(Task 8) → 代码切换到 org_id 查询 4. 验证(Task 8 后) → 抽样确认策略生效 ``` --- ## 完成检查清单 - [ ] DDL 在开发库执行成功 - [ ] `TenantVisitorFloorPolicyDto` 有 `orgId` 字段 - [ ] Mapper XML/Java 使用 `org_id` 查询 - [ ] DAO 接口/实现已切换 - [ ] `addVisitor` 使用 `findPolicyByOrgIds` + `resolveEffectiveFloors` - [ ] W2 修复:JSON 解析失败打 ERROR 而非 WARN - [ ] 错误码 76260533 已在资源文件注册 - [ ] 数据迁移 SQL 已编写 - [ ] `mvn clean install` 通过 - [ ] `mvn formatter:validate` 通过