zhaojie.tech/me
项目实战复盘 2026-10-03T00:00:00.000Z

用 JSqlParser 改写 SQL:自研数据权限引擎管住 137 张表

错误日志里有一句我亲手写不出来也删不掉的 SQL:

Unknown column 'ss_tenant_onboarding.store_id' in 'where clause'
### SQL: SELECT * FROM ss_tenant_onboarding
###       WHERE ss_tenant_onboarding.store_id = 1 AND tenant_id = ?

我的 Mapper 里根本没有 store_id 这个条件——它是数据权限引擎注入的,注进了一张没有 store_id 列的表。门店档用户一读即 500,开店准备、权益、导入三个域对店员整体不可用。而这是同类事故的第二次爆发。

背景约束

连锁零售 ERP,多门店租户里老板、店长、店员能看的门店范围不同,需求是五档门店数据权限:全部、自定义集合、本门店、本店及分组、仅本人。存量是 80+ 模块、7600+ Java 文件、大量 XML 手写 SQL(联表、子查询、UNION),逐个 Mapper 加过滤条件,漏一处就是跨店数据泄漏。

方案推演

注解式方案(Service 上标范围、拼 QueryWrapper)管不住 XML 手写 SQL;视图方案要为 137 张业务表各建一套视图,维护量不可接受。唯一能看到所有 SELECT 的位置在 MyBatis 的 Executor 层——拦截器 + JSqlParser 改写 SQL,让过滤成为基础设施而非业务代码的自觉。

实现

拦截器挂在 Executor.query 上(4 参与 6 参两个签名都要接):JSqlParser 解析出语法树,收集 FROM/JOIN 里的表名对白名单做判断,按当前用户作用域生成条件,addWhere 带别名拼进 WHERE,最后反射替换 BoundSql 里的 SQL。

五档条件的生成规则:

if (ids == null || ids.isEmpty())  return "1=0";          // 仅本人/空集合:无数据
if (ids.size() == 1)               return "store_id=" + ids.get(0);
if (ids.size() <= 50)              return "store_id IN (" + joinIds(ids) + ")";
// >50 时按档位改子查询(档2 查角色门店表,档4 查门店分组表),避免超长 IN 字面量

有两处设计比条件生成本身更重要。

作用域计算与缓存。 用户最终范围是多角色并集:任一角色是”全部”档即全量,否则自定义集合 ∪ 本店分组 ∪ 本店,可写集合只保留营业中的门店。作用域按 user:{userId}:storeScope 缓存在 Redis,TTL 1 小时;角色或门店绑定变更时递增全局版本号 smartshop:storeScope:version,所有用户的缓存因版本不匹配而批量失效——不扫描、不踢人。租户门店数受套餐 100 店封顶,子查询与 IN 的规模都有上界。

重入守卫。 计算作用域本身要跑 SQL(查用户、角色、门店),而这些 SQL 也会经过拦截器——不设防就是”计算作用域 → 触发过滤 → 回查作用域”的死循环。一个 COMPUTING ThreadLocal 标记让构建期间的查询放行,构建完立即清除。

写路径是另一半:读过滤挡不住”按 ID 改他店单据”,所以按 ID 修改/删除的 Service 入口统一调用 StoreScopeGuard.assertWritable(entity.getStoreId()),目标门店不在可写集合直接 403 STORE_SCOPE_DENIED,storeId 为空也拒绝——fail-closed,宁误杀不漏放。拦截器解析失败时同样保守:跳过注入放行原 SQL,租户隔离由 TenantLine 拦截器兜底,两道防线各管一个维度。

踩坑:两次同因事故与结构闸

第一次是 2026-08-16:按生产 RDS 实测一次性补了 34 张表进白名单(无 store_id 列的租户级表)。但只补数据、没立闸——此后每张新建的表都可能漏登,直到运行期 500 才暴露。第二次就是开头那单:ss_tenant_onboarding 按设计无 store_id 列,却不在白名单里。

只补数据是治标。这次同时落了一道结构闸测试:以仓内 DDL 语料(基线 dump + 正典迁件合并)为真相源,与白名单双向对账——Leg A:语料里没有 store_id 列的表必须已登记,否则运行期必 500;Leg B:有 store_id 列却仍被豁免的表,必须逐字等于一份语义豁免快照(有意识的例外,增减必须过评审),否则等于静默关闭门店过滤、跨店泄漏。另有一条活体审计腿直接问 information_schema,抓绕过迁件落到库上的表。两道判据都配了会判红的合成夹具——没有阳性对照的”没报错”不等于绿。反向纪律也入了注释:将来给表加上 store_id 列,必须同批移出白名单,闸会就此判红。

白名单本身也从拦截器里的硬编码迁成了集中登记的 StoreFilterTableRegistry(从最初的 42 张精确表名 + 两个前缀起步,每张带豁免理由),引擎最终精确覆盖 137 张业务表。

一句原则

结构性风险要结构性闸住:靠人记住”137 张表哪些该过滤”必然遗漏,让 DDL 语料与白名单在提交时互相对账,缺陷才会从”运行期 500”前移成”提交即红”。

← 全部文章 赵杰 · me.zhaojie.tech