
创建采购单打印模板时,主表字段都能拖,子表明细却迟迟不出现在字段目录里------业务对象那边主从外键还在改。若这时直接拦截保存,设计器等于被外键配置绑架;若发布后也不拦,运行时又会静默少打明细行,财务对账才发现。
我们在 Forge Admin 打印链路里把这件事拆成了两档:设计草稿 softSkipIncompleteChildren,发布期 fail-loud 并给出可操作提示 。同一条链路还把模板 JSON 当不受信输入做协议硬校验,再用独立打印中心 + PrintDataProvider SPI 把「业务对象取数」和「已发布数据集取数」拆开------数据集路径声明不接受客户端 SQL / 表名 / 连接。
下面三段都带源码锚点(commit / 文件行),推断处标「按源码推演」。
现象:草稿能推进,发布突然报主从
典型现场有两种:
- 创建模板阶段 :子表
modelRefs.relations为空,或外键列名对不上。设计目录仍能打开,主表字段可拖;残缺子表被跳过,不挡主表模板落地。 - 一点「发布打印模板」或走应用发布清单:同一套关系配置直接失败,错误文案指向「业务对象设计器」,并给出 ① relations ② masterDetailConfig ③ 外键列 三条检查。
对应修复提交:586d6a29178ba0d8cf84395644e8230b391e2f13(fix(print): 创建模板不因主从关系未配齐卡住,发布时给出可操作提示)。
根因不是「校验写晚了」,而是故意把「能否继续设计」和「能否上线出纸」分成两个契约。若设计期也严格,主从还在改时打印设计器无法开工;若发布期仍软跳过,上线后明细集合为空却无人知晓------这比报错更危险。
怎么改:一个布尔量贯穿解析
核心在 PrintMetadataResolver(约 472 行)。入口注释写得很直白:
java
/**
* @param softSkipIncompleteChildren true:设计草稿/创建模板允许跳过未配齐的子表关系;
* false:发布前必须配齐,否则给出可操作的拦截提示。
*/
public Metadata draft(..., boolean softSkipIncompleteChildren)
published(...) 固定传入 softSkipIncompleteChildren = false。关系条数不是「恰好一条」、外键列解析失败、或外键落在主键 / tenant_id 等系统列上时:
java
if (relations.size() != 1) {
// 创建/设计草稿:跳过该子表;发布必须严格,并给出可操作提示
if (softSkipIncompleteChildren) {
continue;
}
throw relationConfigInvalid(ref.getModelCode(), relations.size());
}
谁决定这个布尔量?低代码 Provider 在设计授权里按动作切换(LowcodePrintDataProvider):
java
// 创建/打开/保存草稿:软跳过;发布打印模板:严格校验
boolean softSkip = action != PrintDesignAction.PUBLISH;
metadata.draft(actor, source, softSkip);
单测也钉死了反差:draftSkipsChildWhenRelationCountIsNotExactlyOne 允许跳过;draftStrictRejectsMissingRelationWithActionableMessage / publishedRejectsMissingRelationWithActionableMessage 要求文案里出现「业务对象设计器」。
两档策略对照表(可收藏)
| 维度 | 设计草稿(softSkip=true) | 发布 / 已发布快照(softSkip=false) |
|---|---|---|
| 调用入口 | draft / candidate 默认 true;创建·打开·保存草稿 |
PUBLISH 动作;published(...);应用发布清单校验 |
| 残缺子表关系 | continue 跳过该子表,主表可继续设计 |
relationConfigInvalid 拦截 |
| 外键列对不上 / 落在系统列 | 跳过子表 | 拦截,提示改主从配置 |
| 产品目标 | 不因外键未配齐卡住设计 | fail-loud,防运行时静默丢明细 |
| 证据 | PrintMetadataResolver 151--177 行;单测 draftSkips* |
同文件 published;单测 *ActionableMessage |
relationConfigInvalid 把「怎么修」写进异常,而不是只抛 path。缺关系时约等于:
- 子表
modelRefs.relations是否声明了sourceField/targetField masterDetailConfig是否指定了该子表及外键- 子表模型字段里是否真有该外键列
修好后:重新打开打印设计刷新字段目录,再保存或发布。这是给实施同学的检查清单,不是给开发看的堆栈。
发布失败自查清单
| 序号 | 你看到的现象 | 先查哪里 | 修完后 |
|---|---|---|---|
| 1 | 无法绑定子表「xxx」:未找到唯一外键 | 页面设计 → modelRefs.relations;主从配置 | 刷新打印字段目录再发布 |
| 2 | 匹配到 N 条主从关系(N≠1) | 清理重复 relation,或在 masterDetailConfig.children 显式指定唯一一对字段 | 同上 |
| 3 | 关联字段无效(主键 / tenant_id 等) | 主从配置改用子表指向主表的业务外键(如 xxxId) | 同上 |
| 4 | PRINT_VERSION_INVALID 发布模板完整性校验失败 |
模板 JSON 是否被改写、schemaHash 是否与规范化结果一致 | 按协议重新保存/发布版本 |
| 5 | 数据集打印参数报错 | 参数是否在数据集协议内;recordIdParam 是否被客户端覆盖 |
只传协议允许的业务参数 |
第二块:模板当不受信输入------PrintProtocolValidator
两档策略解决的是业务元数据是否配齐 ;模板本体是前端拖出来的 JSON,同样不能当内部可信对象。PrintProtocolValidator(commit 链路与 forge-plugin-print/.../protocol/)做的是协议层 hardening:
- Jackson 开启
STRICT_DUPLICATE_DETECTION,重复键直接拒 StreamReadConstraints:嵌套深度JSON_DEPTH = 64,字符串/文档上限对齐DOCUMENT_BYTES = 1 MiB- 规则层再扫标识重复、节/元素数量等(
PrintProtocolRules/PrintProtocolLimits) - canonicalize(键排序 + 数字规范化)后算 SHA-256 ,得到
schemaHash
类注释写明意图:验证与规范化是单次纯计算;不修改输入、不解析业务字段、不执行模板内容。 Parser 异常只打异常类型,不把原始片段回传前端------按源码推演,这是防探测 / 防日志泄漏的常见做法。
运行时还会再验一遍。PrintPrepareService 在列出可用模板与 prepare 出纸前:
java
var document = protocol.validate(version.getSchemaJson());
if (!document.schemaHash().equals(version.getSchemaHash())) {
throw PrintFailure.of(409, "PRINT_VERSION_INVALID", "发布模板完整性校验失败");
}
也就是说:库里存的版本不是「信任状」;每次准备打印都用同一套规范化规则重算哈希。草稿软跳过解决的是产品节奏,协议层硬校验解决的是「模板可被任意 JSON 污染」的安全边界。

第三块:独立打印中心 + SPI,数据集不准塞 SQL
低代码应用内打印之外,还有一批「系统页 / 自定义单据」也要打。提交 e056bf6cae7f1bb5c9dec50733601f303171580b 落地独立打印中心(前端 print/center/*、PRINT_CENTER.md):侧栏一个工作台完成「登记可打印业务 → 模板设计发布 → 挂载场景 → 启用」,业务页挂 BusinessPrintButton。
多数据源运行时在 451a439b0492b6efd6d5ca8234490ce936d56a76:PrintDataProvider SPI 要求业务模块明确实现授权 ,没有默认放行。PrintProviderRegistry 按 sourceType + supports 恰好选出一个 Provider,否则 PRINT_PROVIDER_UNAVAILABLE。
来源类型在配置校验里拆开(PrintSourceConfigValidator):
| 类型 | 配置约束 | 取数方式 |
|---|---|---|
| SERVICE | 必须选受管 providerCode,不能带 datasetId |
业务模块实现的 Provider(如示例采购单) |
| DATASET | 必须选已发布 datasetId,不能带 providerCode |
DatasetPrintDataProvider |
| API | 直接拒绝「尚未开放」 | --- |
数据集适配器类注释是安全边界的一句话版:
java
/**
* 已发布数据集到打印协议的受控适配器。
* 表名、SQL、连接和租户身份均不接受客户端输入。
*/
public class DatasetPrintDataProvider implements PrintDataProvider { ... }
prepareQuery 只使用来源上登记的 datasetId、服务端校验过的参数 Map、以及 mapping 里的 maxRows;recordIdParam 由服务端写入,调用方若试图覆盖会直接 PRINT_PARAMETER_INVALID 。PrintParameterValidator 另拦 tenantId / userId / templateVersionId 等保留键,参数体还有数量与 64KiB 上限。
按源码推演:这和「报表接口随便拼 SQL」是反着的------打印中心开放的是已发布数据集的受控查询入口,不是数据库控制台。

可带走的几条
- 设计期可软、发布期必须硬:软跳过是为了不堵设计,不是为了上线容忍脏关系;静默丢明细比报错更贵。
- 错误信息要可操作:把「去业务对象设计器看哪三处」写进异常,实施才用得上。
- 模板 JSON = 不受信输入:重复键、深度、大小、规范化哈希,发布入库与 prepare 出纸各验一次。
- 多数据源用 SPI 分型,而不是一个万能取数接口:SERVICE 走代码适配器,DATASET 只跑已发布数据集,客户端永远不带 SQL。
仓库:Forge Admin(Gitee)。若你也在做单据打印 / 低代码主从,欢迎点赞收藏,少踩「草稿卡住」和「上线少明细」这两类坑;有具体报错文案也可以评论区贴 path,我按协议字段帮你对一下该查 relations 还是 schemaHash。