积分与成本控制
积分账本、套餐与积分包、服务端拒绝、每日预算、退还与管理员恢复。
账本
每一次余额变化都是 D1 中只追加的 ledger 表里的一行,余额就是这些行的总和。每行的 ID 编码了用户、类型与来源,所以重试、队列重复投递或连续点击写入的是同一个 ID,会被直接忽略:
| 类型 | ID 格式 | 写入时机 |
|---|---|---|
grant | {user}:grant:{source} | 邮箱验证后的欢迎积分、已支付订单、经审计的管理员发放 |
reserve | {user}:reserve:{task} | 生成请求发给提供方之前 |
release | {user}:release:{task} | 预占的生成失败、被取消或被退还时,只写一次 |
成功的生成直接保留预占,完成时不会再扣一次。每行还带有 reason 与 ref,用量页 /app/usage 会把它们显示成可读的说明。新增任何写入方都必须设置这两个字段,冲正行要从被冲正的行复制它们。
不要在组件里或请求之前扣积分,也不要在队列重试中再扣一次。余额只能通过这些账本写入方改变。
售卖积分
可售目录位于 packages/config/src/index.ts:
| 条目 | kind | 默认值 |
|---|---|---|
| Pro | plan,订阅 | 每月 1,000 积分 |
| Lifetime | plan,一次性 | 10,000 积分 |
| 1,000 credits | pack,一次性 | 1,000 积分;设置 requiresPlan 可只卖给已有套餐的用户 |
积分包一定是一次性购买。积分数在结账时写入订单,之后修改目录也不会改变已有订单发放的数量。按 支付配置 填写各网关的商品 ID 并启用网关。
服务端拒绝
每次生成都在服务端计价,并在预占之前完成拒绝判断:
| 状态码 | 代码 | 触发条件 |
|---|---|---|
402 | 余额与下一步 | 余额低于价格;响应会说明下一步是购买套餐还是积分包 |
429 | too_many_active_tasks | 用户已有 ai.maxActiveTasks 个未完成任务 |
503 | capacity_reached | 今日预估上游花费加上本任务会超过 ai.dailyBudgetUsd |
界面上禁用按钮不能替代这些检查。
每日预算
每个目录模型都带有预估的 costUsd。预占任务时会把预估值计入 UTC 零点以来的花费;这个上限由所有用户共享,用来防止滥用或定价失误导致账单失控。Mock 运行计为零成本。把 ai.dailyBudgetUsd 设为 0 可关闭上限,同时也请在提供方一侧设置限额。
毛利检查保证定价合理:只有当 积分 × 最便宜的积分单价 ≥ costUsd × (1 + ai.minGrossMargin) 时模型才有效。修改价格或积分包后请重新运行 pnpm test:unit。
失败与退还
- 可重试的提供方失败最多再尝试
ai.maxAttempts(3)次,最终失败只退还一次积分。 - 当
ai.refundOnContentRefusal为true时,提供方的内容拒绝会退还积分。 - 每 5 分钟的 cron 会回收过期租约,退还用尽尝试次数或超时的任务(视频为 30 分钟),并补发 outbox。
管理员恢复
管理员在 /app/admin 查看最近的 AI 任务,数据来自 GET /api/admin/ai_tasks:状态、尝试次数、错误类别、成本,以及积分是否已退还。POST /api/admin/ai-refund,正文 { "taskId": "..." },可为已结束的任务退还一次积分;再次调用会返回 409 not_refundable。通过 POST /api/admin/credits 发放的积分会记录审计。
验证
- 积分不足时发起生成,确认返回
402且余额不变。 - 同时发起超过
ai.maxActiveTasks个生成,确认返回429。 - 在 Live 测试中调低
ai.dailyBudgetUsd,确认返回503。 - 取消一个排队中的任务,或让一个 Live 任务失败,确认只有一行
release。重试与退还由pnpm test:workers覆盖。 - 以管理员身份对同一个已结束任务退还两次,确认第二次被拒绝。