业务要给发票明细加 8 个筛选条件,我没有再写 8 个查询接口

上一篇,我直接从 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,但响应里的 total0

所以本轮能确认的是分页窗口和顺序正确,不能宣称 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-*.json
  • docs/wide-world-importers-dataagent-evidence/2026-08-05-wide-world-importers-eight-filters.md
相关推荐
爱勇宝2 小时前
人到了一定年纪,才会看懂这些人性真相
前端·后端
阿标在干嘛2 小时前
从RESTful到GraphQL:政策快报平台接口设计的演进
后端·restful·graphql
万少3 小时前
用 TraeWork 给小孩做一个家庭工作台
前端·人工智能·后端
2601_963870183 小时前
【计算机毕业设计】基于Vue + Spring Boot的社区养老服务管理平台设计与实现
spring boot·后端·课程设计
前端开发张小七3 小时前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员
颜进强3 小时前
Calude Code - 23 用 MCP 把 Jenkins 变成 AI 队友:一次对话完成自动化发布
前端·后端
Awna3 小时前
Golang 大小写可见性规范
开发语言·后端·golang
神奇小汤圆3 小时前
分布式事务没有银弹:从CAP定理到AT与TCC模式的选择指南
后端
YuePeng3 小时前
不写一行接口,让 DBeaver 直连你的指标层——背后只用了一个端口
后端·架构·github