工程防护 / 资金与奖励
English

资金与奖励

防止重复发放、无上限发放,以及对不上账的余额。

更新于 2026年11月5日查看 Markdown
本页内容

重复发放的积分,可能在没人察觉时就被花掉了。积分、优惠券、额度和返现都和钱一样:每一笔变动都要说得清是谁的、为什么变,而且能对上账。

让 AI agent 审查资金和奖励的变动

Prompt
审查 [Jira 编号、PR 链接或 diff] 是怎么增减资金和奖励的。
如果是 Jira ticket,通过已连接的 Jira MCP 或已认证的 acli 读取描述、验收标准和评论;无法访问时,让我粘贴详情。
从资格判断和金额计算一路追到账本分录和余额。对照本页的要求,检查重试、并发请求、预算限额和批量输入的审批。
逐条报告缺口,附上用合成数据测试得到的证据。
说明本页列出的测试哪些已在项目测试套件里。能自动化的补上,其余的列出来。
不要发放奖励,也不要修改生产记录。

记下每一笔资金和奖励的变动

  • 金额永远不用浮点数。从数据库到 API 再到导出,一律用最小货币单位的整数(比如分)或定点小数。

    注意小数金额用字符串传;整数要在 API 客户端能精确表示的范围内,例如 JavaScript 的安全整数上限。解析和导出也要检查。

  • 资金和奖励的每一次增减,都记一条账本分录,只追加,不修改。余额从账本算出来,或者和分录在同一个事务里更新缓存的余额。要更正,就新记一条分录指向原来那条,绝不改原记录。
  • 归属、资格、金额和价格都由服务端决定。客户端只说要做什么,不传金额或积分这类数值。
  • 兑换码猜不到,也无法枚举。兑换码随机且足够长,失败尝试既按 IP 限制,也按账号限制。

示例:客户端只说要做什么,不传数值

较差
POST /vouchers/V-81/redeem
{"customer_id": 42, "points": 500}

更好
POST /vouchers/V-81/redeem
{}
服务端从会话中取客户,从优惠券中取积分。

让重试和并发都安全

  • 每个会增减资金或奖励的操作都有幂等键。重放时返回第一次的结果,不会再增减任何东西。

    注意幂等键存在数据库里,并加唯一约束。缓存过期或被淘汰,不能让重放保护跟着失效。

  • “只能用一次”“每位客户限几次”这类限制,由数据库保证。用唯一约束或条件更新。不要在应用代码里先检查再写入。
  • 超时只说明结果未知。重试一个会动到资金、奖励或产生费用的调用之前,先查清楚实际发生了什么。
  • 发放用的幂等键从业务事件生成,例如 (campaign, customer, period),不要用每次重试都会变的请求 ID。
  • 请求的幂等键按已登录的客户和操作类型区分。同一个键带着不同输入再来,直接拒绝。

PostgreSQL:每位客户在每个活动周期只发放一次

-- 所有键列和 kind 都必须是 NOT NULL。
CREATE UNIQUE INDEX ledger_issue_once
  ON ledger_entries (campaign_id, customer_id, period)
  WHERE kind = 'issue';

INSERT INTO ledger_entries
  (campaign_id, customer_id, period, kind, amount)
VALUES ($1, $2, $3, 'issue', $4)
ON CONFLICT (campaign_id, customer_id, period)
  WHERE kind = 'issue' DO NOTHING
RETURNING id;
  • 如果有缓存余额,在同一事务里更新,并且只在确实插入了一行时才更新。
  • 没有返回行,说明这次插入什么都没做:用另一条语句查出原来的发放记录,比对输入,然后返回它。冲突处理。

上线前给发放设限

  • 所有发放奖励的地方都有上限和 kill switch。按客户、按活动和总量分别设上限。kill switch 不需要部署就能生效。
  • 给活动设置预算上限和结束日期,这样没人管的活动也会自己停下来。
  • 上线前想清楚别人会怎么薅这个活动;光靠资格判断,挡不住反复薅。
  • 上线前测试 kill switch,确认运营人员不用部署就能停止发放。
  • 对发放速率和花费设置告警,在预算花光之前就能发现异常。
  • 扣减活动预算和总预算,要和发放在同一个原子操作里完成。先读“剩余预算”再插入,高负载下会出现竞态。
  • 如果发放奖励还会产生费用,例如发短信,也要用上付费接口的那些限制。

审批确切的批量输入

  • 批量发放是幂等、可续跑的任务。由第二个人批准一次试运行,试运行列出接收人、金额和总额。执行时必须用批准时的那份输入,而且它不能再被修改;输入一变,就要重新批准。
失败案例 · 示意

批准过的文件被换了

试运行有 500 位接收人,运营人员批准了。worker 启动之前,上传的文件被换成了 5,000 行。任务本身是幂等的,发出去的却是批准数量的十倍。批准只对那一份不能再改的输入有效,就不会出这种事。

端到端完成一次优惠券兑换

一次兑换先在本地占用优惠券并记账,再通过合作方交付,所以上面的规则要和后台任务与外部调用里的规则一起用。

  1. 鉴权。客户和租户从已认证的会话里取。归属、资格和限额都在服务端检查,并限制兑换码的失败尝试(兑换码不可猜测)。
  2. 记录操作。在事务里插入一条操作记录,在客户、操作类型和幂等键的组合上加唯一约束,同时保存请求指纹。发生冲突时,输入有变就拒绝,否则返回已保存的结果。
  3. 占用并记账。在同一事务里有条件地占用优惠券,写账本分录,更新缓存余额(如有),并保存一条待履约记录。占用失败就回滚。
  4. 可靠地交付。worker 处理待履约记录,把请求发给合作方,用操作 ID 作为合作方那边的幂等键,并记录结果。重试不能产生第二笔订单。
  5. 处理不确定的结果。超时后先查合作方的记录,再决定是否重试。查不出结果,就让这笔履约保持待处理,交给核对。

PostgreSQL:第 3 步中的优惠券占用

UPDATE vouchers
SET redeemed_at = now(), operation_id = $1
WHERE code = $2
  AND tenant_id = $3 AND customer_id = $4
  AND redeemed_at IS NULL AND expires_at > now()
RETURNING id;
-- $3 和 $4 来自鉴权得到的范围,不取自请求字段。
-- 没有返回行:不履约,回滚,也不要让对方知道这个兑换码是别人的。
  • 在 PostgreSQL 默认的 Read Committed 隔离级别下,竞争的更新会等待,然后重新检查条件,所以条件占用是安全的。并发行为。
  • 积分过期或退回,也要记一条账本分录;不要隐藏或覆盖原来那笔变动。
失败案例 · 示意

占用了,却一直没交付

事务提交了,进程却在把请求发给合作方之前挂了。优惠券显示已兑换,客户什么也没收到。有了持久化的待履约记录,worker 重启后就能接着交付。

测试重放并核对余额

  • 用相同输入执行两次发放。第二次什么也不发。
  • 批量任务跑到一半时杀掉,再续跑。每位接收人最终恰好有一条分录。
  • 在请求和它的重试之间重启服务。重试仍返回第一次的结果。
  • 每个账户的账本合计等于它的缓存余额。
  • 用不同的幂等键并发发起同一笔兑换 20 次。只有一次占用成功。
  • 第一次请求还在处理时和处理完后,各用同一个幂等键重放一次。确认只执行一次、只记一次账;完成后返回已保存的结果。同一个幂等键换了输入,会被拒绝。
  • 在提交之后、交付之前让进程崩溃,再让 worker 续跑。在合作方受理之后强制超时。恢复时能找到已经存下的待履约记录和合作方那边已有的订单,不会再建一单。
  • 用账本合计核对缓存余额,找出没有账本分录就改了的余额;再和服务商的独立记录核对,找出两边都漏掉的问题。定时运行,有差异就告警。