FForm Platform
enzh-CN

Identity, roles and form access

Manage system identity, respondent identity and explicit form access rules without treating authorization as a client-side feature.

表单访问权限:设计与实现开发指南

本文面向刚接触 FormPlatform 的开发者,说明表单权限从数据库、管理表单、权限计算、HTTP 接口,到 Form Center 卡片和游标分页的完整链路。

本文讨论的是“系统用户访问表单”的 ACL(Access Control List)。受访者通过 Deployment 访问问卷调查的资格仍由 Survey 模块管理,不能用普通表单 ACL 替代。

1. 设计目标

这套权限系统需要同时解决五个问题:

1. 决定谁可以通过 Form Viewer 查看或运行表单。

2. 决定谁可以通过 Form Designer 修改表单。

3. 决定谁可以删除表单。

4. 决定管理员或 SurveyAssistant 是否可以代填问卷调查。

5. 决定哪些角色或系统用户可以建立新表单。

还必须满足以下安全和性能约束:

  • deny 的优先级高于普通 grant 和角色默认权限。
  • Administrator 必须始终可以恢复系统,不能被 ACL 锁在系统之外。
  • 客户端隐藏按钮只是界面行为,服务器端每一个敏感接口仍必须重新鉴权。
  • Form Center 不能先读取全部表单再分页;数据库应先产生有权限的候选表单,再使用游标读取当前页。
  • FormStorage 可以是 Database、File 或 Memory。只有表单库与管理库位于同一关系数据库时,Form Center 才能执行数据库快速查询;其他模式必须保持逻辑正确的兼容路径。

2. 核心术语

2.1 Subject

权限可以授予或拒绝两类主体:

  • `user`:一个系统用户,`subject_id` 是 `app_users.id`。
  • `role`:一个系统角色,`subject_id` 是 `app_roles.id`。

运行时用户 ID 来自 `ClaimTypes.NameIdentifier`,角色名来自 `ClaimTypes.Role`。

2.2 Permission

`app_form_access_rules.permission` 当前支持:

含义
`deny`明确拒绝。对非 Administrator 优先于 read/edit/delete 和角色默认值。
`read`允许通过 Viewer 读取和运行表单。
`edit`允许读取设计定义、metadata,并通过 Designer 修改表单。
`delete`允许请求删除表单;领域删除保护仍会继续执行。
`create`仅在全局“建立表单”作用域中使用,允许建立空白表单、从 Data Model 生成表单,或调用创建 API。

`assist` 没有存成 ACL permission。问卷代填由 Administrator 和 SurveyAssistant 的内置规则决定,代码入口是 `CanAssistSurveyAsync`。

`create` 不是某个具体表单的规则。它复用 `app_form_access_rules` 的索引和审计字段,使用固定全局 scope `form_id = 00000000-0000-0000-0000-000000000000`;该值不会作为真实表单 ID 使用。首次升级会写入一次 `create-configured` 系统标记和 FormDesigner 的 `create` grant,以保持旧版默认行为。管理员保存空角色/用户列表后,标记仍保留,因此只有 Administrator 能建立表单。

2.3 显式规则和默认规则

  • 显式规则:保存在 `app_form_access_rules` 中。
  • 默认规则:由平台角色和表单类型在代码中计算,不需要为每张表单插入重复记录。

这样即使有几万个表单,也不需要为 FormDesigner 自动生成几万条 edit 记录。

3. 有效权限规则

3.1 默认权限矩阵

主体/情况ReadEditDeleteAssist survey
Administrator
FormDesigner + 非匿名 System/Functional
FormDesigner + Survey
FormDesigner + 匿名表单
SurveyAssistant + Survey
普通已登录用户由显式规则决定由显式规则决定由显式规则决定
未登录用户 + 匿名表单
Respondent由 Deployment/List/时间范围决定

“默认否”不表示永远不能授权。例如可以给一个普通角色显式授予 edit;但 SurveyAssistant 的默认角色本身不会自动获得 edit。

3.2 优先级

普通系统用户的计算顺序是:

Administrator?
  └─ 是:直接允许

Read 且表单匿名?
  └─ 是:直接允许

没有登录?
  └─ 是:拒绝

存在匹配 user/role deny?
  └─ 是:拒绝

存在对应显式 grant?
  └─ 是:允许

命中内置角色默认规则?
  └─ 是:允许

否则拒绝

重要细节:

  • Administrator 判断发生在 deny 之前,因此 Administrator 用户上的 deny 也不能锁死管理员。
  • 保存 ACL 时明确禁止把 Administrator 角色放入 deny。
  • 匿名 read 是表单的公共属性,不允许为匿名表单配置 deny/read。
  • 匿名表单仍可以配置 edit/delete,因为这两个操作要求已登录身份。

3.3 五个判定方法

代码位于 `Data/FormAccessControl.cs`:

CanReadAsync(formId, metadata, principal, ct)
CanEditAsync(formId, metadata, principal, ct)
CanDeleteAsync(formId, principal, ct)
CanAssistSurveyAsync(formId, metadata, principal, ct)
CanCreateAsync(principal, ct)

不要在 Endpoint、Vue 或 Action Code 中重新复制角色判断。新的服务器入口必须调用这些方法之一。

`EvaluateAsync` 是批量版本,返回:

FormEffectiveAccess(
    bool CanRead,
    bool CanEdit,
    bool CanDelete)

它用于 File/Memory/分库模式下的 Form Center 兼容查询。关系数据库同库模式使用等价的数据库端计算。

4. 数据库结构

4.1 ACL 表

表名:`app_form_access_rules`

用途
`id`规则 ID。
`form_id`表单 ID。
`subject_type``user` 或 `role`。
`subject_id`用户或角色 ID。
`permission``deny/read/edit/delete`。
`created_at`创建时间。
`created_by`执行配置的管理员 ID。

唯一约束:

(form_id, subject_type, subject_id, permission)

主要索引:

ix_app_form_access_rules_form(form_id)
ix_app_form_access_rules_subject(subject_type, subject_id, permission, form_id)

第二个索引用于快速找到当前用户和角色相关的规则。表结构和索引由 `Data/ManagementDatabaseInitializer.cs` 管理。

ACL 表没有强制引用 `form_definitions` 的外键。这是有意设计:FormStorage 可能是 File、Memory,或位于另一数据库。删除表单时由应用服务显式清理 ACL。

4.2 可索引的 metadata 投影

`FormMetadata` 的完整内容仍保存在 `form_metadata.metadata_json`,但 Form Center 高频过滤需要两个普通数据库列:

form_definitions.form_type
form_definitions.is_anonymous

实现位于 `Data/RelationalFormStore.cs`。

保存 metadata 时,同一个数据库事务执行:

UPDATE form_definitions
  SET form_type = ..., is_anonymous = ...

UPSERT form_metadata.metadata_json

COMMIT

如果表单不存在,事务回滚并返回 `KeyNotFoundException`。因此 JSON 和索引列不会出现一边成功、一边失败的状态。

旧数据通过一次性迁移 `normalize-form-metadata-v1` 回填。迁移记录保存在:

form_store_migrations

完成后,后续启动不会再次扫描所有 metadata。

Form Center 使用以下索引:

ix_form_definitions_updated_id(updated_at DESC, id DESC)
ix_form_definitions_access_cursor(is_anonymous, updated_at DESC, id DESC)
ix_form_definitions_type_cursor(form_type, updated_at DESC, id DESC)

5. 管理员配置权限的链路

5.1 管理表单

系统表单:

Name: System_Form_Access
ID:   f0000000-0000-0000-0000-000000000064

定义位于 `Data/FormAccessSystemForms.cs`。它本身是普通 FormPlatform 表单,使用:

  • AsyncSelect 选择表单;
  • AsyncSelect 多选角色;
  • AsyncSelect 多选用户;
  • 独立的“建立表单”角色/用户 AsyncSelect 与保存按钮;
  • Action Code 加载和保存 ACL。

它只向 Administrator 显示,并且后端 `/api/admin/form-access` 分组要求 `Administration` policy。前端隐藏菜单不能替代这项服务器授权。

5.2 管理 API

方法路由用途
GET`/api/admin/form-access/forms/options`搜索选择表单。
GET`/api/admin/form-access/create`读取全局建立表单角色/用户授权。
PUT`/api/admin/form-access/create`替换全局建立表单角色/用户授权。
GET`/api/admin/form-access/{formId}`读取该表单的显式规则。
PUT`/api/admin/form-access/{formId}`替换该表单的显式规则。
GET`/api/admin/system-access/roles/options`AsyncSelect 读取角色。
GET`/api/admin/system-access/users/options`AsyncSelect 读取用户。

表单选项接口是管理员低频配置接口,目前仍使用内存过滤和 OFFSET。不要把它复用为大型公共表单目录;公开 Form Center 使用专门的数据库游标查询。

普通已登录用户可调用 `GET /api/forms/create-access` 获得自己的 `canCreate` 标志,仅用于显示 Form Center 的“新建”和“从 Data Model 生成”按钮;服务器创建接口仍会再次调用 `CanCreateAsync`。

5.3 保存事务

`FormAccessControlService.SaveAsync` 的流程:

flowchart TD
    A["接收 role/user ID 数组"] --> B["清理空值并去重"]
    B --> C["验证表单存在"]
    C --> D["读取 FormMetadata"]
    D --> E{"是否匿名表单"}
    E -->|是| F["拒绝 deny/read 配置"]
    E -->|否| G["继续"]
    G --> H["验证所有 role/user ID 存在"]
    H --> I["禁止 deny Administrator role"]
    I --> J["开启 Unit of Work"]
    J --> K["删除该 form 的旧规则"]
    K --> L["插入新的完整规则集合"]
    L --> M["Commit"]
    M --> N["重新读取并返回保存结果"]

这里采用“整组替换”而不是逐项 PATCH,原因是管理表单提交的是一个完整权限快照。删除旧规则和插入新规则在同一事务中完成。

5.4 匿名状态改变

`PUT /api/forms/{id}/metadata` 保存后,如果 `IsAnonymous = true`,会调用:

ClearAnonymousReadRulesAsync(formId, ct)

它删除旧的 deny/read 规则,避免表单从非匿名改成匿名后遗留互相矛盾的数据。edit/delete 不会被清理。

6. 单个表单请求的服务器鉴权

6.1 Viewer 路径

典型入口:

GET /api/forms/{id}/view
GET /api/forms/{id}/action-module.js
GET /api/forms/{id}/grids/{gridId}/data
GET /api/forms/{id}/item-renderers/{rendererId}/data
POST /api/forms/{id}/async-select/{controlId}/data
POST /api/forms/{id}/submissions

这些接口按用途调用 `CanReadAsync`。允许匿名的入口仍会在代码中区分:

  • 未登录且不允许:通常返回 `401 Unauthorized`;
  • 已登录但无权限:返回 `403 Forbidden`;
  • Survey 表单通过普通 `/forms/{id}` 访问:按 Survey 路由规则返回 `404`。

数据接口和 Action Module 也必须鉴权。只保护最初的 `/view` 而放开后续数据接口,会形成直接 API 绕过。

6.2 Designer 路径

典型入口:

GET /api/forms/{id}
PUT /api/forms/{id}
GET /api/forms/{id}/metadata
PUT /api/forms/{id}/metadata
DELETE /api/forms/{id}/metadata/mapping
GET /api/forms/{id}/data

它们调用 `CanEditAsync`。这解释了一个重要区别:

  • `/api/forms/{id}/view` 是运行时读取接口;
  • `/api/forms/{id}` 是设计定义接口。

只有 read 的用户不能为了方便而调用设计接口,否则会收到 403。

6.3 Delete 路径

DELETE /api/forms/{id}

删除过程不是只有 ACL:

flowchart LR
    A["请求删除"] --> B["CanDeleteAsync"]
    B -->|拒绝| C["403"]
    B -->|允许| D["检查系统保护表单"]
    D --> E["检查 Survey 部署/锁定等领域约束"]
    E --> F["删除表单存储"]
    F --> G["DeleteAllAsync 清理 ACL"]

因此 delete grant 只表示“有资格请求删除”,不能绕过:

  • System shell/management protected forms;
  • 已部署或被锁定的调查表单;
  • 其他领域完整性规则。

旧的 system-grid 删除入口也会执行同样的 ACL 和清理,避免从历史 API 绕过。

6.4 SurveyAssistant 代填

代填入口通过 `CanAssistSurveyAsync`:

  • Administrator 始终允许;
  • 必须是 Survey 表单;
  • SurveyAssistant 必须登录;
  • 匹配 deny 时拒绝。

普通 read grant 不自动等于“可以代填”。这是有意分离的业务能力。

6.5 Respondent 边界

Respondent 使用独立认证 scheme、Deployment、Survey List、开始/结束时间和 response 规则。Respondent 不是普通系统角色,不应通过给它写 `app_form_access_rules` 来开放问卷。

管理员代填和受访者作答最终可以进入同一份调查数据,但授权入口不同。

7. Form Center:从权限到卡片

Form Center 是权限系统中最需要关注性能的列表。

完整请求链:

sequenceDiagram
    participant UI as FormItemRenderer
    participant API as /api/forms/.../item-renderers/.../data
    participant ACL as FormAccessControlService
    participant FC as FormCenterQueryService
    participant DB as RelationalFormStore / Database

    UI->>API: limit, search, cursor
    API->>ACL: CanRead(Form_Center)
    ACL-->>API: allow / deny
    API->>FC: principal + query
    FC->>FC: validate cursor and detect same database
    FC->>DB: candidate IDs + effective ACL + seek + limit+1
    DB-->>FC: at most pageSize+1 rows
    FC->>FC: hasMore and nextCursor
    FC-->>UI: items + canRead/canEdit/canDelete
    UI->>UI: show Open/Edit/Delete buttons

注意第一项 ACL 检查:用户必须先有 Form Center 系统表单本身的 read 权限。之后数据库查询才决定这个用户能看到哪些业务表单卡片。

7.1 同库快速路径判定

`FormCenterQueryService` 比较 FormStorage 和 ManagementDatabase 的:

  • provider;
  • server/host;
  • port;
  • database;
  • schema。

账号和密码可以不同,只要最终指向同一数据库和 schema。

同库并且底层是关系存储时,日志出现:

Form Center uses database-side ACL candidate filtering and cursor pagination.

日志在该 Singleton 第一次被请求创建时写入,不一定出现在应用启动阶段。

如果不是同库,则使用兼容路径:

Form Center uses the compatible in-memory ACL pagination path ...

兼容路径保证权限正确,但会读取全部摘要和 metadata,不适合几万表单的高并发目录。

7.2 候选表单 ID

非 Administrator 的数据库查询先构造候选 ID:

匿名表单
UNION
当前 user/role 的显式 read/edit/delete grant
UNION
内置角色默认范围

内置角色范围:

  • FormDesigner:所有表单都是 edit 候选,因此直接从完整 catalog 开始,避免冗余 UNION。
  • SurveyAssistant:`form_type = 'Survey'`。

然后聚合当前主体匹配的规则:

denied
can_read_grant
can_edit_grant
can_delete_grant

最后计算有效权限并执行:

WHERE can_read = 1
   OR can_edit = 1
   OR can_delete = 1

没有任何权限的表单不会发送给浏览器。这样既避免信息泄露,也保证每次加载得到完整的授权页面。

Administrator 使用独立快速分支,直接读取有序 `form_definitions`,不连接 ACL 表。

7.3 卡片按钮

数据库为每条卡片返回:

{
  "canRead": true,
  "canEdit": false,
  "canDelete": false
}

`Data/FormCenterSystemForms.cs` 中三个按钮的条件是:

fc-open:   row.canRead === true
fc-edit:   row.canEdit === true
fc-delete: row.canDelete === true

不要再写:

context.user.roles.includes('FormDesigner')

因为它无法表达显式 user grant、role grant、deny、匿名规则和 Administrator 恢复规则。

再次强调:按钮条件不是安全检查。即使用户用开发者工具强制显示按钮,目标 API 仍会调用 `CanReadAsync`、`CanEditAsync` 或 `CanDeleteAsync`。

8. 游标分页

排序固定为:

ORDER BY updated_at DESC, id DESC

游标保存上一页最后显示记录的:

{
  "UpdatedAt": "2026-08-08T10:30:00Z",
  "Id": "..."
}

对外将 JSON 编码成 Base64Url 字符串。客户端不应解析或构造它,只需要原样回传。

下一页条件:

WHERE updated_at < @cursorUpdatedAt
   OR (
        updated_at = @cursorUpdatedAt
        AND id < @cursorId
      )

`id` 是相同时间下的唯一稳定排序键。只使用 `updated_at` 会漏掉更新时间相同的记录。

每次查询 `pageSize + 1`:

  • 多出的第 1 条不显示,只说明 `hasMore = true`;
  • 当前显示页最后一条用于生成 `nextCursor`;
  • 不执行完整 `COUNT(*)`;
  • `total = -1` 表示此游标结果不提供精确总数。

响应示例:

{
  "items": [],
  "total": -1,
  "limit": 24,
  "nextCursor": "eyJVcGRhdGVkQXQiOi4uLn0",
  "hasMore": true
}

参照记录被删除不会破坏下一页,因为查询使用游标中的排序值,不会先按 ID 重新查找参照记录。

客户端实现位于 `ClientApp/src/components/FormItemRenderer.vue`:

  • 首次查询不带 cursor;
  • Load More 发送上次 `nextCursor`;
  • `hasMore` 优先使用服务器布尔值;
  • 搜索、pageSize 或数据源改变时重新从第一页开始。

9. 受保护的平台角色

平台角色:

Administrator
FormDesigner
SurveyAssistant

由 `ManagementIdentityStore` 幂等初始化。`SystemAccessManagementService` 阻止:

  • 删除这些角色;
  • 重命名这些角色。

原因是默认权限逻辑使用稳定的角色名。如果允许删除或改名,代码默认规则会失效。

新增平台角色时必须同时检查:

1. `FormAccessNames`;

2. 角色初始化;

3. 角色保护;

4. 单表单 ACL 计算;

5. Form Center 数据库查询;

6. 管理表单说明和 i18n;

7. 本文默认矩阵。

10. 新增服务器功能时怎样接入权限

10.1 先确定能力

不要根据路由名称猜权限。先回答:

  • 这是运行表单所需的数据吗?使用 read。
  • 这是 Designer、schema、metadata 或批量数据管理吗?使用 edit。
  • 这是删除表单吗?使用 delete,并继续领域检查。
  • 这是管理员代填调查吗?使用 assist。
  • 这是 Respondent 作答吗?使用 Survey Deployment 授权链。

10.2 Endpoint 模板

Viewer 数据接口示例:

var form = await store.GetAsync(id, ct);
if (form is null) return Results.NotFound();

var metadata = await store.GetMetadataAsync(id, ct)
    ?? FormMetadata.Default(id);

if (!await access.CanReadAsync(id, metadata, principal, ct))
    return principal.Identity?.IsAuthenticated == true
        ? Results.Forbid()
        : Results.Unauthorized();

// 只有通过鉴权后才能读取业务数据。

Designer 接口将 `CanReadAsync` 换成 `CanEditAsync`。

10.3 不要采用的写法

// 错误:绕过显式 grant/deny 和匿名规则
if (!principal.IsInRole("FormDesigner")) return Results.Forbid();
// 错误:客户端角色判断不能保护服务器资源
const allowed = context.user.roles.includes('Administrator');
// 错误:只保护 view,却忘了 action-module、grid、async-select 等子资源
return Results.Ok(await repository.QueryAsync(...));

11. 修改权限模型时的检查清单

如果增加新的 permission,例如 `publish`,至少需要修改:

1. `FormAccessSelection` 和 `SaveFormAccessRequest`。

2. `FormAccessControlService.GetAsync/SaveAsync/Rules`。

3. 单项有效权限方法。

4. `EvaluateAsync` 批量语义。

5. `FormCenterStoreQuery/FormCenterStoreRow`(如果影响卡片)。

6. `RelationalFormStore.QueryFormCenterAsync` 中的规则聚合、候选集和 CASE。

7. `System_Form_Access` 的组件与 Action Code 字段数组。

8. 管理 API DTO。

9. i18n 文本。

10. 所有需要该能力的服务器 Endpoint。

11. 索引是否仍覆盖新的查询模式。

12. 单元/集成测试矩阵和本文档。

最重要的原则是:单表单判定和数据库批量判定必须保持同一语义。只修改其中一个,会导致“卡片显示但接口 403”或“卡片消失但直接 URL 可以访问”。

12. 建议验证矩阵

至少建立以下测试用户:

  • Administrator;
  • FormDesigner;
  • SurveyAssistant;
  • 普通用户 A;
  • 普通用户 B;
  • Respondent。

对 System、Functional、Survey,以及匿名/非匿名组合验证:

场景Form CenterOpenEditDelete直接 API
无任何权限不显示拒绝拒绝拒绝401/403
只有 read显示显示隐藏隐藏Viewer 允许,Designer 拒绝
只有 edit显示隐藏显示隐藏Designer 允许,Viewer 按规则拒绝
只有 delete显示隐藏隐藏显示删除仍受领域保护
grant + deny不显示拒绝拒绝拒绝403
Administrator + deny显示显示显示显示允许
匿名、未登录Form Center 通常不可进入Viewer 允许拒绝拒绝公共子资源按 read 开放

还应验证:

  • role grant 与 user grant 分别生效;
  • 用户同时属于多个角色时,只要任一匹配 deny 就拒绝;
  • metadata 从非匿名改为匿名后 deny/read 被清理;
  • 删除表单后 ACL 被清理;
  • Load More 不重复、不漏记录;
  • 游标参照记录删除后仍能继续;
  • 搜索改变后 cursor 被重置;
  • 同库日志显示数据库快速路径;
  • File/Memory/分库日志显示兼容路径。

13. 常见排错

13.1 Form Center 返回 403

先检查用户是否对 Form Center 系统表单本身有 read:

f0000000-0000-0000-0000-000000000060

不要调用 `GET /api/forms/{id}` 来加载 Form Center Viewer;该接口需要 edit。运行时页面应调用:

GET /api/forms/{id}/view

13.2 卡片按钮与直接 API 不一致

检查:

1. `FormAccessControlService` 的单项规则;

2. `EvaluateAsync`;

3. `RelationalFormStore.QueryFormCenterAsync` 的 CASE;

4. 卡片是否使用 `row.canRead/canEdit/canDelete`;

5. metadata 投影列是否已同步。

13.3 没看到快速路径日志

日志只在 `FormCenterQueryService` 第一次实例化时写一次,通常发生在首次请求 ItemRenderer 时。查看 `Logs/formplatform-YYYYMMDD.log`,搜索:

Form Center uses database-side ACL candidate filtering and cursor pagination.

13.4 出现兼容回退日志

核对 `appsettings.json`:

  • FormStorage 和 ManagementDatabase provider 是否相同;
  • host、port、database、schema 是否相同;
  • FormStorage 是否为 File/Memory。

不同账号不是问题,只要连接到同一个数据库/schema。

13.5 SQL 里仍看到单表单 ACL 查询

进入 Form Center 前,服务器必须先验证当前用户能否读取 Form Center 本身。日志中的 `SystemAccessRole` 和只含 Form Center ID 的 `SystemFormAccessRule` 查询属于入口鉴权,不代表卡片查询退回了全量模式。

14. 主要代码导航

文件职责
`Data/FormAccessControl.cs`ACL DTO、单表单/批量有效权限、保存、清理。
`Data/FormAccessSystemForms.cs`Form Access 管理表单和管理员菜单入口。
`Data/PlatformMigrationModule.cs`、`Data/ManagementSchema.cs`ACL 表和索引的版本化 migration。
`Data/SystemAccessManagementService.cs`ACL EntityModel、用户/角色管理、平台角色保护。
`Data/ManagementIdentityStore.cs`受保护角色的幂等初始化。
`Data/RelationalFormStore.cs`metadata 投影列同步、候选 ID、有效权限 SQL、游标查询。
`Data/IFormStore.cs``IFormCenterPageStore` 可选快速路径契约。
`Data/PublishingFormStore.cs`将快速查询转发到内部关系存储。
`Data/FormCenterQueryService.cs`同库检测、游标编解码、`pageSize + 1`、兼容回退。
`Data/FormCenterSystemForms.cs`卡片结构和三个权限按钮条件。
`ClientApp/src/components/FormItemRenderer.vue`保存/回传 cursor,处理 `hasMore`。
`Program.cs`管理 API、Viewer/Designer/Delete/Survey 等服务器鉴权入口。

15. 设计原则总结

新开发者只需要牢牢记住以下原则:

1. 权限在服务器判定,Vue 只负责呈现。

2. deny 优先;Administrator 是不可锁死的恢复主体。

3. read、edit、delete、assist 是不同能力,不要互相推断。

4. Respondent Deployment 授权与系统用户 ACL 是两条不同链路。

5. 单表单判定和列表批量判定必须语义一致。

6. 大列表必须先在数据库过滤权限,再搜索、排序和分页。

7. Load More 使用 `(updated_at,id)` 游标和 `pageSize + 1`,不需要精确 COUNT。

8. delete grant 后仍必须执行系统表单、Deployment 和领域完整性保护。

9. metadata JSON 是完整配置;`form_type/is_anonymous` 是为查询建立的事务同步投影。

10. 新增任何权限能力时,要沿“数据库 → 管理表单 → ACL 服务 → 批量查询 → Endpoint → 客户端呈现 → 测试”整条链路修改。