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": "Guid", "IsNullable": false, "IsKey": true, "IsGenerated": false },
          { "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
          { "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
          { "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "Guid", "IsNullable": false },
          { "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 uuid PRIMARY KEY,name varchar(200) NOT NULL,quantity integer NOT NULL DEFAULT 0,updated_by uuid 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 binary(16) NOT NULL PRIMARY KEY,name varchar(200) NOT NULL,quantity int NOT NULL DEFAULT 0,updated_by binary(16) 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 uniqueidentifier NOT NULL PRIMARY KEY,name nvarchar(200) NOT NULL,quantity int NOT NULL CONSTRAINT df_inventory_quantity DEFAULT(0),updated_by uniqueidentifier 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)"
            ])
    ];
}

ID 和内部引用由应用产生 UUID v7,不设置数据库 UUID 默认值。完整规范见原生 UUID v7 标识符规范。发布过的 `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`、`registerRespondentTool`、`extendActionApi`、`extendRuntimeApi`、`registerMessages`、`configureApp`。可信模块页面可复用 `components.FormReader`;离线包在联网时调用 `preloadFormComponents(schema)`,即可解析需要缓存的懒加载控件 chunk,而不依赖 Vite 文件名。可信宿主还可传入只存在于运行时的 `fileHandler`(`stage`、`preview`、`remove`)和 `optionHandler.query`;未传处理器时控件保持普通在线 API,且处理器绝不能保存到表单 JSON。选项处理器必须返回标准 `{ items, total, offset, limit, hasMore, nextCursor }` 页面结构,并执行快照版本与过期检查。必须在断网时启动的路由使用 `meta.public`;它只跳过浏览器路由守卫,包/数据端点仍须由服务器要求相应认证策略。

自定义控件必须在 `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` 目录。

---

源代码目录与程序集边界

目的

仓库现在按程序集所有权组织。每个源文件只被一个项目编译;Host 通过 ProjectReference 使用公共代码,不再以链接源码的方式重复编译。程序集名称和公开命名空间保持不变,所以已部署模块仍引用 `FormPlatform.Sdk.dll` 与 `FormPlatform.Extension.Abstractions.dll`。

目录

路径生成物职责
`src/FormPlatform.Host/``FormPlatform.dll`ASP.NET Core Host、官方领域服务、端点、配置、Vue 应用和开发期模块包
`src/FormPlatform.Sdk/``FormPlatform.Sdk.dll`公共 ORM、动态实体/查询/事务 API、Trigger/Action 契约、迁移、表单 Store 契约及共享媒体/错误契约
`src/FormPlatform.Extension.Abstractions/``FormPlatform.Extension.Abstractions.dll`稳定的模块 manifest 与生命周期契约
`src/FormPlatform.Licensing/``FormPlatform.Licensing.dll`许可证文档的加密/签名基础组件
`samples/FormPlatform.Extension.Sample/`示例模块使用 SDK 和 Abstractions 的最小参考模块
`tests/`测试程序集PostgreSQL 与 SQL Server 集成测试
`src/FormPlatform.Host/Modules/`运行时包本地开发 Host 自动发现的可信模块

仓库根目录的 `FormPlatform.slnx` 是 IDE 与 CI 的统一解决方案入口。

常用命令

# 从仓库根目录运行 Host
dotnet run --project src/FormPlatform.Host/FormPlatform.csproj

# 构建 Host 与所有官方项目
dotnet build FormPlatform.slnx

# 手工构建浏览器客户端
cd src/FormPlatform.Host/ClientApp
npm ci
npm run build

standalone 与 DNN 打包脚本仍位于 `deploy/`,并会自动使用新的 Host 项目。第三方模块部署脚本应传入 Host 目录,例如 `-FormPlatformDirectory C:\src\FormPlatform\src\FormPlatform.Host`。

规则

1. 第三方需要编译引用的代码放入 `src/FormPlatform.Sdk` 或 `src/FormPlatform.Extension.Abstractions`,不要放入 Host。

2. 模块只能引用 SDK 与 Abstractions,不能引用 `FormPlatform.dll`。

3. 保持 `Program.cs` 为组合根;Host 专属端点放在 `src/FormPlatform.Host/Hosting/Endpoints`。

4. 不要通过 `Compile Include=... Link=...` 将 SDK 源码重新链接到 Host 编译。

5. `src/FormPlatform.Host/Modules` 是运行时包目录,不是编写模块源码的目录。

---

FormPlatform 私有化部署与可信二次开发

1. 目标与适用范围

私有化客户得到的是编译后的 FormPlatform 服务器程序和 Vue 生产文件,

而不是 FormPlatform 的 `.cs`、`.vue` 等源码。客户作为可信的二次开发者,

可以让自己的代码在 FormPlatform 应用进程中运行,并且能够:

  • 修改部署目录中的 `appsettings.json`,增加自己的配置;
  • 使用公开的 ORM、Data Model、表单存储和元数据 API;
  • 注册 Server Action、Trigger、DI 服务和 Hosted Service;
  • 编写任意 Minimal API,为控件、外部系统或业务流程提供数据;
  • 编写自己的 Vue 控件,并加入 Form Designer 控件区;
  • 扩展客户端 Action API、Form Runtime API、i18n 和 Vue Router;
  • 对 Vue app、Router 和 Pinia执行高级初始化。

这个方案解决的是“不交付源码”,不是运行时安全隔离。服务器模块与

FormPlatform 在同一个进程中运行,拥有 FormPlatform 服务账号所拥有的权限,

因此只能安装可信、经过审查的模块。

2. 私有化交付内容

平台供应方建议只交付:

1. `FormPlatform` 的 Release/Publish 输出;

2. `wwwroot` 中编译后的 Vue 生产文件;

3. 供服务器二次开发引用的 `FormPlatform.Sdk.dll`;

4. `FormPlatform.Extension.Abstractions.dll` 或对应的 NuGet 包;

5. 公开 API 的 XML 文档;

6. 本文档和示例扩展。

不应交付:

  • FormPlatform 的 `.cs`、`.vue` 等源文件;
  • source map;
  • Git 仓库和提交历史;
  • `obj`、构建缓存等中间文件;
  • 不对外发布的 PDB;
  • 签名私钥、数据库密码和其它秘密。

需要明确:.NET IL 和发送到浏览器的 JavaScript 都可以被反编译或重新格式化。

混淆、程序集签名和许可证机制只能提高逆向成本,不能保证绝对不可读。任何密码、

密钥和真正需要保密的算法都不应放在浏览器端。

3. 服务器扩展模块

服务器扩展实现 SDK 中的 `IFormPlatformSdkModule`。它有四个生命周期入口:

1. `ConfigureConfiguration`:加入客户自己的配置源;

2. `ConfigureServices`:注册 DI、ORM 服务、Action、Trigger 和后台任务;

3. `ConfigureApplication`:注册可信 ASP.NET Core middleware;

4. `MapEndpoints`:注册 Minimal API。

执行顺序为:

标准 appsettings
    → 客户模块配置源
    → 重新应用 Development User Secrets / 应用生产秘密文件
    → FormPlatform 内置服务
    → 客户服务
    → Build
    → FormPlatform 异常处理、身份认证和授权 middleware
    → 客户 middleware
    → FormPlatform API
    → 客户 API
    → SPA fallback

3.1 客户项目引用

客户项目不需要 FormPlatform 源码。它只引用供应方提供的编译文件:

<ItemGroup>
  <Reference Include="FormPlatform.Sdk"
             HintPath="..\sdk\FormPlatform.Sdk.dll"
             Private="false" />
  <Reference Include="FormPlatform.Extension.Abstractions"
             HintPath="..\sdk\FormPlatform.Extension.Abstractions.dll"
             Private="false" />
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

`Private="false"` 表示构建客户模块时,不要把平台 DLL 再复制一份到客户模块包中。

部署环境已经拥有与该版本相匹配的平台 DLL。

源码仓库中的 `FormPlatform.Extension.Sample` 使用 ProjectReference,只是为了方便

平台开发者同时维护示例;真正交给客户的项目应采用上面的文件引用或私有 NuGet。

3.2 完整模块骨架

using FormPlatform.DataAccess.Triggers;
using FormPlatform.Extension.Abstractions;
using FormPlatform.Sdk;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

public sealed class CustomerModule : IFormPlatformSdkModule
{
    public void ConfigureConfiguration(
        ConfigurationManager configuration,
        IHostEnvironment environment)
    {
        configuration.AddJsonFile(
            Path.Combine(
                environment.ContentRootPath,
                "Modules",
                "Customer",
                "appsettings.customer.json"),
            optional: true,
            reloadOnChange: true);
    }

    public void ConfigureServices(
        IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        services.Configure<CustomerOptions>(
            configuration.GetSection("Customer"));

        services.AddSingleton<CustomerService>();
        services.AddSingleton<IServerActionsProvider, CustomerActions>();
        services.AddHostedService<CustomerBackgroundService>();
    }

    public void ConfigureApplication(WebApplication application)
    {
        // 可选:加入客户 middleware。
        // FormPlatform 的异常处理、身份认证和授权 middleware 已经位于它前面。
    }

    public void MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        var api = endpoints.MapGroup("/api/customer")
            .RequireAuthorization();

        api.MapGet("/items", async (
            CustomerService service,
            CancellationToken ct) =>
            Results.Ok(await service.LoadAsync(ct)));
    }
}

客户注册发生在平台内置服务注册之后,因此可以使用标准 ASP.NET Core DI 规则:

  • 同一个接口注册多个实现时,可以通过 `IEnumerable<T>` 获取全部实现;
  • 对普通单一服务,后注册的实现通常会成为 `GetRequiredService<T>()` 的结果;
  • 如果客户有意替换平台服务,需要严格测试版本兼容性。

4. 模块目录与加载方式

每个客户模块是 `Modules` 下的独立目录:

Modules/
  Customer/
    module.json
    Customer.Module.dll
    Customer.Module.deps.json
    appsettings.customer.json
    Other.Private.Dependency.dll
    client/
      index.js
      customer.css
      customer-icon.svg

`module.json` 示例:

{
  "entryAssembly": "Customer.Module.dll",
  "type": "Customer.Module.CustomerModule"
}
  • `entryAssembly` 是模块入口程序集,只允许指向当前模块目录中的文件;
  • `type` 是实现 `IFormPlatformSdkModule` 的完整类型名;
  • 一个 descriptor 必须准确解析出一个模块入口;
  • 模块可以携带自己的私有依赖 DLL。

部署应用的 `appsettings.json` 配置:

{
  "FormPlatformExtensions": {
    "ModulesDirectory": "Modules",
    "ClientModules": [
      "/extension-assets/Customer/index.js"
    ]
  },
  "Customer": {
    "ApiBaseUrl": "https://customer.example/api",
    "PageSize": 50
  }
}

安装或更换服务器 DLL 后必须重启 .NET 应用。客户 JSON 可以设置

`reloadOnChange: true`,但配置变化能否即时反映,还取决于客户代码使用的是

`IOptionsMonitor<T>` 还是启动时读取一次的 Options。

模块按 `module.json` 完整路径稳定排序。如果一个客户模块需要有意覆盖另一个模块

的服务,可使用带序号的目录名,例如:

Modules/010-Customer/
Modules/020-Customer-Overrides/

5. 在模块中使用 ORM

FormPlatform ORM 是普通的公开 DI 服务。客户可以注入:

  • `IEntityModelResolver`:按名称解析 Data Model;
  • `IDynamicRepository`:动态实体查询和增删改;
  • `IUnitOfWorkFactory`:建立事务;
  • `IJoinQueryService`:关联查询;
  • `IAggregateQueryService`:聚合查询;
  • `IFormDataMapper`:表单数据和实体之间映射;
  • `IFormStore`:读取或保存表单结构和 metadata。

查询示例:

var model = await models.ResolveAsync("Customer", ct);

await using var work = await workFactory.BeginAsync("Business", ct);

var rows = await repository.QueryAsync(
    model,
    new QuerySpec(
        Filter: new FilterCondition(
            "IsActive",
            FilterOperator.Equal,
            true),
        OrderBy: [new SortTerm("Name")],
        Offset: 0,
        Limit: 50,
        Select: ["Id", "Name", "Email"]),
    work,
    ct);

await work.CommitAsync(ct);

建议始终:

  • 使用 Data Model 的逻辑属性名,不拼接数据库列名;
  • 使用 ORM Filter 和参数化查询,不拼接 SQL 值;
  • 明确设置 `Select`,只读取真正需要的字段;
  • 用 `IUnitOfWorkFactory` 管理事务;
  • 为客户 API 设置合理的分页上限;
  • 不把数据库异常原文直接返回匿名客户端。

客户可以在部署的 `appsettings.json` 中通过 `DataAccess` 配置 Data Source 和

Entity,也可以继续使用系统的 Data Model 管理功能。

模块配置加载后,Development User Secrets 会重新应用,Production 的外部秘密

JSON也会最后应用,因此模块普通配置不会意外覆盖数据库密码等秘密。

6. 扩展 Server Action 和 Trigger

实现现有的 `IServerActionsProvider`:

public sealed class CustomerActions : IServerActionsProvider
{
    public IReadOnlyCollection<string> FormActions =>
        ["SubmitCustomerRequest"];

    public IReadOnlyCollection<string> TriggerActions =>
        ["SetCustomerAuditFields"];

    public ValueTask<FormActionResult> ExecuteFormActionAsync(
        string action,
        FormActionContext context,
        CancellationToken cancellationToken = default)
    {
        // context.Form、context.Data、context.UserId、context.Options
        // 在这里调用客户服务并返回控件需要的数据和通知。
        return ValueTask.FromResult(new FormActionResult(context.Data));
    }

    public ValueTask<TriggerResult> ExecuteTriggerAsync(
        string action,
        EntityModel model,
        List<dynamic> entities,
        TriggerExecutionContext context,
        JsonElement? options,
        CancellationToken cancellationToken = default)
    {
        // 可在 BeforeInsert、BeforeUpdate 等 Trigger 中修改实体、
        // 返回 validation 或终止处理链。
        return ValueTask.FromResult(TriggerResult.Success());
    }
}

在模块中注册:

services.AddSingleton<IServerActionsProvider, CustomerActions>();

现有 `ServerActionRegistry` 会自动收集客户实现,不需要远程协议、API Key 或

Action 白名单。Action 名称必须在所有 Provider 中保持唯一,否则应用启动时会

拒绝建立含糊的 Action 映射。

7. 编写客户 API

客户可在 `MapEndpoints` 中直接使用 ASP.NET Core Minimal API:

public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
    var api = endpoints.MapGroup("/api/customer/orders")
        .RequireAuthorization("Administration");

    api.MapGet("/", QueryOrdersAsync);
    api.MapPost("/", SaveOrderAsync);
    api.MapDelete("/{id}", DeleteOrderAsync);
}

这些 API 可以:

  • 为 AsyncSelect、Tree、DataGrid 或自定义控件提供数据;
  • 接收表单或控件提交的数据;
  • 调用 FormPlatform ORM;
  • 调用客户自己的数据库、ERP、CRM 或其它服务;
  • 返回标准 JSON、文件或流。

客户 API不会自动获得授权规则。应根据用途显式调用:

.AllowAnonymous()

或:

.RequireAuthorization()
.RequireAuthorization("Administration")

不要因为 API 位于 `/api/customer` 下就假设它自然受到保护。

8. 客户端扩展入口

FormPlatform 在挂载 Vue app 之前读取:

GET /api/platform/client-extensions

然后依次加载 `FormPlatformExtensions:ClientModules` 中的 JavaScript。

标准 ES module 的默认导出应提供 `install(api)`:

export default {
  install(api) {
    // 在这里注册控件和客户端扩展。
  }
}

可用 API:

  • `api.Vue`:FormPlatform 正在使用的同一个 Vue 运行时;
  • `api.registerControl(definition)`:注册控件和 Designer 信息;
  • `api.extendActionApi(name, implementation)`:扩展客户端 Action API;
  • `api.extendRuntimeApi(callback)`:扩展每个 Form Runtime;
  • `api.registerMessages(owner, messages)`:注册 i18n 消息;
  • `api.registerRoute(route)`:增加 Vue Router 路由;
  • `api.configureApp(callback)`:在 mount 前配置 Vue app、Router 和 Pinia。

扩展文件通过下面的 URL读取:

/extension-assets/{模块目录名}/{client目录下的文件路径}

例如:

/extension-assets/Customer/index.js
/extension-assets/Customer/customer.css

服务器只会从 `Modules/Customer/client` 读取允许的 Web 文件类型,并阻止路径跳出

模块目录。

`ClientModules` 只列 JavaScript 入口,不会自动把单独的 CSS 文件加入页面。客户

构建可以把样式注入到生成的 JavaScript 中,或者由入口模块明确加载

`/extension-assets/Customer/customer.css`。不要假设把 CSS 复制到 `client` 目录后

它会自动生效。

9. 编写自定义控件

最小控件示例:

export default {
  install(api) {
    const { h } = api.Vue

    const CustomerPicker = {
      props: {
        value: { type: String, default: '' },
        designerMode: { type: Boolean, default: false },
        readOnly: { type: Boolean, default: false },
        validationClass: { type: String, default: '' }
      },
      emits: ['update:value'],
      setup(props, { emit }) {
        return () => h(
          'button',
          {
            type: 'button',
            disabled: props.readOnly,
            class: ['customer-picker', props.validationClass],
            onClick: () => emit('update:value', 'customer-1')
          },
          props.value || 'Choose customer')
      }
    }

    api.registerControl({
      type: 'customerPicker',
      component: CustomerPicker,
      group: 'controls',
      label: 'Customer Picker',
      icon: '◆',
      description: 'Selects a customer',
      dataComponent: true,
      create: () => ({
        props: {
          value: '',
          placeholder: 'Choose customer'
        }
      }),
      events: [
        { name: 'onChange', label: 'Value changed' }
      ]
    })
  }
}

主要设置:

属性作用
`type`保存到表单 JSON 中的唯一控件类型。不能覆盖平台现有类型。
`component`Vue component;也可以配合 `loader: true` 传入异步 loader。
`group`Designer 控件区分组,如 `controls`、`containers`、`collections`。
`label`、`icon`、`description`控件区展示信息。
`dataComponent`是否参与表单数据、Mapping、验证和提交。
`create`拖入设计区时建立默认组件 JSON 的函数。
`propertyEditor`可选的 General 属性编辑 Vue component。
`events`该控件在 Events tab 中支持的事件。

注册后,该控件会自动用于:

  • Form Viewer;
  • Preview;
  • Designer 设计区;
  • Designer 控件区;
  • 表单数据 runtime和验证系统(`dataComponent: true` 时)。

9.1 自定义 General 属性编辑器

`propertyEditor` 接收当前 `component`,通过 `update` 事件返回修改:

const CustomerPickerProperties = {
  props: {
    component: { type: Object, required: true }
  },
  emits: ['update'],
  setup(props, { emit }) {
    return () => h('input', {
      value: props.component.props?.placeholder || '',
      onInput: event => emit('update', {
        props: {
          ...props.component.props,
          placeholder: event.target.value
        }
      })
    })
  }
}

然后在 `registerControl` 中设置:

propertyEditor: CustomerPickerProperties

客户只需要实现 General tab;平台现有的 Style、Events、Tooltip 和 Other tab 会

继续出现。

10. 扩展 Action API

api.extendActionApi(
  'openCustomer',
  ({ context }, customerId) => {
    return context.api.navigate(`/customers/${customerId}`)
  })

注册后可从表单 Action Code 使用:

export default {
  async onOpenCustomer({ api, data }) {
    await api.openCustomer(data.customerId)
  }
}

扩展 Action API 不能覆盖现有 API 名称。这可以避免客户模块意外改变

`notify`、`fetch`、`navigate`、`setValue` 等平台行为。

11. 扩展 Form Runtime API

api.extendRuntimeApi(runtime => {
  runtime.customerValue = propertyName => {
    const component = runtime.components().find(
      item => item.other?.propertyName === propertyName)

    return component ? runtime.data[component.id] : undefined
  }
})

每次建立新的 Form Runtime 时都会执行这个 callback。活动表单准备完成后,

Action Code 可通过下面的方式调用:

const value = context.api.runtime?.customerValue('customerId')

不要直接替换 runtime 的核心方法。更稳妥的方式是增加有明确命名空间的方法,

例如:

runtime.customer = {
  getValue() {},
  refresh() {}
}

12. 扩展 i18n、Router 和 Vue app

注册多语言消息:

api.registerMessages('customer-module', {
  en: {
    'customer.title': 'Customers'
  },
  'zh-CN': {
    'customer.title': '客户'
  }
})

增加页面路由:

api.registerRoute({
  path: '/customer/reports',
  name: 'customer-reports',
  component: CustomerReports,
  meta: {
    requiresAuth: true,
    roles: ['Administrator']
  }
})

高级 Vue 初始化:

api.configureApp(({ app, router, pinia, api }) => {
  app.provide('customerApiBase', '/api/customer')
})

这些 callback 在 Vue mount 前执行。客户路由应继续使用系统已经采用的

`requiresAuth`、`roles` 等路由 metadata,服务器 API仍必须独立执行授权检查。

13. 使用 `.vue` SFC 开发控件

客户可以正常使用 `.vue` 文件开发,然后只交付构建后的 JavaScript 和 CSS。

为了避免网页中出现第二套 Vue runtime,客户构建应把 `vue` externalize 到

FormPlatform 暴露的 Vue 实例。

Vite/Rollup 示例:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/index.js',
      name: 'CustomerControls',
      formats: ['iife']
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'window.FormPlatform.Extensions.Vue'
        }
      }
    }
  }
})

IIFE 入口直接调用:

window.FormPlatform.Extensions.registerControl({
  type: 'customerPicker',
  component: CustomerPicker,
  // ...
})

这种自注册 bundle 可以没有 ES module `default export`。FormPlatform loader 支持:

  • `export default { install(api) {} }`;
  • 加载时通过 `window.FormPlatform.Extensions` 自行注册的 IIFE。

客户保留 `.vue` 源码和 Vite 工程,只把生成的 JS/CSS 复制到

`Modules/Customer/client`。

14. 安全与运维要求

服务器模块是完全可信代码,因此必须遵守:

1. 只允许部署管理员安装和更新 DLL;

2. 不提供通过网页上传服务器 DLL 的功能;

3. `Modules` 目录不应给予 FormPlatform 服务账号写权限;

4. 客户模块代码必须经过审核;

5. FormPlatform 服务账号和数据库账号采用最小权限;

6. 客户 API必须设置明确的授权;

7. 不在日志中写密码、Token、问卷答案等敏感数据;

8. 不在客户 JavaScript 中放秘密;

9. 更新平台 Runtime 前,应测试全部已安装客户模块;

10. 更新 DLL 后通过受控方式重启应用。

模块自己的秘密仍应使用:

  • Development User Secrets;
  • 环境变量;
  • systemd credentials;
  • IIS 外部秘密 JSON;
  • 项目现有的生产秘密文件加载机制。

不要把真实秘密放入:

  • 普通 `appsettings.json`;
  • `module.json`;
  • `client/*.js`;
  • Git 仓库。

15. 版本兼容策略

建议平台供应方发布明确版本的 SDK/runtime 组合:

FormPlatform Runtime 1.2.0
FormPlatform.Extension.Abstractions 1.2.0
Client Extension API 1.2.0

基本规则:

  • 增加兼容 API:提升 minor version;
  • 修复内部实现且不改变契约:提升 patch version;
  • 删除、重命名或改变公开 API:提升 major version;
  • 每个发布版本附带兼容性说明;
  • 客户模块构建时固定 SDK 版本;
  • 生产升级前运行客户模块的 API/contract tests。

16. 示例位置

完整示例位于:

samples/FormPlatform.Extension.Sample/

其中包括:

  • `SampleModule.cs`:配置、DI、ORM、API、Server Action 和 Trigger;
  • `module.json`:服务器模块入口;
  • `appsettings.sample.json`:客户模块配置;
  • `client/sample-control.js`:控件、属性编辑器、Action API 和 Runtime API。

测试示例时,把示例构建输出中的模块文件放到:

Modules/Sample/

并在部署应用的 `appsettings.json` 中加入:

{
  "FormPlatformExtensions": {
    "ModulesDirectory": "Modules",
    "ClientModules": [
      "/extension-assets/Sample/sample-control.js"
    ]
  }
}

服务器 DLL 安装完成后重启 .NET 应用,再刷新浏览器即可。客户控件是启动时动态

加载的,不需要重新构建 FormPlatform Vue 前端。只修改客户 JavaScript/CSS 时,

替换对应文件并刷新浏览器即可;修改服务器 DLL 时才需要重启服务器。单纯编辑

数据库中的普通表单结构也不需要重新编译客户模块。