Why
- src/services/ was an unordered mix of single-file services and module
directories with no shared classification axis, plus several long-dead
admin batch helpers that survived the move to the simpler synchronous
admin-flux-grants flow.
What
- services/ now has two top-level layers:
domain/ — DB state + business rules (billing, characters, chats,
flux, flux-transaction, llm-router, providers, request-log,
stripe, user-deletion, admin/{flux-grants,router-config})
adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
(flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.
Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
external consumers are kept.
Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
drizzle-kit generate diff stays empty. Removing them is a separate PR
that owns the drop-table migration for flux_grant_batch /
flux_grant_batch_recipient.
Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
(all are pre-existing node/prefer-global/buffer in envelope-crypto and
scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
run are the known pushSchema-per-worker infra cost, not a regression.
5.5 KiB
5.5 KiB
Redis Boundaries And Pub/Sub
目标
这篇文档约束服务端使用 Redis 时的几个高风险边界:
- key / channel 拼接
- Pub/Sub payload 序列化与反序列化
- Redis 返回值的运行时校验
- Redis 与 Postgres 的职责边界
这不是“推荐写法”集合,而是后续改代码时应该默认遵守的约束。
一句话规则
- 不要在业务代码里到处手写 Redis key / channel 模板字符串。
- 不要把 TypeScript 类型注解当成 Redis 边界的运行时校验。
- 不要把 Pub/Sub 当持久化通道。
- 不要让 Redis 承担余额、账本、订单这类真相源职责。
Redis 职责边界
当前 apps/server 中 Redis 主要承担四类职责:
- cache
- 用户 Flux 余额读缓存
user:{userId}:flux - TTS voices 上游响应(按 model 分片)
tts:voices:upstream:{model}—— TTL 600s,仅 200 响应入缓存,见src/routes/openai/v1/index.ts::handleListVoices
- 用户 Flux 余额读缓存
- config KV
- 例如
config:{key}
- 例如
- Pub/Sub
- 例如聊天跨实例广播
chat:{userId}:broadcast
- 例如聊天跨实例广播
- 计量债务账本(atomic counter + TTL)
- 例如 TTS 累计字符
user:{userId}:flux-meter:tts:debt - 见 flux-meter.md
- 例如 TTS 累计字符
其中:
- Postgres 是余额、账本、订单、聊天消息等持久状态的唯一真相源
- Redis Pub/Sub 只负责降低跨实例通知延迟,不提供持久化、回放、补偿
NOTICE: Redis Streams(曾经的
billing-events)已被移除。现在没有任何业务依赖 Stream 抽象,未来如果要再上 Stream,请先重新评估是否真的需要异步副作用,而不是把它作为默认选项。
Key / Channel 收口规则
必须收口
Redis key 和 Pub/Sub channel 必须通过单独 helper 构造,不要在多个调用点重复写模板字符串。
推荐模式:
function fluxRedisKey(userId: string): string {
return `user:${userId}:flux`
}
function userBroadcastChannel(userId: string): string {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('user broadcast channel requires a non-empty string userId')
}
return `chat:${userId}:broadcast`
}
这样做的原因不是“风格统一”,而是为了避免:
- key 前缀分散在多个文件
- 某个调用点把对象、空串、错误 id 拼进 channel
- 后续重构前缀或路由粒度时漏改
禁止依赖模板字符串兜底
不要假设 `${value}` 可以安全把任意值转成 Redis key。
原因:
- 如果
value在运行时是对象,会得到[object Object] - 这类错误不会在 TypeScript 编译期暴露
- 一旦写进 Redis channel / key,排查成本很高
Pub/Sub Payload 规则
发布侧
发布侧必须显式构造消息对象,不要把“业务对象刚好长得像 payload”当成协议。
推荐模式:
interface BroadcastMessage {
userId: string
payload: {
chatId: string
messages: unknown[]
fromSeq: number
toSeq: number
}
}
function createBroadcastMessage(
userId: string,
payload: BroadcastMessage['payload'],
): BroadcastMessage {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('broadcast message requires a non-empty string userId')
}
return { userId, payload }
}
消费侧
消费侧不要只写:
const data = JSON.parse(message) as BroadcastMessage
因为这只是类型断言,不是校验。
至少要验证:
userId是非空字符串payload.chatId是字符串payload.messages是数组fromSeq/toSeq是数字
如果消息不合法,应该记录错误并丢弃,而不是继续广播到本地连接。
Chat WS 当前约束
src/routes/chat-ws/index.ts 当前采用:
- 同实例内存连接表
- 跨实例 Redis Pub/Sub
这个设计的语义必须明确:
- 广播通知不是持久化消息
- 丢广播不会丢聊天真相数据
- 客户端补齐消息仍然依赖
pullMessages
因此后续如果改聊天同步:
- 需要”可重放”时,不要继续堆在 Pub/Sub 上
- 需要”跨实例即时通知”时,可以继续用 Pub/Sub
- 需要”持久事件消费”时,先回到 NOTICE 评估是否真的需要异步副作用,再考虑引入 Streams 这类抽象
修改 Redis 代码时的检查清单
- 这个 Redis 数据是 cache、KV、Pub/Sub 还是 Stream?
- 它是不是被误当成真相源?
- key / channel 是否通过 helper 统一构造?
- 是否校验了关键标识符,例如
userId、chatId、streamMessageId? - Pub/Sub payload 是否有显式创建函数和解析函数?
- 解析失败时是否会安全丢弃,而不是继续传播?
- 这个需求是否其实应该用 Streams,而不是 Pub/Sub?
当前代码可直接参考的位置
- key helper 集中点
src/utils/redis-keys.ts
- 余额读 cache-aside
src/services/domain/flux.ts
- Sub-Flux 计量债务账本
src/services/domain/billing/flux-meter.ts
- TTS voices 上游响应缓存
src/routes/openai/v1/index.ts::handleListVoices
- Pub/Sub 聊天广播
src/routes/chat-ws/index.ts
- 命名规范和待迁移事项
config-and-naming-conventions.md
对 AI / 后续修改者的直接要求
- 新增 Redis key / channel 时,先写 helper,再写调用点
- 新增 Pub/Sub payload 时,先定义消息 shape 和 parse / create 边界,再接业务逻辑
- 如果看到业务代码里散落
`prefix:${id}`,优先做小范围收口 - 如果看到
JSON.parse(...) as SomeType出现在 Redis 边界,默认把它视为待修复点