FForm Platform
enzh-CN

模块开发

使用 manifest、版本化 SDK 契约、迁移、endpoint 和可选 Vue 扩展建立可维护的产品模块。

FormPlatform 模块开发 Step-by-Step

English: MODULE_DEVELOPMENT_STEP_BY_STEP.md

本教程从空目录建立一个独立 `Inventory` 模块。它包含配置、三数据库 migration、ORM Data Model、领域服务、API、Server Action、客户端 i18n 和部署脚本。模块与 Host 源码分离,只引用发布的 SDK。

1. 什么时候需要模块

只有表单布局、字段和事件变化时,不需要模块。需要独立数据库表、服务器 API、Server Action/Trigger、可复用控件、独立路由或客户专有逻辑时使用模块。模块是可信同进程代码,拥有完整服务器权限;不可信代码应放到独立服务。

2. 前置条件与目录

先构建/发布 FormPlatform,使 SDK 目录至少包含:

FormPlatform.Sdk.dll
FormPlatform.Sdk.xml
FormPlatform.Extension.Abstractions.dll
FormPlatform.Extension.Abstractions.xml

建议同级目录:

workspace/
  FormPlatform/
  Inventory/
    InventoryModule.csproj
    module.json
    appsettings.inventory.json
    InventoryModuleEntry.cs
    InventoryMigrations.cs
    InventoryService.cs
    InventoryActions.cs
    client/
      index.js
      inventory.css
    deploy.ps1
    deploy.bat
    uninstall.ps1
    uninstall.bat

3. 建立项目

`InventoryModule.csproj`:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Version>1.0.0</Version>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AssemblyName>InventoryModule</AssemblyName>
    <RootNamespace>InventoryModule</RootNamespace>
    <GenerateDependencyFile>true</GenerateDependencyFile>
    <FormPlatformSdkDirectory Condition="'$(FormPlatformSdkDirectory)' == ''">..\FormPlatform\src\FormPlatform.Host\bin\Debug\net10.0</FormPlatformSdkDirectory>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
    <Reference Include="FormPlatform.Sdk" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Sdk.dll" Private="false" />
    <Reference Include="FormPlatform.Extension.Abstractions" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Extension.Abstractions.dll" Private="false" />
  </ItemGroup>
  <ItemGroup>
    <None Update="module.json" CopyToOutputDirectory="PreserveNewest" />
    <None Update="appsettings.inventory.json" CopyToOutputDirectory="PreserveNewest" />
    <None Update="client\**\*" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

`Private=false` 很重要:Host 提供唯一 SDK/Abstractions,模块包不应复制私有版本。

4. 建立 module.json

{
  "manifestVersion": 1,
  "name": "Inventory",
  "version": "1.0.0",
  "entryAssembly": "InventoryModule.dll",
  "type": "InventoryModule.InventoryModuleEntry",
  "sdk": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
  "platform": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
  "navigation": [
    { "token": "inventory-management", "titleKey": "menu.inventory.management", "icon": "📦", "isGroup": true },
    { "token": "inventory-items", "titleKey": "menu.inventory.items", "target": "/inventory", "icon": "📋", "parentToken": "inventory-management" }
  ],
  "ownedSystemForms": []
}

`name` 只能包含字母数字、`.`、`_`、`-`,最多 100 字符。JSON version 必须与程序集 Major/Minor/Build 一致。兼容范围是左闭右开。

`navigation` 是模块完整的声明式菜单所有权清单。每项都有稳定的 `token`、`titleKey` 和 `icon`;普通链接还要提供应用相对 `target`。菜单分组使用 `isGroup: true`;其子链接使用 `parentToken`。宿主会补充缺失项,并把模块拥有的子链接移动到声明的父项下。`ownedSystemForms` 只列出模块可重新生成的固定系统表单 ID;即使模块暂时不拥有菜单或表单,也要保留显式空数组。

5. 模块配置和 ORM Entity

`appsettings.inventory.json`:

{
  "Inventory": { "DefaultPageSize": 25, "MaximumPageSize": 200 },
  "DataAccess": {
    "Entities": {
      "InventoryItem": {
        "Schema": "public",
        "TableName": "inventory_item",
        "Attributes": [
          { "PropertyName": "id", "ColumnName": "id", "ValueKind": "String", "IsNullable": false, "IsKey": true, "IsGenerated": true, "CanWrite": false, "MaxLength": 36 },
          { "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
          { "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
          { "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "String", "IsNullable": false, "MaxLength": 36 },
          { "PropertyName": "updatedAt", "ColumnName": "updated_at", "ValueKind": "DateTimeOffset", "IsNullable": false, "CanWrite": false }
        ]
      }
    }
  }
}

模块配置在 Core 绑定 `DataAccessOptions` 之前加入,因此 Entity 会进入同一个 Registry。Data Model 名称是稳定逻辑契约,不要把 UI label 当名称。

6. 建立三 provider migration

using FormPlatform.DataAccess.Migrations;

namespace InventoryModule;

public sealed class InventoryMigrations : IDatabaseMigrationModule
{
    public string Name => "Inventory";
    public string DataSource => "Management";
    public IReadOnlyList<ModuleDatabaseMigration> Migrations { get; } =
    [
        new("001_create_inventory_item",
            [
                "CREATE TABLE IF NOT EXISTS inventory_item (id varchar(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,name varchar(200) NOT NULL,quantity integer NOT NULL DEFAULT 0,updated_by varchar(36) NOT NULL,updated_at timestamptz(0) NOT NULL DEFAULT CURRENT_TIMESTAMP)",
                "CREATE INDEX IF NOT EXISTS ix_inventory_item_name ON inventory_item(name)"
            ],
            [
                "CREATE TABLE IF NOT EXISTS inventory_item (id varchar(36) PRIMARY KEY DEFAULT (UUID()),name varchar(200) NOT NULL,quantity int NOT NULL DEFAULT 0,updated_by varchar(36) NOT NULL,updated_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),INDEX ix_inventory_item_name(name))"
            ],
            [
                "IF OBJECT_ID(N'inventory_item',N'U') IS NULL CREATE TABLE inventory_item (id varchar(36) CONSTRAINT df_inventory_id DEFAULT(CONVERT(varchar(36),NEWID())) PRIMARY KEY,name nvarchar(200) NOT NULL,quantity int NOT NULL CONSTRAINT df_inventory_quantity DEFAULT(0),updated_by varchar(36) NOT NULL,updated_at datetimeoffset NOT NULL CONSTRAINT df_inventory_updated DEFAULT(SYSUTCDATETIME()))",
                "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name=N'ix_inventory_item_name' AND object_id=OBJECT_ID(N'inventory_item')) CREATE INDEX ix_inventory_item_name ON inventory_item(name)"
            ])
    ];
}

发布过的 `001` 不能修改。下一次变化新增 `002_...`。真实 MySQL migration 要考虑索引已存在时的条件执行,不能假定所有 DDL 都支持 `IF NOT EXISTS`。

7. 编写领域服务

服务使用 SDK 的 ORM,不直接依赖 Host implementation。典型方法接收当前 user id 和 `CancellationToken`,在服务器端应用 owner/权限 filter。写入时使用一项 unit of work 完成检查和 mutation,并 commit。

伪代码:

public sealed class InventoryService(IUnitOfWorkFactory workFactory, IDynamicRepository repository)
{
    public async Task<object> CreateAsync(string userId, InventorySaveRequest request, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Name))
            throw new PlatformApiException(400, "inventory.nameRequired", "inventory.nameRequired", "Name is required.");
        await using var work = await workFactory.BeginAsync("Management", ct);
        var row = new DynamicEntity("InventoryItem");
        row["name"] = request.Name.Trim();
        row["quantity"] = request.Quantity;
        row["updatedBy"] = userId;
        var saved = await repository.InsertAsync(work, row, ct);
        await work.CommitAsync(ct);
        return saved;
    }
}

具体 Repository/Query 类型以 SDK XML 文档和 Todo/ResourceBooking 示例为准。不要把请求的 sort/filter 字符串直接变成 SQL。

8. Server Action/Trigger provider

需要让表单 Event/Trigger 调用服务器逻辑时实现 `IServerActionsProvider`,注册稳定 action 名。Action 返回结构化成功/失败结果;业务失败使用 `PlatformApiException` 或 SDK Trigger result,不抛裸字符串协议。

Action 必须重新读取身份,不相信客户端传入的 `userId`。参数中的 `{property}` substitution 在客户端/提交链解析后仍需服务器类型验证。

9. 实现模块入口

using FormPlatform.DataAccess.Migrations;
using FormPlatform.DataAccess.Triggers;
using FormPlatform.Extension.Abstractions;
using FormPlatform.Sdk;

namespace InventoryModule;

public sealed class InventoryModuleEntry : IFormPlatformSdkModule
{
    public void ConfigureConfiguration(ConfigurationManager configuration, IHostEnvironment environment)
    {
        var directory = Path.GetDirectoryName(typeof(InventoryModuleEntry).Assembly.Location)!;
        configuration.AddJsonFile(Path.Combine(directory, "appsettings.inventory.json"), optional: false, reloadOnChange: true);
    }

    public void ConfigureServices(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
    {
        services.Configure<InventoryOptions>(configuration.GetSection("Inventory"));
        services.AddSingleton<InventoryService>();
        services.AddSingleton<IServerActionsProvider, InventoryActions>();
        services.AddSingleton<IDatabaseMigrationModule, InventoryMigrations>();
    }

    public void MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        var api = endpoints.MapGroup("/api/inventory/items").RequireAuthorization();
        api.MapGet("/", InventoryEndpoints.QueryAsync);
        api.MapPost("/", InventoryEndpoints.CreateAsync);
        api.MapPut("/{id}", InventoryEndpoints.UpdateAsync);
        api.MapDelete("/{id}", InventoryEndpoints.DeleteAsync);
    }
}

四个 hook 的顺序是 Configuration → Services → Application → Endpoints。只实现需要的 hook。中间件会影响整个 Host,应非常谨慎。

10. API 规则

  • API 使用 `/api/{module}/...` 命名空间,页面路由使用 `/{module}/...`。
  • 每个 endpoint 显式应用授权 policy。
  • 成功使用标准 200/201/204;创建响应包含资源 ID/Location。
  • 可复用错误抛 `PlatformApiException`,以便 Host 生成含 traceId 的统一响应。
  • `KeyNotFoundException` 可由 Host 转换为 404;不要返回自定义 `{message}`。
  • 所有异步方法透传 RequestAborted/CancellationToken。

11. 系统表单还是客户端页面

CRUD/编辑流程优先建立 Data Model、系统表单和 DataGrid。复杂日历、拖拽看板或图形可做独立 Vue/JS 路由,但编辑细节仍可跳到普通表单。ResourceBooking 是混合模式示例。

系统表单 initializer 应幂等:不存在时建立;若代码拥有该表单则做语义同步;允许用户长期编辑的表单不要每次启动覆盖。系统文案保存 `@inventory.key`。

12. 客户端扩展

`client/index.js` 导出默认对象:

export default {
  install(api) {
    api.registerMessages('Inventory', {
      en: { inventory: { title: 'Inventory' } },
      'zh-CN': { inventory: { title: '库存' } }
    })

    api.registerRoute({
      path: '/inventory',
      component: () => import('/extension-assets/Inventory/inventory-page.js')
    })

    api.extendActionApi('refreshInventory', async () =>
      window.FormPlatform.API.fetch('/api/inventory/items'))
  }
}

实际 extension API 支持:`registerControl`、`registerComponentEvents`(由 control definition events 触发)、`registerRoute`、`extendActionApi`、`extendRuntimeApi`、`registerMessages`、`configureApp`。

自定义控件必须在 `designerMode` 下使用模拟数据且不请求 API。HTTP 调用复用 `window.FormPlatform.API.fetch`,它自动处理 cookie、语言和统一错误。控件状态使用 Vue `ref/reactive/computed`;只在加载 stylesheet、焦点或第三方库时进行受控 DOM 操作。

13. 让 Host 加载客户端模块

部署静态文件到 `Modules/Inventory/client` 后,在 Host 配置:

{
  "FormPlatformExtensions": {
    "ModulesDirectory": "Modules",
    "ClientModules": [
      "/extension-assets/Inventory/index.js"
    ]
  }
}

客户端扩展在 Vue mount/router initial navigation 之前加载,因此直接访问模块路由可以正确匹配。

14. 构建与部署

dotnet build InventoryModule.csproj -c Debug `
  -p:FormPlatformSdkDirectory="C:\path\FormPlatform\bin\Debug\net10.0"

部署目录至少包含 DLL、deps.json、module.json、模块配置和 client 资产:

FormPlatform/Modules/Inventory/
  InventoryModule.dll
  InventoryModule.deps.json
  module.json
  appsettings.inventory.json
  client/index.js
  client/inventory.css

使用脚本复制明确文件,避免把 SDK/Host DLL 复制进模块目录。重启 Host 才会重新加载服务器 DLL;纯客户端文件刷新即可,但浏览器缓存可能需要 hard reload。

Windows 部署启动器规范

每个面向 Windows 的模块都必须同时提供 `deploy.ps1` 和 `deploy.bat`。`deploy.ps1` 是唯一的、适用于自动化/CI 的部署实现;`deploy.bat` 是给命令提示符或资源管理器使用的轻量启动器。它固定脚本目录、原样转发全部参数,并将 PowerShell 的退出码返回给调用方:

@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
exit /b %ERRORLEVEL%

例如管理员可直接执行 `deploy.bat -Configuration Release -FormPlatformDirectory C:\inetpub\FormPlatform`,无需手工拼写 PowerShell 命令。部署逻辑只应写在 `deploy.ps1`,不要在批处理文件中复制一份。

卸载规范

每个模块还必须提供 `uninstall.ps1` 和 `uninstall.bat`,并采用同样的包装器规则。常规卸载应在 Host 停止后,只删除 `FormPlatform/Modules/<module-name>`。`-RemoveMenuEntries` 删除声明的叶子 token,随后只在分组为空时删除声明的分组;绝不会删除管理员后来加入的链接。`-RemoveSystemForms` 只删除 `ownedSystemForms`。这两个参数会先写入由下次启动时 Host 通过 `IFormStore` 执行的 provider-neutral 清理请求。默认必须保留数据库表和 migration 历史:静默删除业务数据会让以后重新安装不一致,或造成不可恢复的数据丢失。允许提供清除数据参数,但必须是明确的破坏性参数(例如 `-RemoveDatabase`)、要求显式提供 Provider 和连接字符串、删除模块 schema,并作为一次有意的清除操作删除相应 migration 历史。PostgreSQL/SQL Server 可以使用事务;MySQL 的 DDL 会隐式提交,因此必须在首个失败处停止并报告。绝不能从 secrets 文件自动推断用于破坏性操作的凭据。

@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall.ps1" %*
exit /b %ERRORLEVEL%

15. 调试顺序

1. 构建 Host,确认 SDK/Abstractions DLL 是当前版本。

2. 用同一 SDK 路径构建模块。

3. 检查输出程序集 version 与 `module.json`。

4. 部署完整包并重启 Host。

5. 启动日志确认 migration 和 module load。

6. 直接调用 API,检查认证和统一错误。

7. 查看 `/api/platform/client-extensions` 是否列出 client URL。

8. 浏览器 Network 确认 JS/CSS 200、route 未被 SPA fallback 抢走。

9. Designer 验证控件模拟数据,Viewer 验证真实 API。

常见错误:

  • `TypeLoadException`:模块仍引用 Host 或旧 SDK,重新构建部署。
  • manifest name/version 错:修正 JSON并确保匹配程序集。
  • Entity 未定义:模块配置未加载、DataAccess 路径错误或名称不一致。
  • migration checksum 不符:不要编辑已执行 migration,恢复原文并新增 ID。
  • API 401/403:检查 cookie scheme、policy 和 owner/deployment 条件。
  • 路由回主页:客户端扩展没有在 router 初始化前注册或资产 URL 错。

16. 版本升级

修复不改公共契约可升 patch;兼容新增升 minor;破坏 SDK/模块契约升 major。每次发布同步 csproj version、module.json、SDK/Host 范围、变更日志和所有模块 CI。模块需要新 SDK 时提高 minimum;不要无依据扩大 maximum。

17. 完成检查清单

  • 模块只引用 SDK/Abstractions,`Private=false`。
  • manifest、程序集和兼容范围一致。
  • migration 三 provider 完整且不可变。
  • ORM Entity 与 schema、类型、generated/write flags 一致。
  • API 授权、owner filter、CancellationToken 和统一错误齐全。
  • Action/Trigger 不信任客户端身份。
  • 控件 Designer 无真实 API,Viewer/Preview value 一致。
  • i18n 同时提供英文和中文 fallback。
  • 部署包不夹带 Host/SDK 私有副本。
  • PostgreSQL integration 与关键 Playwright 流程已增加。

完整 Todo 源码级教程 可单独阅读;混合式参考项目位于同级 `ResourceBooking` 目录。