Skip to content

界面是怎么生成的:核心思路与全链路 ​

状态:Current —— 本文是新手视角的设计讲解:解释这个项目的核心创新思路、 界面从无到有的完整流程,以及每个关键决策的「为什么」。读完本文再去看规范文档 (术语表 → 页面模型 → PageSpec 协议 → 生成与运行时)会顺畅得多。

一句话版本 ​

你注册一个函数,界面就出现了。 不写前端页面、不配菜单、不手动对接权限—— 写一次契约,表单、校验、页面、菜单、权限、审批、审计全部自动派生。

这就是 Croupier 的核心创新:描述驱动(descriptor-driven)。 本文回答三个问题:为什么这样设计?流程是怎么走的?每一步为什么非这样不可?

为什么会有这个设计:传统做法的三个死结 ​

做游戏 GM 后台(运营工具)通常是这样:

  1. 每个功能都要「排期开发页面」。服务端写了个封号函数,前端还得再花一天 做表单、列表、详情——功能越积越多,页面开发永远排不过来。
  2. 治理靠人自觉。页面是手写的,「这个按钮有没有接权限校验」「高危操作 有没有走审批」取决于前端同学是否记得。审计和页面是两张皮。
  3. 页面会腐烂。函数参数改了,页面忘了改——用户填了旧参数提交,直到线上 报错才发现。

Croupier 对这三个死结的回答是同一个:把「函数契约」作为唯一事实源。

  • 函数注册时声明:入参 JSON Schema、能力语义(CRUD/动作/任务/报表)、 审批策略、呈现提示(x-ui-*)。
  • 界面不再由人编写,而是从契约确定性生成——契约不变,页面不变; 契约变了,页面必须重新对齐(stale 检测强制做到)。
  • 治理不挂在页面上,而挂在函数绑定上——页面无论怎么生成、怎么组合, 执行都必须经过服务端的权限/审批/审计链路,浏览器没有绕过的可能。

一句话对比:传统做法是「函数 → 人 → 页面」(人是最不可靠的一环); Croupier 是「函数 → 生成器 → 页面」(确定性、可校验、可快照)。

全链路走读:一个封号函数的旅程 ​

以一个真实场景贯穿:游戏服务端开发者写了一个封号函数 player.ban, 运营要在控制台用它。从注册到执行共六步。

① 注册:开发者声明契约 ​

游戏服务端用 SDK 注册函数,同时声明「这个函数长什么样、是什么性质」:

go
// 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(提案)不是直接上线的页面,它是一个「待审核的生成结果」。

为什么要过一道生成器,而不是注册完直接出页面? 三个理由:

  1. 确定性:相同契约 + 相同生成器版本 ⇒ 相同页面,生成过程可测试、可复现;
  2. 可审核:生成结果先落在 Proposal Inbox 里给人过目,而不是直接改变线上;
  3. 可降级:语义不完整(比如资源缺 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 ​

运营填完表单点「执行」。注意浏览器提交的是什么:

text
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本文核心思路是什么?全流程怎么走?为什么这么设计?
2Dashboard 术语表FunctionContract/Proposal/PageSpec 这些词的精确定义
3组合模型与表达力边界组合页的心智模型、能力边界、怎么扩展新原语
4Dashboard Resource/Page 模型契约/提案/发布的领域模型权威定义
5PageSpec 协议规范wire 层的 JSON 长什么样(前端↔后端唯一契约)
6ProComponents 页面生成与运行时生成器/运行时的实现规范与约束
7组合页编辑器 V3实际动手搭一个组合页的操作指南