Appearance
统一支付接入规范
类型:
INVARIANT(必须遵守) · 适用范围:MintPop 品牌下所有需要收款的产品MintPop 各产品统一以 Stripe 作为支付网关,后端走 PaymentIntent + client_secret 模式,前端把支付方式拍平展示为「微信支付 / 支付宝 / 银行卡」三个并列选项。本页回答「一个新产品该怎么接支付、后端怎么建单验签、前端怎么长、文案图标用什么」。参考实现:
mintpop-api(后端backend/internal/payment/,前端user-portal/)。
敏感值不在本公开站
Stripe secretKey / webhookSecret 等密钥不在这里,也不进代码仓库与 .env——一律存服务端数据库(或密管服务),由管理端配置。本页只写「对外可讲的接入约定」。
一句话心智模型
后端只有一个「stripe」通道,前端把它摊开成三张牌。 后端把 Stripe 实例聚合成单一
stripe支付方式,PaymentIntent 按配置携带多种payment_method_types;前端在纯展示层把它拍平成「微信支付 / 支付宝 / 银行卡」三个并列卡片,用户选哪张、支付弹窗就只渲染哪种方式。收窄只发生在展示层,后端契约不感知子方式。
一、六条架构主张(品牌级,不可协商)
- 网关统一 Stripe,模式统一 PaymentIntent。 后端创建 PaymentIntent、把
client_secret交给前端,由 Stripe.js 在前端完成确认。不用 Checkout Session(因此也不存在 success/cancel URL 一说),不自建收银台跳转页。 - 前端拍平展示三种方式,顺序固定:微信支付 → 支付宝 → 银行卡。 用户看到的是三个并列选项,绝不出现「Stripe」作为一个支付方式选项——Stripe 是处理方,不是用户心智里的支付方式;它只以「由 Stripe 安全处理」的角标形式出现。
- 密钥只存服务端。
secretKey/webhookSecret存数据库(管理端配置),不进.env.example、不进代码;前端只通过接口拿publishable key。 - 金额与商品由自己的数据库定义,不用 Stripe Dashboard 的 Price/Product。 套餐价格存本地表,下单时换算成最小货币单位塞进 PaymentIntent;Stripe 侧不维护商品目录,避免双源漂移。
- Webhook 是唯一的成单真相源,且必须验签 + 幂等。 前端轮询只是「查结果」,入账一律由 webhook 驱动;同一事件重放不得重复入账。
- 全品牌共用一个 Stripe 账户,业务线靠约定区分、不靠开新账户。 微信/支付宝的开通审核挂在 Stripe 账户上(新账户、Connect 子账户都要重新审核),因此新业务线一律接入品牌现有账户;业务归属由订单号前缀 +
Metadata["product"]认领机制承载(详见 2.7)。
二、后端接入规范
2.1 SDK 与初始化
- Go 用
github.com/stripe/stripe-go/v85(其它语言用对应官方 SDK 的等价物)。 - client 用
secretKey懒初始化;配置项固定六个 key:secretKey(敏感)、webhookSecret(敏感)、publishableKey、currency(3 位 ISO,默认CNY)、productCode(业务线标记,见 2.7)、statementDescriptorSuffix(卡账单描述符后缀,见 2.7)。 - 有未完成订单时,
secretKey/webhookSecret/currency三项锁定不可改(防止在途订单验签/对账错乱)。
2.2 创建 PaymentIntent(参数逐条为硬约定)
go
params := &stripe.PaymentIntentCreateParams{
Amount: stripe.Int64(amountInMinorUnit), // 金额按币种换算最小单位(CNY→分)
Currency: stripe.String(strings.ToLower(currency)),
PaymentMethodTypes: pmTypes, // 见下方映射表
Description: stripe.String(subject),
Metadata: map[string]string{
"orderId": outTradeNo, // webhook 靠它找单
"product": productCode, // 业务线标记,webhook 靠它认领(见 2.7)
},
StatementDescriptorSuffix: stripe.String(descriptorSuffix), // 卡账单显示 MINTPOP* <后缀>(见 2.7)
}
// 只要含 wechat_pay,必须显式指定客户端类型
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
Client: stripe.String("web"),
},
}
params.SetIdempotencyKey("pi-" + outTradeNo) // 幂等键固定形态:pi-<我方单号>支付方式映射表(品牌统一,逐字一致):
我方支付类型 Stripe payment_method_typeswxpaywechat_payalipayalipaycardcard实例支持哪些子方式由配置(如
supported_types = "card,alipay,wxpay")决定,映射后合并;为空一律回退["card"]。Metadata["orderId"]存我方外部单号(out_trade_no),是 webhook 回调定位订单的唯一钥匙。Metadata["product"]存本业务线代码(如shop),是多业务共用账户时 webhook 认领事件的依据(见 2.7)。订单号(
out_trade_no)必须以mintpop<业务>_为前缀(全小写,如mintpopshop_):订单号 = 前缀 + 时间戳 + 随机数,order_no列至少VARCHAR(64)。前缀让人眼在 Stripe Dashboard、日志、客服对话里一眼辨出订单所属业务线(机器侧认Metadata["product"],人眼侧认前缀,二者缺一不可)。金额换算必须按币种小数位处理(CNY 2 位、JPY 0 位、BHD 3 位…),不许硬编码
*100。响应回传前端:
client_secret、intent_id(即 PaymentIntent ID,落库为payment_trade_no)、currency、pay_amount、out_trade_no。
2.3 Webhook(验签 + 幂等,缺一即为事故)
- 端点:
POST /api/v1/payment/webhook/stripe。读取原始 body(限 1MB),取Stripe-Signature头,用官方 SDKwebhook.ConstructEvent(rawBody, sig, webhookSecret)验签——禁止自己解析 JSON 后再校验。 - 只处理两个事件,其余一律回 200 空响应忽略:
payment_intent.succeeded→ 成功payment_intent.payment_failed→ 失败
- 先认领、再找单(顺序不可反,且认领必须在验签之后):取
Metadata["product"],不是本业务线的事件静默回 2xx 跳过——Stripe 事件是账户级广播,别的业务的支付也会投递到本端点(见 2.7);无product标记的事件(打标约定之前创建的旧意图)放行,走查单兜底。 - 从事件里取
Metadata["orderId"]找单;查无此单也回 2xx(止住 Stripe 重试风暴)。 - 入账幂等用条件 UPDATE(乐观并发):只有当前状态是
PENDING(或允许恢复的CANCELLED/ 宽限期内EXPIRED)才置PAID,受影响行数为 0 视为已处理过,直接返回成功。 - 置
PAID前校验:provider 一致、金额与pay_amount在币种容差内一致;不符写审计并拒绝。 - 履约(充值余额 / 发订阅)与「置 PAID」分离,用租约锁 + 审计表唯一约束(如
(order_id, action))保证只履约一次。
2.4 订单状态机(SCREAMING_SNAKE_CASE,前后端逐字一致)
PENDING → PAID → RECHARGING → COMPLETED
↘ EXPIRED / CANCELLED / FAILED
COMPLETED → REFUND_REQUESTED → REFUNDING → REFUNDED / PARTIALLY_REFUNDED
↘ REFUND_PENDING / REFUND_FAILED前端判定口径统一:已支付 = PAID | COMPLETED;轮询可停(成功) = PAID | COMPLETED | RECHARGING。
2.5 退款
- 走
Refunds.Create,参数PaymentIntent=<payment_trade_no>+ 最小单位金额,Reason=requested_by_customer。 - 退款必须用下单时那个实例的凭证(订单落库时记住
provider_instance_id),不允许换实例猜。 pending的退款单置REFUND_PENDING,由查询任务向网关拉终态再结算。
2.6 面向前端的 API 契约(统一 ApiResponse<T> 包装,code=0 为成功)
| 端点 | 作用 |
|---|---|
GET /payment/checkout-info | 返回可用支付方式 methods(含限额)+ stripe_publishable_key |
POST /payment/orders | 下单:{ amount, payment_type, order_type, return_url, plan_id? } → { client_secret, out_trade_no, pay_amount, currency, ... } |
POST /payment/orders/verify | 按 out_trade_no 主动向网关核实并推进状态(前端轮询用) |
GET /payment/orders/my · POST /payment/orders/:id/cancel | 我的订单 / 取消 |
payment_type 取值:拍平的三个 Stripe 子方式统一下发 stripe(子方式不进下单参数,只决定前端渲染哪种确认 UI);如有微信/支付宝直连通道则为 wxpay / alipay。
2.7 单账户多业务线共存(品牌硬约定)
MintPop 全品牌共用一个 Stripe 账户。原因:微信支付/支付宝的开通审核挂在账户维度,新开账户或 Connect 子账户(direct charges)都要重新审核,而 Connect destination charges 虽免重审、事件却仍落在平台账户上,隔离等于没做——单账户 + 约定式区分是唯一免重审且工程代价最小的路。
账户级配置(一次性,已配好,新业务线不动它):公开商家名称、对账单描述符、缩短的描述符均为 MINTPOP(缩短版是卡账单后缀能拼出 MINTPOP* <后缀> 的前提)。
代价与对策——webhook 是账户级广播:账户下每个 webhook endpoint 都会收到其订阅事件类型的全部事件,Stripe 不做业务归属过滤;A 业务的支付会原样投递到 B 业务的 endpoint。因此各业务线必须遵守认领制,三条缺一不可:
- 打标:创建 PaymentIntent 写
Metadata["product"] = <业务代码>(全小写,如shop)。 - 认领:webhook 验签通过后先看
product标记,不是自己的静默回 2xx 跳过(回非 2xx 会触发 Stripe 重试风暴、最终禁用端点)。 - 人眼侧同步区分:订单号前缀
mintpop<业务>_+ 卡账单描述符后缀(大写 4–8 字符,如SHOP;前缀MINTPOP+*+ 后缀合计 ≤22 字符)。
新业务线接入清单(账户层面零改动):
- [ ] Dashboard「付款方式域名」注册本业务域名;
- [ ] 新建独立 webhook endpoint(只订阅
payment_intent.succeeded/payment_intent.payment_failed),取各自的webhookSecret——endpoint 不共用; - [ ] 配置本业务的
productCode、订单号前缀、statementDescriptorSuffix(三者语义一致,如shop/mintpopshop_/SHOP)。
三、前端接入规范(品牌 UI 标准)
3.1 拍平算法(标准实现,照抄)
ts
/** 展示顺序即数组顺序:微信 → 支付宝 → 银行卡(INVARIANT) */
export const STRIPE_SUB_METHODS = ['wxpay', 'alipay', 'card'] as const
/** 子方式 → Stripe payment_method_types(与后端映射表逐字一致) */
export const STRIPE_PM_TYPE = { wxpay: 'wechat_pay', alipay: 'alipay', card: 'card' }
/** 从后端 methods 构建拍平列表:直连微信/支付宝优先,Stripe 补位;银行卡只来自 Stripe */
export function buildPayOptions(methods) {
const options = []
const hasStripe = 'stripe' in methods
for (const m of ['wxpay', 'alipay']) {
if (m in methods) options.push({ key: m, paymentType: m })
else if (hasStripe) options.push({ key: `stripe:${m}`, paymentType: 'stripe', subMethod: m })
}
if (hasStripe) options.push({ key: 'stripe:card', paymentType: 'stripe', subMethod: 'card' })
return options
}默认选中 = 拍平列表首项(通常是微信支付)。
3.2 视觉规范(INVARIANT)
| 项 | 微信支付 | 支付宝 | 银行卡 |
|---|---|---|---|
| 中文名 | 微信支付 | 支付宝 | 银行卡 |
| 英文名 | WeChat Pay | Alipay | Bank card |
| 副文案(中/英) | 扫码即时到账 / Scan to pay instantly | 扫码即时到账 / Scan to pay instantly | Visa · 万事达 / Visa · Mastercard |
| 图标底色 | #09BB07(微信绿) | #1677FF(支付宝蓝) | #635BFF(Stripe 紫) |
| 图标 | 白色微信 logo(内联 SVG) | 白色粗体「支」字 | 白色银行卡轮廓(内联 SVG) |
- 图标一律内联 SVG(含支付宝的「支」字),不引外链图片(兼顾全球访问可达性与体积);容器 34×34px、圆角 9px。
- 布局:
role="radiogroup"网格——移动端单列竖排、桌面端三列横排(grid-cols-1 sm:grid-cols-3),三张卡片平级并列,无主次之分。 - 选中态:边框换品牌强调色 + 强调色 6% 底 + 右侧单选圆点;未选中 hover 只轻描边框。
- 必带两处 Stripe 标注(合规与信任感,不可省):
- 标题行右侧角标:「由 Stripe 安全处理」/ “Processed securely by Stripe”(Stripe 一词着色
#635BFF)。 - 底部安全提示(盾牌图标 + 虚线分隔):「微信支付与支付宝均通过 Stripe 安全处理,到账与额度一致。」
- 标题行右侧角标:「由 Stripe 安全处理」/ “Processed securely by Stripe”(Stripe 一词着色
- 无障碍:radiogroup 需支持 roving tabindex 与方向键循环选择。
3.3 支付确认交互(按子方式分流)
统一用 @stripe/stripe-js(动态 import 懒加载,publishable key 来自 checkout-info):
| 子方式 | 确认方式 |
|---|---|
| 微信支付 | confirmWechatPayPayment(clientSecret, { payment_method_options: { wechat_pay: { client: 'web' } } }, { handleActions: false }) → 取 next_action.wechat_pay_display_qr_code 本地渲染二维码(qrcode 库,200×200) |
| 支付宝 | 桌面端 handleActions: false 取托管页 URL 本地生成二维码;移动端整页跳转 |
| 银行卡 | 挂 Payment Element(layout: 'tabs',appearance: { theme: 'stripe', variables: { borderRadius: '12px' } }),confirmPayment({ redirect: 'if_required' }) |
- Elements 用 deferred 模式初始化:
elements({ mode: 'payment', amount, currency, paymentMethodTypes: [所选那一种] })——只渲染用户选中的那一种方式,这是「拍平」在确认层的延续;仅旧单续付等信息不全时才退回 clientSecret 模式(渲染全部方式)。 - Payment Element 语言跟随应用 locale(zh-CN→
zh,en-US→en)。 - 二维码展示需带有效期倒计时文案(「二维码有效期:{time}」)。
3.4 回跳与轮询
- 下单必传
return_url = ${origin}/payment/result;每个产品固定提供/payment/result回流路由。 - 轮询统一原语:每 2s 调
POST /orders/verify,命中成功口径(PAID | COMPLETED | RECHARGING)即停;整页跳转回流场景轮询约 15 次(~30s)后转「结果待确认」而非报失败。 - 回流页处理完毕要清理 URL 上的
payment_intent/payment_intent_client_secret/redirect_status等 query。 - 回流成功文案按订单类型区分:「余额已更新」/「订阅已生效」。
3.5 i18n 基准文案(中英一一对应,新产品照抄)
| key | 中文 | 英文 |
|---|---|---|
| paymentMethod | 支付方式 | Payment method |
| poweredBy | 由 {provider} 安全处理 | Processed securely by |
| securityNote | 微信支付与支付宝均通过 {provider} 安全处理,到账与额度一致。 | WeChat Pay and Alipay are both processed securely by {provider} — credited at the same amount. |
| methodWxpay / methodAlipay / methodCard | 微信支付 / 支付宝 / 银行卡 | WeChat Pay / Alipay / Bank card |
| methodScanDesc / methodCardDesc | 扫码即时到账 / Visa · 万事达 | Scan to pay instantly / Visa · Mastercard |
| confirmPay / submitting | 确认支付 / 下单中… | Confirm payment / Placing order… |
| stripePay / processing | 立即支付 / 正在确认支付 | Pay now / Confirming payment |
四、安全清单(上线前逐条核对)
- [ ] Stripe 密钥不出现在仓库任何文件(含
.env.example);grepsk_live/sk_test为零命中。 - [ ] CSP 放行 Stripe:
script-src/frame-src含https://*.stripe.com(这是.env/ 配置里唯一允许出现 stripe 字样的地方)。 - [ ] Webhook 用官方 SDK 验签,原始 body 限长;无关事件与查无此单均回 2xx。
- [ ] Webhook 先验签、后按
Metadata["product"]认领;非本业务线事件静默回 2xx(见 2.7)。 - [ ] 订单号前缀为
mintpop<业务>_,PaymentIntent 带product标记与卡账单后缀,三者语义一致。 - [ ] 入账为条件 UPDATE 幂等;履约有租约锁 / 唯一约束防重。
- [ ] 金额校验:回调金额与订单
pay_amount按币种容差比对,不符拒绝入账。 - [ ] 退款使用下单实例的凭证。
- [ ] 前端不落任何密钥,只持有 publishable key 与 client_secret。
附、术语
- PaymentIntent 模式:后端建意图、前端 Stripe.js 确认的集成方式;区别于整页跳转的 Checkout Session。本品牌只用前者。
- 拍平(flatten):后端聚合为单一
stripe通道,前端将其展开为「微信 / 支付宝 / 银行卡」并列选项的展示策略。 - 子方式(sub method):拍平后的单个选项(
wxpay/alipay/card),只存在于前端展示与确认层,不进下单参数。 out_trade_no:我方外部单号,唯一索引,以mintpop<业务>_为前缀;写入 PaymentIntent 的Metadata["orderId"],是 webhook 找单的钥匙。- 认领(claim):单 Stripe 账户多业务线下,webhook 收到账户级广播事件后按
Metadata["product"]判断归属的机制——是本业务才处理,不是则静默回 2xx 跳过。 - deferred Elements:Stripe Elements 的延迟意图初始化模式(传
mode/amount/currency而非 clientSecret),可按需收窄渲染的支付方式。