# 资金与奖励

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

工程防护 · /zh/value-abuse/ · 2026-11-05

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

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

**Prompt**

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

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

- **VAL-1** (高风险; 由代码评审检查): **金额永远不用浮点数。** 从数据库到 API 再到导出，一律用最小货币单位的整数（比如分）或定点小数。

  注意: 小数金额用字符串传；整数要在 API 客户端能精确表示的范围内，例如 JavaScript 的安全整数上限。解析和导出也要检查。
- **VAL-2** (高风险; 由数据库和代码评审检查): **资金和奖励的每一次增减，都记一条账本分录，只追加，不修改。** 余额从账本算出来，或者和分录在同一个事务里更新缓存的余额。要更正，就新记一条分录指向原来那条，绝不改原记录。
- **VAL-5** (高风险; 由代码评审检查): **归属、资格、金额和价格都由服务端决定。** 客户端只说要做什么，不传金额或积分这类数值。
- **VCH-1** (高风险; 由测试检查): **兑换码猜不到，也无法枚举。** 兑换码随机且足够长，失败尝试既按 IP 限制，也按账号限制。

_示例：客户端只说要做什么，不传数值_

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

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

## 让重试和并发都安全

- **VAL-3** (高风险; 由测试检查): **每个会增减资金或奖励的操作都有幂等键。** 重放时返回第一次的结果，不会再增减任何东西。

  注意: 幂等键存在数据库里，并加唯一约束。缓存过期或被淘汰，不能让重放保护跟着失效。
- **VAL-4** (高风险; 由数据库和测试检查): **“只能用一次”“每位客户限几次”这类限制，由数据库保证。** 用唯一约束或条件更新。不要在应用代码里先检查再写入。
- **VAL-6** (高风险; 由代码评审和测试检查): **超时只说明结果未知。** 重试一个会动到资金、奖励或产生费用的调用之前，先查清楚实际发生了什么。

* 发放用的幂等键从业务事件生成，例如 `(campaign, customer, period)`，不要用每次重试都会变的请求 ID。
* 请求的幂等键按已登录的客户和操作类型区分。同一个键带着不同输入再来，直接拒绝。

_PostgreSQL：每位客户在每个活动周期只发放一次_

```sql
-- 所有键列和 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;
```

- 如果有缓存余额，在同一事务里更新，并且只在确实插入了一行时才更新。
- 没有返回行，说明这次插入什么都没做：用另一条语句查出原来的发放记录，比对输入，然后返回它。[冲突处理](https://www.postgresql.org/docs/current/sql-insert.html)。

## 上线前给发放设限

- **VAL-7** (高风险; 由应用本身和测试检查): **所有发放奖励的地方都有上限和 kill switch。** 按客户、按活动和总量分别设上限。kill switch 不需要部署就能生效。
- **CMP-1** (高风险; 由应用本身检查): **给活动设置预算上限和结束日期**，这样没人管的活动也会自己停下来。
- **CMP-2** (中风险; 由代码评审检查): **上线前想清楚别人会怎么薅这个活动**；光靠资格判断，挡不住反复薅。
- **CMP-3** (高风险; 由测试检查): **上线前测试 kill switch**，确认运营人员不用部署就能停止发放。
- **CMP-4** (中风险; 目前没有任何检查): **对发放速率和花费设置告警**，在预算花光之前就能发现异常。

* 扣减活动预算和总预算，要和发放在同一个原子操作里完成。先读“剩余预算”再插入，高负载下会出现竞态。
* 如果发放奖励还会产生费用，例如发短信，也要用上[付费接口的那些限制](/zh/otp/)。

## 审批确切的批量输入

- **VAL-8** (高风险; 由代码评审和应用本身检查): **批量发放是幂等、可续跑的任务。** 由第二个人批准一次试运行，试运行列出接收人、金额和总额。执行时必须用批准时的那份输入，而且它不能再被修改；输入一变，就要重新批准。

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

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

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

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

_PostgreSQL：第 3 步中的优惠券占用_

```sql
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 隔离级别下，竞争的更新会等待，然后重新检查条件，所以条件占用是安全的。[并发行为](https://www.postgresql.org/docs/current/transaction-iso.html)。
- 积分过期或退回，也要记一条账本分录；不要隐藏或覆盖原来那笔变动。

> **失败案例 (示意): 占用了，却一直没交付**
>
> 事务提交了，进程却在把请求发给合作方之前挂了。优惠券显示已兑换，客户什么也没收到。有了持久化的待履约记录，worker 重启后就能接着交付。

## 测试重放并核对余额

- [ ] **VAL-T1**: 用相同输入执行两次发放。第二次什么也不发。
- [ ] **VAL-T2**: 批量任务跑到一半时杀掉，再续跑。每位接收人最终恰好有一条分录。
- [ ] **VAL-T3**: 在请求和它的重试之间重启服务。重试仍返回第一次的结果。
- [ ] **VAL-T4**: 每个账户的账本合计等于它的缓存余额。
- [ ] **VCH-T1**: 用不同的幂等键并发发起同一笔兑换 20 次。只有一次占用成功。
- [ ] **VCH-T2**: 第一次请求还在处理时和处理完后，各用同一个幂等键重放一次。确认只执行一次、只记一次账；完成后返回已保存的结果。同一个幂等键换了输入，会被拒绝。
- [ ] **VCH-T3**: 在提交之后、交付之前让进程崩溃，再让 worker 续跑。在合作方受理之后强制超时。恢复时能找到已经存下的待履约记录和合作方那边已有的订单，不会再建一单。

* 用账本合计核对缓存余额，找出没有账本分录就改了的余额；再和服务商的独立记录核对，找出两边都漏掉的问题。定时运行，有差异就告警。
