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.
5.5 KiB
5.5 KiB
Admin Flux Grants
Admin 一次性给若干用户发 FLUX(Beta 致谢、补偿、运营赠送等)的接口。整个流程单一同步 HTTP 调用搞定,没有 batch 表、没有状态机、没有后台 loop。
1. 背景
旧设计是 flux_grant_batch + flux_grant_batch_recipient 两张表 + 状态机 + 异步处理 + retry 端点 + advisory-lock poller,~800 行代码。实际产品里 admin 发放频率"几周一次、几十个用户",过度工程。简化为:
- 一个
POST /api/admin/flux-grants接口 - 同步处理:resolve emails → 顺序调
creditFlux→ 返回每条的 outcome - 审计走
flux_transaction表(type='promo'、metadata.description/metadata.idempotencyKey) - 失败处理:admin 看响应里的
failed[],自己再发一次(用idempotencyKey防止已成功的部分被双发)
2. 路由
POST /api/admin/flux-grants?dryRun=true|false
Auth:authGuard + adminGuard(ADMIN_EMAILS allowlist + 验证邮箱)。
Body:
{
description: string, // 1..500 chars; 写入 flux_transaction.metadata.description
amount: number, // 1..MAX_GRANT_AMOUNT_PER_USER (10_000), 单人发放数量
emails: string[], // 1..MAX_EMAILS_PER_GRANT (200) 个 email
idempotencyKey?: string, // 可选,最长 100 chars。提供后每个 recipient 的
// requestId = `flux-grant:${idempotencyKey}:${userId}`,
// 重发同 (key, recipients) 是 no-op;不提供则每次 grant
// 都会重发。
}
dry-run 响应:
{ preview: { totalEmails, willGrant, willSkip: { notFound, userDeleted, duplicateInInput }, totalFluxToIssue, samples } }
实发响应:
{
summary: { totalEmails, willGrant, willSkip, totalFluxToIssue, samples },
result: {
granted: [{ email, userId, fluxTransactionId, balanceAfter }],
skipped: [{ email, reason: 'duplicate_in_input' | 'not_found' | 'user_deleted' }],
failed: [{ email, userId, error }], // creditFlux 抛错时进这里
},
}
3. 处理流程(同步)
- 路由层 valibot 校验 body
service.resolveEmails(emails):- 输入小写化后
IN (...)查user.email(不能 wrapLOWER(),会 break unique index → seq scan) - 命中 user 后再查
user_flux.deletedAt - 重复输入按出现顺序首条留下,后续标
duplicate_in_input
- 输入小写化后
- 对每个
status='pending'的 recipient 顺序调BillingService.creditFlux({ userId, amount, type: 'promo', requestId, description, source: 'admin_promo', auditMetadata }) - 抛错记到
result.failed[],循环继续;成功记到result.granted[] - HTTP 返回完整
result
没有 sleep / throttle —— 200 个 recipient × 20–50ms 单条 ≈ 4–10s,安心进 LB 30s 超时窗口。如果以后真的需要更大批量,先评估是否值得拆,再决定加 cap 还是引入异步。
4. 失败 / 恢复
| 故障 | 表现 | 恢复 |
|---|---|---|
单条 recipient creditFlux 抛错(DB blip 等) |
出现在 result.failed[] |
admin 看响应,自己再发一次相同请求;如果用了 idempotencyKey,已 granted 的不会被双发,只重试 failed 的 |
| 整个请求超 LB 超时 | 客户端看到超时,部分 recipient 已扣账 | admin 用同 idempotencyKey 重发,已成功的直接幂等跳过 |
| Operator 输错邮箱 / 数量 | 先用 ?dryRun=true 看 preview |
改完再去掉 dryRun |
5. 审计
- 每条成功 grant 在
flux_transaction写一行(type='promo'、metadata.description、metadata.issuedByUserId、可选metadata.idempotencyKey) - 没有专门的 admin 报表;用
/api/v1/flux/history或直接 SQL 按metadata->>'description'/metadata->>'idempotencyKey'查
-- 看某次 grant 实际发了多少
SELECT user_id, amount, balance_after, created_at
FROM flux_transaction
WHERE metadata->>'idempotencyKey' = 'beta-2026-q2'
ORDER BY created_at;
6. 实现位置
- 路由:
apps/server/src/routes/admin/flux-grants/index.ts - Service:
apps/server/src/services/admin-flux-grants/index.ts - 单测:
apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts - adminGuard:
apps/server/src/middlewares/admin-guard.ts - 数据库:没有专门的表;唯一持久化是
flux_transactionledger - 已废弃:
flux_grant_batch/flux_grant_batch_recipient(drizzle migration0011_superb_lady_deathstrike.sql删表)
7. 不做
- 不做 batch 状态机 / retry endpoint —— 同步响应里已经有 failed 列表,admin 看到失败就自己再发
- 不做异步处理 / 后台 loop —— 200 用户上限完全可以塞进一个 HTTP 请求
- 不做 dashboard 展示 —— 直接查
flux_transaction即可 - 不做高并发 / 大批量 —— 这是 admin 工具不是 bulk import;超 200 就让 admin 拆请求
8. 已知不足
- 无 admin-side 失败留痕:
failed[]只在 HTTP 响应里返回一次,admin 关掉浏览器就没了。如果将来发现需要"上次失败的那批"持久化,再单独加一张admin_grant_attempt_log之类的,不要把它做回 batch 表。 emails上限 200 是经验估算:单creditFlux假设 20–50ms。如果实际生产数据显示更慢,下调上限。