资讯动态

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

发布时间:2026/9/16 21:02:34 来源:尧图企业网站定制
openstatus 服务层架构框架无关的 Workspace 业务逻辑设计ADR-0001 深度解析【免费下载链接】openstatus Status page with uptime monitoring API monitoring as code 项目地址: https://gitcode.com/GitHub_Trending/op/openstatusopenstatus 是一套集状态页、可用性监控与 API 监控于一身的多入口系统同一项「创建监控、更新状态报告、删除集成」之类的 Workspace 级操作会同时暴露给 tRPCNext.js Dashboard、Honoapps/server、MCP Server 与后台任务apps/workflows。本篇基于仓库架构决策记录 ADR-0001Workspace business logic lives in a framework-agnostic services layer讲解 openstatus 如何通过提取packages/services这一框架无关的服务层统一实现工作空间隔离、审计追踪与 API Key 权限校验读完你不仅能复刻这套「一实现多入口」的分层方案还能在仓库源码中逐行验证每个约定背后的真实实现。背景同一个业务操作四个入口一份逻辑openstatus 的 Workspace 级业务操作需要被以下多个入口复用tRPC handlersNext.js Dashboardapps/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 DriversADR 中明确记录了驱动这一决策的六项硬性要求同一操作必须能被 tRPC、Hono、MCP、Jobs 无重复地调用每个 mutation 必须限定在 Workspace 内——禁止跨工作空间数据访问每个 mutation 必须产生审计记录且与变更在同一个事务内原子完成API Key 携带read/write作用域必须约束每一次写操作共享代码必须 Edge-safe不依赖node:*内置模块错误必须能干净地映射到各传输层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-report、maintenance等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.ts、update.ts、remove.ts、list.ts……再从该实体的index.ts统一 re-export。调用方通过openstatus/services/entity导入。以status-report为例实体 index 导出了createStatusReport、updateStatusReport、deleteStatusReport、addStatusReportUpdate、resolveStatusReport、notifyStatusReport以及全套 Zod schema外部代码永远不需要直接触碰create.ts等具体文件。标准函数签名verbEntity(args: { ctx, input })每个服务动词统一采用export async function createStatusReport(args: { ctx: ServiceContext; input: CreateStatusReportInput; }): PromiseCreateStatusReportResultServiceContext是贯穿一切的核心类型定义在 context.tsexport 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.tsexport 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 约定不写审计行并携带keyId、userId、workspaceId、required 与 held scopes方便密钥泄露事件快速溯源到创建者。底层的纯函数匹配器 matches-scope.ts 定义了作用域层级* ⊇ write ⊇ read即持有write也满足read要求持有*满足一切同时采用fail-closed策略——任何无法识别的 scope 字符串一律不匹配宁可「无权限」也不「默认放行」防止脏数据或手工 SQL 修改造成越权。withTransaction(ctx, fn)事务复用与忙重试事务处理定义在 context.tsexport async function withTransactionT( ctx: ServiceContext, fn: (tx: DB) PromiseT, ): PromiseT { 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 中的getReportInWorkspaceexport 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 查询本身就同时带上id与workspaceId两个条件——不是先查再过滤而是把 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自动计算当before与after快照都提供时用diffTopLevel计算顶层键差异updatedAt、createdAt被DIFF_IGNORE集合排除始终变动、无信息量null/undefined视作缺失避免不同数据源读取差异造成误报。深度比较手写实现deepEqual是手写的对象键序无关、数组有序、Date 按getTime()比较原因正是 ADR 提到的 Edge 约束——node:util的isDeepStrictEqual在 Edge runtime 不可用Turbopack 下会报isDeepStrictEqual is not a function。空 diff 跳过写入before after且无metadata时直接 return避免产生零信息量的审计行但存在metadata时仍写入例如page_subscriber的组件级 scope 编辑只改关联表metadata才是信号本身。审计行携带workspaceId、actorType、actorId、actorUserId、action、entityType、entityId、before、after、metadata、changedFields等完整字段。审计动作名遵循{entity}.{verb}约定且必须在 audit_logs/validation.ts 的可辨识联合中显式声明没有逃生舱口。每个实体至多三种动词create/update/deleteacknowledge、resolve、revoke等操作语义在结构上归类到三者之一具体意图由审计行上的changed_fields还原metadata只保留实体快照无法推导的旁路上下文如clonedFromMonitorId、statusReportId。错误体系ServiceError子类 传输层映射错误模型定义在 errors.tsServiceError携带机器可读的code联合类型export type ServiceErrorCode | NOT_FOUND | FORBIDDEN | UNAUTHORIZED | CONFLICT | VALIDATION | LIMIT_EXCEEDED | PRECONDITION_FAILED | INTERNAL;配套的具名子类包括NotFoundError携带 entity 与 id、ForbiddenError、UnauthorizedError、ConflictError、ValidationError、LimitExceededError携带 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; }): PromiseCreateStatusReportResult { 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提供withComponentImpacts与withPageComponentIds作为唯一的快照构造入口对 impacts 按pageComponentId稳定排序——因为 diff 对数组是顺序敏感的快照构造不统一会导致跨动词的changed_fields漂移。validatePageComponentIds必须在调用方事务内执行以关闭「校验与关联写入之间的 TOCTOU 窗口」。getCurrentImpactsForReport则按「最新 date并列按 id胜出」的规则推导每个组件当前的 impact 状态。后果评估好的、坏的与中性的ADR 对这项决策的后果做了坦率的评估好tRPC、Hono、MCP、Jobs 共享一份被测试覆盖的实现好审计与 scope 检查统一且难以绕过——review 时缺失emitAudit或requireScope会被视为阻塞性问题好服务层从构造上保证 Edge-safe手写deepEqual即为例证坏Router 与服务层变成需要同时理解的两层坏ctx线程传递、事务复用、审计快照、密钥脱敏等约定有学习成本——这些约定由CLAUDE.mdServices Audit Log Pattern、Scope Enforcement 小节与各__tests__/套件记录中性Router 里内联直接访问 DB 依然能通过编译——强制靠约定与代码评审而非类型系统。如何验证这套架构没有失效ADR 的 Confirmation 一节给出了两条可验证的保障每个动词都有测试套件位于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 调用写动词必须被拒绝代码评审纪律Review 拒绝任何直接写在 router 里的业务逻辑。测试基础设施方面packages/services/src/__tests__/下还有context.test.ts、with-transaction.test.ts、with-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.tsemitAudit见 audit/emit.tsrequireScope见 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.tsAgent 约定速查根目录 CLAUDE.md【免费下载链接】openstatus Status page with uptime monitoring API monitoring as code 项目地址: https://gitcode.com/GitHub_Trending/op/openstatus创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价