数据持久化与标识符
将表单映射到关系表、保存通用提交记录,并使用可移植的原生 UUID v7 标识符。
从表单生成数据库表
用途
在 **管理数据模型 → 从表单建立数据表** 中,可以把一个已经保存的表单转换为三个相互配合的对象:
1. 保存到系统元数据中的可编辑 Data Model;
2. 通过跨数据库 ORM Migration Planner 建立的新物理数据表;
3. 把当前表单写入该 Data Model 的 Form and Data Mapping。
这个流程刻意采用保守策略:**只建立新表**,绝不自动修改或删除已有的数据表、字段或映射。
目前它支持尚未映射的 **系统表单**。功能表单已有 URL/Server Action 提交契约,问卷调查表单的答卷表由 Survey Deployment 管理;这两类表单会被明确拒绝,而不会被静默改写。
操作步骤
1. 为每个需要保存的控件设置唯一的 **其它 → Property Name**。
2. 进入 **管理数据模型**,选择 **从表单建立数据表**。
3. 选择表单;如有需要,再覆盖自动生成的 Data Model 名称、表名、Schema 或数据源。
4. 点击 **生成预览**,检查字段和 SQL 变更计划。
5. 点击 **建立数据表和映射**。系统会校验模型、建立表、注册模型并保存表单映射。
系统生成的表包含由应用赋值的 UUID v7 主键,分别保存为 PostgreSQL `uuid`、SQL Server `uniqueidentifier` 或 MySQL `binary(16)`。FormPlatform 在插入前赋值,因此不依赖数据库 UUID 默认值或不同的 identity/returning 写法。参见原生 UUID v7 标识符规范。
类型规则
| 表单控件 | 生成的 Data Model 类型 | 说明 |
|---|---|---|
| 单行文本、多行文本 | `String` | 文件上传控件保存平台媒体 ID,类型也是 `String`。 |
| 数字/范围输入 | `Decimal` | 如确实需要整数类型,可在生成后手动编辑模型。 |
| 日期/日历 | `Date` | |
| 日期时间 | `DateTime` | |
| Checkbox | 配置值是布尔值时为 `Boolean`,否则为 `String` | Boolean Checkbox 为 `true`/`false`;自定义值 Checkbox 未选中时为 `null`。 |
| Radio | 由选项值推断 | 全部为布尔值时是 `Boolean`;全部为整数时是 `Int64`;其余为 `String`。 |
| Dropdown | 由选项值推断 | 多选 Dropdown 保存为 `Json`。 |
| AsyncSelect / Tree | 单选为 `String`;多选为 `Json` | 单选保存选择项 ID;如要外键关系,可在生成后手动配置 `Reference`。 |
| GeoLocation | 默认 `Json` | 浏览器位置快照(纬度、经度、精度、采集时间等)保存在一个 JSON 字段。若 `Value format = coordinates`,则保存为 `latitude,longitude` 形式的 `String`。 |
必填控件生成不可为空的字段;其它控件生成可空字段。没有 Property Name 的控件不会被持久化,并会在预览中列出警告。
建立后
如需高级配置,请进入 **表单与数据映射**:
- 保持物理列名不变,改逻辑属性名;
- 创建 `Reference` 属性和带关联关系的选择控件;
- 改变持久化模式;
- 加入 Trigger、所有者规则或字段级读写映射。
若数据库表已经存在,请使用 **从数据库表建立**。若已有 Data Model,请使用通用 ORM migration-plan 接口,并人工审核所有非安全操作。
已生成表单的增量同步
首次建表后,系统表单可以继续增加控件。进入 **管理数据模型 → 同步表单新增字段**,选择已经映射的表单并生成预览。
该流程严格遵守“只新增”的边界:
- 新的可持久化控件且拥有新的 Property Name:新增对应的可空 Data Model 属性、可空数据库列和显式表单映射;
- 控件对应已有可写 Data Model 属性、但尚无映射:只新增映射,不新增列;
- 已经映射的控件:显示为无需变更;
- 类型改变、Radio 选项值类型改变、属性被改名或丢失、引用路径映射、主键变化,以及任何 SQL `ALTER` / 删除操作:只显示为人工审核,绝不会由该流程自动执行。
即使表单控件是必填,新列也始终先建立为可空列,确保已有记录仍然有效。后续新提交的数据是否必填,仍由表单验证契约负责。
每次成功同步后,系统会在 `mapping.options.formTableSynchronization` 写入来源表单 ID/名称、当前 schema 的 SHA-256 哈希、同步时间和格式版本。它用于管理员和后续工具追踪来源,并不会据此静默执行迁移。
安全与恢复
建立接口会拒绝已存在的数据表、已存在的 Data Model 名称,以及已经有 Data Model 映射的表单。预览和实际执行前都会重复校验。
若建表失败,刚建立的元数据 Data Model 会被删除。若建表成功、但后续保存表单映射失败,系统会保留数据表和 Data Model,绝不会自动删表;请解决错误后进入 **表单与数据映射** 完成映射。
增量同步也采用同一保留原则:一旦 `ADD COLUMN` 成功,即使随后发布 Data Model 或保存映射失败,系统也不会删除该字段。解决提示的元数据问题后再次同步,系统会识别已存在字段,并安全完成剩余映射工作。
---
通用提交记录
目的
没有 Data Model 映射的表单同样需要可长期维护的数据。FormPlatform 现在将每一次这类提交视为可维护的通用文档记录,而不再只是一次“只写入、不读取”的事件。
它适合联系表、轻量工作流、申请表、导入的第三方表单,以及尚不值得建立关系型数据模型的原型。
存储模型
关系型表单存储会为 `form_submissions` 增加以下字段:
| 字段 | 含义 |
|---|---|
| `id` | 稳定记录 ID,用于 URL。 |
| `form_id` | 所属表单。 |
| `data_json` | 已提交的 JSON 文档。 |
| `submitted_at` | 首次提交时间。 |
| `submitted_by` | 建立记录的系统用户 ID;匿名提交为 `NULL`。 |
| `updated_at` | 最近一次写入时间。 |
| `updated_by` | 最近一次编辑的系统用户 ID;没有时为 `NULL`。 |
| `version` | 乐观并发版本号。 |
| `schema_revision` | 建立记录时使用的、不可变的表单语义结构版本。 |
`Memory` 与 `File` Provider 也实现相同契约。File Provider 的版本快照保存在配置的表单存储目录的 `_revisions` 下;关系型存储保存在 `form_schema_revisions`。
权限与路由
当普通表单访问规则允许时,匿名访问者可以建立记录,但永远不会获得读取记录的路由。登录后的系统用户必须对该表单具有 **编辑** 权限,才能查看、更新或删除通用记录。
| 用途 | 路由 |
|---|---|
| 打开已提交记录 | `/forms/{formId}/{recordId}` |
| 列出通用记录 | `/admin/forms/{formId}/submissions` |
| 列表 API | `GET /api/forms/{formId}/submissions` |
| 读取 API | `GET /api/forms/{formId}/generic-submissions/{recordId}` |
| 更新 API | `PUT /api/forms/{formId}/generic-submissions/{recordId}` |
| 删除 API | `DELETE /api/forms/{formId}/generic-submissions/{recordId}` |
| 当前结构快照 | `GET /api/forms/{formId}/schema-revisions/current` |
| 指定结构快照 | `GET /api/forms/{formId}/schema-revisions/{revision}` |
Viewer 会在登录用户新建通用记录后自动切换到 `/forms/{formId}/{recordId}`。匿名提交者则停留在配置的完成页面,不能猜测或重用管理记录路由。
更新请求携带 `keys: { id, version }`。如果另一位管理员已经先保存,服务器会拒绝过期写入,而不会静默覆盖数据。
语义结构历史
每张表单会在首次保存或首次需要版本时建立第一个语义快照。只有影响数据含义、收集或解释的修改才会建立新版本,例如:
- 新增、删除、改名输入控件,或更改其 Property Name;
- 修改验证、必填、可见逻辑、只读逻辑、取值或功能性控件设置;
- 修改与数据有关的嵌套控件结构。
纯展示修改会刻意排除:`runtimeCss`、生成的 CSS 候选类、`exportedAt`、根 `presentation`,以及可视 CSS class/style 属性都不会建立新版本。颜色、间距、渐变或页面背景不应该让旧答案更难解释。
目前快照是表单存储中的不可变 JSON 副本。它不会尝试重放旧的 Action Code、外部 JavaScript 文件或服务器 Trigger;这些是可执行部署行为,应使用自己的发布/版本管理策略。
已有的 `form_submissions` 记录会保留为版本 `0`:系统不知道它们历史上对应的结构,因此不会错误地标记为当前结构。新记录会取得当前的正数版本号。
表单级完成行为与外观
在 **Designer → Form settings** 设置 **Success redirect URL**。它必须是类似 `/thank-you` 的站内相对 URL;Button 已有的转向仍是更明确的单按钮覆盖。**Replace browser history** 决定完成页是否替换当前表单 URL。
在 **Designer → Form appearance** 可以设置:
- 页面级 Tailwind/CSS class;
- 页面内联 CSS(例如背景);
- 全宽布局;
- 全浏览器视口展示。
全视口会在 Viewer 路由隐藏已登录应用的壳层,让表单占据浏览器页面。这只是展示设置,不会建立新的语义版本。
通用提交记录 Trigger
在 **Form and Data Mapping** 中选择 **通用 JSON 提交记录**,即可配置它独立的 Trigger 链。它刻意与 **Main Entity Trigger** 分开:Entity Trigger 处理 ORM `DynamicEntity`,而通用 Trigger 处理可修改的 JSON 文档。
可用生命周期事件为 `Validate`、`BeforeInsert`、`AfterInsert`、`BeforeUpdate`、`AfterUpdate`、`BeforeDelete`、`AfterDelete`。`Validate` 会在新增或更新前始终执行。需要修改将保存的数据时使用 `Before*`;`After*` 会收到一份副本,用于通知和后续动作,其修改不会再写回记录。
内置 Action:
| Action | 参数示例 | 用途 |
|---|---|---|
| `RequireFields` | `{ "fields": ["email"], "message": "必须填写 Email。" }` | 以字段错误拒绝此次操作;支持点号 JSON 路径。 |
| `SetFields` | `{ "updatedBy": "@userId", "updatedAt": "@Datetime" }` | 新增或修改文档属性;支持 `@userId`、`@Datetime`、`@id`。 |
| `Notify` | `{ "message": "已保存。", "type": "success" }` | 向浏览器返回 toast。 |
| `NoOp` | `{}` | 适合测试生命周期链。 |
第三方模块可以实现 `IGenericSubmissionActionsProvider`,并在模块的 `ConfigureServices` 中注册,以添加 Action。Action 名称在系统中全局唯一。这个契约刻意不暴露 SQL、伪实体模型或事务;需要这些保证的工作流应提升为 Data Model。
---
原生 UUID v7 标识符规范
FormPlatform 自有的持久化标识符统一使用 RFC 9562 UUID v7。应用在执行 `INSERT` 前产生 ID,数据库使用原生紧凑类型保存。
| 层/数据库 | 表示方式 |
|---|---|
| .NET 与 ORM | `Guid`、`DataValueKind.Guid` |
| JSON、路由、表单值 | 小写标准 UUID 字符串(`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) |
| PostgreSQL | `uuid` |
| SQL Server | `uniqueidentifier` |
| MySQL | `binary(16)`,MySqlConnector 使用 `GuidFormat=Binary16` |
新建的 FormPlatform 自有实体不要再使用 `varchar(36)` 主键,也不要设置 `DEFAULT gen_random_uuid()`、`NEWID()` 或 `UUID()`。显式建立记录时使用 `Uuid7.NewGuid()`/`Uuid7.NewString()`;也可以让 `DynamicRepository.InsertAsync` 为尚未赋值、可写的 Guid 主键补上 UUID v7。主键定义为 `ValueKind = Guid`、`IsKey = true`、`IsGenerated = false`。
var model = new EntityModel(
"ExampleItem",
"example_item",
null,
[
new("id", "id", DataValueKind.Guid, false, true, false),
new("ownerId", "owner_id", DataValueKind.Guid, false),
new("name", "name", DataValueKind.String, false, MaxLength: 200)
]);
var item = new DynamicEntity(model, new Dictionary<string, object?>
{
["id"] = Uuid7.NewGuid(),
["ownerId"] = ownerId,
["name"] = "Example"
});
三种数据库的 DDL:
-- PostgreSQL
CREATE TABLE example_item (id uuid PRIMARY KEY, owner_id uuid NOT NULL, name varchar(200) NOT NULL);
-- SQL Server
CREATE TABLE example_item (id uniqueidentifier NOT NULL PRIMARY KEY, owner_id uniqueidentifier NOT NULL, name nvarchar(200) NOT NULL);
-- MySQL
CREATE TABLE example_item (id binary(16) NOT NULL PRIMARY KEY, owner_id binary(16) NOT NULL, name varchar(200) NOT NULL);
哪些值仍然是字符串
不要把非 FormPlatform 身份强行改为 UUID。Google、Facebook、DNN 等外部主体 ID,以及 slug、订单号、许可证 ID、安全 token、哈希、可能保存保留字的多态 subject ID 和 JSON 数据继续使用字符串。指向 FormPlatform 自有实体的外键则必须是 Guid,即使公开 DTO 用 UUID 字符串表达。
排序与索引
UUID v7 含时间信息,新写入键的索引局部性显著优于随机 UUID v4,但它不能代替业务上的 `created_at`。PostgreSQL 与 MySQL `binary(16)` 保留 RFC 字节顺序。SQL Server 仍按 `uniqueidentifier` 自己的规则比较;使用 UUID v7 仍能统一跨数据库契约,并避免文本存储与文本比较。
导入与种子数据的源键
外部文件中的标识不一定是 FormPlatform 实体 ID。`AD` 这类国家代码、旧系统自然键和第三方记录键应继续作为导入源键。写入有关联关系的一批数据前,先为完整数据集的每个源键分配一个 UUID v7,再用同一映射转换主键以及父级/外键;`country_code` 等业务代码应保存到自己的字段。不要把任意源键传给 `Uuid7.Parse`。
开发数据库重建
本次转换有意不提供兼容层或数据迁移。更新宿主与模块后,必须重建各开发数据库。继续使用迁移历史仍描述 `varchar(36)` 的旧数据库,会出现迁移 checksum 错误或字段类型不匹配。正式发布后不可修改已有 migration;本次允许破坏性重建,仅因为当前数据是可丢弃的开发数据。
模块检查清单
1. 自有 ID 与外键声明为 `DataValueKind.Guid`。
2. DDL 使用上表的原生类型,并移除数据库 UUID 默认值。
3. 代码显式建立记录时使用 `Uuid7`;ORM 自动补齐的空 Guid 主键也是 UUID v7。
4. API 边界使用标准 UUID 字符串并进行校验。
5. MySQL 通过 FormPlatform 建立连接,以自动应用 `GuidFormat=Binary16`。
6. 为 insert、read、filter、join、update、delete 编写各数据库集成测试。
7. 使用 `Uuid7` 或依赖 ORM 自动生成 UUID v7 的模块,应在 `module.json` 声明 SDK `minimum` 为 `1.3.0`。