docs(server): update ai-context

This commit is contained in:
RainbowBird
2026-05-08 22:21:31 +08:00
parent abd37dd96e
commit f2cf395fd2
8 changed files with 66 additions and 634 deletions
+11 -3
View File
@@ -14,15 +14,17 @@
- `transport-and-routes.md` - `transport-and-routes.md`
- HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件 - HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件
- `data-model-and-state.md` - `data-model-and-state.md`
- 主要表、状态归属、缓存与事件模型 - 主要表、状态归属、缓存边界(事件队列层已拆掉)
- `workers-and-runtime.md` - `workers-and-runtime.md`
-`api` role、进程内后台 loop、advisory lock 协调、运行时约束 -`api` role、后台 loop、运行时约束admin grant / Stripe webhook 等都同步在请求线程)
- `redis-boundaries-and-pubsub.md` - `redis-boundaries-and-pubsub.md`
- Redis key / channel 收口、Pub/Sub 边界、运行时校验约束 - Redis key / channel 收口、Pub/Sub 边界、运行时校验约束
- `config-and-naming-conventions.md` - `config-and-naming-conventions.md`
- `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO - `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO
- `billing-architecture.md` - `billing-architecture.md`
- 计费链路专项说明,重点看 Flux ledger / Stripe 幂等 - 计费链路专项说明,重点看 Flux ledger / Stripe 幂等
- `stripe-pricing.md`
- Flux 充值定价以 Stripe Product/Price 为单一真相源,多币种 / 缓存 / 运营操作
- `flux-meter.md` - `flux-meter.md`
- Sub-Flux 计量服务(TTS/STT 等)的债务账本机制与复用指南 - Sub-Flux 计量服务(TTS/STT 等)的债务账本机制与复用指南
- `observability-conventions.md` - `observability-conventions.md`
@@ -39,6 +41,8 @@
- 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据 - 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据
- `verifications/account-deletion.md` - `verifications/account-deletion.md`
- 账号注销端到端验证:what's verifiedschema/typecheck/units)和 what's pendinglive DB + Resend + Stripe trace - 账号注销端到端验证:what's verifiedschema/typecheck/units)和 what's pendinglive DB + Resend + Stripe trace
- `verifications/admin-flux-grants.md`
- Admin 同步发 FLUX 路径:同步 grant / dry-run / adminGuard 拒绝(架构刚从 batch 切换到同步,待重新实测)
## 快速结论 ## 快速结论
@@ -54,10 +58,14 @@
- 改 API 入口或新增依赖:先看 `architecture-overview.md` - 改 API 入口或新增依赖:先看 `architecture-overview.md`
- 改某个接口行为:先看 `transport-and-routes.md` - 改某个接口行为:先看 `transport-and-routes.md`
- 改表结构、缓存或幂等:先看 `data-model-and-state.md` - 改表结构、缓存或幂等:先看 `data-model-and-state.md`
- 后台 loop、部署形态:先看 `workers-and-runtime.md` - 想加任何"异步副作用 / 后台 loop":先看 `workers-and-runtime.md` 的"运行时修改建议"
- 改 Redis key、Pub/Sub 边界:先看 `redis-boundaries-and-pubsub.md` - 改 Redis key、Pub/Sub 边界:先看 `redis-boundaries-and-pubsub.md`
- 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md` - 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md`
- 改扣费、充值、Stripe:先看 `billing-architecture.md` - 改扣费、充值、Stripe:先看 `billing-architecture.md`
- 改 Flux 充值价格 / 多币种 / Stripe Product/Price:先看 `stripe-pricing.md`
- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md` - 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md`
- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md` - 改认证、OIDC、登录流程:先看 `auth-and-oidc.md`
- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md` - 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md`
- 改账号注销 / 业务 service 的 `deleteAllForUser`:先看 `account-deletion.md`
- 改 admin 发 FLUX 路径:先看 `admin-flux-grants.md`
- 改 TTS / STT / embedding 等 sub-Flux 计量:先看 `flux-meter.md`
@@ -1,518 +0,0 @@
# Admin Flux Grant Batch
- **Date**: 2026-05-07 (initial), 2026-05-08 (rename + docs move)
- **Status**: Implemented (v1) — pending end-to-end live verification
- **Surface**: `apps/server`
> **Naming note**: an earlier draft used the table name `campaign`. Renamed to `flux_grant_batch` before merge because `campaign` is a generic marketing term that overpromises (suggests support for coupons / discounts / referral rewards) while underdescribing the actual capability (batch FLUX grants only).
## 1. 背景
目前 `BillingService.creditFlux` 提供了"给单个 userId 加余额"的事务安全实现([billing-service.ts:162](apps/server/src/services/billing/billing-service.ts#L162)),但没有任何上层入口(路由 / 脚本 / CLI)调用它。所有 promo / 客诉补偿 / 活动赠送类的余额发放,目前只能:
1. 直接写裸 SQL 修改 `user_flux` + `flux_transaction` + 手动失效 Redis(绕过 `BillingService`,违反 [`apps/server/CLAUDE.md`](apps/server/CLAUDE.md) "balance 写入只走 BillingService" 的架构约束)
2. 临时跑脚本,每次活动都要 ad-hoc
随着 FLUX 商业化推进,预计批量发放会成为常规运营动作(每月 1+ 次,每次 1k–10k 用户)。需要一个稳定、可观测、可重试的 admin 接口。
## 2. 目标 / 非目标
### 目标
- Dev 通过 admin API 给指定邮箱列表批量发放 FLUX
- 发放过程异步执行,可查询进度(待发 / 已发 / 跳过 / 失败 计数)
- 失败项可手动重试
- ledger 留下"活动赠送"语义标记(区别于 Stripe 充值、初始赠送)
- 创建前可 dry-run 预览(避免错 amount / 错人群)
- 通过 better-auth session + env allowlist 限制访问
### 非目标(v1
- **回滚 / 反向操作**:罕见(年 0–2 次),用 ad-hoc SQL 处理;建固定接口 ROI 不成立
- **email → userId 解析以外的输入解析**:不支持 CSV 上传、不支持电话号码、不支持用户分群条件
- **运营自助 UI**dashboard 提供只读查看 + 可以发起 API 调用,但不是为非技术用户设计
- **完整 RBAC role 系统**env-var allowlist 够用,未来加 role 列再升级
- **细粒度 amount 上限 / 审批流**dev 自律,必要时加 daily 总额限制即可
- **优惠码 / 折扣券 / 邀请奖励等其它优惠形式**:本 schema **不**包含 code 字段、`max_redemptions`、用户行为触发器等机制;未来要做这些功能是新的 schema(`coupon` / `referral_reward`),不在本 batch 表上加列
## 3. 数据模型
### 3.1 新增表
#### `flux_grant_batch`
```ts
export const fluxGrantBatch = pgTable('flux_grant_batch', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
name: text('name').notNull(), // 操作者填写,e.g. "Spring 2026 Promo"
type: text('type').notNull(), // 'promo'v1 只支持这一种)
amount: bigint('amount', { mode: 'number' }).notNull(), // 每人发多少 FLUX
description: text('description'), // 写到每条 flux_transaction.description
status: text('status').notNull(), // 'created' | 'running' | 'completed' | 'failed_partial'
createdByUserId: text('created_by_user_id').notNull(), // 操作者 userId(来自 session
createdAt: timestamp('created_at').defaultNow().notNull(),
startedAt: timestamp('started_at'), // worker 第一次拣起 recipient 的时间
completedAt: timestamp('completed_at'), // 所有 recipient 终态后的时间
}, table => [
index('flux_grant_batch_status_idx').on(table.status),
index('flux_grant_batch_created_by_idx').on(table.createdByUserId),
])
```
**status 状态机:**
```
draft (dryRun, 不入库) → 不存在
created → running → completed (全部 granted)
created → running → failed_partial (部分 failed/skipped 后无法继续)
```
`draft` 仅在 dryRun 响应中出现,不持久化。创建即 `created`worker 首次拣起改 `running`
#### `flux_grant_batch_recipient`
```ts
export const fluxGrantBatchRecipient = pgTable('flux_grant_batch_recipient', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
batchId: text('batch_id').notNull(), // FK 概念上指向 flux_grant_batch.id(不强制 FK 约束以避免级联删除)
inputEmail: text('input_email').notNull(), // 操作者输入快照(审计用),保留原始大小写
userId: text('user_id'), // 解析成功才有;NULL = email 没匹配上
status: text('status').notNull(), // 'pending' | 'granted' | 'skipped' | 'failed'
errorReason: text('error_reason'), // 'not_found' | 'user_deleted' | 'duplicate_in_input' | 'user_deleted_after_resolution' | DB error message
fluxTransactionId: text('flux_transaction_id'), // granted 后回填,指向 flux_transaction.id
attemptCount: integer('attempt_count').notNull().default(0),
lastAttemptedAt: timestamp('last_attempted_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, table => [
index('flux_grant_batch_recipient_batch_status_idx').on(table.batchId, table.status),
index('flux_grant_batch_recipient_pending_idx').on(table.status, table.lastAttemptedAt).where(sql`status = 'pending'`),
uniqueIndex('flux_grant_batch_recipient_batch_email_uniq').on(table.batchId, table.inputEmail),
])
```
**status 状态机:**
```
pending → granted (creditFlux 成功)
pending → failed (重试上限后)
pending → skipped (worker 执行时 user_flux.deleted_at 已置位)
created at insert:
- pending (email 解析成功,userId 已写入)
- skipped (email 没匹配 / 用户已注销 / input 重复)
```
**`(batch_id, input_email)` 唯一索引**:同一 batch 内邮箱不会重复处理。
**Partial index `WHERE status = 'pending'`**:worker 轮询效率,避免扫整表。
### 3.2 现有表扩展
#### `flux_transaction.type` 枚举注释扩展
[flux-transaction.ts:13](apps/server/src/schemas/flux-transaction.ts#L13) 现在的注释:
```ts
type: text('type').notNull(), // 'credit' | 'debit' | 'initial'
```
改为:
```ts
type: text('type').notNull(), // 'credit' | 'debit' | 'initial' | 'promo'
```
注:`type``text` + 注释伪枚举,DB 不强制。新增值无需 migration,但需要 grep 现有代码里是否有硬编码 `type === 'credit'` 的地方。已知影响面:
- `BillingService.creditFlux` 写入处([billing-service.ts:194](apps/server/src/services/billing/billing-service.ts#L194)):硬编码 `'credit'`,需要扩展为可入参
- `BillingService.creditFluxFromStripeCheckout` ([billing-service.ts:285](apps/server/src/services/billing/billing-service.ts#L285)):仍写 `'credit'`,不动
- `BillingService.creditFluxFromInvoice` ([billing-service.ts:397](apps/server/src/services/billing/billing-service.ts#L397)):仍写 `'credit'`,不动
- `flux-transaction.ts``TransactionEntry.type` 类型 union 加 `'promo'`
- `getStats` 的 capacity 算法把 `'promo'` 计入 `inArray` 列表(promo 入账后用户进度条 capacity 应跟着涨)
**为什么不拆 `type` + `category` 两列**:当前 credit 子类型只会增加 1 个(promo)。等出现第 2 个不同来源(refund / staff_grant / beta)时再做这次重构。YAGNI。
### 3.3 `BillingService.creditFlux` 签名扩展
```ts
async creditFlux(input: {
userId: string
amount: number
requestId?: string
description: string
source: string
type?: 'credit' | 'promo' // 新增,默认 'credit'
auditMetadata?: Record<string, unknown>
}): Promise<{ balanceBefore: number, balanceAfter: number, fluxTransactionId: string }>
```
写入 ledger 时使用 `input.type ?? 'credit'`。其他逻辑(事务、Redis 失效、`flux.credited` 事件)不变。
返回值新增 `fluxTransactionId`worker 拿到后回填到 `flux_grant_batch_recipient.flux_transaction_id`,为后续报表查询省掉一次 join。
## 4. API 表面
所有路由挂在 `/api/admin/*`,前置 `sessionMiddleware` + 新增 `adminGuard` middleware。
### 4.1 `POST /api/admin/flux-grant-batches`
创建 batch。支持 dry-run。
**Request:**
```http
POST /api/admin/flux-grant-batches?dryRun=false
Content-Type: application/json
Authorization: Bearer <session-jwt>
{
"name": "Spring 2026 Promo",
"amount": 200,
"description": "Spring promo grant",
"emails": ["alice@example.com", "bob@example.com"]
}
```
**Query params:**
- `dryRun` (`true` | `false`,默认 `false`):true 时只解析、不写库、不调度
**Body 字段:**
- `name`: 1100 chars
- `amount`: integer ≥ 1, ≤ `MAX_GRANT_AMOUNT_PER_USER`(默认 10000
- `description`: 0500 chars
- `emails`: array of email strings, length 110000
**Response (dryRun=true):**
```json
{
"preview": {
"totalEmails": 1000,
"willGrant": 947,
"willSkip": {
"notFound": 30,
"userDeleted": 18,
"duplicateInInput": 5
},
"totalFluxToIssue": 189400,
"estimatedDurationSec": 19,
"samples": {
"willGrant": ["alice@example.com", "bob@example.com", "..."],
"notFound": ["typo@old-domain.com", "..."],
"userDeleted": ["deleted-user@example.com", "..."]
}
}
}
```
`samples` 每类截取前 5 条用于人工 sanity check。
**Response (dryRun=false):**
```json
{
"batch": {
"id": "fgb_abc123",
"name": "Spring 2026 Promo",
"status": "created",
"createdAt": "2026-05-07T10:00:00.000Z",
"createdByUserId": "uid_xyz"
},
"summary": {
"totalEmails": 1000,
"pending": 947,
"skipped": 53,
"totalFluxToIssue": 189400
}
}
```
写库后立刻返回 `202 Accepted`worker 异步执行。
**Errors:**
- `400` validation 失败 / amount 超上限 / emails 列表为空
- `401` 未登录
- `403` 已登录但不在 admin 白名单
### 4.2 `GET /api/admin/flux-grant-batches/:id`
查询单个 batch 详情和进度。
**Response:**
```json
{
"batch": {
"id": "fgb_abc123",
"name": "Spring 2026 Promo",
"type": "promo",
"amount": 200,
"status": "running",
"createdByUserId": "uid_xyz",
"createdAt": "2026-05-07T10:00:00.000Z",
"startedAt": "2026-05-07T10:00:05.000Z",
"completedAt": null
},
"progress": {
"total": 1000,
"pending": 200,
"granted": 745,
"skipped": 53,
"failed": 2
},
"recentFailures": [
{
"id": "fgr_xxx",
"inputEmail": "alice@example.com",
"userId": "uid_alice",
"errorReason": "DB connection timeout",
"attemptCount": 3,
"lastAttemptedAt": "2026-05-07T10:05:00.000Z"
}
]
}
```
`recentFailures` 限 20 条,最近优先。
### 4.3 `GET /api/admin/flux-grant-batches`
列表分页查询。
**Query:**
- `limit`: default 20, max 100
- `cursor`: opaque cursor (created_at + id, base64-encoded)
- `status`: optional filter
**Response:**
```json
{
"batches": [{ "id": "...", "name": "...", "status": "...", ... }],
"nextCursor": "..."
}
```
### 4.4 `POST /api/admin/flux-grant-batches/:id/retry`
把所有 `status='failed'` 的 recipient 改回 `status='pending'`,重置 `attemptCount``lastAttemptedAt`。worker 下次轮询自动拣起。如果 batch 已经在 `completed` / `failed_partial` 终态,会被改回 `running`
**Response:**
```json
{ "retriedCount": 2 }
```
**Idempotency**: 重复调用安全(没有 failed 项时返回 `retriedCount: 0`)。
## 5. 调度与执行
### 5.1 Worker 部署
复用 `billing-consumer` Railway role[`apps/server/CLAUDE.md`](apps/server/CLAUDE.md) "Railway role 越少越好维护"),不开新 role。
`billing-consumer` 当前只跑 Redis Stream 消费循环(`src/bin/run-billing-consumer.ts`)。增加一个并行的 flux grant batch 轮询循环,独立 lifecycle。两个 loop 共享 DB pool 和 Redis 连接,通过 `Promise.all` 一起跑,任一抛错 + abortSignal 共享让另一个也退出。
### 5.2 调度循环
```
loop forever:
1. findActiveBatches: 找出有 pending recipient 的 batchstatus ∈ {created, running}
2. 对每个 batch,开事务取 N 条:
SELECT recipient.* FROM flux_grant_batch_recipient
INNER JOIN flux_grant_batch ON ...
WHERE batch_id = $1
AND status = 'pending'
AND (last_attempted_at IS NULL OR last_attempted_at + backoff <= NOW())
ORDER BY created_at
LIMIT N
FOR UPDATE SKIP LOCKED -- 多 worker 实例安全
3. 对每条 recipient
a. re-check user_flux.deleted_at;如果已注销 → status='skipped' + reason='user_deleted_after_resolution'
b. 否则调 BillingService.creditFlux({
userId, amount: batch.amount, type: 'promo',
requestId: `flux-grant-batch-${batchId}-${recipientId}`,
description: batch.description ?? `Flux grant batch: ${batch.name}`,
source: 'admin_promo',
auditMetadata: { batchId, batchName, recipientId },
})
c. 成功 → status='granted', flux_transaction_id 回填, attempt_count++
d. 失败 (attempt < MAX_ATTEMPTS) → 保持 pendingattempt_count++,下轮重试
e. 失败 (attempt >= MAX_ATTEMPTS) → status='failed', error_reason=...
4. 当 batch 没有 pending 项时 (finalizeBatchIfDone):
UPDATE flux_grant_batch
SET status = (any failed → 'failed_partial' else 'completed'),
completed_at = NOW()
5. throttle: 每发一条 sleep (1000 / throttlePerSec) ms
6. 没拿到行时 sleep idleSleepMs (默认 1000)
```
**关键约束:**
- `FOR UPDATE SKIP LOCKED`:多 `billing-consumer` 实例同时跑安全,不会重复处理同一行
- `(user_id, request_id)` 唯一索引([flux-transaction.ts:24-26](apps/server/src/schemas/flux-transaction.ts#L24-L26)):crash-recovery 时即使 `creditFlux` 重入也不会双发
- `requestId = flux-grant-batch-${batchId}-${recipientId}`:用 recipientId 而非 userId,因为同一 user 在不同 batch 可以被发多次(不同 recipient 行 → 不同 requestId
### 5.3 Backoff 策略
```
backoffMs(attempt) =
attempt == 0 → 0s
attempt == 1 → 30s
attempt >= 2 → 5min
```
`MAX_ATTEMPTS = 3`,可调(worker option)。
### 5.4 节流
`throttlePerSec` 默认 50。理由:
- `creditFlux` 单条约 1020msDB tx + redis + stream xadd
- 50/s 已经足够 1k 用户在 20s 内完成
- 不抢线上 LLM 流量(`/api/v1/openai` 高峰能到 100+ req/s
## 6. Auth
### 6.1 新增 env var
```ts
// libs/env.ts EnvSchema 中追加一个字段:
const EnvSchema = object({
// ...其它字段...
ADMIN_EMAILS: optional(string(), ''), // 逗号分隔邮箱,e.g. "alice@example.com,bob@example.com"
})
```
空字符串 = 没人是 admin(生产默认安全)。
**为什么用 email 不用 user.id**
- 运营心智模型 = 邮箱,没人记得 `uid_aaa`
- env 配置可读性 + grep 可读性都更好
- 离职 / 接任时直接编辑邮箱列表,不需要先去 DB 查 userId
**为什么必须 `email_verified = true`**
- 否则攻击者可以注册一个新账号,用 admin 邮箱(在 admin 注销 / 邮箱被释放后),不验证就尝试访问 admin 接口
- 走 OAuth (Google/GitHub) 的用户邮箱默认 verifiedemail/password 注册要求显式验证
- 这是零成本的额外防线,admin 用户都会有 verified 邮箱
### 6.2 `adminGuard` middleware
新建 `apps/server/src/middlewares/admin-guard.ts`
```ts
export function adminGuard(env: Env): MiddlewareHandler<HonoEnv> {
const adminEmails = parseAdminEmails(env.ADMIN_EMAILS) // lowercase + trim
return async (c, next) => {
const user = c.get('user')
if (!user) throw createUnauthorizedError(...)
if (!user.emailVerified) throw createForbiddenError('Admin access requires a verified email')
if (!adminEmails.has(user.email.toLowerCase())) throw createForbiddenError(...)
await next()
}
}
```
挂载顺序:`sessionMiddleware → authGuard → adminGuard → 路由`
### 6.3 审计
每个 batch 写 `created_by_user_id`。每条 ledger 的 metadata 含 `{batchId, batchName, recipientId}``description` 字段写 batch 名或 description。事后查"谁在什么时候发了什么活动"通过:
```sql
SELECT b.id, b.name, b.amount,
COUNT(r.id) FILTER (WHERE r.status='granted') AS granted_count,
u.email AS operator_email
FROM flux_grant_batch b
JOIN "user" u ON u.id = b.created_by_user_id
LEFT JOIN flux_grant_batch_recipient r ON r.batch_id = b.id
GROUP BY b.id, u.email
ORDER BY b.created_at DESC;
```
未来加 RBAC 时 `adminGuard` 改成 `user.role === 'admin'`env-var 删除,其他保持。
## 7. Failure Modes & Recovery
| 故障 | 检测 | 恢复 |
|---|---|---|
| worker crash | Railway 自动重启 | `pending` 行被新 worker 拣起,`(user_id, request_id)` 唯一索引防双发 |
| Postgres 短暂故障 | `creditFlux` 抛错 | recipient `attempt_count++`30s 后重试 |
| Redis 短暂故障 | `creditFlux` 在 update cache 阶段抛错 | 同上。已在事务中提交的余额不会回滚 → 下次 retry 会被唯一索引挡住但 cache 仍未刷新。**已知缺口**:v1 接受这个边界,下次 `getFlux` 触发 cache miss 会自动从 DB 读最新值。 |
| batch 创建后立刻 worker 还没拣起就有用户注销 | worker 执行前 re-check `user_flux.deleted_at` | 标 `skipped` + `user_deleted_after_resolution` |
| 单个 recipient 永久失败(非瞬时) | `attempt >= 3` 后标 `failed` | 操作者 `POST /retry` 或人工查 `error_reason` |
| 整个 batch 卡死(worker 不工作) | `progress.pending > 0 && lastUpdated > 5min` | 报警 → 看 `billing-consumer` 日志;手动重启 |
## 8. Configuration
### 8.1 env vars (libs/env.ts)
```
ADMIN_USER_IDS 逗号分隔 userId allowlist;空 = 没人是 admin
```
### 8.2 Worker options(可调,目前在代码里写死默认)
```
batchSize 默认 50 每次轮询拣多少 recipient
throttlePerSec 默认 50 每秒最多发多少条
maxAttempts 默认 3 超过即标 failed
idleSleepMs 默认 1000 无 work 时 sleep 多久
```
未来可以接 configKV 实时调整。
### 8.3 Route-level constants
```
MAX_GRANT_AMOUNT_PER_USER 10_000 单人 amount 上限
MAX_EMAILS_PER_BATCH 10_000 单 batch emails 列表上限
```
## 9. 测试策略
### 9.1 Unit / Integration
- `parseAdminUserIds` + `adminGuard` middlewareallowlist 命中 / 不命中 / 空 allowlist / 无 session
- `resolveEmails`case insensitive、找不到、用户注销、input 重复、samples 截断、estimatedDurationSec 边界
- `createFluxGrantBatchService.create`persist batch + recipient rowspending/skipped 状态正确
- `createFluxGrantBatchService.preview`dry-run 不写库
- `createFluxGrantBatchService.retryFailed`failed → pending 重置,completed batch 改回 running,无 failed 时 idempotent
- `backoffMs`3 个分支边界
### 9.2 文件位置
- `apps/server/src/middlewares/tests/admin-guard.test.ts`
- `apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-service.test.ts`
- `apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-worker.test.ts`
## 10. 实施顺序
1. **Schema + migration**`flux_grant_batch` + `flux_grant_batch_recipient` 表,`drizzle-kit generate`
2. **`BillingService.creditFlux` 扩展**:加 `type` 入参,返回 `fluxTransactionId`
3. **Service 层**`createFluxGrantBatchService`(创建、查询、retry+ 共享 worker helper
4. **Worker**`runFluxGrantBatchWorker` 轮询循环
5. **Middleware**`adminGuard` + `parseAdminUserIds`
6. **Routes**`/api/admin/flux-grant-batches/*`
7. **Worker 接入**:在 `src/bin/run-billing-consumer.ts``Promise.all` 并行启动
8. **配置**env var
9. **Verification**`docs/ai/context/verifications/admin-flux-grant-batch.md`(按 CLAUDE.md verification 格式手动跑一次端到端)
## 11. 已知风险 / Open Questions
- **Redis cache 失效缺口**worker 在 DB 事务 commit 后、cache update 前 crash → 下次 `getFlux` cache miss 触发自动修复。能接受。
- **`type='promo'` 软枚举**:DB 不强制;下游若有人写 `type IN ('credit', 'initial')` 没改的话会漏掉 promo 记录。实施时已 grep 全量过一遍(命中 `flux-transaction.ts:60` 的 capacity 计算,已修复)。
- **跨 batch 同 user 多次发**:允许(不同 recipientId → 不同 requestId)。同一 batch 内通过 `(batch_id, input_email)` 唯一索引防多发。
- **没有 daily total cap**:理论上 dev 可以一次发 10k × 10000 FLUX = 100M FLUX。当前没硬限。靠 dry-run + code review。
- **dashboard 联调**:本设计假设 dashboard 后续会接入。spec 里只规范 API;UI 设计不在范围内。
## 12. 参考
- [`apps/server/CLAUDE.md`](apps/server/CLAUDE.md) — 服务架构总览
- [`apps/server/docs/ai-context/billing-architecture.md`](apps/server/docs/ai-context/billing-architecture.md) — Flux/Stripe/outbox/Streams
- [`billing-service.ts`](apps/server/src/services/billing/billing-service.ts) — 现有 `creditFlux` 实现
- [`flux-transaction.ts`](apps/server/src/schemas/flux-transaction.ts) — ledger schema
- [`flux.ts`](apps/server/src/schemas/flux.ts) — `user_flux` schema
- [`redis-keys.ts`](apps/server/src/utils/redis-keys.ts) — Redis 键规范
@@ -7,7 +7,7 @@
- 路由层负责参数校验、鉴权、错误映射 - 路由层负责参数校验、鉴权、错误映射
- 服务层负责业务逻辑和数据库事务 - 服务层负责业务逻辑和数据库事务
- `Postgres` 负责持久化与账本真相 - `Postgres` 负责持久化与账本真相
- `Redis` 负责缓存、配置 KV、Pub/SubStreams - `Redis` 负责缓存、配置 KV、Pub/Sub(不再使用 Streams
- `injeca` 负责把这些依赖组装成一个可启动应用 - `injeca` 负责把这些依赖组装成一个可启动应用
## 入口与装配 ## 入口与装配
@@ -51,6 +51,10 @@ CLI 入口在 `src/bin/run.ts`,只有一种角色:
- `fluxService` - `fluxService`
- `requestLogService` - `requestLogService`
- `billingService` - `billingService`
- `adminFluxGrantsService`
- `ttsMeter`
- `userDeletionService`
- `emailService`
这个装配顺序说明了几个事实: 这个装配顺序说明了几个事实:
@@ -148,12 +152,12 @@ Redis 在这里同时承担:
- Flux 余额缓存 - Flux 余额缓存
- 运行时配置 KV - 运行时配置 KV
- WebSocket 跨实例广播 Pub/Sub - WebSocket 跨实例广播 Pub/Sub
- 计费事件 Streams - Sub-Flux 计量债务账本(TTS 字符等,TTL 抹零,详见 `flux-meter.md`
- TTS voices 上游响应缓存
但余额真相仍然在 Postgres。 但余额真相仍然在 Postgres。Redis Streams 已全部移除,详见 `redis-boundaries-and-pubsub.md` 的 NOTICE。
## 当前值得注意的实现信号 ## 当前值得注意的实现信号
- `src/services/request-log.ts``src/services/llm-request-log.ts` 职责重复,当前实际注入的是前者 - `/api/v1/openai` 当前开放:`POST /chat/completions``POST /chat/completion``POST /audio/speech``GET /audio/voices``handleTranscription` 路由尚未挂载
- `src/schemas/accounts.ts``src/schemas/auth.ts` 内容重复,`createAuth()` 使用的是 `accounts.ts` - `flux_grant_batch` schema / service / route 已被简化版 `admin-flux-grants` 取代,但旧的 `src/schemas/flux-grant-batch.ts``src/services/admin-flux-grant-batch/``src/routes/admin/flux-grant-batches/` 仍以 dead code 形态残留在仓库里,没有在 `app.ts` 装配。改这块前直接删旧文件,不要继续往里面加东西
- `src/routes/openai/v1/index.ts` 已实现 `handleTTS` / `handleTranscription`,但路由仍被注释掉,当前只开放 chat completions。
@@ -13,10 +13,11 @@
- `Redis` - `Redis`
- Flux 余额缓存 - Flux 余额缓存
- 服务配置 KV - 服务配置 KV
- 聊天跨实例广播 - 聊天跨实例广播 (Pub/Sub)
- 计费事件队列 - Sub-Flux 计量债务账本(TTS 字符等,详见 `flux-meter.md`
- TTS voices 上游响应缓存
如果要判断改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。 如果要判断改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。Redis Streams 已全部移除,没有”计费事件队列”这层抽象。
## 主要表分组 ## 主要表分组
@@ -34,7 +35,7 @@
说明: 说明:
- `better-auth` 直接用这组表 - `better-auth` 直接用这组表
- `src/schemas/auth.ts` 基本是重复副本,目前不是主要依赖入口 - `pnpm -F @proj-airi/server auth:generate` 自动产物,手改会被覆盖
### 角色与用户交互 ### 角色与用户交互
@@ -91,33 +92,28 @@
- 运行时查询时会把系统配置和用户配置拼接成一个结果集 - 运行时查询时会把系统配置和用户配置拼接成一个结果集
- `config``jsonb` - `config``jsonb`
### Flux / 账本 / 审计 ### Flux / 账本
- `user_flux` - `user_flux`
- `flux_transaction` - `flux_transaction`
- `flux_transaction`
来源文件: 来源文件:
- `src/schemas/flux.ts` - `src/schemas/flux.ts`
- `src/schemas/flux-transaction.ts` - `src/schemas/flux-transaction.ts`
- `src/schemas/flux-transaction.ts`
职责边界: 职责边界:
- `user_flux` - `user_flux`
- 当前余额快照 - 当前余额快照(单行/用户)
- `flux_transaction` - `flux_transaction`
- append-only 账本流水 - append-only 账本流水type: credit / debit / initial / promo
- 系统真相源 - 同时承担系统真相源和用户可见历史,`/api/v1/flux/history` 直接读这张表
- `flux_transaction`
- 用户可见历史
- 偏产品展示
关键约束: 关键约束:
- `flux_transaction``(userId, requestId)` 有部分唯一索引 - `flux_transaction``(userId, requestId) WHERE requestId IS NOT NULL` 有部分唯一索引
- 用来做扣费 / 充值幂等 - 用来做扣费 / 充值幂等(含 admin promo grant 的 `idempotencyKey`
### Stripe 业务镜像 ### Stripe 业务镜像
@@ -238,4 +234,4 @@
## 现有代码中的结构信号 ## 现有代码中的结构信号
- `accounts.ts` `auth.ts` 是重复 schema,后续如果做整理,应先统一真实使用入口再删副本 - `src/schemas/flux-grant-batch.ts` 是已废弃的旧 admin batch 设计 schema,没有被 `app.ts` 装配也没有 migration 在用,是 dead code,改这块前直接删除。当前 admin 发 FLUX 走 `/api/admin/flux-grants` 同步路径,不写新表
@@ -130,7 +130,7 @@ const data = JSON.parse(message) as BroadcastMessage
## Chat WS 当前约束 ## Chat WS 当前约束
`src/routes/chat-ws.ts` 当前采用: `src/routes/chat-ws/index.ts` 当前采用:
- 同实例内存连接表 - 同实例内存连接表
- 跨实例 Redis Pub/Sub - 跨实例 Redis Pub/Sub
@@ -143,9 +143,9 @@ const data = JSON.parse(message) as BroadcastMessage
因此后续如果改聊天同步: 因此后续如果改聊天同步:
- 需要可重放”时,不要继续堆在 Pub/Sub 上 - 需要可重放”时,不要继续堆在 Pub/Sub 上
- 需要跨实例即时通知”时,可以继续用 Pub/Sub - 需要跨实例即时通知”时,可以继续用 Pub/Sub
- 需要持久事件消费”时,应优先考虑 Streams - 需要持久事件消费”时,先回到 NOTICE 评估是否真的需要异步副作用,再考虑引入 Streams 这类抽象
## 修改 Redis 代码时的检查清单 ## 修改 Redis 代码时的检查清单
@@ -159,12 +159,16 @@ const data = JSON.parse(message) as BroadcastMessage
## 当前代码可直接参考的位置 ## 当前代码可直接参考的位置
- key helper - key helper 集中点
- `src/utils/redis-keys.ts`
- 余额读 cache-aside
- `src/services/flux.ts` - `src/services/flux.ts`
- Streams 边界封装 - Sub-Flux 计量债务账本
- `src/libs/mq/stream.ts` - `src/services/billing/flux-meter.ts`
- TTS voices 上游响应缓存
- `src/routes/openai/v1/index.ts::handleListVoices`
- Pub/Sub 聊天广播 - Pub/Sub 聊天广播
- `src/routes/chat-ws.ts` - `src/routes/chat-ws/index.ts`
- 命名规范和待迁移事项 - 命名规范和待迁移事项
- `config-and-naming-conventions.md` - `config-and-naming-conventions.md`
@@ -5,6 +5,7 @@
应用在 `src/app.ts` 中挂载以下路由: 应用在 `src/app.ts` 中挂载以下路由:
- `GET /health` - `GET /health`
- `GET /` — 服务标识 JSON,避免邮件链接拼错落到框架默认 404
- `/api/auth/*` - `/api/auth/*`
- `/api/v1/characters` - `/api/v1/characters`
- `/api/v1/providers` - `/api/v1/providers`
@@ -12,6 +13,7 @@
- `/api/v1/openai` - `/api/v1/openai`
- `/api/v1/flux` - `/api/v1/flux`
- `/api/v1/stripe` - `/api/v1/stripe`
- `/api/admin/flux-grants` — adminGuard 守卫,详见 `admin-flux-grants.md`
- `GET /ws/chat` - `GET /ws/chat`
## 鉴权链路 ## 鉴权链路
@@ -124,7 +126,7 @@
实现位置: 实现位置:
- route 注册:`src/app.ts` - route 注册:`src/app.ts`(在 `bodyLimit` 之前注册)
- handler factory: `src/routes/chat-ws/index.ts` - handler factory: `src/routes/chat-ws/index.ts`
- 底层事件适配:`src/libs/eventa-hono-adapter.ts` - 底层事件适配:`src/libs/eventa-hono-adapter.ts`
@@ -163,7 +165,9 @@
- `POST /api/v1/openai/chat/completions` - `POST /api/v1/openai/chat/completions`
- `POST /api/v1/openai/chat/completion` - `POST /api/v1/openai/chat/completion`
- `POST /api/v1/openai/audio/speech` - `POST /api/v1/openai/audio/speech`
- `POST /api/v1/openai/audio/transcriptions` - `GET /api/v1/openai/audio/voices`(按 model 缓存上游响应,TTL 600s,仅 200 入缓存)
`handleTranscription`STT)目前未挂载,需要时参考 `/audio/speech` 接入方式。
请求流程: 请求流程:
@@ -230,6 +234,20 @@
- `billingService` - `billingService`
- 负责真正改余额 - 负责真正改余额
### `/api/admin/flux-grants`
实现位置:
- route: `src/routes/admin/flux-grants/index.ts`
- service: `src/services/admin-flux-grants/index.ts`
- guard: `src/middlewares/admin-guard.ts`
主要能力:
- `POST /api/admin/flux-grants?dryRun=true|false` — 同步给一组邮箱发 FLUX,请求线程内顺序调 `creditFlux`,返回每条 outcome
- 鉴权:`authGuard` + `adminGuard``ADMIN_EMAILS` allowlist + `email_verified=true`
- 没有 batch 表 / 状态机 / 后台 loop;详见 `admin-flux-grants.md`
## 参数校验方式 ## 参数校验方式
输入 schema 位于各资源路由目录下的 `schema.ts` 输入 schema 位于各资源路由目录下的 `schema.ts`
@@ -1,80 +0,0 @@
# Admin Flux Grant Batch — End-to-End Verification
## 用户路径 1admin 发起 batch → worker 异步 credit → 余额到账
- **场景**admin 通过 `POST /api/admin/flux-grant-batches` 给一个邮箱发 5000 FLUX,worker 异步执行 → 用户余额从 44958 升到 49958
- **命令**
```bash
# 1. 创建 batch(不 dry-run
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
'http://localhost:3000/api/admin/flux-grant-batches' \
-d '{"name":"Live test 5000 FLUX 2026-05-08","amount":5000,"description":"End-to-end live verification grant","emails":["rbxin2003@gmail.com"]}'
# 2. 等 worker 处理后查余额
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/flux
# 3. 查 batch 详情
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/admin/flux-grant-batches/cYt0gL3XVPdYfkuqAlwS-
# 4. 查最近 ledger 记录
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:3000/api/v1/flux/history?limit=3&offset=0'
```
- **预期输出**
- 创建:HTTP 202,返回 `{batch:{status:"created"}, summary:{pending:1, totalFluxToIssue:5000}}`
- 余额:`{flux: 49958}`44958 + 5000
- Batch 详情:`{status:"completed", progress:{granted:1, pending:0, failed:0}}``startedAt` / `completedAt` 都非 null
- Ledger 顶上一条:`{type:"promo", amount:5000, metadata:{batchId, batchName, recipientId}}`
- **实际输出**
- 创建:`{"batch":{"id":"cYt0gL3XVPdYfkuqAlwS-","name":"Live test 5000 FLUX 2026-05-08","status":"created","createdAt":"2026-05-07T17:17:29.642Z","createdByUserId":"R89bHt3QoCNkNywbYr7lbnpkb75y77MN"},"summary":{"totalEmails":1,"pending":1,"skipped":0,"totalFluxToIssue":5000}}` HTTP 202 ✓
- 余额:`{"userId":"R89bHt3QoCNkNywbYr7lbnpkb75y77MN","flux":49958}` ✓
- Batch 详情:`{"batch":{"status":"completed","startedAt":"2026-05-07T17:20:53.776Z","completedAt":"2026-05-07T17:20:59.179Z",...},"progress":{"total":1,"pending":0,"granted":1,"skipped":0,"failed":0},"recentFailures":[]}` ✓
- Ledger`{"id":"ouAbZQc93j3uJ1NRhRPgy","type":"promo","amount":5000,"description":"End-to-end live verification grant","metadata":{"batchId":"cYt0gL3XVPdYfkuqAlwS-","batchName":"Live test 5000 FLUX 2026-05-08","recipientId":"_qb-d8T5Js5-nvX3jbOuV"},"createdAt":"2026-05-07T17:20:54.528Z"}` ✓
- billing-consumer 日志:`[billing-service] Credited flux { userId=R89bHt3QoCNkNywbYr7lbnpkb75y77MN amount=5000 balance=49958 }` 和 `[mq-stream] Published event to Redis Stream { stream=billing:events streamMessageId=1778174459013-0 }`
- **环境**
- 本地 dev`pnpm dev` + `pnpm server billing-consumer`
- 服务 commit`server-dev` 分支未 commit 状态(含本次重命名 + SQL fix)
- 用户 ID`R89bHt3QoCNkNywbYr7lbnpkb75y77MN`,邮箱 `rbxin2003@gmail.com`verified
- `ADMIN_EMAILS` 包含 `rbxin2003@gmail.com`
- **最后验证**2026-05-08
## 用户路径 2:dry-run 预览 邮箱列表
- **场景**admin 准备 batch 前,用 dry-run 检查 4 个 emailvalid + 大小写变体重复 + 第三次重复 + 找不到)的解析结果
- **命令**
```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
'http://localhost:3000/api/admin/flux-grant-batches?dryRun=true' \
-d '{"name":"Smoke test","amount":100,"description":"probe","emails":["rbxin2003@gmail.com","RBXIN2003@gmail.com","ghost@nope.example","rbxin2003@gmail.com"]}'
```
- **预期输出**
- HTTP 200`{preview:{willGrant:1, willSkip:{notFound:1, userDeleted:0, duplicateInInput:2}, totalFluxToIssue:100}}`
- 不写库(不创建 batch row
- **实际输出**
- `{"preview":{"totalEmails":4,"willGrant":1,"willSkip":{"notFound":1,"userDeleted":0,"duplicateInInput":2},"totalFluxToIssue":100,"estimatedDurationSec":1,"samples":{"willGrant":["rbxin2003@gmail.com"],"notFound":["ghost@nope.example"],"userDeleted":[]}}}` HTTP 200 ✓
- 后续 `GET /api/admin/flux-grant-batches` 列表里没有 "Smoke test" 这个 batch ✓(不写库验证)
- **环境**:同上
- **最后验证**2026-05-08
## 用户路径 3:未登录 / 非 admin / 未验证邮箱 被挡住
- **场景**adminGuard 三种拒绝路径(401 / 403 未在白名单 / 403 邮箱未验证)
- **命令**
```bash
# 401:无 token
curl -s -w "%{http_code}\n" http://localhost:3000/api/admin/flux-grant-batches
# 403:登录但邮箱不在白名单(用一个非 admin 用户的 token)→ 暂未验证(需要另一个测试 user)
# 403:登录但 emailVerified=false → 暂未验证(需要构造未验证用户)
```
- **预期输出**401 / 403 / 403
- **实际输出**:单元测试 [admin-guard.test.ts](apps/server/src/middlewares/tests/admin-guard.test.ts) 6 条用例全过(覆盖三种拒绝路径 + 通过路径 + case-insensitive 匹配)。**Live 路径 1 + 2 + 3 在浏览器/curl 端的 401/403 验证暂缺,标记为 PARTIAL。**
- **环境**:同上
- **最后验证**2026-05-08unit only
## 已知缺口 / 未验证
- **多 worker 实例并发** `FOR UPDATE SKIP LOCKED` 不双发:单元测试覆盖了幂等约束(`(user_id, request_id)` 唯一索引),多实例 race condition 没真跑
- **重试路径**`POST /retry` 接口已写,未端到端测试。需要构造一个 failed recipientDB 短暂故障)触发 retry 才能验证
- **`flux.credited` 事件下游消费**log 显示 stream XADD 成功,但没追到具体下游消费者的影响(目前 `billing-consumer-handler` 对 `flux.credited` 只 log,无 DB 写)
- **大批量性能**50 throttle/s 是估算,没有跑过 1k+ 用户的 batch 实测
@@ -72,7 +72,7 @@
## 聊天 WebSocket 运行时 ## 聊天 WebSocket 运行时
`src/routes/chat-ws.ts` 是另一种独立运行时: `src/routes/chat-ws/index.ts` 是另一种独立运行时:
- 同实例连接保存在进程内 `Map` - 同实例连接保存在进程内 `Map`
- 跨实例 fan-out 通过 Redis Pub/Sub - 跨实例 fan-out 通过 Redis Pub/Sub