Payment Intent Pattern(支付意图模式)
1. 业务背景
Section titled “1. 业务背景”在我们的账单支付系统中,用户可能存在多个来自第三方系统的未支付账单。这些账单会被同步到本平台,用户可在前端页面中勾选部分或全部账单进行合并支付。为了保证支付金额不被篡改,同时避免前端传递明文账单数据,我们希望建立一个 既安全又易扩展的支付架构。
2. 初始方案(前端加密账单)
Section titled “2. 初始方案(前端加密账单)”最初我们采用过「前端加密账单 JSON → 后端解密 → 调用支付」的思路:
-
前端拉取账单并展示;
-
用户选择账单;
-
前端将所选账单明细打包成 JSON;
-
前端使用 AES-GCM 加密明文,再用后端公钥加密 AES 密钥,最后计算加密明文的哈希签名;
-
传输至后端,由后端解密并验证签名;
-
通过后端校验后发起支付。
虽然加密层面可保证“传输过程的隐私性”,但仍存在结构性问题:
| 问题 | 说明 |
|---|---|
| 1. 前端可篡改 | 前端代码对用户可见,任意人都能伪造 JSON 结构或修改金额。 |
| 2. 密钥泄露风险 | 公钥安全但签名逻辑若在前端执行,可被复现。 |
| 3. 逻辑复杂度高 | 解密、校验、业务验证、幂等全在同一层混杂。 |
| 4. 难以扩展 | 每增加一种支付渠道或优惠规则,都要调整加密结构。 |
因此我们最终改为业界通行的 —— Payment Intent(支付意图)模式。
3. Payment Intent(支付意图)模式
Section titled “3. Payment Intent(支付意图)模式”- 核心理念
前端不计算金额、不签名订单,只提交选择; 后端根据选择重新计算并签发一个带签名的 Token。前端再携带该 Token 发起支付。
换言之:
-
前端仅表达“我想付哪些账单”;
-
后端负责“这些账单实际要付多少钱”;
-
支付网关只信任后端的签名结果。
4. 在系统中的落地设计
Section titled “4. 在系统中的落地设计”-
后端调用第三方接口获取用户所有未支付账单;
-
后端缓存或签发账单列表(仅供展示);
-
前端展示明细与可勾选项。
-
前端仅传递所选账单
billIds; -
后端执行:
-
校验账单归属(当前用户);
-
校验账单状态(未支付、未过期);
-
实时获取账单金额(防篡改);
-
汇总合并金额;
-
生成一个 PaymentIntent 对象。
-
{ "intentId": "pi_20251008_0001", "billIds": ["b1001", "b1003"], "totalAmount": 1500, "currency": "CNY", "userId": "U_12345", "expiresAt": "2025-10-08T15:00:00Z"}4.1. 后端签发支付意图 Token
Section titled “4.1. 后端签发支付意图 Token”-
对 PaymentIntent 内容进行签名(推荐使用 JWS / JWT);
-
生成
token = Sign(intentPayload, privateKey); -
返回给前端:
{ "intentId": "pi_20251008_0001", "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..."}4.2. 前端发起支付请求
Section titled “4.2. 前端发起支付请求”-
用户确认支付;
-
前端调用
/payment/charge; -
请求体只需携带:
{ "intentId": "pi_20251008_0001", "token": "<JWS>" } -
不再包含金额、账单 JSON 等任何敏感信息。
4.3. 后端执行支付
Section titled “4.3. 后端执行支付”-
验签 + 解包 Token
-
验证 Token 是否有效、未过期;
-
Token 内容与本地缓存的 PaymentIntent 是否一致。
-
-
业务复核
-
再次校验账单归属与金额;
-
确保未支付/未过期;
-
校验幂等。
-
-
调用支付网关
-
创建支付订单;
-
记录状态;
-
成功后更新账单状态。
-
4.4. 异步回调 & 对账
Section titled “4.4. 异步回调 & 对账”-
支付网关回调成功;
-
后端验证签名并更新账单;
-
周期性对账确保一致性。
5. 为什么这种模式更安全
Section titled “5. 为什么这种模式更安全”| 风险点 | Payment Intent 模式的防御方式 |
|---|---|
| 前端篡改金额 | 前端不传金额,只传账单 ID |
| 请求重放 | Token 带过期时间(TTL)与唯一 ID |
| 非法调用 | Token 有签名且绑定 userId |
| 并发重复支付 | 后端幂等键锁定 Intent |
| 第三方接口篡改 | 后端每次从源系统重新取数 |
| 敏感数据泄露 | 前端仅展示、后端签名、全程 HTTPS |
6. 系统接口设计参考
Section titled “6. 系统接口设计参考”| 接口 | 说明 | 请求参数 | 响应 |
|---|---|---|---|
GET /bills/unpaid | 获取未支付账单 | userId | [bill...] |
POST /payment/intent | 创建支付意图 | {billIds} | {intentId, token} |
POST /payment/charge | 发起支付 | {intentId, token} | {status, paymentId} |
GET /payment/result/{id} | 查询支付结果 | intentId | {status, amount, bills} |
7. Token 签名结构(JWS 推荐)
Section titled “7. Token 签名结构(JWS 推荐)”JWS(JWT 是其常见序列化)是:
Base64Url(header) . Base64Url(payload) . Base64Url(signature)- header
{ "alg": "RS256", "typ": "JWT", "kid": "sign-2025-10"}Header(头部):说明签名算法、类型、密钥ID等。
- payload
{ "intentId": "pi_20251008_0001", "userId": "U_12345", "billIds": ["b1001","b1003"], "totalAmount": 1500, "currency": "CNY", "nbf": 1733664000, // not-before,不早于(可选) "iat": 1733663700, // issued-at,签发时间(秒级UNIX时间戳) "exp": 1733664600, // 过期时间(例如10分钟后) "nonce": "8d03f3a...", // 防重放随机值 "ver": 1 // 版本号(自定义)}Payload(载荷):把 PaymentIntent 核心信息放进来,再加上一些标准声明。
- signature
对下面这段ASCII 字节串做签名
base64url(header) + "." + base64url(payload)
- 若
alg=RS256:就是 RSASSA-PKCS1-v1_5 with SHA-256 的签名结果;- 若用
PS256则是 RSASSA-PSS with SHA-256。
最终 Token 是一串形如:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMjAyNS0xMCJ9.eyJpbnRlbnRJZCI6InB......任何人如果改动了 payload(比如把 1500 改成 15),再用你的公钥验签都会失败。 所以不需要在 JSON 里增加 “signature 字段”;签名在第三段。
8. 可扩展设计点
Section titled “8. 可扩展设计点”-
多渠道支付:Intent 可携带
channel(微信、支付宝、银联); -
优惠策略:Intent 可锁定优惠规则;
-
风控扩展:Intent 可内嵌用户风险评分;
-
幂等处理:Intent + userId 构成唯一幂等键;
-
对账审计:Intent 记录账单明细快照,方便追溯。
-
Stripe 官方文档:Payment Intents API
-
微信支付 API v3:统一下单 & PrepayId
-
支付宝开发文档:App 支付 - alipay.trade.app.pay
-
RFC 7515:JSON Web Signature (JWS)