上一篇,我直接从 WideWorldImporters 业务库的发票和发票明细出发,用同一个 QueryModel 交付了表格、查询 API 和 AI 问数。
但那只能证明第一版能跑。
真实业务不会停在一张固定列表上。用户很快就会继续要:按日期、发票、客户、客户类别、地区、商品、税率和金额筛选,还要分页、排序和汇总。
这次我把 8 类条件全部加到了同一张发票明细表上。
先看结果:我没有新增专用 Controller、Form / DTO、Mapper、Repository 查询方法或 Vue 页面,也没有修改上一篇的 TM / QM。
同一个 wwi_oltp_invoice_lines 已经实际执行:
| 条件 | 查询字段 | 样例 |
|---|---|---|
| 开票日期 | invoiceDate$id |
2016-05-01 至 2016-05-31 |
| 发票 ID | invoiceId |
70510 |
| 客户 | customer$id |
404 |
| 客户类别 | customer$category |
Novelty Shop |
| 州 / 地区 | customer$stateProvince |
Pennsylvania |
| 商品 | stockItem$id |
130 |
| 税率 | taxRate |
15% |
| 含税明细金额 | totalIncludingTaxDetail |
200 至 400 |
8 个单条件查询、1 个八条件组合查询、分页第二页和同条件服务端汇总,都先通过了 Runtime validate,再执行成功。
八个条件同时使用时,只返回一条明细:
| 字段 | 结果 |
|---|---|
| 开票日期 | 2016-05-31 |
| 发票 ID | 70510 |
| 发票明细 ID | 228265 |
| 客户 | Wingtip Toys (Penns Creek, PA) |
| 客户类别 | Novelty Shop |
| 州 / 地区 | Pennsylvania |
| 商品 | Furry gorilla with big eyes slippers (Black) S |
| 税率 | 15% |
| 含税金额 | 294.40 |
手写 SQL 返回的也是这一条。
这篇不讨论"低代码"方法论,只拆一件具体的事:为什么新增 8 个查询条件以后,我没有再为它们设计 8 个接口参数和 8 段查询分支。
传统列表接口真正麻烦的不是 WHERE
直接写一个发票查询接口并不难。
如果只支持发票 ID,代码大致是:
text
GET /invoice-lines?invoiceId=70510
难的是条件持续增加以后,每一层都要保持一致:
- Controller 要接收新参数。
- Form / DTO 要补字段、类型和校验。
- Service 要判断哪些条件存在。
- Mapper、Repository 或 SQL 模板要追加过滤逻辑。
- 分页查询和 count 查询不能漏掉任何一个条件。
- 汇总 SQL 还要再复制一遍相同范围。
- 前端要补表单项、操作符、默认值和序列化规则。
- AI 如果走另一套接口,还要重新理解这组参数。
这里最容易低估的是:新增的是 8 个筛选条件,不一定是 8 个 HTTP endpoint,但通常会变成多个层次里的 8 组改动。
我没有另外实现一套传统版本,所以不会虚构"节省了多少行代码"。这次只比较真实改动:Foggy 侧的生产模型和接口代码改动是 0;新增的 11 个 JSON、121 行,全部是为了留下可复现的验证证据。
8 个条件只是同一份 Query DSL
八条件组合请求如下:
json
{
"limit": 10,
"columns": [
"invoiceDate$id",
"invoiceId",
"invoiceLineId",
"customer$id",
"customer$caption",
"customer$category",
"customer$stateProvince",
"stockItem$id",
"stockItem$caption",
"taxRate",
"totalIncludingTaxDetail"
],
"slice": [
{"field": "invoiceDate$id", "op": "[)", "value": ["2016-05-01", "2016-06-01"]},
{"field": "invoiceId", "op": "=", "value": 70510},
{"field": "customer$id", "op": "=", "value": 404},
{"field": "customer$category", "op": "=", "value": "Novelty Shop"},
{"field": "customer$stateProvince", "op": "=", "value": "Pennsylvania"},
{"field": "stockItem$id", "op": "=", "value": 130},
{"field": "taxRate", "op": "=", "value": 15},
{"field": "totalIncludingTaxDetail", "op": "[]", "value": [200, 400]}
],
"orderBy": [
{"field": "invoiceDate$id", "dir": "DESC"},
{"field": "invoiceLineId", "dir": "DESC"}
]
}
这份请求没有表名、JOIN 和数据库方言。
调用方只能使用 QM 已经暴露的字段。字段是否存在、能否过滤、是什么类型,由 wwi_oltp_invoice_lines 的 schema 决定;客户、商品和地区如何关联回发票事实,也仍然由模型负责。
这和把任意 SQL 交给前端或 AI 不一样。DSL 提供的是一组受控查询能力,不是数据库通行证。
金额范围为什么用明细字段
上一篇的 QM 同时保留了:
totalIncludingTaxDetail:一条发票明细的含税金额。totalIncludingTax:用于SUM的聚合度量。
这次用户筛的是"单条含税金额在 200 到 400 之间的发票明细",所以条件必须落在 totalIncludingTaxDetail。
json
{
"field": "totalIncludingTaxDetail",
"op": "[]",
"value": [200, 400]
}
如果问题变成"含税销售额在 10 万以上的客户",那已经是先按客户分组、再过滤聚合结果,需要使用聚合 alias / HAVING 语义。
两个问题看起来都叫"金额筛选",实际查询阶段不同。通用 DSL 不代表可以把明细条件和聚合条件混在一起。
分页不是把全部数据拉到浏览器切片
我使用下面的顺序查询 2016 年 5 月发票明细:
text
invoiceDate$id DESC, invoiceLineId DESC
第一页 start=0, limit=10:
text
228265 ... 228256
第二页 start=10, limit=10:
text
228255 ... 228246
手写 SQL 按相同字段排序后的前 20 条,与两页结果逐条一致。
这说明分页和排序发生在服务端查询,不是浏览器先读取 6,351 条 5 月数据再截取第二页。
不过这里也发现了一个稳定版缺口:Runtime 0.1.15 能正确返回 hasMore=true,但响应里的 total 是 0。
所以本轮能确认的是分页窗口和顺序正确,不能宣称 0.1.15 已经提供可靠的列表总条数。
汇总不能拿当前页数据相加
列表页面常见的另一个问题是:底部合计到底是当前页,还是全部筛选结果?
我把完全相同的 8 个 slice 交给服务端汇总查询:
json
{
"columns": [
"count(invoiceLineId) as invoiceLineCount",
"sum(totalIncludingTax) as totalIncludingTax",
"sum(totalExcludingTax) as totalExcludingTax",
"sum(taxAmount) as taxAmount",
"sum(lineProfit) as lineProfit",
"sum(quantity) as quantity"
],
"slice": ["与明细列表相同的 8 个条件"]
}
结果是:
| 指标 | Runtime | OLTP 手写 SQL |
|---|---|---|
| 明细行数 | 1 | 1 |
| 含税金额 | 294.40 | 294.40 |
| 未税金额 | 256.00 | 256.00 |
| 税额 | 38.40 | 38.40 |
| 利润 | 192.00 | 192.00 |
| 数量 | 8 | 8 |
汇总使用的是完整服务端筛选范围,不依赖当前页加载了多少条。
这也是统一查询入口的价值:列表、分页 count 和汇总不应该各自维护一份越来越容易漂移的 WHERE 条件。
QM 元数据怎样变成表格筛选器
models describe wwi_oltp_invoice_lines 返回了 46 个字段的查询元数据。
例如:
text
invoiceDate$id DAY date filter
invoiceId BIGINT number filter
customer$category STRING text filter
customer$stateProvince STRING text filter
stockItem$id INTEGER dimension filter
taxRate NUMBER number filter
totalIncludingTaxDetail MONEY number filter
Data Viewer 不需要我再手写一份这 8 个字段的类型定义。它可以根据 schema 选择日期区间、数值、维度和文本筛选控件,再把用户输入转成同一份 slice。
在当前工作区的 9.2.0-SNAPSHOT 中,由可信宿主带上 X-NS: wwi-oltp-sales 后,页面实际渲染了 11 个业务列、8 个初始条件和 1 条组合结果;客户、商品、金额均与 Runtime 和手写 SQL 一致,浏览器没有 console error 或 failed request。
但直接把查询链接粘贴到普通浏览器时,我又发现一个 namespace 继承问题:
text
查询 data 接口:返回 1 条正确数据
schema 接口:返回 0 个字段
页面:有一条空行,但没有业务列
原因是 Viewer 的 schema 请求没有自动继承查询缓存中的 namespace。手工带 X-NS 后字段恢复为 46 个。
因此当前不能把"多 namespace 环境里复制链接就能零配置打开完整表格"写成已经完成。可信业务宿主可以注入 namespace 头,但分享链接自身仍需要补齐上下文继承。这次没有为了文章临时修产品代码,只把缺口保留下来。
这次实际改了什么
| 资产 | 文件数 | 行数 | 用途 |
|---|---|---|---|
| 8 个单条件 payload | 8 | 70 | 逐项验证字段和操作符 |
| 组合、分页、汇总 payload | 3 | 51 | 验证完整查询行为 |
| TM / QM | 0 | 0 | 复用上一篇模型 |
| Controller / DTO / Mapper | 0 | 0 | 未新增 |
| Vue 页面 / 筛选组件 | 0 | 0 | 未新增 |
这里真正节省的不是"少写 8 条 WHERE"。
一旦查询字段、类型、关联和指标已经进入 QM,页面、Runtime API 和 AI 都能继续组合它们,而不是每新增一个入口就复制一次查询定义。
当然,这不是说以后永远不改模型。
如果用户需要的新字段还没有进入语义层,开发仍然要确认源字段、业务含义和暴露边界,再修改 TM / QM。区别在于这次 8 个条件都属于模型已有能力,因此不需要把它们重新翻译成一套专用接口。
通用查询接口不能替代业务写接口
发票明细的筛选、分页、排序和汇总适合使用 Query DSL。
下面这些动作仍然应该保留在原业务系统:
- 创建和修改发票。
- 审核、作废和红字处理。
- 退款、付款和核销。
- 触发工作流或外部通知。
- 带幂等、审批与状态机的业务操作。
Foggy 在这里提供的是受控读能力,不是把所有业务 API 变成一份万能 DSL。
还有一个问题比筛选条件更重要
这 8 个条件都是用户主动选择的业务条件:查哪个时间、哪个客户、哪个商品。
但企业系统里还有另一类条件:
text
当前用户只能看哪个公司、部门、区域、门店或客户范围?
这类条件不能让前端传,也不能让 AI 自己声明。如果普通 DSL 调用者可以提交"我的数据范围是全公司",权限就已经失效了。
所以在进入"AI 查到的客户,怎样直接打开成业务系统里的完整表格"之前,我准备先插一篇权限实战:
把 Foggy 接进业务系统后,原来的数据权限怎么继续生效?
会分别看数据库原生权限、Spring 内嵌权限和可信业务网关三种接入方式,也会明确哪些独立 Runtime 权限能力仍在演进。
只有业务筛选和系统强制权限被分开,表格、API 和 AI 共用同一个查询入口才真的安全。
验证资产:
dataagent/wide-world-importers-oltp-sales/payloads/filter-*.jsondocs/wide-world-importers-dataagent-evidence/2026-08-05-wide-world-importers-eight-filters.md