黑名单审核权限回收修复实战:从时机错误到完整回收

在物联网门禁系统的黑名单审核流程中,我们发现了一个”审核未通过就删权”的严重 Bug。本文记录了从问题定位到完整修复的全过程,包括两次迭代:第一次解决”时机错误”,第二次解决”删权不完整”。

前言

在物联网门禁系统中,黑名单审核流程的核心逻辑应该是:

  1. 发起拉黑申请 → 仅进入待审核状态
  2. 审核通过 → 下发权限回收
  3. 审核拒绝 → 权限保持不变

然而在实际排查中发现,系统存在两个问题

[!warning]

  1. 时机错误:拉黑申请阶段就提前下发了权限删除任务
  2. 删权不完整:审核通过后只回收了部分权限(卡、黑名单记录),漏掉了二维码、人脸和临时权限

本文详细记录了这两轮修复的完整过程。

问题背景

理想流程 vs 实际流程

阶段 理想流程 实际流程(修复前)
拉黑申请 仅记录待审核状态 ❌ 直接下发权限删除任务
审核通过 完整回收所有权限 ⚠️ 只回收了部分权限
审核拒绝 保持权限不变 ✓ 符合预期

业务影响

  • 误删权限:未审核就删权,可能导致正常用户无法通行
  • 数据不一致:审核通过后部分权限残留,黑名单用户仍可通过二维码/人脸进出
  • 审核失败:历史脏数据导致空指针,审核接口返回 400 + msg:null

Bug根因分析图
图1:Bug 根因分析 - 时机错误、空指针、删权不完整

第一轮修复:解决”时机错误”

问题定位

1. 拉黑申请阶段提前下发权限删除

排查 EmployeeInfoController.pullBlack() 发现:

1
2
3
4
5
6
7
8
9
10
11
12
// 原代码逻辑(问题)
@PostMapping("/pull-black")
public Result pullBlack(@RequestBody BlackListDTO dto) {
// 1. 更新人员状态为"拉黑审核中"
employeeInfoService.updateStatus(employeeId, 7);

// 2. ❌ 立即创建权限删除下发任务
privilegeDeployService.deploy(employeeId, Arrays.asList(5, 9, 14, 18));

// 3. 写入待审核记录
blackListService.save(blackListEntity);
}

问题分析

  • 定时任务只认”待下发记录”,不认”黑名单审核状态”
  • 一旦创建下发任务,就会直接开始执行权限回收
  • 这与”审核通过后才删权”的业务期望完全不符

2. 黑名单审核通过时空指针

接口 /v1/employeewhitelist/black-audit 返回 400 + msg:null,最终定位到:

1
NullPointerException at DbServiceImpl.getSqlSegment(DbServiceImpl.java:225)

根因

  • blackAudit() 最后会同步僵尸用户状态
  • phone_no + company_id 查询 visitor_employee_zombie
  • 旧的黑名单记录里 companyId 没有被写入
  • nulltoString() 导致空指针

修复方案

改动 1:移除申请阶段的权限下发

1
2
3
4
5
6
7
8
9
10
11
12
// EmployeeInfoController.java
@PostMapping("/pull-black")
public Result pullBlack(@RequestBody BlackListDTO dto) {
// 1. 更新人员状态为"拉黑审核中"
employeeInfoService.updateStatus(employeeId, 7);

// 2. ✓ 不再提前下发权限删除

// 3. 写入待审核记录(补充 companyId)
blackListEntity.setCompanyId(companyId); // 新增:防止后续空指针
blackListService.save(blackListEntity);
}

改动 2:在审核通过时统一下发

1
2
3
4
5
6
7
8
9
10
11
12
13
// EmployeeWhiteListServiceImpl.java
@Transactional
public Result blackAudit(BlackAuditDTO dto) {
// ... 审核逻辑 ...

if (approved) {
// ✓ 审核通过时才下发权限回收
privilegeDeployService.deploy(employeeId, Arrays.asList(5, 9, 14));
// 9 携带电梯操作 18
}

// ... 同步僵尸用户 ...
}

改动 3:增强日志与容错

1
2
3
4
5
6
7
8
9
10
11
// 新增多处日志,便于问题定位
log.info("黑名单记录查询结果: {}", blackListEntity);
log.info("当前登录用户: {}", currentUser);
log.info("关联员工信息: {}", employeeInfo);

// 对历史脏数据增加兜底
if (detail.getPhoneNo() == null || detail.getCompanyId() == null) {
log.warn("跳过僵尸用户同步,数据不完整: phoneNo={}, companyId={}",
detail.getPhoneNo(), detail.getCompanyId());
return; // 跳过同步,不打断主流程
}

第一轮验证

1
2
3
4
mvn test -Dtest=BlacklistAuditTimingTest

# 测试结果
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

验证通过:

  • ✓ 拉黑申请阶段不再调用 privilegeDeployService.deploy()
  • ✓ 新写入的黑名单记录会带上 companyId
  • ✓ 审核通过时才会创建 5 / 9 / 14 任务
  • ✓ 黑名单记录缺 companyId 时,不再因为同步僵尸用户而抛异常

第二轮修复:解决”删权不完整”

问题发现

第一轮修复后,人工联调发现:

黑名单审核通过后,用户的门禁服务、梯控授权、设备授权状态看起来仍然正常。

排查发现:原实现只下发了部分回收动作:

操作类型 编码 是否下发 说明
注销卡 5 已下发
黑名单 9 已下发,带电梯 18
权限组删除门禁 14 已下发
注销二维码 15 遗漏
注销人脸 16 遗漏
临时权限清理 - 遗漏

影响:如果员工主要靠二维码或人脸通行,看起来就像”没删权限”。

根因分析

1. 下发语义不完整

原代码只处理了 5 / 9 / 14,没有处理 15 / 16,也没有清理 priviledge_temp 临时权限关系。

2. 纯删除设备请求被拦截

PrivilegeDeployServiceImpl.deploy() 有一个限制:

1
2
3
4
5
6
7
// 原代码逻辑(问题)
public DeployResult deploy(DeployRequest request) {
if (CollectionUtils.isEmpty(request.getDeviceIds()) && !request.isIncludeElevator()) {
return DeployResult.fail("没有可下发的设备"); // ❌ 拦截了纯删除请求
}
// ...
}

问题:黑名单审核后的删除门禁,语义应该是”只删不保留”。旧逻辑把”只有删除设备、没有保留设备”的请求拦掉了。

修复方案

改动 1:完整回收所有权限类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// EmployeeWhiteListServiceImpl.java
@Transactional
public Result blackAudit(BlackAuditDTO dto) {
// ... 审核逻辑 ...

if (approved) {
// 1. 先收集当前所有授权设备
List<Long> allDevices = privilegeDeployService.collectAllDevices(employeeId);

// 2. 依次下发完整的权限回收
privilegeDeployService.deploy(createRequest(employeeId, 5)); // 注销卡
privilegeDeployService.deploy(createRequest(employeeId, 15)); // ✓ 注销二维码
privilegeDeployService.deploy(createRequest(employeeId, 16)); // ✓ 注销人脸
privilegeDeployService.deploy(createRequest(employeeId, 9)); // 黑名单

// 3. 清理业务关系
clearPrivilegeRelations(employeeId); // ✓ 删除权限组关系 + 临时权限关系

// 4. 最后下发删除门禁(携带收集到的设备作为 deletedDeviceIds)
DeployRequest deleteRequest = new DeployRequest();
deleteRequest.setEmployeeId(employeeId);
deleteRequest.setOpType(14); // 权限组删除门禁
deleteRequest.setDeletedDeviceIds(allDevices); // ✓ 纯删除语义
deleteRequest.setIncludeElevator(true); // ✓ 同时注销电梯
deleteRequest.setElevatorOpType(18);
privilegeDeployService.deploy(deleteRequest);
}
}

private void clearPrivilegeRelations(Long employeeId) {
// 删除权限组关系
privilegeGroupRelationService.deleteByEmployeeId(employeeId);
// 删除临时权限关系
privilegeTempService.deleteByEmployeeId(employeeId);
}

改动 2:支持纯删除请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// PrivilegeDeployServiceImpl.java
public DeployResult deploy(DeployRequest request) {
boolean hasDevices = !CollectionUtils.isEmpty(request.getDeviceIds());
boolean hasDeletedDevices = !CollectionUtils.isEmpty(request.getDeletedDeviceIds());

// ✓ 允许"没有保留设备,但有 deletedDeviceIds"的请求继续下发
if (!hasDevices && !request.isIncludeElevator() && !hasDeletedDevices) {
return DeployResult.fail("没有可下发的设备");
}

// ... 执行下发 ...

result.setSuccess(true); // ✓ 补上成功状态赋值
return result;
}

第二轮验证

1
2
3
4
5
6
7
mvn -gs E:\maven363\pai-settings.xml -s E:\maven363\pai-settings.xml \
-Dmaven.repo.local=C:\Users\张\.m2\repository \
-Dtest=BlacklistAuditTimingTest test

# 测试结果
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

完整验证通过:

  • ✓ 审核通过后完整回收卡、二维码、人脸、门禁、梯控
  • ✓ 删除权限组关系和临时权限关系
  • ✓ 纯删除设备任务能够落库(is_delete=1
  • ✓ 历史不完整黑名单数据不会导致审核崩溃

关键代码对比

修复前后流程对比

1
2
3
4
5
6
7
8
9
修复前:
pull-black ──→ 改状态(7) ──→ 立即下发(5/9/14/18) ──→ 写入待审核记录

black-audit ──→ 审核通过 ──→ 下发空操作 ──→ 结束

修复后:
pull-black ──→ 改状态(7) ──→ 写入待审核记录(带 companyId)

black-audit ──→ 审核通过 ──→ 下发(5/15/16/9) ──→ 清理关系 ──→ 下发(14+18) ──→ 结束

修复前后流程对比
图2:修复前后流程对比 - 从”提前下发”到”审核后完整回收”

权限回收覆盖对比

权限类型 修复前 修复后
二维码
人脸
黑名单记录
门禁删除
电梯注销
权限组关系
临时权限关系

权限维度覆盖对比
图3:权限维度覆盖对比 - 修复后完整覆盖 8 个维度

经验总结

1. 时机把控是权限流程的核心

权限操作必须严格对齐业务状态流转,任何”提前”或”延迟”都可能导致严重的安全隐患。

2. 完整的权限维度清单

在设计权限回收功能时,必须有一份完整的”权限维度清单”:

1
2
3
4
物理凭证:卡、二维码、人脸
权限记录:黑名单、白名单、临时权限
设备维度:门禁、梯控、停车场
关系数据:权限组关系、临时权限关系

3. 历史数据兼容性设计

1
2
3
4
5
6
7
// 好做法:对可能为空的字段做防御
if (detail.getPhoneNo() == null || detail.getCompanyId() == null) {
log.warn("数据不完整,跳过附属逻辑");
return; // 不打断主流程
}

// 更好做法:逐步推动数据治理,回填缺失字段

4. 测试覆盖要全面

这次问题本质是”时机错误 + 历史脏数据兼容不足”,测试需要覆盖:

  • 正常路径:新数据完整流程
  • 边界条件:历史脏数据、缺失字段
  • 异常输入:空指针、非法状态

5. 日志是排查问题的关键

1
2
3
4
5
// 关键位置必须打日志
log.info("入口参数: {}", dto);
log.info("查询结果: {}", entity);
log.info("当前用户: {}", currentUser);
log.info("操作结果: {}", result);

推荐后续动作

  1. 人工再验证一次完整业务流

    • 发起拉黑申请
    • 确认申请阶段不产生权限删除下发
    • 审核通过后再确认产生 5 / 15 / 16 / 9 / 14 / 18
  2. 抽样检查历史黑名单数据

    • 关注 visit_white_list.type = 2 的记录里 company_id 是否存在为空的情况
  3. 数据治理(如需要)

    • 如果业务强依赖 visitor_employee_zombie 的黑名单状态,建议增加一次性数据修复脚本,回填历史黑名单记录的 company_id

结语

本次修复经历了两轮迭代:

  1. 第一轮:解决了”时机错误”,确保权限回收只在审核通过后执行,同时兼容了历史脏数据
  2. 第二轮:解决了”删权不完整”,扩展了权限回收覆盖面,从”卡 + 黑名单 + 门禁”升级到”卡 + 二维码 + 人脸 + 临时权限 + 门禁 + 梯控”的完整回收

两次修复都遵循”最小影响面”原则,只修改黑名单审核这条链路,不动全局的停用、离职、注销等其他流程。这种精准打击的策略,既能快速修复问题,又能控制回归风险。