界面是怎么生成的:核心思路与全链路
状态:Current —— 本文是新手视角的设计讲解:解释这个项目的核心创新思路、 界面从无到有的完整流程,以及每个关键决策的「为什么」。读完本文再去看规范文档 (术语表 → 页面模型 → PageSpec 协议 → 生成与运行时)会顺畅得多。
一句话版本
你注册一个函数,界面就出现了。 不写前端页面、不配菜单、不手动对接权限—— 写一次契约,表单、校验、页面、菜单、权限、审批、审计全部自动派生。
这就是 Croupier 的核心创新:描述驱动(descriptor-driven)。 本文回答三个问题:为什么这样设计?流程是怎么走的?每一步为什么非这样不可?
为什么会有这个设计:传统做法的三个死结
做游戏 GM 后台(运营工具)通常是这样:
- 每个功能都要「排期开发页面」。服务端写了个封号函数,前端还得再花一天 做表单、列表、详情——功能越积越多,页面开发永远排不过来。
- 治理靠人自觉。页面是手写的,「这个按钮有没有接权限校验」「高危操作 有没有走审批」取决于前端同学是否记得。审计和页面是两张皮。
- 页面会腐烂。函数参数改了,页面忘了改——用户填了旧参数提交,直到线上 报错才发现。
Croupier 对这三个死结的回答是同一个:把「函数契约」作为唯一事实源。
- 函数注册时声明:入参 JSON Schema、能力语义(CRUD/动作/任务/报表)、 审批策略、呈现提示(x-ui-*)。
- 界面不再由人编写,而是从契约确定性生成——契约不变,页面不变; 契约变了,页面必须重新对齐(stale 检测强制做到)。
- 治理不挂在页面上,而挂在函数绑定上——页面无论怎么生成、怎么组合, 执行都必须经过服务端的权限/审批/审计链路,浏览器没有绕过的可能。
一句话对比:传统做法是「函数 → 人 → 页面」(人是最不可靠的一环); Croupier 是「函数 → 生成器 → 页面」(确定性、可校验、可快照)。
全链路走读:一个封号函数的旅程
以一个真实场景贯穿:游戏服务端开发者写了一个封号函数 player.ban, 运营要在控制台用它。从注册到执行共六步。
① 注册:开发者声明契约
游戏服务端用 SDK 注册函数,同时声明「这个函数长什么样、是什么性质」:
// Go SDK(sdks/go/function/builder.go)
desc, err := function.NewMetadataBuilder().
SetID("player.ban").
SetName("封禁玩家").
SetSummary("封禁指定玩家 N 天").
SetCapability("action"). // 能力语义:这是一个动作(非 CRUD、非任务)
SetInputSchema(`{ // 入参契约:表单和校验的数据来源
"type": "object",
"properties": {
"playerId": { "type": "string", "title": "玩家ID" },
"days": { "type": "integer", "title": "封禁天数", "minimum": 1 },
"reason": { "type": "string", "title": "原因", "x-widget": "TextArea" }
},
"required": ["playerId", "days", "reason"]
}`).
SetApproval("high_risk_ops"). // 高危操作 → 注册即声明审批策略
Build()也可以不写代码,直接从 OpenAPI 3.0 规范导入(RegisterFromOpenAPI)。 Schema 里的 x-widget/x-ui-* 字段是呈现提示(用下拉还是文本框、 是不是敏感字段),细节见 presentation-hints.md。
为什么契约里连「审批策略」都要声明? 因为治理信息必须跟着函数走, 而不是跟着页面走。函数在哪儿被使用(自动生成页、组合页、SDK 直调), 审批和审计就在哪儿生效——不存在「从某个页面绕过审批」的可能。
② 生成:生成器产出页面提案
函数契约落库后(FunctionContract),服务端的生成器 (internal/dashboard/generator/)从契约确定性生成 PageProposal:
player.ban声明为action能力 → 生成一个 OperationPage(操作页): 表单 + 确认 + 受控执行 + 结构化结果展示。- 表单字段从 inputSchema 派生:
string→ 输入框、integer→ 数字输入、x-ui-widget: textarea→ 文本域、required→ 必填校验。 - 显示名、菜单分类也一并生成(humanize 规则 + summary 回退)。
Proposal(提案)不是直接上线的页面,它是一个「待审核的生成结果」。
为什么要过一道生成器,而不是注册完直接出页面? 三个理由:
- 确定性:相同契约 + 相同生成器版本 ⇒ 相同页面,生成过程可测试、可复现;
- 可审核:生成结果先落在 Proposal Inbox 里给人过目,而不是直接改变线上;
- 可降级:语义不完整(比如资源缺 identity)时生成器宁可生成保守的 操作页,也不猜测编造 CRUD 页。
③ 审核发布:提案变成不可变快照
运营打开 Page Studio,第一屏是 Proposal Inbox(提案收件箱):
- 「可直接发布」:
ready/basic的提案,看一眼预览就能接受发布; - 「需要处理」:带诊断信息的提案(缺了什么、怎么修);
- 「契约变更」:函数改过之后,旧页面对不上的情况(见第⑥步)。
点击「接受并发布」后:Proposal 变成 PublishedPageSpec——不可变快照。 页面要出现在控制台左侧导航,还需把它挂到菜单管理中的某个菜单 (PUT /api/v1/pages/{pageKey}/menu,见 运行控制台导航与页面挂载)。
为什么发布的是「快照」而不是「活引用」? 如果运行时页面实时读取最新 契约,函数一改线上页面立即跟着变——运营看到的界面可能在没有任何审核的 情况下悄悄变了。快照保证:线上页面只在有人审核并点击发布时才变化, 且每个历史版本都可回滚(page_versions)。这是治理平台,不是实时的镜子。
④ 渲染:浏览器收到的是 spec,不是代码
运营点开「封禁玩家」菜单,前端 PageRenderer 读取 PublishedPageSpec:
- 操作页 → 渲染表单区块 + 结果区块;
- 表单部分交给唯一的表单运行时
SchemaFormRenderer(基于@rjsf/antd):JSON Schema → 表单控件 + 校验,全部自动完成。
这里没有手写页面。整个平台只有一套表单渲染实现——发布页、组合页弹窗、 编辑器预览、Invoke 调试页用的都是它。
为什么坚持只有一套表单运行时? 如果出现第二套「手写更快」的表单实现, 契约变更时它不会自动对齐(回到死结 3),治理字段也可能被遗漏。 一套实现 = 契约改一处,所有表单同时正确。这是仓库里被 guard 明确禁止 回退的红线(禁止 Formily/自行开发 field factory 并行、禁止渲染路径手写
<input>)。
⑤ 执行:浏览器永远碰不到 functionId
运营填完表单点「执行」。注意浏览器提交的是什么:
POST /api/v1/console/pages/:pageKey/bindings/:bindingId/execute
提交:page_state(表单值)
不提交:functionId、target、scope —— 这些在服务端的 binding 里服务端从 binding 解析出真正的 functionId、校验当前用户权限、检查 scope (game/env)、核对 schema 没有过期,然后走审批判断 → 执行 → 审计落链。 如果 player.ban 配了审批,运营看到的是「等待审批」状态页,审批通过后 才真正执行。
为什么浏览器只提交 page_state? 如果 functionId 由前端传递,攻击者 改一下请求体就能调用任意函数,页面级权限形同虚设。把 functionId 收进 服务端 binding,「页面无论怎么组合都绕不过治理」才成立——这是组合页 可以放开手脚做「自由拖放」的安全前提(详见 组合模型与表达力边界)。
⑥ 契约变更:页面被强制对齐
一个月后,开发者给 player.ban 加了个参数 notifyPlayer。此时:
- 注册侧记录的 schema digest 变化 → 已发布的页面被标记 stale(过期);
- stale 页面拒绝执行——不是提示一下,是真的不能点;
- Proposal Inbox 出现三方 diff(新契约 vs 旧提案 vs 线上快照), 人工确认合并后重新发布,页面才恢复可用。
为什么要把过期页面直接「锁死」? 温和处理(提示但允许提交)的结局 一定是有人忽略提示提交了错误参数。治理平台的选择是:契约与页面不一致 时,宁可不可用,不可错可用。
关键取舍一览
| 决策 | 备选方案 | 为什么不选备选 |
|---|---|---|
| 契约生成页面 | 手写页面 | 死结 1/2/3:排期瓶颈、治理两张皮、页面腐烂 |
| Proposal → 审核发布 → 不可变快照 | 生成后直接生效 | 线上界面必须「变了什么、谁批的」可追溯、可回滚 |
| 服务端 binding 持有 functionId | 前端传 functionId | 治理必须不可被浏览器绕过 |
| 唯一表单运行时(rjsf) | 允许手写表单 | 第二套实现 = 第二个会腐烂的页面来源 |
| stale 锁死执行 | 提示后放行 | 宁可不可用,不可错可用 |
| 白名单组合原语 | 自由画布/任意表达式 | spec 必须可校验、可快照、可 diff;见组合模型 |
它和低代码平台是一回事吗?
形似(都是拖拽搭页面),神不似。市面低代码的卖点是「自由」,本平台的 卖点是「不写代码也能得到治理完备的页面」:
- 组合原语是白名单——每个能力都是 spec 字段 + 渲染器 + 校验器三件套, 没有「任意代码块」「任意表达式」;
- 权限/审批/审计挂在函数绑定上,页面组合得再花也不会产生治理旁路;
- 页面是 JSON 树:可校验、可快照、可 diff、可回滚。
完整的心智模型(图形化的声明式 React、组合四轴、扩展方法论)见 组合模型与表达力边界。
新手阅读地图
建议按这个顺序读,每篇解决一类疑问:
| 顺序 | 文档 | 回答的问题 |
|---|---|---|
| 1 | 本文 | 核心思路是什么?全流程怎么走?为什么这么设计? |
| 2 | Dashboard 术语表 | FunctionContract/Proposal/PageSpec 这些词的精确定义 |
| 3 | 组合模型与表达力边界 | 组合页的心智模型、能力边界、怎么扩展新原语 |
| 4 | Dashboard Resource/Page 模型 | 契约/提案/发布的领域模型权威定义 |
| 5 | PageSpec 协议规范 | wire 层的 JSON 长什么样(前端↔后端唯一契约) |
| 6 | ProComponents 页面生成与运行时 | 生成器/运行时的实现规范与约束 |
| 7 | 组合页编辑器 V3 | 实际动手搭一个组合页的操作指南 |
