openstatus 服务层架构:框架无关的 Workspace 业务逻辑设计(ADR-0001 深度解析)
2026/9/16 21:02:31 网站建设 项目流程

openstatus 服务层架构:框架无关的 Workspace 业务逻辑设计(ADR-0001 深度解析)

【免费下载链接】openstatus🫖 Status page with uptime monitoring & API monitoring as code 🫖项目地址: https://gitcode.com/GitHub_Trending/op/openstatus

openstatus 是一套集状态页、可用性监控与 API 监控于一身的多入口系统:同一项「创建监控、更新状态报告、删除集成」之类的 Workspace 级操作,会同时暴露给 tRPC(Next.js Dashboard)、Hono(apps/server)、MCP Server 与后台任务(apps/workflows)。本篇基于仓库架构决策记录 ADR-0001:Workspace business logic lives in a framework-agnostic services layer,讲解 openstatus 如何通过提取packages/services这一框架无关的服务层,统一实现工作空间隔离、审计追踪与 API Key 权限校验;读完你不仅能复刻这套「一实现多入口」的分层方案,还能在仓库源码中逐行验证每个约定背后的真实实现。

背景:同一个业务操作,四个入口,一份逻辑

openstatus 的 Workspace 级业务操作需要被以下多个入口复用:

  • tRPC handlers:Next.js Dashboard(apps/dashboard)的前端服务调用;
  • Hono routesapps/server中的 HTTP API 服务;
  • MCP Server:为 AI Agent 提供工具调用能力;
  • Background jobsapps/workflows中的后台任务。

在引入服务层之前,这套逻辑直接写在 tRPC router 内部,其他入口要么复制粘贴一份,要么根本无法触达,跨切面关注点(workspace 隔离、审计轨迹、API Key 权限检查)也缺乏一致的落点。更关键的是,Dashboard 的 tRPC 运行在 Next.js Edge runtime 上,任何共享代码都不能引入node:*内置模块——这是整个架构设计中最容易被忽略的硬约束。

决策驱动因素(Decision Drivers)

ADR 中明确记录了驱动这一决策的六项硬性要求:

  1. 同一操作必须能被 tRPC、Hono、MCP、Jobs 无重复地调用;
  2. 每个 mutation 必须限定在 Workspace 内——禁止跨工作空间数据访问;
  3. 每个 mutation 必须产生审计记录,且与变更在同一个事务内原子完成;
  4. API Key 携带read/write作用域,必须约束每一次写操作;
  5. 共享代码必须 Edge-safe(不依赖node:*内置模块);
  6. 错误必须能干净地映射到各传输层(tRPC codes、HTTP statuses)。

备选方案与最终选择

ADR 中比较了三个方案:

方案优点缺点
业务逻辑继续留在 tRPC routers(维持现状)只有一层,无额外间接层Hono/MCP/Jobs 无法复用;跨切面关注点靠临时手段、难以强制执行;tRPC 类型泄漏给非 tRPC 调用方
提取独立的框架无关packages/services层(最终选择)一份实现服务所有传输层;workspace 隔离、审计、权限检查集中一处;传输层与 Edge runtime 无关多了一层与一套需要学习的约定
Dashboard 所有 mutation 改走 Hono HTTP API单一后端代码库每次 mutation 多一次网络往返、丢失端到端类型推断;Jobs/MCP 仍需要非 HTTP 路径,重复问题依旧

最终选择的方案是「提取独立的框架无关packages/services层」,因为它是唯一能让所有入口共享同一实现、同时让 workspace 隔离、审计与权限强制在结构上难以绕过的选项。传输层(tRPC、Hono、MCP)退化为薄适配器:校验输入 → 调用服务动词 → 映射错误。

迁移刻意采用「一个领域一个 PR」的增量策略:PR #2100 搭建包骨架,PR #2101 起迁移status-reportmaintenance等,PR #2118 引入审计日志基础设施。从仓库现状看,packages/services/src/下已覆盖 monitor、page、status-report、notification、member、api-key、oauth、sso、workspace 等 20+ 个领域,验证了这条路径的可行性。

服务层的形状(The Shape of the Layer)

一个动词一个文件,从实体 index 统一导出

每个实体在packages/services/src/<entity>/下按动词拆分文件(create.tsupdate.tsremove.tslist.ts……),再从该实体的index.ts统一 re-export。调用方通过@openstatus/services/<entity>导入。以status-report为例,实体 index 导出了createStatusReportupdateStatusReportdeleteStatusReportaddStatusReportUpdateresolveStatusReportnotifyStatusReport以及全套 Zod schema,外部代码永远不需要直接触碰create.ts等具体文件。

标准函数签名:verbEntity(args: { ctx, input })

每个服务动词统一采用:

export async function createStatusReport(args: { ctx: ServiceContext; input: CreateStatusReportInput; }): Promise<CreateStatusReportResult>

ServiceContext是贯穿一切的核心类型,定义在 context.ts:

export type ServiceContext = { workspace: Workspace; // 当前工作空间,一切查询的强制过滤条件 actor: Actor; // 谁在发起操作 requestId?: string; span?: unknown; db?: DB; // 可选:外部传入的 db / 事务 tb?: OSTinybird; // 可选:时间序列客户端 workos?: WorkOSClient; // 可选:SSO 客户端 };

Actor是一个可辨识联合(discriminated union),覆盖了系统内所有调用主体:

export type Actor = | { type: "user"; userId: number } | { type: "apiKey"; keyId: string; userId?: number; scopes: Scope[] } | { type: "mcp"; keyId: string; userId?: number; scopes: Scope[] } | { type: "slack"; teamId: string; slackUserId: string; userId?: number } | { type: "system"; job: string } | { type: "webhook"; source: string; externalId?: string } | { type: "subscriber"; subscriberId: number };

审计记录里的actorId通过extractActorId从不同 actor 类型提取(user 取userId、apiKey/mcp 取keyId、slack 取slackUserId……),而tryGetActorUserId则用于那些需要回填*_by列的变更操作。

requireScope(ctx, "write"):每个写动词的第一行

权限强制被设计成每个写动词的第一行,先于输入解析与事务开启——这样一次失败的检查不会为了回滚而白白开事务,且该检查不依赖数据库。实现见 require-scope.ts:

export function requireScope(ctx: ServiceContext, required: Scope): void { const { actor } = ctx; if (actor.type !== "apiKey" && actor.type !== "mcp") { return; // user / system / slack / webhook / subscriber:各自信任边界内,直接放行 } if (matchesScope(actor.scopes, required)) { return; } console.warn( `[requireScope] denied: actor=${actor.type} keyId=${actor.keyId} ... required=${required} held=${heldStr}`, ); throw new ForbiddenError(`API key lacks required scope: ${required}`); }

被拒绝的尝试会通过console.warn走既有日志管线(按 ADR 约定不写审计行),并携带keyIduserIdworkspaceId、required 与 held scopes,方便密钥泄露事件快速溯源到创建者。

底层的纯函数匹配器 matches-scope.ts 定义了作用域层级'*' ⊇ 'write' ⊇ 'read',即持有write也满足read要求,持有*满足一切;同时采用fail-closed策略——任何无法识别的 scope 字符串一律不匹配,宁可「无权限」也不「默认放行」,防止脏数据或手工 SQL 修改造成越权。

withTransaction(ctx, fn):事务复用与忙重试

事务处理定义在 context.ts:

export async function withTransaction<T>( ctx: ServiceContext, fn: (tx: DB) => Promise<T>, ): Promise<T> { const db = ctx.db ?? defaultDb; if (isTx(db)) return fn(db); // 外层已有事务则直接复用 return withBusyRetry(() => (db as DrizzleClient).transaction(fn)); }

其关键点在于:如果调用方已经通过ctx.db传入一个事务(例如上层编排需要多个服务动词在同一事务内完成),则直接复用外层事务;否则开启新事务,并经由withBusyRetry处理 SQLite 的 BUSY 锁竞争。事务类型判断使用 drizzle 的is(db, SQLiteTransaction)而非instanceof,因为 pnpm 多解析路径下instanceof不可靠(源码注释明确说明了这一点)。

同文件还提供getReadDb(读侧解析器)与batchReads(多条独立读合并为一次 libsql 往返;事务内退化为Promise.all)。

Workspace 隔离是强制的:getXInWorkspace

每个实体的internal.ts提供「按 workspace 拉取,取不到即抛错」的辅助函数。以status-report为例,internal.ts 中的getReportInWorkspace

export async function getReportInWorkspace(args: { tx: DB; id: number; workspaceId: number; }) { const row = await tx .select() .from(statusReport) .where( and(eq(statusReport.id, id), eq(statusReport.workspaceId, workspaceId)), ) .get(); if (!row) throw new NotFoundError("status_report", id); return row; }

SQL 查询本身就同时带上idworkspaceId两个条件——不是先查再过滤,而是把 workspace 隔离下沉到查询条件里。对于关联行(如 status report update),则通过innerJoin到父表校验父记录所属 workspace,不匹配时抛ForbiddenError

emitAudit(tx, ctx, entry):同一事务内的审计写入(fail-closed)

审计基础设施在 PR #2118 引入,核心实现在 audit/emit.ts。emitAudit接收调用方事务tx,在同一事务内写入审计行;若审计写入失败(例如auditEntrySchema.parse抛出 ZodError),整个 mutation 一并回滚——这就是fail-closed:审计缺失 = 变更回滚。

审计行的生成逻辑值得注意:

  • changed_fields自动计算:当beforeafter快照都提供时,用diffTopLevel计算顶层键差异;updatedAtcreatedAtDIFF_IGNORE集合排除(始终变动、无信息量);null/undefined视作缺失,避免不同数据源读取差异造成误报。
  • 深度比较手写实现deepEqual是手写的(对象键序无关、数组有序、Date 按getTime()比较),原因正是 ADR 提到的 Edge 约束——node:utilisDeepStrictEqual在 Edge runtime 不可用(Turbopack 下会报isDeepStrictEqual is not a function)。
  • 空 diff 跳过写入before === after且无metadata时直接 return,避免产生零信息量的审计行;但存在metadata时仍写入(例如page_subscriber的组件级 scope 编辑只改关联表,metadata才是信号本身)。
  • 审计行携带workspaceIdactorTypeactorIdactorUserIdactionentityTypeentityIdbeforeaftermetadatachangedFields等完整字段。

审计动作名遵循{entity}.{verb}约定,且必须在 audit_logs/validation.ts 的可辨识联合中显式声明,没有逃生舱口。每个实体至多三种动词create/update/deleteacknowledgeresolverevoke等操作语义在结构上归类到三者之一,具体意图由审计行上的changed_fields还原;metadata只保留实体快照无法推导的旁路上下文(如clonedFromMonitorIdstatusReportId)。

错误体系:ServiceError子类 + 传输层映射

错误模型定义在 errors.ts,ServiceError携带机器可读的code联合类型:

export type ServiceErrorCode = | "NOT_FOUND" | "FORBIDDEN" | "UNAUTHORIZED" | "CONFLICT" | "VALIDATION" | "LIMIT_EXCEEDED" | "PRECONDITION_FAILED" | "INTERNAL";

配套的具名子类包括NotFoundError(携带 entity 与 id)、ForbiddenErrorUnauthorizedErrorConflictErrorValidationErrorLimitExceededError(携带 limit 名称、上限 max 与实际用量 current)、PreconditionFailedError(语义性前置条件不满足,如账号因活跃付费订阅被禁止删除——与 FORBIDDEN 的授权语义、CONFLICT 的并发竞态语义区分开)以及InternalServiceError

Router 层通过toTRPCError等适配函数将这些错误转换为各自传输层的形态(tRPC codes / HTTP statuses),服务层自身完全不感知传输层。

以一个真实动词串联全部约定

把上述机制串起来看,status-report的 create.ts 是「标准写动词模板」的教科书级示例:

export async function createStatusReport(args: { ctx: ServiceContext; input: CreateStatusReportInput; }): Promise<CreateStatusReportResult> { const { ctx } = args; requireScope(ctx, "write"); // 1. 权限检查(第一行) const input = CreateStatusReportInput.parse(args.input); // 2. 输入校验 return withTransaction(ctx, async (tx) => { // 3. 事务(复用或新建) // 4. Workspace 隔离:page 必须属于当前 workspace const page_ = await tx.select({ id: page.id }).from(page) .where(and(eq(page.id, input.pageId), eq(page.workspaceId, ctx.workspace.id))) .get(); if (!page_) throw new NotFoundError("page", input.pageId); // 5. 关联校验:组件必须存在、属于 workspace、同属一个 page const validated = await validatePageComponentIds({ tx, workspaceId: ctx.workspace.id, ... }); if (validated.pageId !== null && validated.pageId !== input.pageId) { throw new ConflictError("pageId ... does not match the page ... of the selected components."); } // 6. 主体写入(status_report + 关联 + 初始 update + impacts) const newReport = await tx.insert(statusReport).values({ ... }).returning().get(); await updatePageComponentAssociations({ tx, statusReportId: newReport.id, ... }); const initialUpdate = await tx.insert(statusReportUpdate).values({ ... }).returning().get(); await insertUpdateComponentImpacts({ tx, statusReportUpdateId: initialUpdate.id, ... }); // 7. 同一事务内写审计(fail-closed) await emitAudit(tx, ctx, { action: "status_report.create", entityType: "status_report", entityId: newReport.id, after: withPageComponentIds(newReport, validated.componentIds) }); await emitAudit(tx, ctx, { action: "status_report_update.create", entityType: "status_report_update", entityId: initialUpdate.id, after: withComponentImpacts(initialUpdate, componentImpacts), metadata: { statusReportId: newReport.id } }); return { statusReport: newReport, initialUpdate }; }); }

值得强调的是审计快照的规范性:internal.ts提供withComponentImpactswithPageComponentIds作为唯一的快照构造入口,对 impacts 按pageComponentId稳定排序——因为 diff 对数组是顺序敏感的,快照构造不统一会导致跨动词的changed_fields漂移。validatePageComponentIds必须在调用方事务内执行,以关闭「校验与关联写入之间的 TOCTOU 窗口」。getCurrentImpactsForReport则按「最新 date(并列按 id)胜出」的规则推导每个组件当前的 impact 状态。

后果评估:好的、坏的与中性的

ADR 对这项决策的后果做了坦率的评估:

  • :tRPC、Hono、MCP、Jobs 共享一份被测试覆盖的实现;
  • :审计与 scope 检查统一且难以绕过——review 时缺失emitAuditrequireScope会被视为阻塞性问题
  • :服务层从构造上保证 Edge-safe(手写deepEqual即为例证);
  • :Router 与服务层变成需要同时理解的两层;
  • ctx线程传递、事务复用、审计快照、密钥脱敏等约定有学习成本——这些约定由CLAUDE.md("Services & Audit Log Pattern"、"Scope Enforcement" 小节)与各__tests__/套件记录;
  • 中性:Router 里内联直接访问 DB 依然能通过编译——强制靠约定与代码评审,而非类型系统。

如何验证这套架构没有失效

ADR 的 Confirmation 一节给出了两条可验证的保障:

  1. 每个动词都有测试套件:位于packages/services/src/<entity>/__tests__/(仓库中 status-report、monitor、page、notification、member、api-key、oauth、sso、workspace 等实体均有对应*.test.ts),通过expectAuditRow(...)断言审计副作用,并包含用makeApiKeyCtx(...)构造的"rejects read-only actor"用例——即只持有readscope 的 API Key 调用写动词必须被拒绝;
  2. 代码评审纪律:Review 拒绝任何直接写在 router 里的业务逻辑。

测试基础设施方面,packages/services/src/__tests__/下还有context.test.tswith-transaction.test.tswith-test-transaction.test.ts等针对事务语义的专项测试,配合packages/services/test/下的 fixtures 与 preload 为每个领域测试提供ServiceContext构造能力。

小结:这套模式给我们的启示

openstatus 的 ADR-0001 本质上是把「业务逻辑的归属」问题,从一次性的工程直觉上升为可评审、可追溯、可强制的架构纪律。它给出的不是银弹,而是一组清晰的分层规则:

  • 传输层(tRPC/Hono/MCP)只做三件事:校验输入、调用服务动词、映射错误
  • 服务层用标准签名verbEntity({ ctx, input })统一形态,把workspace 隔离、API Key 作用域、审计轨迹变成每个动词结构上绕不开的前置环节;
  • Edge runtime 的约束(禁node:*)从一开始就被视为一等公民,而非事后补救。

如果你也在维护一个多入口、多运行时的项目,可以从这套模式中直接借鉴的核心动作是:先为「谁在调用(Actor)+ 在哪个空间(Workspace)+ 能否写(Scope)」建立统一的上下文模型,再让每个写操作在同一个事务里同时完成「变更 + 审计」,最后用测试断言审计副作用、用评审纪律堵住内联 DB 访问的捷径。

延伸阅读

  • ADR 原文:docs/adr/0001-business-logic-lives-in-the-services-layer.md
  • 为什么用 MADR 记录架构决策:docs/adr/0000-use-markdown-any-decision-records.md 与 docs/adr/README.md、docs/adr/template.md
  • 服务层核心实现:ServiceContext/withTransaction见 context.ts,emitAudit见 audit/emit.ts,requireScope见 auth/require-scope.ts,错误体系见 errors.ts
  • 审计动作名声明处:audit_logs/validation.ts
  • 领域示例:status-report/create.ts、status-report/internal.ts、status-report/index.ts
  • 测试示例:status-report/tests/status-report.test.ts
  • Agent 约定速查:根目录 CLAUDE.md

【免费下载链接】openstatus🫖 Status page with uptime monitoring & API monitoring as code 🫖项目地址: https://gitcode.com/GitHub_Trending/op/openstatus

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询