PageSpec 协议规范
状态:Current -- 本文是 PageSpec/表单展示/selector 等 wire 契约的唯一文档出处。权威实现是
internal/dashboard/spec(Go DTO)与web/src/types/dashboard.ts(前端共享类型),两侧逐项对应;本文与实现不一致时以代码为准并修正本文。
两种不同的 schema
| 协议 | 所属层 | 用途 | 是否来自注册 |
|---|---|---|---|
| JSON Schema | FunctionContract | 输入、输出、校验和字段候选 | 是 |
| PageSpec | 页面编排 | 页面类型、列表、详情、动作、任务、报表、导航和 binding | 否 |
JSON Schema 不被当作页面布局树。PageSpec 也不复用 JSON Schema 的组件扩展字段。
FormPresentationSpec
表单展示由以下稳定结构描述(对应 spec/form_presentation.go):
interface FormPresentationSpec {
jsonSchema: JSONSchema; // 表单校验的事实源
layout?: "vertical" | "horizontal" | "inline" | "grid";
groups?: FormGroupSpec[]; // 字段分组(key/title/fields/collapsible)
fields?: FormFieldSpec[]; // 逐字段展示覆盖
submitButton?: FormButtonSpec;
cancelButton?: FormButtonSpec;
}
interface FormFieldSpec {
key: string; // JSON Schema 字段路径,如 "name"、"address.city"
label?: LocalizedText; // 覆盖 schema title
description?: LocalizedText; // 帮助文本
placeholder?: LocalizedText;
widget?: FormWidget; // 受控 antd 组件名枚举
width?: number; // grid 栅格 1-12
order?: number; // 排序,小者在前
visible?: boolean;
visibleWhen?: ConditionSpec; // 只读 form/page state
disabled?: boolean;
required?: boolean; // 覆盖 schema required
defaultValue?: JSONValue;
enumOptions?: EnumOption[]; // select/radio 选项
remoteOptions?: RemoteOptionsSpec; // 远程选项源(x-options-source 派生);
// 与 enumOptions 互斥,优先 remoteOptions
widgetProps?: Record<string, JSONValue>;
validationRules?: ValidationRule[];
}
interface RemoteOptionsSpec {
functionId: string; // 数据源函数 ID(同 scope 已注册的 collection_query);
// 调用走既有 RBAC,失败静默降级为普通输入
labelPath?: string; // 选项标签取值路径(JSON Pointer,支持 * 通配数组段)
valuePath?: string; // 选项取值路径;缺省复用 labelPath
searchParam?: string; // 搜索关键词参数名;声明后下拉搜索以该参数重新调用
}渲染器生效语义(SchemaFormRenderer,2026-09 收口):required 与 validationRules[].type="required" 并入运行时 schema required(required: false 可撤销 schema 原必填;visibleWhen 隐藏字段仍豁免);min/max 按字段类型映射(string→minLength/maxLength,number/integer→minimum/maximum,array→minItems/maxItems),pattern 直写 JSON Schema pattern;custom 无法用 JSON Schema 表达,渲染时显式告警并忽略(不静默丢弃)。required 的字符串字段(schema required 与 fields 覆盖两来源一致生效)在派生运行时 schema 时若未显式声明 minLength 自动补 minLength: 1——AJV required 只查键存在、空串合法,不补则「必填」拦不住清空后的提交(BUG-038 关联观察:空串出网到游戏侧才报错);显式 minLength(含 validationRules 映射产物)不覆盖,visibleWhen 隐藏豁免字段不注入;错误文案对 minLength limit=1 特化为「{title} 不能为空」。defaultValue 参与表单初值种入:仅补未提供值的字段,显式 initialValues 优先,null/空串视为已提供不覆盖。
widget 是受控枚举,取值为 antd/ProComponents 组件名(PascalCase):Input、TextArea、InputNumber、Password、Select、MultiSelect、Radio、Checkbox、Switch、DatePicker、TimePicker、DateRange、Upload、ImageUpload、FileUpload、RichText、Code、Cascader、TreeSelect、Color、Slider、Rate、JSON、KeyValue、Array、Object。扩展 widget 需要修改 spec 包并同步前端类型,不允许前端私加。
可见性条件是受限表达式,不能读 row/详情数据或调用函数:
interface ConditionSpec {
kind: "equals" | "notEquals" | "exists" | "all" | "any";
path?: string; // 当前表单或 page state 的指针
key?: string; // 区块级条件专用:来源区块 key(非空时从页面状态
// results[key] 按 path 取值,如 /values/mode、/data/total;
// 空 = 表单内相对路径,字段级 visibleWhen 原语义)
value?: JSONValue; // equals/notEquals 的比较值
conditions?: ConditionSpec[]; // all/any 的子条件
}Server 从 input JSON Schema 生成默认 FormPresentationSpec;管理员只能在 Page Studio 改展示信息,不能改变 FunctionContract payload。前端渲染器固定使用 @rjsf/antd + @rjsf/validator-ajv8 校验 payload。rjsf uiSchema 只能作为 adapter 的内存派生结果,不得成为 SDK/OpenAPI 注册字段或持久页面协议。
历史表单展示数据不提供转换或导入路径;只能在删除旧路径前导出、备份,并由管理员在 Page Studio 按当前 FormPresentationSpec 人工重建。旧数据不得进入新页面发布流程。
Descriptor 呈现 hints(x-ui-*)
SDK/游戏方可在 input_schema 的字段 schema 上声明 x-ui-* 扩展字段(widget/label/ 分组/联动/远程选项源等),前端经 derivePresentationSpec(调试路径)与服务端 buildFormFields(发布路径,U4 起含 x-options-source → remoteOptions)推导为本 协议的 FormPresentationSpec,两端表现一致。字段清单、推导规则与治理边界见 呈现 Hints 契约。hints 不进入页面编排(PageSpec),wire 契约零改动。
PageSpec 视图节点
PageSpec 使用业务节点而非组件名。节点集合固定为:
ListViewSpec | DetailViewSpec | ActionSpec | ConfirmActionSpec
| TaskViewSpec | ResultViewSpec | FormPresentationSpec
| DatasetSpec | ChartSpec | CompositePageSpec各页面类型的编排见 Dashboard Resource/Page 模型。每个节点有版本化、强类型字段和服务端校验器。页面 renderer 根据节点类型选择 ProComponents;PageSpec 不得出现具体 React 组件名或任意组件 props。
CompositePageSpec(组合页 wire 契约)
type: composite 页面的主体(权威实现 internal/dashboard/spec/types.go,前端 web/src/types/dashboard.ts)。创建入口 POST /api/v1/versioning/pages/composite,请求为 CompositeSectionRequest[](字段与 spec 同名透传,见 使用指南 §3)。
常量表单区块(static,2026-09):CompositeSection 增加 static: true 形态—— 不绑定 bindingId、不执行任何函数;form.jsonSchema 由编辑器设计期定义并经 CompositeSectionRequest.form 透传(服务端校验 schema 存在性),值仅写入页面状态供 refreshOn/动作链消费。发布校验:static 区块禁止携带 bindingId、view 必须为 form、 必须包含 form.jsonSchema。区块顺序:static 区块按请求输入位置交错落库(2026-09 修订,此前服务端把 static 统一追加到 sections 末尾,导致"常量筛选放在页首"的页面 发布后顺序漂移);accept-and-publish 后发布快照与请求 sections 顺序一致。
{
"sections": [
{
"key": "player.list",
"bindingId": "player.list",
"view": "table",
"span": 24,
"autoRun": true,
"refreshOn": [],
"table": {
"columns": [
{ "key": "uid", "title": { "zh-CN": "用户" }, "dataType": "string" }
],
"rowActions": [
{
"label": { "zh-CN": "发邮件" },
"targetSection": "modal-ab12cd",
"params": { "player_id": "uid" },
"chain": [{ "kind": "refreshNode", "target": "player.list" }]
}
]
},
"toolbar": {
"actions": [
{
"label": { "zh-CN": "批量补偿" },
"targetSection": "modal-ab12cd",
"danger": true
}
]
}
},
{
"key": "mail.send",
"group": "modal-ab12cd",
"bindingId": "mail.send",
"view": "form",
"display": "dialog",
"onSuccessRefresh": ["player.list"]
}
]
}字段语义(详细规则见 Dashboard 页面模型 CompositePage 节):
key:区块唯一标识;同函数多实例依次fid/fid-2…,编辑器可声明固定 key(sectionKey固化,round-trip 不漂移),创建端点重复 key 显式报错display:inline(默认)|dialog(弹窗,不占栅格)|tab(页签,渲染端聚合进 Tabs)|card(卡片分组,渲染端聚合进 Card)group:弹窗/页签/卡片分组——dialog区块按 group 聚合渲染进同一弹窗(表单+字段卡+表格混排);动作目标(targetSection)指向 group;tab区块按 group 聚合渲染进同一 Tabs;card区块按 group 聚合渲染进同一 Cardtab:页签标签(LocalizedText;display=tab时有效)——同 group 内按标签聚合到 Tabs 对应页,页内区块整行堆叠;缺省时渲染端兜底「页签 N」cardTitle:卡片标题(LocalizedText;display=card时有效)——同 group 区块渲染进同一 Card(整行)时的组标题;缺省回退组名。回读时还原为卡片容器的props.titlerowActions[].params:行字段→表单参数映射("player_id": "uid"= 行的 uid 填入弹窗表单 player_id)chain:动作链,主动作后按序执行runBinding|refreshNodeonSuccessRefresh:表单提交成功后自动重跑的区块 keyevents:通用事件绑定(rowClick/rowSelected/success/error/click→ 动作 + 链);动作 kind:runBinding/refreshNode/openModal/closeModal/navigate/showMessage;步骤params支持来源引用("区块key.字段"、"row.字段"、字面量)refreshOn:page_state 联动——上游区块 key 变化自动重跑,上游输出顶层字段同名合并进本区块输入visibleWhen:区块级条件显示(U10)——ConditionSpec叶子必填key(来源区块),从页面状态results[key]按path(/values/字段、/data/字段、/selectedRow/字段)取值求值;false 的 inline/tab 区块不渲染但执行照常(autoRun/refreshOn 不受影响);支持嵌套all/any(深度 ≤4);发布校验 key ∈ 页面区块、path 为 JSON Pointer、equals/notEquals 带 value;dialog 区块不参与(弹窗由动作显式触发)cascadePolicy:refreshOn 级联失败策略(U9,"pause" | "clear" | "keep",空 = 缺省pause)——任一上游依赖最新结果为失败时本区块的行为。三策略均不重跑:pause数据保持 + message.warning 提示(联动已暂停);keep静默保留上次结果;clear清空本区块数据。渲染端实现:区块执行异常时向results[key]写入{error}失败标记,级联信号按「key 集合 + 各区块失败标记(o/e)」计算——失败↔成功翻转触发下游重跑(上游恢复后暂停的级联自动续跑),同态新值不重复触发。发布校验枚举合法(composite_section_cascade_policy_invalid)。static 区块不执行绑定(自身不会失败),该字段无实际作用但允许透传
页面级可选字段 componentTemplates(U11 模板使用快照,位于 PageSpec 根而非 section 内):componentTemplates?: Array<{ key: string; digest: string }>—— 创建端点 POST /api/v1/versioning/pages/composite 请求体可携带,服务端规范化 (trim / 同 key 去重保留首个 / 空白 key 丢弃)后随 PageSpec.componentTemplates 落库,proposal→draft→published 全程 JSON 透传。语义:记录页面创建/编辑时所用 组件模板的内容指纹(digest 算法与模板 API 透出见 组件模板 API),编辑器打开页面时与模板库当前 digest 比对提示「所用模板有新版本」。不参与发布校验、不参与渲染——实例化是 复制语义,页面内容始终是保存时的副本(提醒不自动同步)。
创建端点响应的发布分级字段(T10):响应在 proposalKey/pageKey/ pageType/quality 之外新增 published: boolean 与可选 publishError?: string——
pages.publishReview=required(缺省策略):published: false,无publishError字段,行为与历史一致(保存只建提案,ProposalInbox 人工接受并发布);pages.publishReview=auto:保存成功后直接发布,published: true;发布被质量 门槛(error 级诊断/发布校验)拒绝或失败时published: false且publishError带回原因——保存本身已成功、提案保留,前端按提示降级人工链重试;- 发布走真实链路(快照/版本历史照常),权限沿用保存入口
pages:edit(策略已 声明该 env 免审核),详见 Dashboard Resource/Page 模型 的「发布分级」小节。
数据引用和 mapping(Selector AST)
输入输出 mapping 必须是可校验的 AST(对应 spec/selector_ast.go):
type TransformSpec =
| { type: "pick" }
| { type: "rename"; params: Record<string, string> }
| { type: "default"; params: { value: JSONValue } };
type ValueSource =
| { kind: "form"; path: JsonPointer; transform?: TransformSpec }
| { kind: "row"; path: JsonPointer; transform?: TransformSpec }
| { kind: "selection"; path: JsonPointer; transform?: TransformSpec }
| { kind: "detail"; path: JsonPointer; transform?: TransformSpec }
| {
kind: "page_state";
key: string;
path?: JsonPointer;
transform?: TransformSpec;
}
| { kind: "literal"; value: JSONValue };
interface InputAssignment {
target: JsonPointer;
source: ValueSource;
}
interface OutputAssignment {
stateKey: string;
source: JsonPointer;
shape: "scalar" | "object" | "collection" | "task" | "dataset";
}transform 是白名单受控变换,新增变换必须扩展 spec 包并同步校验器。当前三项:
pick(仅 selection):从每个选中行提取 path 指定字段,输出 identity 数组。rename(row / selection / page_state,且 path 必须为空——变换作用于整对象):params是「源字段名 → 目标字段名」映射表(值必须是字符串)。输出对象只包含 映射表命中的字段(受控白名单,与 pick 同理);selection 源逐元素应用。上游字段 名与函数参数名对不齐时用它改名,映射表未覆盖的字段被丢弃。非对象(且非数组的 selection)源在执行期报 422。default(任意 kind):params: { value: <JSON 字面量> }。源值缺失(上游区块 未产出该字段)或为null时兜底为value;有值时不覆盖。用于参数映射的「缺省值」。
组合页编辑器的参数映射会编译产出这些变换:跨区块字段改名 → rename 映射; 「缺省值」输入 → default(编辑器输入是字符串,纯数字/布尔/null 保持 JSON 类型)。
校验器必须确认:
- target 存在于绑定函数的 input JSON Schema。
- source 只引用已定义的表单、行、选择、详情或页面状态。
- source/target 类型可赋值;不允许裸整行对象自动传给函数。
- ListView 的 collection、分页和 identity 引用可被 output JSON Schema 或 CapabilitySemantics 验证。
- Report 的 dataset、指标和维度引用可验证。
禁止保存无结构 map[string]any、任意 JSONPath 字符串或组件级临时 mapping 覆盖。
PageBinding
interface PageFunctionBinding {
id: string;
functionId: string;
usage:
| "query"
| "detail"
| "action"
| "task"
| "task_status"
| "task_events"
| "task_result"
| "task_cancel"
| "task_retry"
| "report";
selectors?: {
input: SelectorAST; // InputAssignment 集
output: OutputAssignment[];
};
execution: {
mode: "sync" | "task";
requireConfirm?: boolean;
};
}task 生命周期能力拆分为独立 usage(task_status/task_events/task_result/task_cancel/task_retry),由 TaskSemantic 的 start/status/events/result/cancel 函数一一映射;不存在 create/update/delete usage——写操作统一为 action,由 ResourcePage 的 CreateForm/UpdateForm/DeleteAction 节点引用。确认行为由 execution.requireConfirm 表达,没有独立的 ConfirmationSpec。
Schema 节点和页面动作只能引用 bindingId。运行时由服务端根据 active PublishedPageSpec 找到 functionId、权限、风险、scope 和 dispatch target;浏览器无权选择这些信息。
binding execute 错误码(执行阻断契约)
POST /api/v1/console/pages/:pageKey/bindings/:bindingId/execute 在执行前做两道结构化阻断,均返回 409 + { error, message, details }(错误码为前端分支稳定码):
| error | 触发条件 | details |
|---|---|---|
executor_unbound | binding 指向的契约 executionState=unbound(上传物料尚未绑定运行时执行器,检查先于 freshness) | bindingId、functionId |
binding_stale | 发布快照契约与当前契约 digest 漂移(schema/version 变化) | bindingId、functionId、statuses |
前端按 error 稳定码分支:executor_unbound 渲染「未绑定执行器」空态并引导去 OpenAPI Sources 完成绑定(同名函数注册后 T6 自动翻转 bound,无需改页重发);binding_stale 走 diff/重新发布链路。发布页(Console 运行时)禁止任何 mock 数据兜底——执行失败就是显式失败。
Selector 一键同步(sync-selectors wire 契约)
schema 漂移后对草稿做精准修复,只改受影响的 assignment:
// POST /api/v1/pages/:pageKey/sync-selectors (权限 pages:edit)
interface PageSyncSelectorsRequest {
draftRevision: number; // 必填;乐观锁(409 冲突与 SaveDraft 同语义)
dryRun: boolean; // true 只出报告不落库
bindingIds?: string[]; // 省略 = 全部 binding
}
interface PageSyncSelectorsResponse {
pageKey: string;
dryRun: boolean;
applied: boolean;
draftRevision: number; // dryRun=原值;apply=+1
syncedBindings: BindingSelectorSyncReport[];
remainingDiagnostics?: Diagnostic[]; // 同步后发布级校验(不因错误拒绝保存)
}
interface BindingSelectorSyncReport {
bindingId: string;
functionId: string;
changed: boolean;
executionModeFixed?: boolean; // task/sync 模式按最新契约修正
input?: SelectorSyncInputEntry[];
output?: SelectorSyncOutputEntry[];
manual?: Diagnostic[]; // 不可自动修复项(函数缺失/governance 漂移等)
}
interface SelectorSyncInputEntry {
target: JsonPointer;
action:
| "kept" // 未受影响,原样保留(含 Transform/literal 定制)
| "renamed" // 消失 target 重映射(newTarget)
| "removed" // 摘除(无唯一候选且非必需)
| "added" // required 差集补齐:identity 字段语义命中接 row 源,否则 form 同名映射
| "type_changed" // 类型漂移,保留待人工核对
| "manual_required"; // 无法安全自动处理
newTarget?: JsonPointer;
sourceKind?: ValueSource["kind"]; // added 时:row(identity 语义命中)| form(同名回落)
confidence?: "high" | "low"; // high=prev schema 精确命中或 identity 语义命中;low=启发式
reason: string;
}
interface SelectorSyncOutputEntry {
stateKey: string;
source: JsonPointer;
action: SelectorSyncAction;
newSource?: JsonPointer;
newShape?: OutputAssignment["shape"];
required: boolean; // 必需输出(items/detail/dataset/taskStatus/…矩阵)
confidence?: "high" | "low";
reason: string;
}语义要点:dry-run 与 apply 共用同一 planner(spec/selector_sync.go),预览与落库不会漂移;apply 不自动 publish,remainingDiagnostics 有 error 时发布会继续被阻断,须先处理 manual_required 项;必需输出在任何路径上都不被摘成缺失(推导失败保留原 assignment + manual_required)。prev schema 的来源与精确性判定(previousInputSchema/previousOutputSchema、digest 双算法匹配降级策略)见 Dashboard Resource/Page 模型。
批量 selector 同步(bulk-sync-selectors wire 契约,M4)
契约变更队列的草稿侧批量收口——逐页跑与单页 sync-selectors 同源的 planner/apply:
// POST /api/v1/pages/bulk-sync-selectors (权限 pages:edit;body 可为空)
interface PageBulkSyncSelectorsRequest {
pageKeys?: string[]; // 省略/空白 = 审批收件箱 ContractChanges 全量(去重)
}
interface PageBulkSyncSelectorsResult {
total: number;
synced?: string[]; // 成功页(selector 拉齐 + DraftRevision+1)
skipped?: {
pageKey: string;
reason: string; // 恒 "manual_required"
manual?: Diagnostic[]; // 不可自动修复漂移的诊断透传(governance/version 等)
}[];
failed?: { pageKey: string; error: string }[]; // 单页失败不中断其余页面
}语义要点:严格只写 draft(published_page_specs 不动),上线仍需 bulk-republish;revision 由服务端读取当前草稿版本(并发冲突在事务内 409,按页计入 failed 继续);先 dry-run 判定——存在 Manual 诊断(governance/version 等不可由 selector 同步修复的漂移)的页面整体 skipped 并透传诊断,不做半吊子同步;不自动 publish。
草稿列表过滤与资源聚合(wire 契约)
页面工作台的列表过滤、关键词搜索与分页全部由服务端执行(OPEN-ISSUES #13/#30):前端不得拉全量自行推导,列表响应永远是过滤后的一个分页窗口。
// GET /api/v1/pages?resourceKey=&status=&keyword=&page=&pageSize=
// (权限 pages:read;query 条件可组合)
// - resourceKey:按「页面涉及资源」过滤(resourceKey 列 ∪ binding 函数契约资源,
// 服务端读取时计算;旧单列页面同样命中其 binding 关联资源)
// - status:draft / published / archived(SQL 条件下推)
// - keyword:对 pageKey、标题各语言、涉及资源做不区分大小写的包含匹配
// (scope 内候选集内存过滤,scope 本身已限定数据面)
// - page/pageSize:服务端分页窗口;缺省 page=1、pageSize=20,pageSize 上限 200,
// 非法值(<1 或超上限)钳制,不报错
// 响应:{
// items: PageSpecDraftSummary[]; // 当前窗口;条目含 resources 投影(见下)
// total: number; // 过滤后的总条数(分页依据)
// page: number; // 实际生效页码(钳制后)
// pageSize: number; // 实际生效页大小(钳制后)
// }
// GET /api/v1/pages/resources (权限 pages:read)
// scope 内页面涉及资源的服务端聚合——过滤下拉的选项来源,
// 前端禁止从当前列表页自行推导(列表只是过滤后子集)。
interface PageResourceOption {
resourceKey: string;
pageCount: number; // 该资源出现在多少个页面(按 #30 关联口径,多资源页参与计数)
}
// 响应:{ items: PageResourceOption[] },按 resourceKey 升序PageSpecDraftSummary.resources(#30):条目级「页面→涉及资源」投影,服务端读取时由 resourceKey 列 ∪ 顶层 bindings 对应函数契约的 resourceKey 聚合去重(升序)。多资源页在此展开展示;旧 payload 缺该字段时前端回退 resourceKey 单列。该关联只读时计算,不落库——无新列/新表,函数契约重算后下一次列表即反映最新关联。
边界(诚实声明):resources 关联依赖 binding 函数契约在库内的 resourceKey;契约缺失或未重建索引时,该 binding 不贡献资源(此时退化为 resourceKey 列口径)。/pages/resources 聚合与列表侧关联同口径(#30 起多资源页参与 pageCount)。resource catalog(函数/契约侧资源清单)是另一份聚合,两者不复用缓存。菜单管理页的「已挂载页面」视图固定拉 pageSize=200 一页取 scope 全量挂载映射(挂载态是低频小数据面),非分页绕过。
导航与多语言
分类、标题、图标与排序是 PageSpec 顶层字段(category{key,order}、title、icon、order)。category 只保留 key 与 order,是页面工作台侧的分组元数据;运行控制台导航由菜单系统驱动(menu_items 树 + page_specs.menu_id 挂载映射,见 运行控制台动态菜单),不消费 category,分类多语言名称由菜单系统(menu_items.labels)提供。NavigationSpec 仅承载返回导航行为:
interface NavigationSpec {
title?: LocalizedText;
breadcrumb?: LocalizedText[];
showBack?: boolean;
backPath?: string;
}PageProposal 根据 resource/page key 提供默认值;PageDraft 保存最终值;PublishedPageSpec 是 Console 动态菜单的唯一来源。静态 locale 与字典都不得成为动态页面事实源。ConditionSpec 只读取当前表单或 page state(区块级经 key 读页面状态 results[key]——表单当前值/函数输出/表格选中,同属 page state 投影),禁止通过可见性条件访问 row、详情、外部函数或任意 JSONPath,以保证保存、发布和运行时具有一致语义。
组件模板 API(相关 wire 契约)
组件模板(V4 复用层)的 REST 契约不承载 PageSpec 本体,但模板 tree 字段持有组合页 PageNode 子树(经组合页编辑器编译为 PageSpec 后发布)。 端点与 DTO 定义见 组件模板 API。
ABI 与版本
rendererSchemaVersion(当前 page-spec:1)、generatorVersion(当前 page-generator:1)和各 snapshot digest 必须单独保存。发布校验必须拒绝未知版本、未知节点、未知字段和非法 mapping。页面运行时不得尝试降级、猜测或转换成其他 layout/schema。
安全边界
- PageSpec 不存储 route、target service、scope 覆盖、Secret 或任意 HTTP 参数。
- 未知 JSON 只能存在于 JSON Schema 的标准扩展边界;核心 DTO 使用明确类型和
JSONValue,禁止any。 - 表单展示变化不允许静默改变已发布页面;发布快照必须包含 FormPresentationSpec。
