docs(server): add ai-context

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent ef18772798
commit 9641df258e
5 changed files with 935 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# AIRI Server AI Context
这组文档面向后续 AI / 开发者协作,目标是让人快速回答四个问题:
1. 服务端是怎么启动和组装的
2. 每条 API / WS 请求最终落到哪个服务
3. 哪些状态以 Postgres 为真相源,哪些只是缓存或派生数据
4. 计费、充值、事件分发这些高风险链路有哪些约束
## 文档索引
- `architecture-overview.md`
- 入口、依赖注入、应用装配、核心边界
- `transport-and-routes.md`
- HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件
- `data-model-and-state.md`
- 主要表、状态归属、缓存与事件模型
- `workers-and-runtime.md`
- CLI 角色、outbox dispatcher、Redis Streams consumer、运行时约束
- `billing-architecture.md`
- 计费链路专项说明,重点看 Flux / Stripe / outbox / Redis Streams
## 快速结论
- `apps/server/src/app.ts` 是唯一的 API 应用装配入口。
- 服务端采用 `Hono + injeca + Drizzle + Redis + better-auth`
- 路由层整体较薄,业务逻辑主要在 `src/services/`
- **Postgres 是所有余额与计费状态的唯一真相源**,Redis 只做缓存、KV、Pub/Sub、Streams。
- WebSocket 只用于聊天同步,跨实例广播依赖 Redis Pub/Sub。
- 对外 LLM 能力不是本地推理,而是转发到配置里的 gateway,再按 usage / fallback rate 扣 Flux。
## 修改代码前建议先看
- 改 API 入口或新增依赖:先看 `architecture-overview.md`
- 改某个接口行为:先看 `transport-and-routes.md`
- 改表结构、缓存或幂等:先看 `data-model-and-state.md`
- 改 worker、部署角色、事件处理:先看 `workers-and-runtime.md`
- 改扣费、充值、Stripe:先看 `billing-architecture.md`
@@ -0,0 +1,163 @@
# Server Architecture Overview
## 一句话总结
`apps/server` 是一个基于 `Hono` 的 Node 服务端,负责认证、角色/聊天/Provider 配置、Flux 余额、Stripe 充值和面向 gateway 的 LLM 代理。整体模式是:
- 路由层负责参数校验、鉴权、错误映射
- 服务层负责业务逻辑和数据库事务
- `Postgres` 负责持久化与账本真相
- `Redis` 负责缓存、配置 KV、Pub/Sub、Streams
- `injeca` 负责把这些依赖组装成一个可启动应用
## 入口与装配
核心入口在 `src/app.ts`
- `createApp()`
- 初始化 logger
- 解析环境变量
- 初始化 OpenTelemetry
- 建立 Postgres / Redis 连接
- 执行数据库迁移
- 构建各个 service
- 注册路由和中间件
- `runApiServer()`
- 启动 HTTP 服务
- 注入 WebSocket
- 绑定 `uncaughtException` / `unhandledRejection`
CLI 入口在 `src/bin/run.ts`,支持三种角色:
- `api`
- `cache-sync-consumer`
- `outbox-dispatcher`
## 依赖注入结构
`app.ts` 使用 `injeca.provide()` 注册依赖,依赖关系大致如下:
- 基础设施
- `env`
- `otel`
- `db`
- `redis`
- `configKV`
- 服务
- `outboxService`
- `auth`
- `characterService`
- `providerService`
- `chatService`
- `stripeService`
- `fluxAuditService`
- `fluxService`
- `requestLogService`
- `billingService`
这个装配顺序说明了几个事实:
- `billingService` 依赖 `db + redis + outboxService`
- `fluxService` 只读余额,不承担余额写入职责
- `auth` 直接绑定数据库 schema,不是外部独立服务
## 应用层边界
### 1. HTTP / WS 传输层
`src/routes/``src/middlewares/`
- 参数校验使用 `valibot`
- 用户身份来自 `sessionMiddleware``authGuard`
- 业务异常统一抛 `ApiError`
- 全局 `onError` 转成标准 JSON 错误响应
### 2. 业务服务层
`src/services/`
- `characters.ts`
- `chats.ts`
- `providers.ts`
- `flux.ts`
- `billing-service.ts`
- `stripe.ts`
- `outbox-service.ts`
这里是主要改动面。大多数业务改动都不应该直接写进 route handler。
### 3. 持久化层
`src/schemas/`
- Drizzle schema 基本覆盖了所有核心表
- 数据迁移由 `@proj-airi/server-schema` 提供
- `app.ts``run-outbox-dispatcher.ts` 启动时都会执行迁移
## 中间件与通用约束
全局中间件链路大致是:
1. `/api/*` 启用 CORS
2. `hono/logger`
3. 可选的 `otelMiddleware`
4. `sessionMiddleware`
5. `bodyLimit(1MB)`
6. 各 route 的局部 guard
需要记住的行为:
- WebSocket `/ws/chat``bodyLimit` 之前注册
- `sessionMiddleware` 不会阻断匿名请求,只是往 context 填 `user/session`
- `authGuard` 才会真正返回 401
- `rate-limit.ts` 目前是**内存限流**,不是分布式限流
## 错误模型
统一错误类型在 `src/utils/error.ts`
- `ApiError(statusCode, errorCode, message, details)`
约定:
- 业务层可以直接抛 `ApiError`
- 未知异常会被包装成 `500 INTERNAL_SERVER_ERROR`
- 参数错误、权限错误、余额不足都已有明确 helper
## 关键设计取舍
### Flux 读写分离
- `FluxService`
- 面向读取
- Redis cache-aside
- 新用户首次读取时初始化余额
- `BillingService`
- 面向写入
- 事务内更新余额、流水、审计、outbox
这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`
### LLM 网关代理而不是本地 provider 编排
`/api/v1` 并不直接调具体模型 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`
- `v1completions.ts` 已实现 `handleTTS` / `handleTranscription`,但路由仍被注释掉,当前只开放 chat completions。
@@ -0,0 +1,269 @@
# Data Model And State
## 真相源原则
这套服务端最关键的状态归属如下:
- `Postgres`
- 用户认证数据
- 角色、聊天、Provider 配置
- Flux 余额与账本
- Stripe 业务镜像
- LLM 请求日志
- outbox 事件
- `Redis`
- Flux 余额缓存
- 服务配置 KV
- 聊天跨实例广播
- 计费事件队列
如果要判断“改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。
## 主要表分组
### 认证
- `user`
- `session`
- `account`
- `verification`
来源文件:
- `src/schemas/accounts.ts`
说明:
- `better-auth` 直接用这组表
- `src/schemas/auth.ts` 基本是重复副本,目前不是主要依赖入口
### 角色与用户交互
- `characters`
- `character_covers`
- `avatar_model`
- `character_capabilities`
- `character_i18n`
- `character_prompts`
- `user_character_likes`
- `user_character_bookmarks`
来源文件:
- `src/schemas/characters.ts`
- `src/schemas/user-character.ts`
说明:
- 角色实体采用软删除
- 点赞与收藏通过中间表建模
- 计数值冗余保存在 `characters` 表上
### 聊天
- `chats`
- `chat_members`
- `messages`
- `media`
- `stickers`
- `sticker_packs`
来源文件:
- `src/schemas/chats.ts`
说明:
- `messages.seq` 是会话内顺序字段
- 写消息时通过 `SELECT ... FOR UPDATE` 锁 chat 以串行生成 seq
- `senderId` 是宽松字段,不强制外键
### Provider 配置
- `user_provider_configs`
- `system_provider_configs`
来源文件:
- `src/schemas/providers.ts`
说明:
- 运行时查询时会把系统配置和用户配置拼接成一个结果集
- `config``jsonb`
### Flux / 账本 / 审计
- `user_flux`
- `flux_ledger`
- `flux_audit_log`
来源文件:
- `src/schemas/flux.ts`
- `src/schemas/flux-ledger.ts`
- `src/schemas/flux-audit-log.ts`
职责边界:
- `user_flux`
- 当前余额快照
- `flux_ledger`
- append-only 账本流水
- 偏系统真相源
- `flux_audit_log`
- 用户可见历史
- 偏产品展示
关键约束:
- `flux_ledger``(userId, requestId)` 有部分唯一索引
- 用来做扣费 / 充值幂等
### Stripe 业务镜像
- `stripe_customer`
- `stripe_checkout_session`
- `stripe_subscription`
- `stripe_invoice`
来源文件:
- `src/schemas/stripe.ts`
说明:
- 这些表是 Stripe 状态的本地镜像
- 真正的余额变化仍由 `billingService` 写入 `user_flux + flux_ledger`
- `fluxCredited` 字段用于避免重复入账
### LLM 请求日志
- `llm_request_log`
来源文件:
- `src/schemas/llm-request-log.ts`
说明:
- 只做追加写入
- 明确不加 user 外键,以避免高并发写入的额外约束成本
### Outbox
- `outbox_events`
来源文件:
- `src/schemas/outbox-events.ts`
说明:
- 本质上是 DB 内事件暂存区
- 通过 `claimedBy + claimExpiresAt + publishedAt` 实现 lease/claim 分发
## 服务与状态写入边界
### `createFluxService()`
负责:
- 余额读取
- 新用户首次读取时初始化 `user_flux`
- Redis cache-aside
不负责:
- 扣费
- 充值
- ledger / audit / outbox 写入
### `createBillingService()`
负责:
- 所有余额写操作
- DB 事务
- ledger / audit / outbox 联动
- 事务完成后 best-effort 更新 Redis
这是所有 Flux 写路径应收敛到的中心。
### `createStripeService()`
负责:
- Stripe 实体 upsert
不负责:
- 最终 Flux 入账
真正入账通过 `billingService.creditFluxFromStripeCheckout()` 或相关 credit 方法完成。
## Redis 中的数据类型
### Flux 缓存
- key: `flux:<userId>`
- value: 字符串化整数
写入来源:
- `fluxService.getFlux()` cache miss 后回填
- `billingService` 余额事务成功后 best-effort 更新
- `cache-sync-consumer` 消费 `flux.debited` / `flux.credited` 后同步
### 配置 KV
- key: `config:<CONFIG_NAME>`
`config-kv.ts` 管理,支持:
- 数值
- 字符串
- `FLUX_PACKAGES` JSON
### 聊天跨实例广播
- channel: `chat:broadcast:<userId>`
### 计费事件流
- stream: 默认 `billing-events`
## 幂等与并发控制
### 余额并发
`billingService` 在事务中:
1. `SELECT user_flux FOR UPDATE`
2. 计算新余额
3. 写余额
4. 写 ledger / audit / outbox
这保证同一用户余额更新是串行化的。
### Stripe 幂等
主要依赖:
- `stripe_checkout_session.fluxCredited`
- `stripe_invoice.fluxCredited`
- `flux_ledger(userId, requestId)` 唯一约束
### outbox 并发
`outbox-service.ts` 使用:
- `FOR UPDATE SKIP LOCKED`
- `claimExpiresAt`
这允许多个 dispatcher 并行拉取待发布事件。
## 现有代码中的结构信号
- `request-log.ts``llm-request-log.ts` 完全重叠,后者更像旧名残留。
- `accounts.ts``auth.ts` 也是重复 schema,后续如果做整理,应先统一真实使用入口再删副本。
@@ -0,0 +1,258 @@
# Transport And Routes
## 路由总览
应用在 `src/app.ts` 中挂载以下路由:
- `GET /health`
- `/api/auth/*`
- `/api/characters`
- `/api/providers`
- `/api/chats`
- `/api/v1`
- `/api/flux`
- `/api/stripe`
- `GET /ws/chat`
## 鉴权链路
### HTTP
- `sessionMiddleware(auth)`
- 通过 `better-auth` 解析当前 session
-`user` / `session` 注入 Hono context
- `authGuard`
- 检查 `c.get('user')`
- 未登录直接 401
### WebSocket
`GET /ws/chat` 走 query token
- 读取 `token`
-`auth.api.getSession()` 验证 Bearer token
- 校验通过后为该 `user.id` 建立 Eventa peer
这意味着聊天 WS 的鉴权方式和普通 cookie session 路径不完全相同。
## 路由到服务映射
### `/api/auth/*`
实现位置:
- 路由注册:`src/app.ts`
- 实际处理:`auth.handler(c.req.raw)`
- 服务工厂:`src/libs/auth.ts`
特点:
- 基于 `better-auth`
- 开启 email/password、Google、GitHub
- Bearer plugin 已启用
- `/api/auth/*` 有独立 IP 限流,每分钟 20 次
### `/api/characters`
实现位置:
- route: `src/routes/characters.ts`
- service: `src/services/characters.ts`
主要能力:
- `GET /`
- 默认返回当前用户拥有的角色
- `?all=true` 返回全部未删除角色
- `GET /:id`
- `POST /`
- `PATCH /:id`
- `DELETE /:id`
- `POST /:id/like`
- `POST /:id/bookmark`
特点:
- 路由层做 `valibot` 校验
- 更新和删除会额外校验 `ownerId === user.id`
- 点赞和收藏是 toggle 语义
### `/api/providers`
实现位置:
- route: `src/routes/providers.ts`
- service: `src/services/providers.ts`
主要能力:
- 用户 Provider Config CRUD
- 查询时会合并:
- `user_provider_configs`
- `system_provider_configs`
特点:
- `findAll(ownerId)` 通过 `unionAll` 合并系统配置和用户配置
- 用户只能改自己的 user config,不能改 system config
### `/api/chats`
实现位置:
- route: `src/routes/chats.ts`
- service: `src/services/chats.ts`
主要能力:
- Chat CRUD
- 成员增删
聊天核心约束:
- 所有操作都会先校验用户是否属于 chat member
- 删除是软删除,写 `deletedAt`
- 消息序号 `seq` 在写消息时通过锁 chat 行串行分配
### `GET /ws/chat`
实现位置:
- route 注册:`src/app.ts`
- handler factory: `src/routes/chat-ws.ts`
- 底层事件适配:`src/libs/eventa-hono-adapter.ts`
主要 RPC
- `sendMessages`
-`chatService.pushMessages()`
- 再调 `chatService.pullMessages()` 生成广播 payload
- `pullMessages`
-`chatService.pullMessages()`
广播策略:
- 同实例:内存 `Map<userId, Set<EventContext>>`
- 跨实例:Redis Pub/Subchannel 前缀 `chat:broadcast:`
### `/api/v1`
实现位置:
- route: `src/routes/v1completions.ts`
- 依赖服务:
- `fluxService`
- `billingService`
- `configKV`
- `requestLogService`
当前已开放:
- `POST /api/v1/chat/completions`
- `POST /api/v1/chat/completion`
已实现但暂未挂载:
- `handleTTS`
- `handleTranscription`
请求流程:
1. 校验已登录
2. 检查相关配置是否存在
3. 检查用户 Flux 是否大于 0
4. 代理请求到 `GATEWAY_BASE_URL`
5. 解析 usage,计算扣费
6. 记录 metrics
7.`billingService.debitFlux()`
8. 异步写 `llm_request_log`
重要取舍:
- non-streaming
- 先拿完整响应
- 再扣费
- 扣费失败会阻断响应
- streaming
- 先把流回给客户端
- 流结束后再 best-effort 扣费
- 扣费失败只打 error log,不回滚给客户端
### `/api/flux`
实现位置:
- route: `src/routes/flux.ts`
- services:
- `fluxService`
- `fluxAuditService`
主要能力:
- `GET /api/flux`
- 读取当前用户余额
- `GET /api/flux/history`
- 读取用户可见流水
### `/api/stripe`
实现位置:
- route: `src/routes/stripe.ts`
- services:
- `fluxService`
- `stripeService`
- `billingService`
- `configKV`
主要能力:
- `GET /packages`
- `POST /checkout`
- `GET /orders`
- `GET /invoices`
- `POST /portal`
- `POST /webhook`
主要职责拆分:
- `stripeService`
- 负责把 Stripe customer / session / subscription / invoice 持久化
- `billingService`
- 负责真正改余额
## 参数校验方式
输入 schema 位于 `src/api/*.schema.ts`
- `characters.schema.ts`
- `chats.schema.ts`
- `providers.schema.ts`
route 层统一使用 `safeParse`,失败时抛:
- `createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)`
## 中间件与 guard
### `configGuard`
作用:
- 检查某些 Redis 配置项是否已写入
- 缺失时返回 503
使用场景:
- LLM chat
- Stripe checkout
- 未来的 TTS / ASR
### `rateLimiter`
封装自 `hono-rate-limiter`,当前默认是单实例内存存储。
影响:
- 多实例部署下不是全局一致限流
- 适合作为基础保护,不适合作为严格额度控制
@@ -0,0 +1,207 @@
# Workers And Runtime
## 进程角色
统一入口在 `src/bin/run.ts`
- `api`
- 启动 Hono HTTP + WebSocket 服务
- `cache-sync-consumer`
- 消费 Redis Streams 中的计费事件,回写 Flux Redis 缓存
- `outbox-dispatcher`
- 从 Postgres `outbox_events` 拉取未发布事件,投递到 Redis Streams
这三个角色已经是当前服务端部署拆分的基本单位。
## API 角色
启动路径:
- `src/bin/run.ts`
- `runApiServer()`
- `createApp()`
启动时会做的事情:
- 解析 env
- 初始化日志
- 可选初始化 OTel
- 连接 Postgres / Redis
- 跑数据库迁移
- 装配服务
- 启动 HTTP server
- 注入 WebSocket
## Outbox Dispatcher
实现位置:
- 入口:`src/bin/run-outbox-dispatcher.ts`
- 服务:`src/services/outbox-dispatcher.ts`
- 存储:`src/services/outbox-service.ts`
- MQ`src/services/billing-mq.ts`
工作流程:
1.`outbox_events` claim 一批未发布事件
2. 逐条发布到 Redis Stream
3. 发布成功后写 `publishedAt``streamMessageId`
4. 失败则释放 claim,等待下一轮处理
关键机制:
- 支持多实例并发 dispatcher
- claim 通过 TTL 失效,避免 worker 崩掉后永久锁死
相关环境变量:
- `OUTBOX_DISPATCHER_NAME`
- `OUTBOX_DISPATCHER_BATCH_SIZE`
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
- `OUTBOX_DISPATCHER_POLL_MS`
- `BILLING_EVENTS_STREAM`
## Billing Events Consumer
实现位置:
- 入口:`src/bin/run-billing-events-consumer.ts`
- worker`src/services/billing-mq-worker.ts`
- stream adapter`src/services/billing-mq.ts`
当前默认 handler
- `handleCacheSyncMessage()`
它只处理:
- `flux.credited`
- `flux.debited`
并把 `payload.balanceAfter` 写回 Redis
- key: `flux:<userId>`
这说明当前 consumer 的目标非常克制:
- 不是账务真相处理器
- 不是分析流水处理器
- 只是缓存一致性补偿器
相关环境变量:
- `BILLING_EVENTS_STREAM`
- `BILLING_EVENTS_CONSUMER_NAME`
- `BILLING_EVENTS_BATCH_SIZE`
- `BILLING_EVENTS_BLOCK_MS`
- `BILLING_EVENTS_MIN_IDLE_MS`
## Redis Streams 语义
`billing-mq.ts` 把 Redis Streams 抽象成:
- `publish()`
- `ensureConsumerGroup()`
- `consume()`
- `claimIdleMessages()`
- `ack()`
这层约束了消息处理语义:
- 使用 consumer group
- 使用 pending reclaim
- handler 抛错时不 ack,消息保持 pending
因此新增新的 stream consumer 时,最安全的方式通常是复用这层,不要自己裸写 `XREADGROUP`
## 聊天 WebSocket 运行时
`src/routes/chat-ws.ts` 还有一套独立于 Redis Streams 的运行时机制:
- 同实例连接保存在进程内 `Map`
- 跨实例 fan-out 通过 Redis Pub/Sub
这意味着:
- WS 广播不具备持久化和重放能力
- 真正补齐消息还是靠 `pullMessages`
- 广播只是加速客户端同步
## OpenTelemetry
初始化在 `src/libs/otel.ts`
启用条件:
- `OTEL_EXPORTER_OTLP_ENDPOINT` 存在
覆盖面:
- HTTP
- Auth
- Chat engagement
- Revenue
- LLM
- DB / Redis instrumentation
重要实现细节:
- `sdk.start()` 必须发生在 `metrics.getMeter()` 之前
- `/health` 会被 HTTP instrumentation 忽略
## 环境变量分层
### 基础运行
- `HOST`
- `PORT`
- `API_SERVER_URL`
- `CLIENT_URL`
- `DATABASE_URL`
- `REDIS_URL`
### Auth
- `AUTH_GOOGLE_CLIENT_ID`
- `AUTH_GOOGLE_CLIENT_SECRET`
- `AUTH_GITHUB_CLIENT_ID`
- `AUTH_GITHUB_CLIENT_SECRET`
### Stripe
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
### Billing MQ / Outbox
- `BILLING_EVENTS_STREAM`
- `BILLING_EVENTS_CONSUMER_NAME`
- `BILLING_EVENTS_BATCH_SIZE`
- `BILLING_EVENTS_BLOCK_MS`
- `BILLING_EVENTS_MIN_IDLE_MS`
- `OUTBOX_DISPATCHER_NAME`
- `OUTBOX_DISPATCHER_BATCH_SIZE`
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
- `OUTBOX_DISPATCHER_POLL_MS`
### OTel
- `OTEL_SERVICE_NAMESPACE`
- `OTEL_SERVICE_NAME`
- `OTEL_TRACES_SAMPLING_RATIO`
- `OTEL_EXPORTER_OTLP_ENDPOINT`
- `OTEL_EXPORTER_OTLP_HEADERS`
- `OTEL_DEBUG`
## 运行时修改建议
如果你要改:
- 新增 worker
- 先看 `run.ts` 的角色模型和 `billing-mq-worker.ts`
- 改事件分发
- 先看 outbox,而不是直接在业务事务里调用 Redis Streams
- 改聊天同步
- 先区分“持久化消息”与“广播通知”两层
- 改部署限流
- 注意当前 `rate-limit.ts` 仍是单实例内存模型