Skip to content
bitzorcas
中EN

Reference

平台契约层

@bitz/platform-sdk 是 Web 应用与 .NET 后端之间唯一被允许的桥梁——它独占 request pipeline、auth session、ProblemDetails 映射、权限/特性 gate 与 OpenAPI 生成契约。

Last updated

@bitz/platform-sdk 是连接 Web 应用与 .NET 后端的深模块。它在一个 barrel 后面隐藏五件事:配置、request pipeline、auth session、错误映射、租户/权限上下文。页面通过 React context(usePlatform)消费它,绝不直接调 fetch。

1. 该 package 拥有什么

关注点导出
配置createConfig、createConfigFromEnv、DEFAULT_PLATFORM_CONFIG、PlatformSdkConfig
Request pipelinecreatePlatformClient、PlatformClient、PlatformResult、PlatformResponse、PlatformFailure、PlatformRequestOptions、HttpMethod
类型化 APIcreatePlatformApi、PlatformApi(Identity/Menu/User 及 Files、Workflow、Tickets 等数十个模块 API 的扁平聚合)、IdentityApi、MenuApi、UserApi
认证AuthSession、createAuthApi、AuthStatus、LoginRequest、LoginResponse、MfaVerifyRequest、TokenResponse、CurrentUser、SessionStateEvent
错误parseProblemDetails、networkError、cancelledError、isRetryable、isAuthError、isForbidden、AppError、AppErrorType、ValidationErrors
ReactPlatformProvider、usePlatform、PlatformContextValue、PlatformProviderProps
GatePermissionGate、PermissionGateProps、FeatureGate、FeatureGateProps
生成契约client/generated.d.ts + contracts/* 的 schema 类型桥接

2. 配置

PlatformSdkConfig 是一个很小的 readonly 形状:

字段默认含义
baseUrl''(同源)API origin;dev 期为空,由 Vite proxy 处理 /api/*
clientPlatform'Web'作为 X-Client-Platform 头发送(Web / App / Harmony / MiniProgram)
defaultTimeoutMs30000经 AbortSignal.timeout 的单请求超时
refreshPath'/api/auth/refresh'pipeline 发刷新调用的路径
chatHubPath'/hubs/chat'SignalR 聊天 hub 路径
operationsHubPath'/hubs/operations-metrics'运维指标 hub 路径
exportHubPath'/hubs/export'导出进度 hub 路径

createConfigFromEnv(env) 读 VITE_* 变量(Vite 约定):

环境变量映射到回退
VITE_API_BASE_URLbaseUrl''(dev-proxy 模式)
VITE_CLIENT_PLATFORMclientPlatform'Web'
VITE_API_TIMEOUT_MSdefaultTimeoutMs30000

createConfig(partial) 是给测试与非 Vite runtime 用的显式构造器。

3. request pipeline

createPlatformClient 产出应用使用的唯一 client 实例(在 PlatformProvider 里 memo)。一个请求经过这些步骤:

  1. 构建头:Content-Type: application/json、Accept: application/json、X-Client-Platform: <config.clientPlatform>,以及仅当存在 access token 时的 Authorization: Bearer <token>。
  2. 经 AbortSignal.timeout(opts.timeoutMs ?? config.defaultTimeoutMs) 附超时;若调用方传了外部 signal,两者用 AbortSignal.any([...]) 合并——无 setTimeout 泄漏。
  3. 发单次 fetch,带 credentials: 'include',让 HttpOnly refresh cookie 自动随行。
  4. 收到 204 No Content,返回 { ok: true, status: 204, data: undefined }。
  5. 把 body 读成 text,防御性 JSON 解析,再——非 ok 时——调 parseProblemDetails 产出 AppError。
  6. 若结果 isAuthError(error) 为真且调用方没传 skipAuthRefresh,调 auth.refreshOnce()。若返回 token,重试一次。若重试仍然是 401,调 auth.forceRefreshFailed() 防止刷新风暴。
  7. 403 永不自动刷新(立即返回 Forbidden)。
  8. 抛出的 DOMException 且 name === 'AbortError' → cancelledError();TypeError(DNS/CORS/拒绝)→ networkError()。

QUERY 还有一层传输协商:查询条件进入 JSON 正文;收到 405、501 或浏览器网络拒绝时,client 用同一正文尝试一次 POST {path}/_query,并在能够确认链路不支持后记住结果。认证、授权、校验、超时和业务失败不会触发降级。完整状态机、OpenAPI 3.1 生成差异和 .NET 11 迁移门禁见 QUERY 请求协议。

pipeline 返回可辨识联合 PlatformResult<T>:

type PlatformResult<T> =
| { readonly ok: true; readonly status: number; readonly data: T }
| { readonly ok: false; readonly status: number; readonly error: AppError };
经 context 消费 API
import { usePlatform } from "@bitz/platform-sdk";
function UsersPage() {
const { api } = usePlatform();
// API 是扁平的——方法直接挂在 `api` 上,不在 `api.userApi` 下。
// api.searchUsers(query) 返回 PlatformResult<UserPage>,绝不是裸
// Response,绝不是抛出的 fetch 错误。
}

4. 类型化 API 面

PlatformApi = Identity + Menu + User + Announcements + Files + Workflow + …(工厂内 spread 各 create*Api)——方法扁平地挂在 api 上,不在 api.identityApi 下命名空间里。要加一个模块的 API,把 create<Module>Api(client) 展开(spread)进 createPlatformApi。

UserApi(各自返回 PlatformResult<...>):

方法路径返回
searchUsers(query)QUERY /api/usersPage<UserSummaryDto>
getUserById(userId)GET /api/users/{userId}UserSummaryDto
createUser(request)POST /api/usersUserSummaryDto
updateUser(userId, request)PUT /api/users/{userId}UserSummaryDto
deleteUser(userId)DELETE /api/users/{userId}void
enableUser(userId)POST /api/users/{userId}/enablevoid(幂等)
disableUser(userId)POST /api/users/{userId}/disablevoid
lockUser(userId, { durationMinutes })POST /api/users/{userId}/lockvoid
unlockUser(userId)POST /api/users/{userId}/unlockvoid
userStatus—一个 UserStatus 取值常量对象,供渲染

MenuApi:

方法路径返回
getNavigation()QUERY /api/menus/navigationreadonly NavigationItem[]

IdentityApi:

能力组方法
密码requestPasswordReset、resetPassword、changePassword
MFAsetupMfa、confirmMfaSetup、disableMfa
邀请listInvitations、getInvitation、createInvitation、revokeInvitation、acceptInvitation
准入listAdmissions、approveAdmission、rejectAdmission
生命周期completeActivation、sendEmailConfirmation、confirmEmail、sendPhoneConfirmation、confirmPhone
外部登录listExternalLoginProviders、initiateExternalLogin
会话listSessions、revokeSession、revokeAllSessions

5. 错误模型

AppErrorType 是字符串联合(不是 TS enum):

'Validation' | 'NotFound' | 'Conflict' | 'Forbidden' | 'Unauthorized' |
'Failure' | 'Unexpected' | 'ServiceUnavailable' | 'RequestTimeout' |
'RateLimited' | 'Network' | 'Cancelled'

AppError 带:status(网络/取消为 0)、errorType、errorCode、detail、可选 validationErrors(Record<string, string[]>),以及可选 traceId / correlationId / requestId / retryAfterSeconds / type / instance。parseProblemDetails 从响应 body 的根读取这些字段(ASP.NET Results.Problem 把 extensions 合并到根),缺 errorType 时按状态码猜:400→Validation、401→Unauthorized、403→Forbidden、404→NotFound、408→RequestTimeout、409→Conflict、422→Failure、429→RateLimited、503→ServiceUnavailable、≥500→Unexpected。

辅助:isRetryable(Network / RequestTimeout / ServiceUnavailable / RateLimited)、isAuthError(errorType === Unauthorized && status === 401)、isForbidden(errorType === Forbidden && status === 403)。

6. auth session

AuthSession 拥有凭据状态与刷新协调。其不变量是安全关键:

  • access token 留在模块私有字段里(private accessToken: string | null)。永不写入 localStorage / sessionStorage。
  • refresh token 由 HttpOnly cookie 承载。 浏览器经 credentials: 'include' 提交;客户端 JavaScript 读不到。
  • 并发 401 只触发一次刷新。 refreshOnce() 存一个在飞的 promise(refreshPromise)并返回给每个调用方;该字段在 resolve 之后清空,以避开竞态窗口。
  • 刷新失败清理会话。 forceRefreshFailed() 丢掉内存里的 token 并发 refresh-failed;应用据此跳登录。
  • 监听器彼此隔离。 每个监听器在各自 try/catch 里调用,一个抛错的监听器不会拖垮会话。

SessionStateEvent 有七个 type 取值,带可选 userId:

type何时
authenticatedlogin 成功并存了 token
impersonation-restored会话以模拟登录身份恢复
mfa-requiredLoginResponse.requiresMfa 为真(尚未存 token)
logged-outlogout() 完成
refresh-failed刷新被拒或刷新后仍 401
password-change-required登录成功但服务端要求先修改密码
mfa-enrollment-required服务端要求先完成 MFA 注册

LoginResponse 来自 OpenAPI generated schema:accessToken、expiresIn、requiresMfa、requiresPasswordChange,以及可选 mfaToken / requiresCaptcha / captchaChallengeId / captchaRenderData / isHost / refreshToken。登录屏按这些服务端声明的标志分支,而不是自己猜。

7. React context

PlatformProvider 在靠近根处挂一次(main.tsx)。它 memo 化 AuthSession、client 与类型化 API,先用 HttpOnly refresh cookie 恢复内存 access token,再读取 /api/auth/me;bootstrap 完成前 authStatus 保持 bootstrapping,路由守卫不会误跳登录。PlatformContextValue 暴露:config、client、session、api、authStatus、login(request)、verifyMfa(request)、logout()、fetchCurrentUser()、currentUser、tenantId,以及服务端下发的租户特性集 features: readonly string[](FeatureGate 的数据源)。

只挂一次 provider(apps/app/src/main.tsx)
// Provider 顺序固定;PlatformProvider 只创建一次 SDK 单例。
// WorkspacePreferencesProvider 注入首屏 shell 安全默认值,登录后由服务端偏好接管。
<PlatformProvider config={platformConfig}>
<WorkspacePreferencesProvider
defaultBrand={brand} defaultColorMode={colorMode}
defaultDensity={density} defaultShellPreset={shellPreset}>
<QueryClientProvider client={queryClient}>
{/* 主查询缓存边界:登录主体切换时统一失效缓存 */}
<PrincipalQueryCacheBoundary>
<RouterProvider router={router} />
<AppToaster />
</PrincipalQueryCacheBoundary>
</QueryClientProvider>
</WorkspacePreferencesProvider>
</PlatformProvider>

8. gate

package 内有两个声明式 gate。两者都只算 UX——它们决定渲染什么,不决定动作是否被允许。

GateProps行为
PermissionGaterequire: string | readonly string[]、mode?: 'all' | 'any'(默认 'all')、fallback?查 currentUser?.permissions;空 require → 放行;all = 全部,any = 任一
FeatureGatefeature、mode?、children、fallback?读 usePlatform().features 里的租户特性集做判定;特性由服务端随登录下发

9. OpenAPI 生成契约

后端 artifact 与 TypeScript 类型已经闭环:

Terminal window
# 从后端单仓根目录导出 artifact
scripts/build/export-openapi.sh # → artifacts/openapi/openapi-v1.json
# 在 frontend/ 下重新生成类型
yarn workspace @bitz/platform-sdk generate-client
# → openapi-typescript ../../../artifacts/openapi/openapi-v1.json \
# --empty-objects-unknown -o src/client/generated.d.ts
# CI 守护静默 drift
scripts/build/check-openapi-drift.sh

当前 OpenAPI 3.1 artifact 以 POST /_query Operation 承载 QUERY 的 schema,并通过 x-http-query-* 扩展发布首选入口;因此生成类型可能索引 paths['/.../_query']['post'],运行时模块 API 却发送 QUERY /...。这是兼容设计,不是契约漂移。不要在页面里直接引用生成的 transport path,细节见 QUERY 请求协议。

10. 审一次 platform-sdk 改动

  1. 确认新导出已加进 package barrel(src/index.ts);深路径导入不属于公开 API。
  2. 若新增 HTTP 方法,确认它走 createPlatformApi 与共享 pipeline——不是另起炉灶的 client。
  3. 若 auth 行为变化,同步更新 auth-session.ts 的不变量注释与本页;别让注释块过期。
  4. 若新增 gate,确认它仍只是 UX,并文档化对应的后端能力。
  5. 若后端契约变化,重新导出并生成;若需要更友好的模块名,只在 src/contracts/*.ts 建 schema 别名。

11. 源核对

Terminal window
# 确认公开 API 面与唯一的 pipeline/auth 归属。
sed -n '1,80p' frontend/packages/platform-sdk/src/index.ts
rg -n "createPlatformClient|class AuthSession|refreshOnce|forceRefreshFailed" \
frontend/packages/platform-sdk/src/client \
frontend/packages/platform-sdk/src/auth
# 确认 Identity 契约只桥接 generated schema,没有复制字段。
rg -n "components\\['schemas'\\]" frontend/packages/platform-sdk/src/contracts/identity.ts
rg -n "^export interface" frontend/packages/platform-sdk/src/contracts/identity.ts
# 预期第二条无结果。

返回前端 · QUERY 请求协议 · 架构与红线 · Web 管理端

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%