人岗客权限校验(数据权限控制)实战指南

人岗客权限校验(数据权限控制)实战指南

一、概念定义

什么是人岗客权限

"人岗客"是企业级系统中的一种数据权限控制模型 ,核心思想是:用户只能操作其岗位职责范围内的客户数据

要素 含义 举例
当前操作用户 张三(工号 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
权限参数由前端传入 前端登录时已获取权限,操作时直接传递