The Redis Stream `billing-events` + `worker` Railway role +
advisory-lock poller layered together didn't actually buy us reliability
— `debitFlux` swallowed XADD failures, leaving the door open to "balance
updated, ledger row never written". Collapse the whole thing back to:
`creditFlux` and `debitFlux` write `flux_transaction` ledger rows inline
within the same DB transaction that mutates `user_flux`, and `(user_id,
request_id)` remains the partial unique index that keeps retries safe.
Concrete changes:
- Inline ledger inserts in `BillingService.{debitFlux, creditFlux,
creditFluxFromStripeCheckout, creditFluxFromInvoice}`; drop `billingMq`
and `publishEvent` plumbing entirely.
- `routes/openai/v1` writes `llm_request_log` synchronously via the
existing `requestLogService`; the duplicate `llm-request-log.ts` service
module is removed.
- `bin/run-worker.ts`, `libs/mq/*`,
`services/billing/billing-events.ts`,
`services/billing/billing-consumer-handler.ts`, and matching tests are
deleted. CLI now exposes only `api`.
- `BILLING_EVENTS_*` env vars and the `DEFAULT_BILLING_EVENTS_STREAM`
helper are dropped; `docker-compose.yml` no longer ships a worker
service.
- `docs/ai-context/{workers-and-runtime, billing-architecture,
redis-boundaries-and-pubsub, data-model-and-state,
architecture-overview, README}.md`, `CLAUDE.md`, and the existing
verification docs are updated to describe the single-process synchronous
pipeline.
Tests: 29 files / 247 cases pass. Production deployments need to drop
the worker Railway service after this lands.
4.4 KiB
4.4 KiB
Server Architecture Overview
一句话总结
apps/server 是一个基于 Hono 的 Node 服务端,负责认证、角色/聊天/Provider 配置、Flux 余额、Stripe 充值和面向 gateway 的 LLM 代理。整体模式是:
- 路由层负责参数校验、鉴权、错误映射
- 服务层负责业务逻辑和数据库事务
Postgres负责持久化与账本真相Redis负责缓存、配置 KV、Pub/Sub、Streamsinjeca负责把这些依赖组装成一个可启动应用
入口与装配
核心入口在 src/app.ts:
createApp()- 初始化 logger
- 解析环境变量
- 初始化 OpenTelemetry
- 建立 Postgres / Redis 连接
- 执行数据库迁移
- 构建各个 service
- 注册路由和中间件
runApiServer()- 启动 HTTP 服务
- 注入 WebSocket
- 绑定
uncaughtException/unhandledRejection
CLI 入口在 src/bin/run.ts,只有一种角色:
api(HTTP/WS;没有常驻后台 loop,也没有 fire-and-forget 异步任务。admin flux grant 在 POST 请求线程内同步处理完返回;详见workers-and-runtime.md)
依赖注入结构
app.ts 使用 injeca.provide() 注册依赖,依赖关系大致如下:
- 基础设施
envoteldbredisconfigKV
- 服务
authcharacterServiceproviderServicechatServicestripeServicefluxTransactionServicefluxServicerequestLogServicebillingService
这个装配顺序说明了几个事实:
billingService依赖db + redisfluxService只读余额,不承担余额写入职责auth直接绑定数据库 schema,不是外部独立服务
应用层边界
1. HTTP / WS 传输层
在 src/routes/ 和 src/middlewares/:
- 参数校验使用
valibot - 用户身份来自
sessionMiddleware和authGuard - 业务异常统一抛
ApiError - 全局
onError转成标准 JSON 错误响应
2. 业务服务层
在 src/services/:
characters.tschats.tsproviders.tsflux.tsbilling-service.tsstripe.ts
这里是主要改动面。大多数业务改动都不应该直接写进 route handler。
3. 持久化层
在 src/schemas/:
- Drizzle schema 基本覆盖了所有核心表
- 数据迁移由
@proj-airi/server-schema提供 app.ts启动时会执行迁移
中间件与通用约束
全局中间件链路大致是:
/api/*启用 CORShono/logger- 可选的
otelMiddleware sessionMiddlewarebodyLimit(1MB)- 各 route 的局部 guard
需要记住的行为:
- WebSocket
/ws/chat在bodyLimit之前注册 sessionMiddleware不会阻断匿名请求,只是往 context 填user/sessionauthGuard才会真正返回 401rate-limit.ts目前是内存限流,不是分布式限流
错误模型
统一错误类型在 src/utils/error.ts:
ApiError(statusCode, errorCode, message, details)
约定:
- 业务层可以直接抛
ApiError - 未知异常会被包装成
500 INTERNAL_SERVER_ERROR - 参数错误、权限错误、余额不足都已有明确 helper
关键设计取舍
Flux 读写分离
FluxService- 面向读取
- Redis cache-aside
- 新用户首次读取时初始化余额
BillingService- 面向写入
- debitFlux / credit 方法:事务内同步更新余额并写
flux_transactionledger;事务提交后 best-effort 刷 Redis 余额缓存
这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 flux.ts。
LLM 网关代理而不是本地 provider 编排
/api/v1/openai 并不直接调具体模型 provider,而是转发到 config: GATEWAY_BASE_URL。因此:
- 服务端关心的是鉴权、限流、计费、日志、观测
- 具体模型执行和 usage 返回格式由 gateway 决定
Redis 有多种职责,但都不是余额真相源
Redis 在这里同时承担:
- Flux 余额缓存
- 运行时配置 KV
- WebSocket 跨实例广播 Pub/Sub
- 计费事件 Streams
但余额真相仍然在 Postgres。
当前值得注意的实现信号
src/services/request-log.ts和src/services/llm-request-log.ts职责重复,当前实际注入的是前者。src/schemas/accounts.ts和src/schemas/auth.ts内容重复,createAuth()使用的是accounts.ts。src/routes/openai/v1/index.ts已实现handleTTS/handleTranscription,但路由仍被注释掉,当前只开放 chat completions。