---
id: FEAT-013
title: envelope-buy-real
domain: envelope
status: done
since_version: 0.5.1
---

# FEAT-013 — 信封真付（支付宝扫码）

## User Story

As a **今日 3 封免费用完还要发信的用户**, I want to **花真钱买更多信封**, so that **不被每日额度卡住追到喜欢的人**。

**商业闭环关键**：这是 xiangqin **唯一真付费点**。真有人为 ¥1/封付钱 = 商业模式成立，没人付 = 假设不成立需要 pivot。

## Acceptance Criteria

- [x] `xq envelope buy --count N` 默认真付（不加 --mock）
- [x] 服务端建 pending 订单 + 调 alipay.trade.precreate → 拿二维码 URL
- [x] CLI 用 qrcode 库渲染 ASCII 二维码（用户手机支付宝扫）
- [x] 支付宝异步 POST `/envelope/webhook/alipay` → 服务端 RSA2 验签 + 更新订单 + 充值余额
- [x] webhook 幂等：同订单多次回调只充值一次（`paid=1` 判断）
- [x] webhook 返回纯文本 `success` 才停止支付宝重试（否则返 `fail`）
- [x] `xq envelope order <order_id>` 查订单状态（CLI 轮询用）
- [x] alipay 调用失败 → 订单标 `failed` + 抛 `payment_provider_error`
- [x] --mock 保留（测试用，paid=1 不调 alipay）
- [x] ENVELOPE_PRICE_CENTS=100（¥1/封）
- [x] 前 3 封免费额度不变（FEAT-011）

## Tests

- `test/test_mail.py::test_buy_envelopes_real_creates_pending_order`（mock alipay 创建 pending 订单）
- `test/test_mail.py::test_buy_envelopes_real_propagates_alipay_error`（alipay 挂订单标 failed）
- `test/test_mail.py::test_alipay_webhook_paid_credits_balance`（回调 paid 充值）
- `test/test_mail.py::test_alipay_webhook_idempotent`（同订单多次回调只充一次）
- `test/test_mail.py::test_get_order_returns_status`
- `test/test_mail.py::test_get_order_forbidden_for_others`

## CLI / API

- CLI:
  - `xq envelope buy --count N` 默认真付（`cli.py::envelope_buy_cmd`）
  - `xq envelope buy --count N --mock` mock 到账（测试）
  - `xq envelope order <order_id>` 查订单状态（`cli.py::envelope_order_cmd`）
- API:
  - `POST /envelope/buy {count, mock:false}` 默认真付（`app.py::envelope_buy`）
  - `GET /envelope/order/{order_id}` 订单查询（`app.py::envelope_order_get`）
  - `POST /envelope/webhook/alipay` 支付宝异步回调（`app.py::envelope_alipay_webhook` 返 `PlainTextResponse`）
- 核心：
  - `src/xiangqin/alipay.py::create_precreate` 调 API 生成二维码
  - `src/xiangqin/alipay.py::verify_webhook` RSA2 验签
  - `src/xiangqin/mail.py::buy_envelopes(mock=False)` 建订单 + 调 alipay
  - `src/xiangqin/mail.py::handle_alipay_webhook` 幂等充值

## 依赖 / 被依赖

- **依赖**：
  - `FEAT-001/002` 登录
  - vault `alipay-kongxuanpin` credential（app_id + app_private_key + alipay_public_key）
  - `cryptography` / `qrcode` / `httpx` Python 依赖
  - epsilon `XIANGQIN_BASE_URL` env 指公网（支付宝 notify_url 回调要到达）
  - 支付宝开放平台应用已配 `notify_url` 白名单（之前 M3 付费曝光时代已配过）
- **被依赖**：
  - `FEAT-014 dm.send` 第 4 封起消耗付费余额，触发真付需求
  - v0.5.0 商业闭环 O3.KR3.2 解锁
  - `issue #020 退款`（未来接 `alipay.trade.refund`）

## 风险 / 边界

- **notify_url 必须公网可达**：支付宝只能回调公网 IP/域名。alpha 开不了因没公网 DNS；epsilon 有 `xq.agentaily.com`
- **webhook 防 replay**：当前只验签不验时间戳。支付宝 notify 一般在几分钟内，幂等足够防 replay
- **证书轮换**：支付宝平台公钥可能轮换，vault 里 `alipay_public_key` 要跟着更；建议加 "public_key 过期告警"
- **并发同订单**：sqlite 用 begin immediate 锁了（单连接）但幂等逻辑先读后写有竞态窗口 —— 两次 webhook 同时到，都读到 `paid=0`，都执行充值。**加锁或用 INSERT OR IGNORE 兜底**（待优化）
- **金额单位**：支付宝要元（`3.00`），db 存分（300），小心转换错位
- **不做**：分期 / 花呗（信封单价 ¥1 无意义）；退款（#020 未做）
- **审核环境**：支付宝沙箱需要另一套 AK；当前直接上 prod（app 已真实授权）

## 相关

- issue `#013`
- FEAT-012 mock 版（沿用 db schema + API 合约）
- 历史代码：git commit `25a14d1^` 前的 `src/xiangqin/payment/alipay.py`（M3 exposure 时代）
- ADR `2026-04-23-m4-mail-model-touchreach`（M4 信封商业模式）
- 下阶段：#014 微信支付（并列方式） / #020 退款
