Skip to content
bitzorcas
中EN

Concept

单仓、深模块与红线

前端把 apps 与 packages 组织成依赖方向固定的深模块,并用红线把后端契约收敛为单一来源、把凭据挡在脚本够不到的地方。

Last updated

前端不是一堆松散的 package。它围绕深模块组织:少数几个 package 拥有一大片复杂度,却只暴露很窄的公开 API。apps 与页面只从 barrel 导入,因此最难的部分——request pipeline、auth session、复合 UI glue——都各只有一处归属。

1. 这里的”深模块”是什么意思

深模块用很小的接口隐藏很宽的实现。在本仓里这是一条明文选择:package barrel(src/index.ts)是唯一被允许的导入入口,且 index 只再导出一小段刻意收窄的列表。

Package隐藏暴露
@bitz/platform-sdkrequest pipeline、auth session、ProblemDetails 解析、租户/权限上下文、OpenAPI 生成契约createPlatformClient、PlatformProvider、usePlatform、PermissionGate、FeatureGate、createPlatformApi、config + error 辅助
@bitz/widgets分页、加载、空、错误、权限 glue;PlatformResult 映射ServerDataTable、AppShell、SidebarNav、HeaderBar、FormField、ConfirmAction、QueryState、EmptyState、ErrorState、PermissionGate
@bitz/componentsshadcn 原语与 cn 工具shadcn 原语 barrel(button、sidebar、table、dialog 等数十个再导出)
@bitz/editorTiptap 配置基于 StarterKit 的 useBitzEditor 封装
@bitz/i18nReact Context + 类型化 t()可替换的 i18n seam(I18nProvider、useI18n、useT、tRaw、zhCN)

一个从 @bitz/widgets 导入 ServerDataTable 的页面,并不知道这张表是用 React Query 取数、如何把 ProblemDetails 错误映射成重试态、删除列又被哪个 gate 隐藏。这正是目的。

2. 依赖方向

generated from

apps/app
pages · routes

@bitz/widgets

@bitz/platform-sdk

@bitz/i18n

@bitz/components

@bitz/utils

src/contracts/
schema 类型桥接

generated.d.ts
(openapi-typescript)

后端 OpenAPI artifact

箭头方向是固定的:apps 依赖 packages,packages 只依赖更底层的 package,且谁都不能深入 package 的内部路径。@bitz/platform-sdk 是唯一与后端通信的 package;@bitz/widgets 是唯一把数据获取与 UI 状态组合在一起的 package(它同时依赖 SDK 与 i18n,前一张图漏画了)。

3. Web 应用的请求路径

usePlatform().api

页面组件

PlatformProvider
(单 client + 单 session,memoized)

request pipeline
createPlatformClient

base URL · Authorization: Bearer · X-Client-Platform

credentials: 'include'
(HttpOnly refresh cookie 自动随行)

单次 fetch · AbortSignal.timeout(30s)

解析 RFC 9457 ProblemDetails → AppError

React Query 缓存

整个应用只有恰好一个 request pipeline 实例,在 PlatformProvider 里 useMemo 创建,经 React context(usePlatform().client)共享。各页面从不自建 client,也从不重写重试、刷新协调、超时、ProblemDetails 映射——这些全在唯一那条 pipeline 里。字段级细节见 平台契约层。

4. auth-session 状态机

AuthSession 是凭据状态与刷新协调的唯一归属。它的状态转移是架构契约,不是实现细节:

login 成功refreshOnce(单例)requiresMfaMFA 通过logout刷新失败 / 重试仍 401清理会话

Anonymous

Authenticated

MfaRequired

LoggedOut

RefreshFailed

承重的不变量是单例刷新:并发 401 共享同一个在飞的 refreshOnce() promise。如果重试请求仍然是 401,会话调 forceRefreshFailed() 并发 refresh-failed 事件——这防止刷新风暴。监听器彼此隔离(各自 try/catch),所以一个抛错的监听器不会拖垮整个会话。

5. 红线

这些不可商量。每一条都因为失败后果是安全或正确性回归,而不是风格问题,并且已经落地。

#红线状态原因
1生成的 client 不手改当前generated.d.ts 镜像后端 OpenAPI artifact;手改会 drift 并被下一次生成覆盖。
2前端权限判断只算 UX当前后端授权管道才是权限的唯一事实。
3refresh token 不进 localStorage当前refresh 只走 HttpOnly cookie;access token 是模块私有字段,任何脚本都偷不走。
4权限事实不进 localStorage当前TenantId / UserId / 权限来自 /api/auth/me 或 JWT claim,不能自报。
5禁手写后端 DTO当前src/contracts/ 只能给 generated schema 建命名桥接,不得复制字段。
6页面禁散落 fetch当前所有 HTTP 走 usePlatform().api。
7单一 request pipeline 实例当前PlatformProvider memo 一个 client;不得每页建 HTTP adapter。

6. 这种结构值得的地方

深模块、单 pipeline 的前期成本是纪律:你不能在页面里随手 fetch、不能抄 DTO、不能读 refresh token。回报是系统的硬不变量靠构造就成立:

  • 安全不变量集中一处。 token 存储、刷新协调、ProblemDetails 处理全在 @bitz/platform-sdk。审这一个 package 就等于审整个 Web 应用的 auth 姿态。
  • 后端变更靠重新生成传播。 后端 DTO 一变,重新生成 client 就把静默的运行时错配变成编译错误。
  • 复合 UI 状态一致。 每个数据驱动的屏幕共享同样的 loading/empty/error/权限 形状,因为它们来自 @bitz/widgets,而不是每页自己拼 JSX。

7. 审一次架构改动

  1. 说出改动所在的 package,并确认它是 barrel 导入,不是深路径导入。
  2. 若引入新的 HTTP 调用,确认它走 usePlatform().api,不是页面级 fetch。
  3. 若引入新的权限判断,确认后端同样强制了该能力。
  4. 若后端契约变化,重新导出 OpenAPI 并生成 client;src/contracts/ 只调整 schema 别名。
  5. 提交前跑 workspace 检查(yarn lint && yarn typecheck && yarn build)——Husky 与 lint-staged 会对暂存文件强制格式化与 lint。

8. 源核对

Terminal window
# 确认 barrel 是导入入口,apps/app 没有消费深路径。
rg -n "from '@bitz/platform-sdk'|from '@bitz/widgets'" frontend/apps/app/src
rg -n "from '@bitz/widgets/src/|from '@bitz/platform-sdk/src/" frontend/apps/app/src
# 预期:第一条命令返回多条匹配;第二条无匹配。
# 确认单一 request pipeline 与 auth session 的归属。
rg -n "createPlatformClient|class AuthSession|refreshOnce|forceRefreshFailed" \
frontend/packages/platform-sdk/src/client frontend/packages/platform-sdk/src/auth

返回前端 · 平台契约层 · Web 管理端

100%

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