后端 Adapter 接入合同

UG Admin 不捆绑后端。接入方通过认证、数据和操作权限三个 Provider 适配已有服务,不让页面直接依赖某个后端 DTO,也不为接入而复制 UG UI 组件。基础用户/部门/角色模型见 基础管理合同。

先确定接口写在哪里

下面路径以创建器生成的独立项目为准;模板仓库中的 src 对应 apps/react/src 或 apps/vue/src,shared 对应 packages/admin-core。后端代码仍在你自己的服务项目中,不放进这些前端目录。

内容 位置 维护方式
服务地址、认证/数据模式 .env.development.local、.env.production.local 只保存公开地址;开发代理配置见 部署指南
项目统一认证与数据适配 src/backend/index.ts 已按环境装配 HTTP Auth/Data,修改这里的配置;复杂时按职责拆 auth.ts、data.ts
登录 DTO、token 流程、会话转换 src/backend/auth.ts(需要时创建) 实现或配置 Auth Provider,由 index.ts 导出 auth
全项目响应包裹、分页参数、业务码 src/backend/data.ts(需要时创建) 配置 HTTP Data Provider,由 index.ts 导出 data 工厂,并保留 onUnauthorized
订单等业务模型、请求及动作 src/modules/orders/api.ts 页面调用这个文件;常见 CRUD 用 Provider,特有动作封装在模块 API
业务详情/表单和查询缓存 src/modules/orders/ 对应页面或业务 Hook/composable 使用模块模型,传取消信号;写成功失效该资源查询
菜单、页面路径、权限码目录 src/modules/orders/index.ts 自动收集,不修改 app/router、app/modules 或共享路由表
后端 HTTP handler、数据库与授权 你的 Go/Java/Python 等服务仓库 对应路由/controller/handler/service,按后端自己的结构放置

后端 DTO 和业务模型不一定相同。全项目共同差异在 backend 处理;只属于订单的字段映射可留在 orders/api.ts。一个转换只做一次,不能同时在页面和 Provider 中重复转换。新增业务不改 shared/data 合同、不在 UG UI 中增加业务接口。

给后端开发者的最小接入顺序

  1. 先实现登录、会话恢复和退出,返回下面的 AdminSession;401 表示未登录/会话失效,403 表示已识别身份但无权操作。
  2. 选择一个业务资源,例如 orders,按“最小 CRUD 协议”实现列表、单条、新增、更新、删除;为测试账户授予对应权限。菜单声明不能给账户授予权限。
  3. 需要内置用户/部门/角色/字典时,再按 管理合同、字典合同 实现这些资源及选项接口;不要为了打开订单页伪造全部管理资源。
  4. 已有服务保持自己的 URL 和 DTO,用 Adapter 转换。下面默认协议是可以直接实现的基线,不是所有后端必须重构遵从的规范。

新项目没有随附生产后端;演示账户和 Mock 不会替你创建数据库、业务表或真实账户。Go、Java、Python、Rust、Node、Next.js、PHP 的实现语言不改变此边界。

最小 CRUD 协议

以 bun run scaffold:resource --framework react --name orders --label 订单 --crud 生成的起步模块为例,初始业务字段只有 name。ID 由服务端分配,建议用字符串(特别是大整数);记录名称不作为主键。创建/编辑时 name 为去掉首尾空白后的 1–100 个字符,服务端再次校验。

操作 默认 HTTP(API 根为 /api) 请求 成功响应 权限码
列表 GET /api/orders?page=1&pageSize=20&search=样例 可选 sort/order、JSON 字符串 filters 200,下面的列表 JSON orders:list
详情 GET /api/orders/123 无 body 200,单条 JSON orders:read
新增 POST /api/orders {"name":"样例订单"} 201 或 200,含分配 ID 的单条 JSON orders:create
修改 PATCH /api/orders/123 {"name":"新名称"} 200,单条 JSON;或 204 无 body orders:update
删除 DELETE /api/orders/123 无 body 204 无 body;或 200,单条 JSON orders:delete

列表 JSON:

{ "data": [{ "id": "123", "name": "样例订单" }], "total": 1, "page": 1, "pageSize": 20 }

单条 JSON:

{ "data": { "id": "123", "name": "样例订单" } }

列表页码从 1 开始,total 是服务端授权、搜索和筛选之后的总数;空列表为 data:[]、total:0。服务端先筛选排序再分页,限制 pageSize 并白名单校验排序字段。生成的基础模块只启用名称查询和分页,不冒充已经接好所有业务字段、排序和筛选。

PATCH 未传字段保持原值,null 只有合同明确允许时才代表清空;不要把 PATCH 当作完整覆盖。生成编辑页先读取详情,因此前端入口要求 read + update,服务端仍独立执行更新授权和记录范围检查。对存在并发覆盖风险的业务,模块追加版本号/ETag,冲突返回 409/412,不让模板臆造全局锁。

错误使用对应 HTTP 状态及 JSON,例如 422:

{ "message": "请检查输入内容", "fieldErrors": { "name": ["名称已被使用"] } }

统一处理:401 清会话和缓存;403 显示拒绝;404 显示不存在;409 显示业务冲突;422 将 fieldErrors 映射到规范字段;429 可带 Retry-After;5xx 保留失败/重试反馈。错误消息应适合最终用户,不返回堆栈或数据库细节。GET 可按查询层策略重试,新增/更新/删除不自动重试;请求超时不代表服务端未写入,重要业务应使用服务端幂等键及结果查询。

如果后端统一返回 HTTP 200 + code,Adapter 必须映射失败,不能把失败包装当记录返回。字段改名时 fieldErrors 也要从后端字段名映射到前端字段名。需要批量、用户选项或字典时,继续阅读下文资源合同,不用临时自造不同格式。

适配器合规自检

接入方实现完 AdminDataProvider 后,不必等模板作者人工复核:跑一遍内置合规用例即可自证契约。

import {
  assertConformance,
  runAdminDataProviderConformance,
} from "@ug666/admin-core/data/conformance";
import { createMyProvider } from "./backend/adapter";

const report = await runAdminDataProviderConformance(() => createMyProvider(), {
  resource: "users", // 被测资源
  writable: true, // 只读后端传 false,跳过写操作用例
});
assertConformance(report, "my-backend"); // 有失败项时抛错并打印完整报告

用例覆盖:列表分页结构、pageSize 上限、未知记录抛 not_found、创建往返、更新字段、删除后不可读。用例只依赖公开类型,不假设路由风格、错误文案或后端技术栈;失败报告包含用例名与原因,便于直接贴进 issue 或 CI 日志。

后端无关的边界

Go、Java、Python、Rust、Next.js、Node、PHP 等服务均可接入,选择依据是实际 API 协议,不是后端语言。新增后端只修改消费项目的 src/backend/,品牌和启动装配在 src/project.ts;业务代码在 src/modules/。后端负责认证、最终授权、数据范围、持久化和业务事务,前端适配层负责协议转换。

接入情况 实现位置与方法
后端接受本文默认 HTTP 协议 配置公开 API 地址及 http 模式,无须新建适配器
路径、GET/POST/PUT/PATCH、查询或提交字段不同 createHttpDataProvider 的 rest.mapRequest(context, request)
返回包裹层、ID、业务字段或状态编码不同 rest.responseMappers;根据 context.resource/operation 分流并验证 DTO
HTTP 200 内含业务失败码,或字段错误名不同 mapError 返回 AdminDataError;统一 401 清理及页面错误展示仍生效
登录字段、端点、会话结构不同 HTTP Auth 的 endpoints、mapCredentials、mapSession、mapError
token 登录后再获取身份权限、多服务或其他传输 在 backend 中实现对应 Provider;复用现有 HTTP 客户端,不把流程写进页面

请求映射接收未经 HTTP 序列化的规范参数,可同时转换查询、方法与 body;未处理的操作返回默认 request。响应映射必须补齐前端的规范记录,不能只把 rows 改名为 data 而忽略 userId/id、0/1 状态和权限码。custom 的 path 是前端约定的操作标识,也可映射,例如 users/options 不要求后端必须有同名 URL。所有数据请求保留原始取消信号。

参考 RuoYi 的分页形态可以将 page/pageSize 转为 pageNum/pageSize、rows/total 转为 data/total;这只是协议转换示例,不代表原生 RuoYi 的登录、菜单、全部 CRUD 与 UG 管理模型已完成对接。也不要把各技术后端的全部字段都塞进共享模型。

选择模式与注入时机

在所选应用 .env.development.local 设置公开运行配置:

VITE_AUTH_PROVIDER=http
VITE_DATA_PROVIDER=http
VITE_ADMIN_API_URL=/api
VITE_API_BASE_URL=/api

认证和数据模式独立。生产默认 http/http,显式 demo/mock 会被拒绝;不要把带演示覆盖的 .env.development.local 用于生产构建。/api 必须由同源服务、开发代理或部署代理实际承接,Vite 不会自动找到后端。

各端公开接入入口:

能力 入口 合同
认证 @/app/providers/auth 的 configureAdminAuthProvider(provider, demoCredentials?) AdminAuthProvider,真实接入不传演示凭据
数据 @/app/providers/data 的 configureAdminDataProvider(providerOrFactory) AdminDataProvider 或同步工厂
操作权限 @/app/providers/access 的 configureAdminAccessControlProvider(provider) AdminAccessControlProvider

先配置品牌,再在应用挂载及认证初始化前注入。自定义 Auth Provider 不会被启动时的演示分支覆盖。React 的 AdminDataProviderRoot/AdminAccessControlRoot 也接受 provider;Vue 可在 installAdminDataLayer(app, { provider }) 注入数据实现。不要在页面加载后更换模块级提供者。

标准同源 HTTP 接入示例放在目标应用 src/backend/index.ts。已有 project.ts 的 setupProject() 自动注入,启动入口会在初始化认证/挂载应用之前调用它:

import { createHttpAuthProvider } from "@ug666/admin-core/auth/http-auth-provider";
import { createHttpDataProvider } from "@ug666/admin-core/data/rest-data-provider";
import type { AdminBackendAdapter } from "@ug666/admin-core/config/app-config";

export const backend: AdminBackendAdapter = {
  auth: createHttpAuthProvider({
    baseUrl: import.meta.env.VITE_ADMIN_API_URL || "/api",
    getStorage: () => window.localStorage,
    credentials: "include",
  }),
  data: ({ onUnauthorized }) =>
    createHttpDataProvider({
      baseUrl: import.meta.env.VITE_API_BASE_URL || "/api",
      credentials: "include",
      onUnauthorized,
    }),
};

这与默认 HTTP 行为一致;需要改端点/格式时才扩展对应配置,不必仅为选择 HTTP 重写初始化。工厂上下文定义在 packages/admin-core/src/config/app-config.ts:getIdentity() 返回当前身份或 undefined,onUnauthorized() 清会话与缓存,onIdentityChange(identity | null) 同步最新身份;revalidateSession() 向 Auth Provider 求证,onSessionInvalidated() 清理复核失败的会话。直接传入 Provider 实例时也必须接好请求 401 的失效回调。

system 模块通过 decorateData 登记写后身份复核,通用数据层不识别 users/roles。默认 HTTP、configureAdminDataProvider 的实例/工厂以及 React Root/Vue installer 的显式注入都会自动接入下述写后复核;示例工厂不用自行重复包装。

已有后端的完整包裹层示例

下面是可放进 src/backend/index.ts 的替代实现,仅在选择 http 模式时启用。假设已有服务使用 Cookie 会话、/v1 前缀、account 登录字段,以及统一响应 { code: 0, result: ... };result 内仍使用本文规范的记录和分页字段。非零 code 为业务错误,错误 message 和 fieldErrors 放在顶层。它不是 RuoYi 完整适配器,也不需要后端按语言安装某个前端 SDK。

import { AdminAuthError, isAdminSession } from "@ug666/admin-core/auth/auth";
import { createHttpAuthProvider } from "@ug666/admin-core/auth/http-auth-provider";
import {
  type AdminBackendAdapter,
  resolveAdminAuthMode,
  resolveAdminDataMode,
} from "@ug666/admin-core/config/app-config";
import {
  AdminDataError,
  type AdminDataErrorCode,
  type AdminFieldErrors,
} from "@ug666/admin-core/data/data-provider";
import { createHttpDataProvider } from "@ug666/admin-core/data/rest-data-provider";

function envelope(value: unknown) {
  return value && typeof value === "object"
    ? (value as {
        code?: number;
        result?: unknown;
        message?: string;
        fieldErrors?: AdminFieldErrors;
      })
    : undefined;
}
export const backend: AdminBackendAdapter = {};
if (resolveAdminAuthMode(import.meta.env.VITE_AUTH_PROVIDER, import.meta.env.PROD) === "http") {
  backend.auth = createHttpAuthProvider({
    baseUrl: import.meta.env.VITE_ADMIN_API_URL || "/api",
    credentials: "include",
    getStorage: () => window.localStorage,
    endpoints: {
      login: "/v1/login",
      session: "/v1/me",
      logout: "/v1/logout",
      refresh: "/v1/refresh",
      callback: "/v1/callback",
    },
    mapCredentials: ({ username, password }) => ({ account: username, password }),
    mapSession(payload) {
      const value = envelope(payload)?.result;
      if (!isAdminSession(value)) throw new AdminAuthError("invalid_session", "认证响应格式不正确");
      return value;
    },
    mapError(payload, response) {
      const value = envelope(payload);
      if (value?.code !== undefined && value.code !== 0) {
        const status =
          value.code === 401 || value.code === 403
            ? value.code
            : response.ok
              ? 400
              : response.status;
        return new AdminAuthError("invalid_session", value.message || "认证失败", status);
      }
    },
  });
}
if (resolveAdminDataMode(import.meta.env.VITE_DATA_PROVIDER, import.meta.env.PROD) === "http") {
  backend.data = ({ onUnauthorized }) =>
    createHttpDataProvider({
      baseUrl: import.meta.env.VITE_API_BASE_URL || "/api",
      credentials: "include",
      onUnauthorized,
      rest: {
        mapRequest(context, request) {
          const url = new URL(request.path, "https://adapter.invalid/");
          if (context.operation === "getList" && url.searchParams.has("page")) {
            url.searchParams.set("pageNum", url.searchParams.get("page")!);
            url.searchParams.delete("page");
          }
          return { ...request, path: "v1" + url.pathname + url.search };
        },
      },
      afterResponse: [
        (context) => {
          const value = envelope(context.data);
          // 失败包保持原样,交给 mapError;204 无响应体保持 null。
          return context.response.ok && value?.code === 0
            ? { ...context, data: value.result }
            : context;
        },
      ],
      mapError({ data, response }) {
        const value = envelope(data);
        if (value?.code === undefined || value.code === 0) return;
        const codes: Record<number, AdminDataErrorCode> = {
          401: "unauthorized",
          403: "forbidden",
          404: "not_found",
          409: "conflict",
          422: "validation",
          429: "rate_limited",
        };
        return new AdminDataError(codes[value.code] ?? "unknown", value.message || "请求失败", {
          status: response.ok ? value.code : response.status,
          fieldErrors: value.fieldErrors,
        });
      },
    });
}

如果字段也不同,例如 order_id/title 对应 id/name,在业务 API 或 rest.responseMappers 中做读取转换,create/update 的 mapRequest 做反向转换,fieldErrors.title 转为 fieldErrors.name。不要只改包裹层后使用类型断言声称业务 DTO 已兼容。转换函数应拒绝缺失 ID、无效状态或错误总数,并保留取消信号和准确的错误码。

管理写入后的会话复核

packages/admin-core/src/management/session.ts 的 withManagementSessionRevalidation 在当前用户的 update/delete/deleteMany、角色的 update/delete/deleteMany 成功后调用 Auth Provider 的 checkAuth()。新建、读取、其他用户写入和任务资源不因此增加会话请求;默认 Mock 由管理演示 Provider 的身份回调处理。

复核成功会更新当前会话,菜单、路由与操作权限使用新快照。同一账户刷新时重置查询数据并重新请求活跃查询,保留正在使用的观察对象;切换账户或清除身份时清空缓存。即使后端返回内容相同的新身份对象,连续身份回调与写后复核也不能使页面停留旧数据或永久加载。复核失败则清身份和缓存并回到登录流程,但仍返回已经成功的写入结果;不能把会话请求失败包装为可重试的保存失败。登录、退出或更晚的复核会使较早的会话结果失效,旧响应不能覆盖新的身份。

这仅覆盖本客户端通过标准资源方法执行的写入,不是实时权限推送。custom 写操作、其他客户端撤权和外部身份事件需由消费方 Adapter/会话策略接入同等复核;服务端始终负责最终授权。不要在角色更新响应中只改变列表而保留旧权限会话。

认证协议

mapCredentials 转换登录请求字段;mapSession(payload, operation) 支持异步转换,返回值仍须通过会话结构与过期校验。mapError(payload, response, operation) 同时检查成功 HTTP 状态中的业务失败和非成功响应,失效会话统一映射为带 401/403 状态的 AdminAuthError。Cookie 或用户 Bearer 凭据通过 headers/credentials 接入;token 的获取、续期与清理留在项目认证适配器中,不把服务端密钥写入前端。包含验证码、第三方跳转等额外用户交互时,需要对应登录功能扩展,不能宣称仅改 URL 即兼容。

事实源:packages/admin-core/src/auth/auth.ts、packages/admin-core/src/auth/http-auth-provider.ts。AdminAuthProvider 包含 login/logout/checkAuth/getIdentity/getPermissions/refresh/handleCallback/onAuthError 八个方法。

默认请求 请求内容 成功响应
POST /auth/login { username, password } AdminSession
GET /auth/session 无 body AdminSession
POST /auth/refresh 无 body AdminSession
POST /auth/callback { url } AdminSession
POST /auth/logout 无 body 后端成功状态;本地会话总会清理

路径相对 baseUrl,可通过 endpoints 覆盖。会话响应默认为直接对象,不包在 data 中:

interface AdminSession {
  id: string;
  expiresAt: number; // Unix 毫秒时间,必须晚于当前时间
  user: {
    id: string;
    username: string;
    name: string;
    roles: readonly string[];
    permissions: readonly string[];
    isSuperAdmin?: boolean;
  };
}

不同后端响应通过 mapSession(payload) 转换;认证头用 headers,测试或特殊传输用 fetcher。checkAuth() 始终向 /auth/session 求证,本地记录不能绕过该步骤;401/403 清本地 HTTP 会话并返回 null,网络或其他服务错误会拒绝并清理本地会话。当前两端启动初始化将这类失败收束为匿名状态并进入登录页,尚未提供独立的启动网络故障页;不能将该行为描述为恢复了缓存登录态。

默认 HTTP 会话存入 ${storageNamespace}-session-http,演示会话存入 ${storageNamespace}-session-demo。旧 ug-admin-session 不再被当作真实会话恢复。权限是服务端快照,角色变更后须返回新权限或撤销旧会话,不能仅改变前端菜单。

Auth 和 Data 各自默认 credentials: "include",可独立覆盖。跨源需服务端允许对应来源携带凭据,并配置正确 Cookie 与 CSRF 防护;请求头、API 地址等浏览器可见配置不能存放服务端密钥。使用其他认证协议时实现八方法 Adapter,不将认证流程拼进业务页面。

数据协议

管理与其他业务使用同一个稳定用户主体:共享 AdminUserId 对应 session.user.id 和 users.id。Adapter 必须映射相同 ID,不能用可修改的 username/name 拼出关联键。user.roleIds 引用 roles.id,部门归属引用 departments.id;角色编码与权限码是授权标识,不能用展示名称代替。

事实源:packages/admin-core/src/data/data-provider.ts、packages/admin-core/src/data/rest-data-provider.ts、packages/admin-core/src/data/http-client.ts。R 表示资源名,id 会被编码:

Provider 方法 默认 HTTP
getList(R, params) GET /R?page=1&pageSize=20&...
getOne(R, { id }) GET /R/:id
getMany(R, { ids }) GET /R?ids=...&ids=...
create(R, { data }) POST /R,body 为 data
update(R, { id, data }) PATCH /R/:id,body 为 data
delete(R, { id }) DELETE /R/:id
deleteMany(R, { ids }) DELETE /R,body 为 { ids }
custom({ path, method, query, data }) 指定相对路径;默认 GET,data 为 body

列表参数为 page/pageSize/sort/order/filters/search,filters 默认序列化成一个 JSON 查询参数;多记录的 ids 使用重复查询参数。页码从 1 开始。URL/方法/查询/body 差异通过 rest.mapRequest 转换,响应通过 rest.responseMappers 转换;原有默认行为保持兼容。复杂协议实现 AdminDataProvider,所有方法应转发 signal。

例如某个已有服务要求 GET /system/user/list?pageNum=... 和 PUT /system/user,数据提供者中的请求转换可以写为:

rest: {
  mapRequest(context, request) {
    if (context.resource !== "users") return request;
    if (context.operation === "getList") {
      const { page = 1, pageSize = 20, search, filters = {} } = context.params;
      const query = new URLSearchParams({ pageNum: String(page), pageSize: String(pageSize) });
      if (search) query.set("userName", search);
      // 其他筛选、排序字段按这个后端的真实协议继续映射。
      if (filters.status) query.set("status", filters.status === "active" ? "0" : "1");
      return { path: `system/user/list?${query}`, options: { method: "GET" } };
    }
    if (context.operation === "update") {
      return {
        path: "system/user",
        options: { method: "PUT", body: { ...context.params.data, userId: context.params.id } },
      };
    }
    return request;
  },
  // responseMappers 按实际 DTO 补齐;这段仅说明请求转换,不是完整 RuoYi Adapter。
}

保持差量更新语义:有些旧接口的 PUT 是完整替换,不能直接用上述示例提交部分字段;此时由项目 Adapter 补齐后端需要的数据或使用后端提供的部分更新接口。字段映射必须包含读取和写入两个方向,不能把缺失字段写成空值。

默认列表响应是 { data: Record[], total, page?, pageSize?, meta? },单条是 { data: Record, meta? },多条是 { data: Record[], meta? }。记录必须有字符串或数字 ID;基础管理模型进一步要求字符串 ID。update/delete/deleteMany 支持无响应体,Data Adapter 会用请求 ID 重建最小结果;创建应返回含 ID 的记录。查询统计必须反映服务端筛选和授权后的总数。

用户资源可映射 phone、remark 与只读 createdAt;旧接口缺失时保留缺失状态,不在前端推算创建时间。手机号查询为 filters.phone 包含匹配,创建时间使用 filters.createdFrom/createdTo 的 YYYY-MM-DD 业务日历日期,包含首尾日、支持单边范围;与后台 createdAt 的日历日期比较,不做客户端 UTC 偏移。服务端按同样语义筛选并返回准确 total,日期筛选排除缺失创建时间的记录。创建时间由服务端生成,PATCH 忽略或拒绝客户端覆盖。手机号、创建时间排序同其他字段一样在 Provider 中执行,不能只排序当前页。详情通过 getOne("users", { id, signal }) 返回最新用户资料并执行 users:read 权限检查。

用户、部门、角色编辑在具备对应读取权限时先 getOne;仅列表和更新授权不触发未授权详情请求,保留对已授权列表字段的编辑能力。三个资源的 PATCH 只含实际变动字段,Adapter/服务端应将其作为部分更新处理,不能把未传字段清空;状态写入只传 status。树展示字段、用户只读创建时间与角色受保护标记不属于更新载荷。详情每次打开求证最新记录,读取期间不显示缓存资料,失败显示重试。需要阻止同一字段的并发覆盖时,由接入方使用版本号或 ETag 完成服务端并发校验。

用户成员查询使用 filters.departmentId(包含该部门及后代)和 filters.roleId(精确匹配 roleIds 元素),与关键词、手机、状态、日期共同在服务端过滤后分页,total 反映完整筛选结果。页面 URL 使用 department/role,Adapter 仍接收规范 filters,不能只在当前页做成员筛选或按角色编码模糊匹配。缺失角色 ID 返回空匹配,不静默取消筛选。

权限目录由模块 index.ts 的 permissions 在启动时统一登记,示例目录仅在显式 examples 模块启用时登记。该配置不是服务端授权证明。后端须禁止新增未知权限码、超出操作者权限的授予,并保留既有未登记码;它们在 UI 中只读,登记后才能调整。受保护角色、最后超级管理员、当前账户停用/删除、部门循环及关联删除继续由服务端原子检查;停用部门不能新增归属,既有归属可以保留。不新增真实认证凭据生命周期或数据范围字段。

getHeaders 配置公共认证头;beforeRequest/afterResponse 实现统一拦截;rest.responseMappers.list/one/many/custom 适配响应结构。401 调用 onUnauthorized;403 不伪装成空列表;422 映射 fieldErrors(也兼容 errors),409 表达关系或并发冲突,429 可保留 Retry-After。页面只消费归一化后的 AdminDataError。

用户关联选项

用户页通过 getManagementUserOptions(provider, signal) 调用 custom({ path: "users/options", method: "GET", signal }),默认请求 GET /users/options。这是独立的精简读取合同,不需要赋予用户操作者完整的 roles:list 或 departments:list 权限。

{
  "data": {
    "departments": [
      { "id": "department-1", "name": "服务部", "status": "active", "parentId": null }
    ],
    "roles": [{ "id": "role-1", "name": "客服", "assignable": true }]
  }
}

可访问规则为 users 的 list/read/create/update 任一授权。返回当前数据范围内用于显示名称和选择的完整集合,不截断分页;不要把完整角色权限、成员或敏感组织字段塞进选项。角色 assignable 由服务端按当前操作者授权计算:不可分配的既有角色可以显示,但不能通过选择器新增;提交时服务端仍要重新验证。若已有接口不同,在 custom Adapter 中映射该路径与响应,不改页面去读取受限资源的全量记录。

departments[].parentId 为可选字段;提供后用户页的组织快捷筛选按层级展示,旧接口缺失时回退平铺。用户列表传 filters.departmentId 时,后端应返回该部门及其可见下级部门的用户;total 必须与该数据范围一致。演示 Provider 遵循这一语义,真实后端仍须自行校验部门数据权限,不能把前端树当授权边界。

字典资源与业务选项

dictionary-types 与 dictionary-entries 使用上表的标准 CRUD URL、分页和差量 PATCH;中性字段、稳定值、权限和前端交互见 字典合同。类型 code、条目 typeId/value 创建后不可变;统计 entryCount/usageCount 只读。filters.status、filters.typeId 精确匹配,搜索和排序先在服务端完成,再分页和返回准确总数。重复码/值和非法字段返回 422 fieldErrors,有关联的删除返回 409,不静默级联。

业务调用 custom({ path: "/dictionaries/options", method: "GET", query: { code }, signal }),默认对应 GET /dictionaries/options?code=...。返回 { data: { code, name, status, entries: [{ id, label, value, sort, status, tone }] } },完整精简集合须含供历史显示的停用项,不携带管理权限或引用明细。按具体业务授权读取,不要求业务使用者拥有字典管理权限;例如 task_priority 的任务消费允许 tasks:list/read/create/update 任一。服务端应按 code 明确绑定授权规则,不能以“已经登录”开放所有字典。

已有后端路径不同时,在注入的 Provider custom 中映射该路径和 query;已有类型/条目 DTO 在资源方法中映射。仅包裹层不同时可使用 rest.responseMappers.custom/list/one。页面和共享 helper 不改成后端专属字段。信号、401失效回调、403真实拒绝、422字段错与409关系冲突沿用统一错误合同。

写入后双端失效两个字典资源和按 code 隔离的选项查询,让活跃业务选择与标签重新读取。后端在事务中校验唯一性、不可变值、启用集合和引用删除。新建业务或改变值只能选择启用项;更新时未变更的停用/未知旧值允许保留,不能清空历史。新增业务消费者时扩展对应引用检查与 options 授权;演示任务的引用保护不能代替新系统自己的完整关联约束。

自定义访问控制与验收

canAccess({ resource, action, record?, context? }) 异步返回 { allowed, reason? },可实现记录级和业务上下文策略。管理 mutation 在真正调用 Data Provider 前重新等待该判定;保存传现有 record 与 context: { data },删除传现有记录,不能只依赖打开弹窗时的按钮显隐。

页面准入仍由共享路由声明与当前身份快照共同决定。自定义操作权限 Provider 不会动态注册页面或自动授予未声明的导航入口。服务端每次请求仍需独立检查身份、权限、记录范围与管理不变量。

连接完成后用真实服务验证:登录/恢复/退出、过期会话、Cookie 实际发送、分页与错误映射、三个管理资源 CRUD、用户选项读取和越权拒绝。分别报告静态检查、浏览器验证与真实后端验证;演示与构建通过不能替代真实服务验收。