diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md new file mode 100644 index 000000000..6c80a77cd --- /dev/null +++ b/apps/server/docs/ai-context/README.md @@ -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` diff --git a/apps/server/docs/ai-context/architecture-overview.md b/apps/server/docs/ai-context/architecture-overview.md new file mode 100644 index 000000000..fe8c088b6 --- /dev/null +++ b/apps/server/docs/ai-context/architecture-overview.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。 diff --git a/apps/server/docs/ai-context/data-model-and-state.md b/apps/server/docs/ai-context/data-model-and-state.md new file mode 100644 index 000000000..2c7b858ed --- /dev/null +++ b/apps/server/docs/ai-context/data-model-and-state.md @@ -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:` +- value: 字符串化整数 + +写入来源: + +- `fluxService.getFlux()` cache miss 后回填 +- `billingService` 余额事务成功后 best-effort 更新 +- `cache-sync-consumer` 消费 `flux.debited` / `flux.credited` 后同步 + +### 配置 KV + +- key: `config:` + +由 `config-kv.ts` 管理,支持: + +- 数值 +- 字符串 +- `FLUX_PACKAGES` JSON + +### 聊天跨实例广播 + +- channel: `chat:broadcast:` + +### 计费事件流 + +- 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,后续如果做整理,应先统一真实使用入口再删副本。 diff --git a/apps/server/docs/ai-context/transport-and-routes.md b/apps/server/docs/ai-context/transport-and-routes.md new file mode 100644 index 000000000..b485aa617 --- /dev/null +++ b/apps/server/docs/ai-context/transport-and-routes.md @@ -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>` +- 跨实例:Redis Pub/Sub,channel 前缀 `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`,当前默认是单实例内存存储。 + +影响: + +- 多实例部署下不是全局一致限流 +- 适合作为基础保护,不适合作为严格额度控制 diff --git a/apps/server/docs/ai-context/workers-and-runtime.md b/apps/server/docs/ai-context/workers-and-runtime.md new file mode 100644 index 000000000..1b318fca2 --- /dev/null +++ b/apps/server/docs/ai-context/workers-and-runtime.md @@ -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:` + +这说明当前 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` 仍是单实例内存模型