@bitz/platform-sdk 是连接 Web 应用与 .NET 后端的深模块。它在一个 barrel 后面隐藏五件事:配置、request pipeline、auth session、错误映射、租户/权限上下文。页面通过 React context(usePlatform)消费它,绝不直接调 fetch。
1. 该 package 拥有什么
| 关注点 | 导出 |
|---|---|
| 配置 | createConfig、createConfigFromEnv、DEFAULT_PLATFORM_CONFIG、PlatformSdkConfig |
| Request pipeline | createPlatformClient、PlatformClient、PlatformResult、PlatformResponse、PlatformFailure、PlatformRequestOptions、HttpMethod |
| 类型化 API | createPlatformApi、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 |
| React | PlatformProvider、usePlatform、PlatformContextValue、PlatformProviderProps |
| Gate | PermissionGate、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) |
defaultTimeoutMs | 30000 | 经 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_URL | baseUrl | ''(dev-proxy 模式) |
VITE_CLIENT_PLATFORM | clientPlatform | 'Web' |
VITE_API_TIMEOUT_MS | defaultTimeoutMs | 30000 |
createConfig(partial) 是给测试与非 Vite runtime 用的显式构造器。
3. request pipeline
createPlatformClient 产出应用使用的唯一 client 实例(在 PlatformProvider 里 memo)。一个请求经过这些步骤:
- 构建头:
Content-Type: application/json、Accept: application/json、X-Client-Platform: <config.clientPlatform>,以及仅当存在 access token 时的Authorization: Bearer <token>。 - 经
AbortSignal.timeout(opts.timeoutMs ?? config.defaultTimeoutMs)附超时;若调用方传了外部signal,两者用AbortSignal.any([...])合并——无setTimeout泄漏。 - 发单次
fetch,带credentials: 'include',让 HttpOnly refresh cookie 自动随行。 - 收到
204 No Content,返回{ ok: true, status: 204, data: undefined }。 - 把 body 读成 text,防御性 JSON 解析,再——非 ok 时——调
parseProblemDetails产出AppError。 - 若结果
isAuthError(error)为真且调用方没传skipAuthRefresh,调auth.refreshOnce()。若返回 token,重试一次。若重试仍然是 401,调auth.forceRefreshFailed()防止刷新风暴。 - 403 永不自动刷新(立即返回
Forbidden)。 - 抛出的
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 };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/users | Page<UserSummaryDto> |
getUserById(userId) | GET /api/users/{userId} | UserSummaryDto |
createUser(request) | POST /api/users | UserSummaryDto |
updateUser(userId, request) | PUT /api/users/{userId} | UserSummaryDto |
deleteUser(userId) | DELETE /api/users/{userId} | void |
enableUser(userId) | POST /api/users/{userId}/enable | void(幂等) |
disableUser(userId) | POST /api/users/{userId}/disable | void |
lockUser(userId, { durationMinutes }) | POST /api/users/{userId}/lock | void |
unlockUser(userId) | POST /api/users/{userId}/unlock | void |
userStatus | — | 一个 UserStatus 取值常量对象,供渲染 |
MenuApi:
| 方法 | 路径 | 返回 |
|---|---|---|
getNavigation() | QUERY /api/menus/navigation | readonly NavigationItem[] |
IdentityApi:
| 能力组 | 方法 |
|---|---|
| 密码 | requestPasswordReset、resetPassword、changePassword |
| MFA | setupMfa、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 | 何时 |
|---|---|
authenticated | login 成功并存了 token |
impersonation-restored | 会话以模拟登录身份恢复 |
mfa-required | LoginResponse.requiresMfa 为真(尚未存 token) |
logged-out | logout() 完成 |
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 顺序固定;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——它们决定渲染什么,不决定动作是否被允许。
| Gate | Props | 行为 |
|---|---|---|
PermissionGate | require: string | readonly string[]、mode?: 'all' | 'any'(默认 'all')、fallback? | 查 currentUser?.permissions;空 require → 放行;all = 全部,any = 任一 |
FeatureGate | feature、mode?、children、fallback? | 读 usePlatform().features 里的租户特性集做判定;特性由服务端随登录下发 |
9. OpenAPI 生成契约
后端 artifact 与 TypeScript 类型已经闭环:
# 从后端单仓根目录导出 artifactscripts/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 守护静默 driftscripts/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 改动
- 确认新导出已加进 package barrel(
src/index.ts);深路径导入不属于公开 API。 - 若新增 HTTP 方法,确认它走
createPlatformApi与共享 pipeline——不是另起炉灶的 client。 - 若 auth 行为变化,同步更新
auth-session.ts的不变量注释与本页;别让注释块过期。 - 若新增 gate,确认它仍只是 UX,并文档化对应的后端能力。
- 若后端契约变化,重新导出并生成;若需要更友好的模块名,只在
src/contracts/*.ts建 schema 别名。
11. 源核对
# 确认公开 API 面与唯一的 pipeline/auth 归属。sed -n '1,80p' frontend/packages/platform-sdk/src/index.tsrg -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.tsrg -n "^export interface" frontend/packages/platform-sdk/src/contracts/identity.ts# 预期第二条无结果。返回前端 · QUERY 请求协议 · 架构与红线 · Web 管理端