电商系统的数据权限 SQL 应该怎么做?一次讲清店铺、仓库、类目隔离与动态查询设计

大家好,我是一名有 4 年工作经验的 Java 后端开发。
最近在系统整理后台系统和电商业务里的权限设计问题,准备沉淀成一个系列。
上一篇我写了电商后台的菜单权限、按钮权限、数据权限怎么设计,这一篇继续往下拆更实战的一层:数据权限在 SQL 里到底应该怎么落地。

🦅个人主页
🐼

文章目录


一、前言

很多团队在做权限系统时,菜单权限和按钮权限往往做得还不错:

  • 菜单能看到哪些
  • 按钮能不能点
  • 接口有没有权限码

但一到数据权限,系统就开始变得很模糊。

比如:

  • 商品运营只能看自己负责类目的商品,这个 SQL 怎么写?
  • 仓库人员只能看自己仓库的订单,查询条件放哪?
  • 客服只能看自己店铺的售后单,导出 Excel 时怎么限制?
  • 财务只能看已结算店铺的数据,分页接口和统计接口怎么保持一致?
  • 平台后台和商家后台共用一套表时,merchant_id 怎么隔离?

很多项目做到最后会出现这些问题:

  • 前端做了筛选限制,但后端 SQL 没有限制
  • 列表接口有限制,导出接口忘了限制
  • 查询接口有限制,详情接口没限制
  • SQL 全靠 if/else 拼来拼去,后面越来越难维护
  • 数据范围逻辑散落在 Controller、Service、Mapper 到处都是

所以数据权限真正要解决的问题,不是“多加几个 where 条件”,而是:

如何把用户的数据可见范围稳定、统一、可维护地落到后端查询层。

这篇文章就结合电商后台场景,把数据权限 SQL 的设计思路、数据库建模、动态查询方式和常见坑点系统讲透。


二、业务场景

先假设这样一个典型的电商后台。

2.1 后台角色

系统里有这些典型角色:

  • 商品运营
  • 仓库人员
  • 客服
  • 财务
  • 店长
  • 平台运营

2.2 数据隔离维度

电商后台最常见的数据隔离维度有这些:

  • shop_id:店铺维度
  • warehouse_id:仓库维度
  • category_id:类目维度
  • merchant_id:商户维度
  • dept_id:组织维度
  • creator_id:本人数据维度

2.3 真实业务要求

这个场景下,通常会有下面这些要求:

  • 商品运营只能查看自己负责类目的商品
  • 仓库人员只能查看自己仓库的待发货订单
  • 客服只能查看自己店铺的订单和售后
  • 财务只能查看自己负责商户的结算数据
  • 平台管理员可以看全部数据
  • 导出、详情、统计接口都必须遵守同一套数据权限规则

这就意味着:

数据权限不是某一个页面的特殊逻辑,而应该是整套后端查询体系的一部分。


三、问题现象

很多项目一开始做数据权限,通常是这样写的。

3.1 在 Controller 里随手塞几个查询条件

比如:

@GetMapping("/order/list")
public Result list(OrderQuery query) {
    query.setShopId(LoginUserHolder.getShopId());
    return Result.success(orderService.list(query));
}

这种写法的问题是:

  • 逻辑分散
  • 只覆盖了某一个接口
  • 后面导出、详情、统计接口很容易忘记加

3.2 前端传什么范围,后端就查什么

比如前端请求:

{
  "shopId": 1001,
  "warehouseId": 3
}

然后后端就直接信任这个参数去查。

这种做法非常危险,因为:

  • 前端参数可以被篡改
  • 用户完全可能传入不属于自己的店铺 ID

也就是说:

数据权限永远不能靠前端约束,必须由后端根据登录人重新收敛。

3.3 列表接口做了数据权限,详情接口忘了做

这是实际项目里特别常见的坑。

比如:

  • 订单列表只能看到自己店铺
  • GET /order/{id} 却没做校验

结果就是:

  • 列表看不到
  • 手动拼一个订单 ID 却能直接看详情

3.4 导出接口和统计接口绕过了数据权限

很多系统列表页面限制做了,但:

  • 导出接口没加
  • 汇总统计 SQL 没加
  • 大屏报表接口没加

这种问题在电商后台尤其危险,因为:

  • 订单金额
  • 成本价
  • 结算数据

都很敏感。


四、原理分析

数据权限 SQL 真正要解决的,不只是“能不能查出来”,而是:

在任何查询入口上,都能稳定地把用户可见范围收敛成后端可信的查询条件。

4.1 数据权限和菜单权限、按钮权限有什么本质区别?

菜单权限和按钮权限更多是:

  • 功能入口控制

而数据权限控制的是:

  • 查询结果边界
  • 可操作对象边界

也就是说:

  • 菜单权限控制“能不能进页面”
  • 按钮权限控制“能不能点操作”
  • 数据权限控制“进入页面后能看到谁的数据”

这是三层不同问题。

4.2 数据权限本质上是“行级过滤”

大多数业务里的数据权限,本质上都可以理解成:

在原始 SQL 的基础上,附加一段与当前用户身份相关的 where 条件。

比如:

  • 店铺权限:shop_id in (...)
  • 仓库权限:warehouse_id in (...)
  • 类目权限:category_id in (...)
  • 本人权限:creator_id = currentUserId

这就是为什么数据权限最终会落到 SQL 层。

4.3 为什么电商系统的数据权限往往是多维的?

因为电商后台里的可见范围不只有一个维度。

比如“订单列表”这个模块,可能同时受这些维度影响:

  • 店铺维度
  • 仓库维度
  • 商户维度
  • 数据状态维度

所以在真实项目里,数据权限通常不是一个简单的 dept_id = ? 就完了,而是:

多个维度共同约束。

4.4 为什么不能把数据权限逻辑写死在每个 Mapper 里?

因为一旦模块多了,你会看到下面这种情况:

  • 订单列表写一套
  • 订单导出写一套
  • 订单统计写一套
  • 售后列表再写一套

最后就会越来越难维护,而且很容易漏。

所以更合理的思路通常是:

  • 先统一抽象“当前用户的数据范围”
  • 再统一落到查询条件

五、数据库怎么设计最合适

如果你要做电商后台数据权限,我建议数据库至少这样建模。

5.1 基础角色表和权限表

这一层和普通 RBAC 一样:

  • sys_user
  • sys_role
  • sys_user_role
  • sys_permission
  • sys_role_permission

5.2 数据范围表

电商系统建议按业务维度拆范围表。

sys_role_shop_scope

角色可见店铺范围

字段示例:

  • id
  • role_id
  • shop_id
sys_role_warehouse_scope

角色可见仓库范围

字段示例:

  • id
  • role_id
  • warehouse_id
sys_role_category_scope

角色可见类目范围

字段示例:

  • id
  • role_id
  • category_id
sys_role_merchant_scope

角色可见商户范围

字段示例:

  • id
  • role_id
  • merchant_id

5.3 为什么我更推荐“范围关系表”,而不是一个 scope_json 字段?

因为关系表更适合:

  • SQL Join 查询
  • 索引优化
  • 权限配置管理
  • 后续扩展

而 JSON 字段虽然看起来灵活,但:

  • 查询不方便
  • 索引能力弱
  • 后期维护很麻烦

所以如果是正式项目,我更建议用关系表。


六、推荐的后端设计方式

这里给一版更贴近线上落地的思路。

6.1 先统一拿到当前用户的数据范围

比如登录后,后端可以把当前用户的数据范围整理成一个对象:

public class DataScopeContext {
    private boolean allScope;
    private Set<Long> shopIds;
    private Set<Long> warehouseIds;
    private Set<Long> categoryIds;
    private Set<Long> merchantIds;
    private Long userId;
}

这个对象可以来自:

  • 用户角色
  • 角色绑定的数据范围
  • 平台管理员特殊规则

6.2 查询前,把数据范围转成查询条件

比如订单列表查询对象:

public class OrderQuery {
    private Long shopId;
    private Long warehouseId;
    private Integer status;
    private LocalDateTime startTime;
    private LocalDateTime endTime;

    private Set<Long> allowedShopIds;
    private Set<Long> allowedWarehouseIds;
}

然后在 Service 层做统一收敛:

public void fillDataScope(OrderQuery query, DataScopeContext scope) {
    if (!scope.isAllScope()) {
        query.setAllowedShopIds(scope.getShopIds());
        query.setAllowedWarehouseIds(scope.getWarehouseIds());
    }
}

这一步的核心意义是:

前端传的是业务筛选条件,后端补的是权限过滤条件。

两者不能混为一谈。

6.3 SQL 里永远以“后端收敛后的权限范围”为准

也就是说,用户前端即使传了:

  • shopId = 9999

但如果 9999 不在当前用户允许范围里,后端依然不能放行。

这也是数据权限设计里最重要的一条原则。


七、落地 SQL 和代码示例

下面给一版比较贴近实际项目思路的示例。

7.1 订单列表 SQL 怎么做数据权限

MyBatis XML 示例:

<select id="selectOrderPage" resultType="com.demo.OrderDTO">
    select
        id, order_no, shop_id, warehouse_id, status, total_amount, create_time
    from order_info
    <where>
        <if test="status != null">
            and status = #{status}
        </if>
        <if test="shopId != null">
            and shop_id = #{shopId}
        </if>
        <if test="warehouseId != null">
            and warehouse_id = #{warehouseId}
        </if>
        <if test="allowedShopIds != null and allowedShopIds.size() > 0">
            and shop_id in
            <foreach collection="allowedShopIds" item="shopId" open="(" separator="," close=")">
                #{shopId}
            </foreach>
        </if>
        <if test="allowedWarehouseIds != null and allowedWarehouseIds.size() > 0">
            and warehouse_id in
            <foreach collection="allowedWarehouseIds" item="warehouseId" open="(" separator="," close=")">
                #{warehouseId}
            </foreach>
        </if>
    </where>
    order by create_time desc
</select>

这个写法的核心点是:

  • shopIdwarehouseId 是业务筛选条件
  • allowedShopIdsallowedWarehouseIds 是权限过滤条件

这两个必须分开。

7.2 如果用户选择了一个自己没权限的店铺怎么办?

比如前端筛选:

  • shopId = 1005

但当前用户允许范围是:

  • shopIds = [1001, 1002]

后端有两种常见处理方式:

方式一:直接返回空数据

这适合列表查询。

方式二:直接抛权限异常

这更适合详情、导出、操作接口。

比如详情接口:

public OrderDetailDTO detail(Long orderId, Long currentUserId) {
    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        return null;
    }

    DataScopeContext scope = dataScopeService.getCurrentScope(currentUserId);
    if (!scope.isAllScope() && !scope.getShopIds().contains(order.getShopId())) {
        throw new RuntimeException("无权查看该订单");
    }

    return buildDetail(order);
}

7.3 导出接口必须复用同一套数据权限

这是最容易漏的地方。

错误做法:

  • 列表接口带权限 SQL
  • 导出接口重新写一套查询,忘了加范围限制

更好的做法是:

  • 导出接口复用同一个 Query 对象
  • 统一走同一个数据权限填充逻辑

比如:

public void exportOrder(OrderQuery query) {
    DataScopeContext scope = dataScopeService.getCurrentScope(LoginUserHolder.getUserId());
    fillDataScope(query, scope);
    List<OrderDTO> rows = orderMapper.selectOrderForExport(query);
    excelExporter.export(rows);
}

7.4 统计 SQL 也不能绕开数据权限

比如统计某个时间段订单金额:

select sum(total_amount)
from order_info
where status = 1

如果不加数据权限,这种报表最容易越权。

更合理的做法是:

select sum(total_amount)
from order_info
where status = 1
  and shop_id in (...)

所以一定要记住:

数据权限不是只管列表,也必须管统计和报表。

7.5 更进一步:统一拦截器或注解式注入

如果项目规模更大,可以考虑:

  • MyBatis 拦截器
  • AOP 注解
  • 自定义 DataScope 注解

比如:

@DataScope(shop = true, warehouse = true)
public PageResult<OrderDTO> page(OrderQuery query) {
    return orderService.page(query);
}

然后在切面里统一把当前人的 shopIdswarehouseIds 注入查询对象。

这种方式的优点是:

  • 权限逻辑更集中
  • 不容易漏
  • 维护成本更低

但缺点也有:

  • 调试复杂度更高
  • 需要团队规范统一

如果团队规模不大,我会建议先从“Service 层统一填充数据范围”开始,别一上来就做过度复杂的自动拦截。


八、为什么很多项目的数据权限最后会失控?

这也是线上项目非常常见的情况。

8.1 前端限制了筛选项,就以为安全了

这是最常见的误区。

前端不展示某个店铺,不代表别人不能手动传这个店铺 ID。

8.2 列表有限制,详情和导出忘了限制

这类问题在电商系统非常高频,而且影响很大。

8.3 数据权限逻辑散落在各层

Controller 里写一点,Service 里补一点,Mapper 里再拼一点,最后谁都说不清真正的限制条件是什么。

8.4 多维数据权限硬拼 OR / IN,SQL 越来越难维护

比如:

  • 店铺一套
  • 仓库一套
  • 类目一套
  • 商户一套

如果没有统一抽象,后面 SQL 会越来越乱。

8.5 没有管理员特权和商家维度的统一规则

比如平台管理员、商家管理员、店铺运营本来就不是一套范围模型。
如果没有统一定义,很容易写出很多特判。

8.6 只考虑查询,不考虑操作权限

比如发货、退款审核、库存调整这些动作,除了按钮权限,还必须校验目标数据对象是否在当前人的范围内。

也就是说:

数据权限不仅影响 select,也影响 update、export、approve 这些动作。


九、压测与监控怎么写,文章才更像做过项目的人写的?

数据权限这类文章如果只讲模型,不讲 SQL 成本和监控,容易显得太抽象。

所以建议补上观测和优化视角。

9.1 压测场景示例

这里给一个适合写进文章的测试场景:

场景配置:

  • 订单表数据量:5000 万
  • 订单列表峰值 QPS:3000
  • 用户角色包含店铺和仓库双维度范围
  • 应用实例数:4
  • MySQL:主从

对比方案:

  • 方案 A:Controller 拼条件,接口各写各的
  • 方案 B:Service 层统一填充数据范围
  • 方案 C:统一数据权限注入 + 复合索引优化

9.2 压测结果示例

指标 各写各的 Service 统一收敛 统一收敛 + SQL 优化
权限遗漏风险
SQL 可维护性
查询 RT 210ms 180ms 95ms
导出一致性 容易出错 较好
项目可扩展性

从结果上可以看出:

  • 数据权限最怕的不是多几个条件,而是逻辑散乱和遗漏
  • 真正可维护的方式,一定是统一抽象、统一收敛、统一复用

说明:以上压测数据为示例写法,实际结果需要结合范围数据量、索引设计、SQL 条件组合方式综合评估。

9.3 线上建议重点监控哪些指标?

如果你准备真正落地数据权限 SQL,至少建议监控这些指标:

  • 列表、详情、导出接口的越权审计日志
  • 不同角色的查询 RT
  • 范围过滤后的 SQL 扫描行数
  • 导出接口耗时
  • 统计报表接口耗时
  • 数据权限命中规则数
  • 管理员全量查询占比
  • 店铺/仓库范围过大用户占比

这些指标一旦写进文章里,会明显更像真实线上经验总结。


十、面试中怎么回答这个问题?

如果面试官问你:

电商系统的数据权限 SQL 一般怎么做?

你可以这样回答。

10.1 回答思路

第一,电商系统的数据权限一般不是简单的部门维度,而是店铺、仓库、类目、商户等多维范围组合,所以需要先把当前用户的数据可见范围抽象出来。

第二,后端不能信任前端传入的范围参数,而是应该根据当前登录人的角色和范围配置,统一收敛出允许访问的 shopIdswarehouseIdscategoryIds 等,再把这些条件附加到 SQL 查询里。

第三,业务筛选条件和权限过滤条件必须分开,比如前端选择店铺是业务筛选,而当前人能看哪些店铺是权限条件,这两者不能混在一起。

第四,数据权限不能只作用于列表查询,详情、导出、统计、审批、发货这些接口也必须统一走同一套范围校验逻辑,否则非常容易出现越权。

第五,如果项目规模较小,可以先在 Service 层统一填充数据范围;如果规模更大,可以再考虑通过注解或拦截器统一注入数据权限条件。

10.2 面试官更想听到什么?

面试官真正想听的,通常不是一句“where 后面拼个 shop_id”,而是你有没有这些意识:

  • 你知道前端参数不可信
  • 你知道业务筛选和权限过滤要分开
  • 你知道详情、导出、统计都必须统一管控
  • 你知道数据权限是多维组合,不只是部门维度
  • 你知道逻辑要统一收敛,不能散落在各层
  • 你知道 SQL 性能和索引也要一起考虑

如果你能把这些点讲清楚,面试官会明显觉得你做过真实后台权限设计,而不只是知道 RBAC 名词。


十一、总结

电商系统的数据权限 SQL,真正难的不是“多加几个查询条件”,而是如何把:

  • 当前用户的角色
  • 当前用户的数据范围
  • 业务筛选条件
  • 统一的后端过滤逻辑

真正组合成一套稳定、统一、可复用的查询体系。

如果只记一句结论,我觉得可以记住这句:

电商系统的数据权限 SQL,核心不是让前端传什么查什么,而是后端根据当前用户统一收敛范围,再把权限条件稳定注入到所有查询入口。

这套思路不一定最轻,但通常更接近真实电商后台系统的做法。


十二、后续可以继续展开的内容

如果这篇你觉得还可以,后面这个系列我还可以继续写:

  • 电商后台的订单状态机怎么设计?
  • 平台后台和商家后台权限怎么隔离?
  • 后台动态菜单和前端路由怎么结合?
  • 电商系统的店铺、仓库、类目权限怎么统一抽象?
  • 审批流和数据权限怎么结合?

十三、结尾

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注。
后面我会继续整理一些更偏实战的 Java 后端和电商系统设计文章。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐