Skip to content

Payment Intent Pattern(支付意图模式)


在我们的账单支付系统中,用户可能存在多个来自第三方系统的未支付账单。这些账单会被同步到本平台,用户可在前端页面中勾选部分或全部账单进行合并支付。为了保证支付金额不被篡改,同时避免前端传递明文账单数据,我们希望建立一个 既安全又易扩展的支付架构。

最初我们采用过「前端加密账单 JSON → 后端解密 → 调用支付」的思路:

  1. 前端拉取账单并展示;

  2. 用户选择账单;

  3. 前端将所选账单明细打包成 JSON;

  4. 前端使用 AES-GCM 加密明文,再用后端公钥加密 AES 密钥,最后计算加密明文的哈希签名;

  5. 传输至后端,由后端解密并验证签名;

  6. 通过后端校验后发起支付。

虽然加密层面可保证“传输过程的隐私性”,但仍存在结构性问题:

问题说明
1. 前端可篡改前端代码对用户可见,任意人都能伪造 JSON 结构或修改金额。
2. 密钥泄露风险公钥安全但签名逻辑若在前端执行,可被复现。
3. 逻辑复杂度高解密、校验、业务验证、幂等全在同一层混杂。
4. 难以扩展每增加一种支付渠道或优惠规则,都要调整加密结构。

因此我们最终改为业界通行的 —— Payment Intent(支付意图)模式

  • 核心理念

前端不计算金额、不签名订单,只提交选择; 后端根据选择重新计算并签发一个带签名的 Token。前端再携带该 Token 发起支付。

换言之:

  • 前端仅表达“我想付哪些账单”

  • 后端负责“这些账单实际要付多少钱”

  • 支付网关只信任后端的签名结果

  • 后端调用第三方接口获取用户所有未支付账单;

  • 后端缓存或签发账单列表(仅供展示);

  • 前端展示明细与可勾选项。

  • 前端仅传递所选账单 billIds

  • 后端执行:

    1. 校验账单归属(当前用户);

    2. 校验账单状态(未支付、未过期);

    3. 实时获取账单金额(防篡改);

    4. 汇总合并金额;

    5. 生成一个 PaymentIntent 对象。

{
"intentId": "pi_20251008_0001",
"billIds": ["b1001", "b1003"],
"totalAmount": 1500,
"currency": "CNY",
"userId": "U_12345",
"expiresAt": "2025-10-08T15:00:00Z"
}
  • 对 PaymentIntent 内容进行签名(推荐使用 JWS / JWT);

  • 生成 token = Sign(intentPayload, privateKey)

  • 返回给前端:

{
"intentId": "pi_20251008_0001",
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..."
}
  • 用户确认支付;

  • 前端调用 /payment/charge

  • 请求体只需携带:

    { "intentId": "pi_20251008_0001", "token": "<JWS>" }
  • 不再包含金额、账单 JSON 等任何敏感信息。

  1. 验签 + 解包 Token

    • 验证 Token 是否有效、未过期;

    • Token 内容与本地缓存的 PaymentIntent 是否一致。

  2. 业务复核

    • 再次校验账单归属与金额;

    • 确保未支付/未过期;

    • 校验幂等。

  3. 调用支付网关

    • 创建支付订单;

    • 记录状态;

    • 成功后更新账单状态。

  • 支付网关回调成功;

  • 后端验证签名并更新账单;

  • 周期性对账确保一致性。

风险点Payment Intent 模式的防御方式
前端篡改金额前端不传金额,只传账单 ID
请求重放Token 带过期时间(TTL)与唯一 ID
非法调用Token 有签名且绑定 userId
并发重复支付后端幂等键锁定 Intent
第三方接口篡改后端每次从源系统重新取数
敏感数据泄露前端仅展示、后端签名、全程 HTTPS
接口说明请求参数响应
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}

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)
  1. alg=RS256:就是 RSASSA-PKCS1-v1_5 with SHA-256 的签名结果;
  2. 若用 PS256 则是 RSASSA-PSS with SHA-256

最终 Token 是一串形如:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMjAyNS0xMCJ9.eyJpbnRlbnRJZCI6InB...
...

任何人如果改动了 payload(比如把 1500 改成 15),再用你的公钥验签都会失败。 所以不需要在 JSON 里增加 “signature 字段”;签名在第三段。

  • 多渠道支付:Intent 可携带 channel(微信、支付宝、银联);

  • 优惠策略:Intent 可锁定优惠规则;

  • 风控扩展:Intent 可内嵌用户风险评分;

  • 幂等处理:Intent + userId 构成唯一幂等键;

  • 对账审计:Intent 记录账单明细快照,方便追溯。