apps/server/docs/ai-context/admin-flux-grants.md
Admin 一次性给若干用户发 FLUX(Beta 致谢、补偿、运营赠送等)的接口。整个流程单一同步 HTTP 调用搞定,没有 batch 表、没有状态机、没有后台 loop。
旧设计是 flux_grant_batch + flux_grant_batch_recipient 两张表 + 状态机 + 异步处理 + retry 端点 + advisory-lock poller,~800 行代码。实际产品里 admin 发放频率"几周一次、几十个用户",过度工程。简化为:
POST /api/admin/flux-grants 接口creditFlux → 返回每条的 outcomeflux_transaction 表(type='promo'、metadata.description / metadata.idempotencyKey)failed[],自己再发一次(用 idempotencyKey 防止已成功的部分被双发)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 抛错时进这里
},
}
service.resolveEmails(emails):
IN (...) 查 user.email(不能 wrap LOWER(),会 break unique index → seq scan)user_flux.deletedAtduplicate_in_inputstatus='pending' 的 recipient 顺序调 BillingService.creditFlux({ userId, amount, type: 'promo', requestId, description, source: 'admin_promo', auditMetadata })result.failed[],循环继续;成功记到 result.granted[]result没有 sleep / throttle —— 200 个 recipient × 20–50ms 单条 ≈ 4–10s,安心进 LB 30s 超时窗口。如果以后真的需要更大批量,先评估是否值得拆,再决定加 cap 还是引入异步。
| 故障 | 表现 | 恢复 |
|---|---|---|
单条 recipient creditFlux 抛错(DB blip 等) | 出现在 result.failed[] | admin 看响应,自己再发一次相同请求;如果用了 idempotencyKey,已 granted 的不会被双发,只重试 failed 的 |
| 整个请求超 LB 超时 | 客户端看到超时,部分 recipient 已扣账 | admin 用同 idempotencyKey 重发,已成功的直接幂等跳过 |
| Operator 输错邮箱 / 数量 | 先用 ?dryRun=true 看 preview | 改完再去掉 dryRun |
flux_transaction 写一行(type='promo'、metadata.description、metadata.issuedByUserId、可选 metadata.idempotencyKey)/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;
apps/server/src/routes/admin/flux-grants/index.tsapps/server/src/services/domain/admin/flux-grants/index.tsapps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.tsapps/server/src/middlewares/admin-guard.tsflux_transaction ledgerflux_grant_batch / flux_grant_batch_recipient(drizzle migration 0011_superb_lady_deathstrike.sql 删表)flux_transaction 即可failed[] 只在 HTTP 响应里返回一次,admin 关掉浏览器就没了。如果将来发现需要"上次失败的那批"持久化,再单独加一张 admin_grant_attempt_log 之类的,不要把它做回 batch 表。emails 上限 200 是经验估算:单 creditFlux 假设 20–50ms。如果实际生产数据显示更慢,下调上限。