人岗客权限校验(数据权限控制)实战指南
一、概念定义
什么是人岗客权限
"人岗客"是企业级系统中的一种数据权限控制模型 ,核心思想是:用户只能操作其岗位职责范围内的客户数据。
| 要素 | 含义 | 举例 |
|---|---|---|
| 人 | 当前操作用户 | 张三(工号 A001) |
| 岗 | 用户所在的组织/岗位 | 青岛分公司销售岗 |
| 客 | 归属于特定组织的客户 | 客户8800012345 归属青岛分公司 |
控制逻辑:用户 A 只能操作归属于自己所在分公司(或渠道、区域)的客户数据,不能跨组织操作。
与功能权限的区别
| 维度 | 功能权限 | 数据权限(人岗客) |
|---|---|---|
| 控制粒度 | 接口/菜单/按钮级别 | 数据行级别 |
| 判断依据 | 角色是否有此操作权限 | 数据是否属于用户管辖范围 |
| 示例 | "能否调用导入接口" | "能否导入这条客户数据" |
| 实现位置 | 网关/拦截器/注解 | 业务逻辑层 |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、业务模式设计
2.1 权限维度选择
不同业务场景选择不同的权限维度:
| 权限维度 | 适用场景 | 数据关系 |
|---|---|---|
| 分公司编码(fcCode) | 区域型组织,按地区划分 | 客户 → 归属分公司 |
| 渠道编码(channelCode) | 渠道型组织,按销售渠道划分 | 客户 → 归属渠道 |
| 门店编码(storeCode) | 零售型组织,按门店划分 | 客户 → 归属门店 |
| 组合维度 | 复杂组织,多维度交叉 | 需同时满足多个条件 |
2.2 权限数据流转模式
┌──────────────────────────────────────────────────────┐
│ 前端 │
│ 登录 → 获取用户权限列表 → 存储到本地/Token │
│ 操作时 → 将权限列表作为参数传给后端 │
└────────────────────────┬─────────────────────────────┘
│ 请求携带权限列表
▼
┌──────────────────────────────────────────────────────┐
│ 后端 │
│ 接收权限列表 → 查询目标数据的归属组织 │
│ → 判断目标归属是否在用户权限范围内 │
│ → 通过则执行操作,不通过则拒绝 │
└──────────────────────────────────────────────────────┘
2.3 权限获取方式对比
| 方式 | 描述 | 优缺点 |
|---|---|---|
| 前端传入 | 前端登录时获取权限列表,操作时传给后端 | 简单,但需信任前端数据 |
| 后端查询 | 后端根据当前用户Token中的userId去权限中心查 | 安全,但增加一次远程调用 |
| Token携带 | JWT 中直接编码权限列表 | 无额外查询,但Token体积大 |
| 缓存 + 后端校验 | 后端从Redis缓存中获取用户权限 | 安全且高性能 |
三、在新增和导入中的应用模式
3.1 新增(单条)场景
前端传入: { data: {...}, permissionList: ["ORG001", "ORG002"] }
│
▼
后端处理: 查询目标数据归属组织 → 比对是否在 permissionList 中
│
┌───────┴───────┐
▼ ▼
在范围内 不在范围内
执行新增 抛出异常拒绝
特点:校验不通过直接抛异常,中断操作。
3.2 导入(批量)场景
前端传入: { file: excel, permissionList: ["ORG001", "ORG002"] }
│
▼
后端处理: 批量查询所有数据的归属组织 → 逐条比对
│
┌───────┴───────┐
▼ ▼
在范围内 不在范围内
加入成功列表 加入失败列表(标注原因)
│ │
▼ ▼
批量入库 生成失败报告Excel
特点:校验不通过不中断,记录到失败列表,最终生成失败报告。
3.3 两者的核心差异
| 维度 | 单条新增 | 批量导入 |
|---|---|---|
| 失败处理 | 抛异常中断 | 记录失败原因,继续下一条 |
| 权限数据获取 | 从请求体中取 | 从请求参数中取 |
| DB 交互 | 单条查询 | 批量预查询 + Map 内存比对 |
| 返回值 | 成功/异常 | 成功数 + 失败数 + 失败文件URL |
四、关键技术点
4.1 本地表关联 vs 远程调用
| 方式 | 实现 | 适用场景 |
|---|---|---|
| 本地表关联 | 直接查本地数据库的组织字段 | 本地有客户归属数据 |
| 远程Feign调用 | 调用用户中心/权限中心获取 | 本地无客户详情 |
选择原则:优先使用本地数据,减少远程调用对性能的影响。
4.2 批量场景的性能考量
java
// ❌ 逐条查询组织归属(N次DB)
for (Data data : dataList) {
OrgInfo org = orgService.getByCode(data.getCode());
if (!permList.contains(org.getOrgCode())) { fail; }
}
// ✅ 批量预查询 + 内存比对(1次DB)
Map<String, OrgInfo> orgMap = orgService.batchGetByCodeIn(allCodes);
for (Data data : dataList) {
OrgInfo org = orgMap.get(data.getCode()); // O(1)
if (!permList.contains(org.getOrgCode())) { fail; }
}
4.3 权限列表为空的处理策略
| 策略 | 逻辑 | 适用场景 |
|---|---|---|
| 严格模式 | 权限列表为空 → 所有数据不通过 | 强管控业务 |
| 宽松模式 | 权限列表为空 → 跳过校验 | 管理员/超管场景 |
java
// 严格模式
if (isEmpty(permList) || !permList.contains(orgCode)) {
throw new BizException("无操作此数据的权限");
}
// 宽松模式
if (isNotEmpty(permList) && !permList.contains(orgCode)) {
throw new BizException("无操作此数据的权限");
}
五、完整示例代码
以下以"商品管理系统-商品上架"为例,演示人岗客权限的完整实现。
5.1 业务场景
- 商品归属于某个仓库(warehouseCode)
- 用户只能上架归属于自己管辖仓库的商品
- 前端传入用户有权限的仓库编码列表
5.2 DTO 定义
java
/**
* 商品上架入参DTO.
*/
@Data
@ApiModel(description = "商品上架入参")
public class ProductListingParamsDto {
@ApiModelProperty(value = "商品编码", required = true)
private String productCode;
@ApiModelProperty(value = "上架价格")
private BigDecimal price;
@ApiModelProperty(value = "当前登录人有权限的仓库编码列表", required = true)
private List<String> warehouseCodeList;
@ApiModelProperty(value = "操作人工号")
private String operatorCode;
}
/**
* 商品批量上架导入DTO.
*/
@Data
public class ProductListingExcelDto {
@ApiModelProperty(value = "商品编码")
private String productCode;
@ApiModelProperty(value = "上架价格")
private String price;
}
/**
* 导入结果DTO.
*/
@Data
public class ImportResultDto {
@ApiModelProperty(value = "成功数量")
private int successCount;
@ApiModelProperty(value = "失败数量")
private int failCount;
@ApiModelProperty(value = "失败报告文件URL")
private String failFileUrl;
}
5.3 Entity 定义
java
/**
* 商品基础信息实体.
*/
@Data
@Entity
@Table(name = "product_base")
public class ProductBase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 商品编码. */
@Column(name = "product_code")
private String productCode;
/** 商品名称. */
@Column(name = "product_name")
private String productName;
/** 归属仓库编码. */
@Column(name = "warehouse_code")
private String warehouseCode;
/** 商品状态:1-待上架 2-已上架 3-已下架. */
@Column(name = "status")
private Integer status;
}
5.4 Repository 定义
java
/**
* 商品基础信息Repository.
*/
public interface ProductBaseRepository extends JpaRepository<ProductBase, Integer> {
/**
* 根据商品编码查询第一条记录.
*/
ProductBase findFirstByProductCode(String productCode);
/**
* 根据商品编码列表批量查询.
*/
List<ProductBase> findByProductCodeIn(List<String> productCodes);
}
5.5 Service 实现
java
@Slf4j
@Service
public class ProductListingServiceImpl implements ProductListingService {
@Resource
private ProductBaseRepository productBaseRepository;
/**
* 单条商品上架(严格模式:权限为空则拒绝).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean listingProduct(ProductListingParamsDto paramsDto) {
String productCode = paramsDto.getProductCode();
// 1. 校验商品是否存在
ProductBase product = productBaseRepository.findFirstByProductCode(productCode);
if (product == null) {
throw new BizException(-1, null, "商品不存在");
}
// 2. 人岗客权限校验:仓库维度
List<String> warehouseCodeList = paramsDto.getWarehouseCodeList();
if (warehouseCodeList == null || warehouseCodeList.isEmpty()
|| !warehouseCodeList.contains(product.getWarehouseCode())) {
throw new BizException(-1, null, "无操作此仓库商品的权限");
}
// 3. 业务状态校验
if (!Objects.equals(product.getStatus(), 1)) {
throw new BizException(-1, null, "当前商品状态不支持上架");
}
// 4. 执行上架
product.setStatus(2);
productBaseRepository.save(product);
return Boolean.TRUE;
}
/**
* 批量导入上架(批量预查询 + 失败记录不中断).
*/
@Override
public ImportResultDto batchListingImport(List<ProductListingExcelDto> dataList,
List<String> warehouseCodeList, String operatorCode) {
ImportResultDto resultDto = new ImportResultDto();
List<String[]> failList = new ArrayList<>();
List<ProductBase> successList = new ArrayList<>();
// ========== 批量预查询 ==========
List<String> allProductCodes = dataList.stream()
.map(ProductListingExcelDto::getProductCode)
.filter(Objects::nonNull)
.map(String::trim)
.distinct()
.collect(Collectors.toList());
// 一次性查出所有商品信息,转Map
Map<String, ProductBase> productMap = new HashMap<>();
if (!allProductCodes.isEmpty()) {
List<ProductBase> productList =
productBaseRepository.findByProductCodeIn(allProductCodes);
if (productList != null) {
productMap = productList.stream()
.collect(Collectors.toMap(
ProductBase::getProductCode, p -> p, (p1, p2) -> p1));
}
}
// ========== 逐条校验(内存比对,0次DB) ==========
Set<String> batchDuplicate = new HashSet<>();
for (ProductListingExcelDto dto : dataList) {
String productCode = dto.getProductCode();
// 校验编码不能为空
if (productCode == null || productCode.trim().isEmpty()) {
failList.add(new String[]{productCode, "商品编码不能为空"});
continue;
}
String trimmedCode = productCode.trim();
// 校验批次内重复
if (batchDuplicate.contains(trimmedCode)) {
failList.add(new String[]{productCode, "商品编码在导入文件中重复"});
continue;
}
// 校验商品是否存在(Map查找 O(1))
ProductBase product = productMap.get(trimmedCode);
if (product == null) {
failList.add(new String[]{productCode, "商品不存在"});
continue;
}
// ★ 人岗客权限校验:仓库维度(严格模式)
if (warehouseCodeList == null || warehouseCodeList.isEmpty()
|| !warehouseCodeList.contains(product.getWarehouseCode())) {
failList.add(new String[]{productCode, "无操作此仓库商品的权限"});
continue;
}
// 业务状态校验
if (!Objects.equals(product.getStatus(), 1)) {
failList.add(new String[]{productCode, "当前商品状态不支持上架"});
continue;
}
// 校验通过,修改状态
product.setStatus(2);
successList.add(product);
batchDuplicate.add(trimmedCode);
}
// ========== 批量保存 ==========
int successCount = 0;
if (!successList.isEmpty()) {
try {
productBaseRepository.saveAll(successList);
successCount = successList.size();
} catch (Exception e) {
log.error("批量保存失败,降级为逐条保存", e);
for (ProductBase product : successList) {
try {
productBaseRepository.save(product);
successCount++;
} catch (Exception ex) {
failList.add(new String[]{product.getProductCode(),
"保存失败:" + ex.getMessage()});
}
}
}
}
resultDto.setSuccessCount(successCount);
resultDto.setFailCount(failList.size());
if (!failList.isEmpty()) {
resultDto.setFailFileUrl(generateFailReport(failList));
}
return resultDto;
}
/**
* 生成失败报告Excel.
*/
private String generateFailReport(List<String[]> failList) {
// 生成Excel → 上传OSS → 返回URL(逻辑省略)
return "https://oss.example.com/import-fail-xxx.xlsx";
}
}
5.6 Controller 层
java
@Slf4j
@RestController
@RequestMapping("/api/product")
public class ProductListingController {
private static final int MAX_IMPORT_SIZE = 5000;
@Resource
private ProductListingService productListingService;
/**
* 单条商品上架.
*/
@PostMapping("/listing")
public RestResult<Boolean> listingProduct(
@RequestBody ProductListingParamsDto paramsDto) {
if (paramsDto == null || paramsDto.getProductCode() == null) {
throw new BizException(-1, null, "商品编码不能为空");
}
return RestResult.success(productListingService.listingProduct(paramsDto));
}
/**
* 批量导入上架.
*/
@PostMapping("/batch-listing-import")
public RestResult<ImportResultDto> batchListingImport(
@RequestParam("file") MultipartFile file,
@RequestParam("warehouseCodeList") List<String> warehouseCodeList,
@RequestParam("operatorCode") String operatorCode) {
// 文件校验
if (file == null || file.isEmpty()) {
throw new BizException(-1, null, "导入文件不能为空");
}
// 解析Excel
List<ProductListingExcelDto> dataList =
ExcelUtil.parse(file, ProductListingExcelDto.class);
if (dataList == null || dataList.isEmpty()) {
throw new BizException(-1, null, "导入数据为空");
}
// 条数限制
if (dataList.size() > MAX_IMPORT_SIZE) {
throw new BizException(-1, null,
"单次导入不能超过" + MAX_IMPORT_SIZE + "条,当前:" + dataList.size() + "条");
}
return RestResult.success(
productListingService.batchListingImport(dataList, warehouseCodeList, operatorCode));
}
}
六、设计要点总结
| 要点 | 说明 |
|---|---|
| 权限维度要与业务匹配 | 区域管控用分公司编码,渠道管控用渠道编码 |
| 优先使用本地数据校验 | 避免为权限校验发起远程调用,影响性能 |
| 单条操作抛异常,批量操作记失败 | 单条中断用户流程,批量允许部分成功 |
| 严格模式 vs 宽松模式根据业务选 | 强管控用严格模式(权限为空=无权限) |
| 批量导入必须预查询 | 避免 N 次循环查询,改为 1 次 IN 查询 + Map |
| 权限参数由前端传入 | 前端登录时已获取权限,操作时直接传递 |