上一篇讲到,字段有了语义以后,还要区分 TM 和 QM。
TM 描述底层数据从哪里来。QM 描述业务上允许怎么查。DSL 再表达这一次具体要查什么字段、加什么条件、怎么分页排序。
到这里,查询入口已经比直接拼 SQL 稳定很多了。但还有一个问题没有解决:
当前用户真的能看到这些数据吗?
对于企业应用来说,权限不是一个简单的前端开关,也不是一个页面参数。更麻烦的是,它很容易散在不同地方:前端传一点,Service 补一点,SQL 模板里再拼一点。
这篇不讲“权限应该什么时候生效”。这个通常不是争议点。
更值得讨论的是:当系统已经有 TM、QM 和 DSL 以后,权限规则应该放在哪里。
业务条件不等于权限条件
先区分两个东西。
用户在页面上输入的筛选条件,是业务条件。
比如:
- 查询本月的入库单;
- 只看已完成的调拨;
- 按客户名称搜索;
- 按目的库位筛选;
- 按操作人筛选。
这些条件表达的是“用户想看什么”。
但权限条件表达的是“系统允许他看什么”。
比如:
- 只能看当前公司;
- 只能看自己有权限的仓库;
- 只能看所在团队的数据;
- 某些字段对当前角色不可见;
- 某些模型当前用户没有读取权限。
这两类条件不能混在一起。业务条件是调用方这次想查什么,权限条件是系统无论如何都必须加上的边界。
如果把权限当成普通筛选参数,就会出现一个危险倾向:调用方传了就有,没传就没有。
但权限不是可选项。它不应该由前端决定,也不应该由调用方决定。
权限规则也需要稳定入口
早期写动态 SQL 时,权限条件很容易变成这样的代码:
if 不是管理员,加 company_id 条件
if 只能看自己仓库,加 warehouse_id 条件
if 是团队主管,加 team_id in (...) 条件
这样写的问题不是某一段条件不对,而是每个查询都要记得补一遍。
一个列表补了,导出接口可能漏。
一个 Service 补了,另一个报表接口可能又换了一种写法。
模板里补了,后来换成 DSL 查询入口时又要重新迁移。
所以权限规则也需要一个稳定入口。它不应该跟着页面、接口、SQL 字符串到处跑。
放到 TM/QM 里意味着什么
当 DSL 进入语义查询层以后,系统已经能识别:
- 查询的是哪个 QM;
- 引用了哪些字段;
- 每个字段能不能筛选、排序、分组;
- 这些字段最终来自哪些 TM、维度或计算表达式。
这时候再加入权限上下文,边界才是清楚的。
比如当前用户、当前组织、可访问仓库、可访问团队、模型读取权限,都不应该只靠调用方临时传参。
可以把它理解成:
业务 DSL + 查询模型 + QM 内部权限规则 + 当前用户上下文 -> 受权限约束的查询
这里的重点不是某一段 SQL 条件怎么写。
重点是权限条件不能散在前端、Service 分支、SQL 模板或某个调用方传参里。它应该作为查询引擎处理入口的一部分。
这样系统才能做到几件事:
- 查询入口无权限时,能统一拒绝;
- 模型无读取权限时,能统一拒绝;
- 行级数据范围有固定来源;
- 页面、导出、内部 API 可以复用同一套规则;
- 审计日志能记录这次查询使用了哪些权限上下文。
一个小示意
TM 不做“这个用户能看什么”的判断,但它要把权限可能用到的底层字段和关联建模出来。比如库存转移里,运单、状态、计划日期、公司、来源库位这些字段,先要在 TM 里有稳定入口:
export const model = {
name: 'StockTransferModel',
tableName: 'stock_transfer',
idColumn: 'id',
properties: [
{ column: 'code', caption: '运单号' },
{ column: 'state', caption: '状态' },
{ column: 'planned_time', name: 'plannedTime', caption: '计划时间' }
],
dimensions: [
{
name: 'company',
tableName: 'company',
foreignKey: 'company_id',
primaryKey: 'id',
captionColumn: 'name',
caption: '公司'
},
{
name: 'warehouse',
tableName: 'warehouse',
foreignKey: 'warehouse_id',
primaryKey: 'id',
captionColumn: 'name',
caption: '仓库'
}
]
};
这一步只回答“字段在哪里、关联怎么走、caption 从哪里来”。它不应该写死某个用户能看哪些公司、哪些仓库。
QM 描述这个查询入口允许暴露哪些业务字段,也可以放内部权限规则:
const st = loadTableModel('StockTransferModel');
import { getCurrentUserContext } from '@securityContext';
export const queryModel = {
name: 'StockTransferQueryModel',
caption: '库存转移查询',
model: st,
columnGroups: [
{
caption: '基础信息',
items: [
{ ref: st.code },
{ ref: st.state },
{ ref: st.plannedTime },
{ ref: st.company },
{ ref: st.warehouse }
]
}
],
accesses: [
{
queryBuilder: (context) => {
const query = context.query;
const user = getCurrentUserContext(context);
if (user.role === 'ADMIN') {
return;
}
query.and(st.company$id, user.companyId);
query.and(st.warehouse$id, user.warehouseId);
}
}
]
};
这个例子里,company、warehouse 来自 TM。TM 知道它们底层怎么关联,QM 决定这个查询入口暴露哪些字段,以及要执行哪些内部权限规则。
这里的 queryBuilder 不是页面筛选条件,也不是调用方临时传进来的参数。它属于 QM 的内部规则,用来把公司、仓库这类行级范围统一追加到查询里。
用户这次请求可能只是:
{
"model": "StockTransferQueryModel",
"columns": ["code", "state", "plannedTime", "company$caption"],
"slice": [
{ "field": "plannedTime", "op": "[)", "value": ["2026-06-01", "2026-07-01"] }
]
}
columns 和 slice 是用户这次想查什么。QM 的 accesses 会继续把内部权限条件合进去。用户筛“6 月的库存转移”是一层条件;系统限制“只能看这些公司和仓库”是另一层规则。
还有一类:维度成员查询权限
除了主查询里的 accesses,TM/QM 里还有一类更具体的内部权限:维度成员权限。
这个能力主要服务于“查某个维度有哪些可选成员”的场景。比如页面上有一个商品筛选框,用户输入关键字后,需要返回商品候选项。这个查询看起来只是查 product,但它同样不能变成随意扫维度表。
在 TM 里,可以先给某个维度配置默认成员权限:
dimensions: [
{
name: 'product',
tableName: 'dim_product_nested',
foreignKey: 'product_key',
primaryKey: 'product_key',
captionColumn: 'product_name',
caption: '商品',
memberPermission: {
patch: {
forcedSlice: [
{ field: 'brand', op: '=', value: 'Apple' },
{
field: 'tenantId',
op: '=',
valueBuilder: (ctx) => ctx.security.tenantId
}
],
forcedOrderBy: [
{ field: 'caption', dir: 'ASC' }
],
hierarchyEnabled: true,
allowedHierarchyOps: ['childrenOf', 'descendantsOf']
},
queryBuilder: (context) => {
context.query.andSql('enabled = ?', true);
}
}
}
]
forcedSlice 表示这类成员查询天然要带上的过滤条件。它既可以写静态值,也可以通过 valueBuilder(ctx) 从运行时上下文里取值,比如当前租户、当前用户、当前组织。
这里的动态注入主要发生在参数值上。valueBuilder 只负责生成值,field 仍然是固定字段名,并且必须存在于维度成员查询的字段空间里。
forcedOrderBy 表示候选项默认排序。hierarchyEnabled 和 allowedHierarchyOps 用来限制成员查询是否允许走层级操作,比如只允许查子节点或后代节点。
queryBuilder 则是更靠近 SQL 构建阶段的增强入口,适合追加不方便用简单 slice 表达的条件。它拿到的是查询上下文,和 valueBuilder(ctx) 那种轻量参数上下文不是同一个东西。
到了 QM 层,还可以针对这个查询入口做覆盖或收窄:
memberPermissions: [
{
dimension: 'product',
patch: {
forcedSlice: [
{ field: 'id', op: '!=', value: 0 }
],
forcedOrderBy: [
{ field: 'caption', dir: 'DESC' }
]
}
}
]
同一个 TM 维度可以被多个 QM 使用,但不同查询入口对维度成员的暴露方式不一定一样。引擎会把 TM 维度侧的 memberPermission 和 QM 侧的 memberPermissions 合并成最终规则:
forcedSlice:同字段时 QM 覆盖 TM,不同字段合并;forcedOrderBy:同字段时 QM 覆盖 TM;hierarchyEnabled/allowedHierarchyOps:QM 可以继续收窄成员层级操作;queryBuilder:TM 和 QM 都保留,按 TM 到 QM 的顺序执行。
主查询的 accesses 管行级数据范围。维度成员权限管成员候选项怎么查、默认带哪些约束、是否允许层级查询。两者都是模型内部规则,只是作用对象不同。
这和早期 SQL 模板权限注入不一样
前面第 4 篇讲过,在动态 SQL 模板阶段,我们也做过权限条件注入。
那时候的问题是:不要让 tenant_id、team_id、created_by 这些条件散落在 Java 拼接分支里。通过 Spring Bean 和 helper,把权限上下文稳定接进模板。
那一步解决的是模板阶段的混乱。
现在问题往前走了一层。
当系统已经有 TM、QM 和 DSL 以后,权限不应该只是某个 SQL 模板里的几行条件。它应该成为语义查询模型的一部分。
也就是说,权限不只是“拼到 SQL 上”,而是要稳定回答:
- 这个查询入口能不能用;
- 这个查询入口默认要带哪些行级约束;
- 当前上下文能不能得到必要的权限范围;
- 这个用户的数据范围是什么;
- 维度成员候选项应该暴露到什么程度。
这样一来,同一个 QM 被页面、导出或内部 API 调用时,权限边界才不会各写各的。
先把边界放对
把权限规则放进 TM/QM,不是说引擎可以自动理解所有企业定制权限。
现实里,企业系统经常有定制组织结构、特殊数据范围、临时业务规则。这些东西不可能靠一个通用引擎一次性猜完。
但引擎至少要把位置留对:
主查询的数据范围要有固定入口。
维度成员查询也要有自己的规则。
拒绝和审计应该能说明原因。
这才是工程上更稳的做法。
到这里,语义查询入口已经不只是“字段更清楚”了。它开始有了真正的访问边界。
后面继续往前走时,重点也应该沿着这条线展开:查询入口、字段目录、权限绑定和执行证据要放在同一个链路里,而不是让每个调用方各自补一层判断。
相关代码:QueryModel accesses 示例
相关代码:TM 维度成员权限示例
相关代码:QM memberPermissions 覆盖示例
相关文档:QueryModel accesses 权限控制
相关文档:TM / QM 建模