支付
Commerce 对支付媒介保持无关。网关在结算时描述「下一步该发生什么」(一个 PaymentAction),并通过引擎的幂等 settler 在带外确认。本页讲这个模型、两个内置网关,以及如何编写你自己的卫星网关。
确认反转模型
引擎从不问网关「这单付了吗?」,而是:
结算 → gateway.StartPayment → PaymentAction(店面渲染它)
↓
买家用网关的任意方式付款
↓
网关内部确认(webhook / 后台手动 / 链上轮询)
↓
网关调用 commerce.GetSettler(bus).Settle(paid, txn, idempotencyKey)
↓
引擎(幂等地)推进订单状态机 → 提交库存、发邮件
所有确认路径 —— 推送(webhook)、手动(后台「标记已付」)、拉取(Reconciler)—— 都收敛到一个幂等 Settle。payments.idempotency_key 唯一索引去重,因此 webhook 重投、或「回跳与 webhook 竞争」都不会重复推进订单。
四种下一步动作
StartPayment 返回密封集合之一:
| Action | 使用者 | 店面行为 |
|---|---|---|
RedirectAction{URL} |
托管 PSP(PayPal) | 302 到审批 URL |
DisplayAction{Title, Rows, QR, ExpiresAt} |
离线转账、加密货币 | 渲染说明,订单 → on_hold |
InlineAction{ClientData} |
tokenize 卡 SDK | 渲染内联组件(不落卡号) |
CompletedAction{} |
店铺余额、赠单 | 同步结算 |
运行时可用性与安全启动支付
就绪状态取决于设置的网关实现可选的 GatewayAvailability.Available 契约;只接受特定订单币种的网关还实现 GatewayCurrencySupport.SupportsCurrency。结算页在生成可见选项和校验提交方式时都会计算这两项能力,因此已激活但被禁用、配置不完整或币种不兼容的网关既不会显示,也无法用伪造/过期表单值选中。未实现这些可选接口的旧网关为兼容仍视为可用且不限制币种。
Commerce 先提交本地订单、库存预留、订单行/地址快照和 pending 支付,再用稳定的 PaymentRequest.IdempotencyKey(start:<订单号>)调用 StartPayment。网关必须把此键传给支付服务商,保证重试不会重复创建远端支付。
外部调用前不会清空购物车。只有 DefinitiveStartFailure(网关能证明远端尚未产生副作用)才会把订单/支付标为失败、释放预留并保留购物车。普通错误、超时、无效动作或动作落库失败都按结果未知处理:支付进入 reconciliation,库存继续保留,本次购物车快照被消费,等待 webhook 或人工核对;TTL 清理器会跳过这类订单。如果 webhook 已抢先推进锁定订单,则复用其结果。即使订单已取消/失败,迟到的已付款事件也会将其转入订单级 reconciliation,而不会留下「已扣款但订单关闭」的静默状态。
可选的拉取式确认
无法推送的网关(加密货币常见)实现 Reconciler。引擎调度器周期性把该网关的待确认支付(连同 StartPayment 时存下的不透明上下文)交给它;网关查自己的真值来源并返回 SettleRequest。链选型、确认数阈值、indexer 全在网关内部。
内置离线银行转账网关
位于 Commerce 引擎内(gateway_offline.go),使全新店铺零外部配置即可收单。StartPayment 返回带设置里银行信息的 DisplayAction,订单转 on_hold。确认是订单详情页的后台标记已付操作,它像任何网关一样调 Settle(paid)。它声明 Refund: false(离线退款手工记录)。
退款安全与累计限额
每次后台退款先锁定订单,并用稳定、唯一的幂等键新建或恢复一条 refunds 记录。记录状态为 pending、succeeded 或 failed;pending 与失败/结果未知的尝试会继续占用对应可退额度,因为即使服务商响应丢失,它也可能已经完成退款。此类尝试必须用同一个键重试。其他请求只能使用「订单总额 − 已成功退款 − 已占用额度」,因此并发或反复部分退款的累计值不会超过订单总额。非空网关退款号按「网关 + 远端 id」联合唯一;若两个等额尝试使网关事件无法唯一归属,Commerce 会拒绝本次事件并等待重试,而不会猜测认领某一行。
对于声明 Refund: true 的网关,Commerce 要求可选的 IdempotentRefunder 契约。RefundWithResult 必须把 RefundRequest.IdempotencyKey 传给服务商,并返回非空的 RefundResult.TransactionID。Commerce 持久化该远端 id,以便后续 webhook 关联同一笔退款而不是重复记录。已注册且声明 Refund: false 的网关仍可走明确的手工/离线记录;历史支付网关缺失时会拒绝操作,不会静默降级成本地退款。
远端退款成功这一资金事实会先提交,再同步订单状态;因此备注/状态同步失败无法抹掉已退资金的证据。部分退款只保留为财务记录,不改变 processing/completed;只有累计成功退款达到订单全额时,订单才进入 refunded。
PayPal 卫星(plugins/commerce-paypal)
一个独立、可选的插件,端到端印证 A 方案:go list -deps 确认它只 import core/commerce(外加一小片 core 用于 hook bus、options、站点 URL)—— 绝不 import plugins/commerce。它用一个窄 appHost 接口取宿主能力;只有 register.go import core 去调 RegisterPlugin。
流程:
Available要求 PayPal 网关已启用且凭据完整;随后StartPayment创建 PayPal Orders v2 订单(intentCAPTURE,custom_id= 我方订单号)并返回RedirectAction{审批URL}。- 买家回跳路由
GET /commerce/paypal/return→ 同步 capture 并 settle,再把买家转发到 Commerce 的 return URL。这让本地沙盒无需公网 webhook 也能闭环。若返回ORDER_ALREADY_CAPTURED,回退去读已有 capture,因此可与 webhook 并存。 - Webhook
POST /commerce/paypal/webhook→ 经 PayPalverify-webhook-signatureAPI 验签,再处理CHECKOUT.ORDER.APPROVED(capture)、PAYMENT.CAPTURE.COMPLETED(paid)和PAYMENT.CAPTURE.DENIED(failed)。PayPal 的PAYMENT.CAPTURE.REFUNDED资源描述的是汇总后的 capture,并非某一笔退款交易;处理器会校验并确认该信号,但不会把 capture ID 或原始 capture 金额误当成一笔新退款。本系统发起的退款以RefundWithResult返回的真实退款号为权威记录。 - 两条路径都以
IdempotencyKey = "paypal:capture:" + captureID幂等 settle,因此回跳与 webhook 互相去重。创建订单、capture 与退款请求还会通过 PayPalPayPal-Request-Id携带稳定键。 RefundWithResult用 Commerce 作为RefundRequest.PaymentID传入的 capture id 调 PayPal 退款 API,并返回 PayPal 退款 id 供持久化与 webhook 关联。
验签 API 的临时故障、以及结算/数据库失败会让 webhook 返回 5xx,要求 PayPal 重试;确实无效的签名仍返回 400。
凭据(client id/secret、sandbox 开关、webhook id)存插件设置页;secret 是 password 字段、留空保存时保持不变。插件为 default_inactive。
PayPal 订单 vs 我方订单:「Orders v2」是 PayPal 的 REST API 版本 —— 创建出的 PayPal 订单 是 PayPal 那边的临时支付对象,不是我方 orders 表里的行。我方库里不存 PayPal 订单 id;二者仅靠 custom_id = 我方订单号 关联。
由于生产确认是 webhook 驱动,完整沙盒测试需要 PayPal 沙盒凭据 + 一个公网可达的 webhook URL(如内网穿透)。代码路径已完整;这项线上实测是唯一尚未跑过的手工步骤。
USDT 加密货币卫星(plugins/commerce-usdt)
内置的 USDT(ERC-20)网关是参考的拉取式 / 展示型卫星 —— 对应 PayPal 重定向流的加密货币版本。它同样只依赖 core/commerce(外加一小片宿主),且不注册任何路由:确认靠插件自有的链上 watcher,而非 webhook。
流程:
StartPayment从一个 watch-onlyxpub为每个订单派生唯一收款地址(BIP-32 非硬化,服务器无花费权限),记录含精确应付 token 金额的 invoice,返回DisplayAction(网络、币种、收款地址、精确金额、二维码、到期)。commerce 将订单置on_hold。- watcher goroutine(Activate 时启动,非 core Scheduler)增量扫描已确认的 ERC-20
Transfer日志。保存设置时及每轮扫描前都会验证 RPC 的 chain id、代币合约字节码和decimals();返回日志还会逐项复核合约、事件签名、目标地址集合、区块范围、哈希、topic 与金额。 - 入账与扫描游标在同一数据库事务中提交,并按
(EVM chain id + 代币合约, tx_hash, log_index)去重。即便没有新区块范围,也会从持久化入账重试结算,因此“扫描已提交、结算前崩溃”不会永久漏款。每张 invoice 快照网络、合约、币种、汇率、dust 容差和确认阈值;只要仍处于正常或迟到账观察窗口,设置页就禁止停用或切换结算身份。 - 到期判断使用“最后安全扫描区块”的链上时间,而不是应用服务器时钟;每笔转账也按其确认区块时间判定是否按时。按时全额结算为
paid/overpaid,按时部分金额为underpaid,没有按时资金为expired。迟到资金继续观察七天,并进入 Commerce 的明确 reconciliation 路径,不会静默接受或丢失。结算键按 invoice 与结果保持稳定。 - 网关只声明支持 USD。其他币种在结算页被过滤,伪造的非 USD
StartPayment请求也会被拒绝。代币换算全程使用定点整数和 invoice 的不可变汇率快照。 - 退款
Refund: false—— 链上退款需花费权限,改为手工记录。
它是 EVM 可扩展的:泛化 evmChain 提供可复用实现,但当前唯一受支持的 preset 是以太坊。新增网络仍必须独立评审链常量、代币语义、确认策略、测试 fixture 并完成真实网络验收,不能只改一个名称就启用。金额保持单币种:订单总额为 USD,链上 USDT 金额按可配定点 usd_rate(默认 1.00)换算,结算回报 Commerce 的仍是 USD。收款说明标签按请求语言本地化(commerce-usdt.* 店面 catalog);设置页按后台语言本地化。
投入生产前,应使用专用 watch-only xpub、带监控的可信或自托管以太坊 RPC 及独立备用节点,审核 USD/USDT 汇率来源,配置 watcher/RPC/数据库错误告警与数据库备份,并建立归集、退款和 reconciliation 操作流程;还必须完成一笔真实小额主网验收付款,以及一次数据库恢复/重放演练。插件刻意不保存花费私钥,因此不会自动执行链上退款或归集。
编写你自己的卫星网关
一个最小网关只需几个文件。核心骨架:
// gateway.go —— 实现 core 契约
type myGateway struct{ p *Plugin }
func (myGateway) ID() string { return "mygw" }
func (myGateway) Title(*gin.Context) string { return "My Gateway" }
func (myGateway) Icon() string { return "💳" }
func (myGateway) Capabilities() corecommerce.Capabilities {
return corecommerce.Capabilities{Refund: true}
}
func (g myGateway) Available(*gin.Context) bool { return g.p.configReady() }
func (g myGateway) StartPayment(c *gin.Context, req corecommerce.PaymentRequest) (corecommerce.PaymentAction, error) {
// 用 req.Amount、req.OrderRef、req.ReturnURL、req.CancelURL、req.IdempotencyKey … 在你的 PSP 建单/会话
return corecommerce.RedirectAction{URL: approvalURL}, nil
}
func (g myGateway) Refund(c *gin.Context, req corecommerce.RefundRequest) error {
_, err := g.RefundWithResult(c, req)
return err
}
func (g myGateway) RefundWithResult(c *gin.Context, req corecommerce.RefundRequest) (corecommerce.RefundResult, error) {
// 把 req.IdempotencyKey 传给 PSP,并返回其持久退款 id
return corecommerce.RefundResult{TransactionID: providerRefundID}, nil
}
// plugin.go —— 在 Activate 中经 hook bus 注册
func (p *Plugin) Activate(app plugin.App) {
host := app.(appHost) // 窄接口:HookBus()、OptionsStore()、PublicSiteURL()
p.hooks = host.HookBus()
p.filters = append(p.filters, corecommerce.RegisterPaymentGateway(p.hooks, myGateway{p: p}))
// 在 routes.register action 上注册你的 webhook/回跳路由
}
// 确认时(webhook/回跳/轮询):
settler := corecommerce.GetSettler(p.hooks)
settler.Settle(ctx, corecommerce.SettleRequest{
OrderRef: orderNumber, // = 你透传给 PSP 作为元数据的 req.OrderRef
Gateway: "mygw",
TxnID: chargeID,
Amount: corecommerce.New(minorUnits, currency),
Status: corecommerce.SettlePaid,
IdempotencyKey: "mygw:charge:" + chargeID, // 重投时保持稳定
})
清单:
- 只依赖
core/commerce;不要 importplugins/commerce。 - 把 Commerce 的
OrderRef透传给你的 PSP(metadata /custom_id),确认时才能找回订单。 - 就绪状态依赖运行时配置时实现
GatewayAvailability;不要只依赖前端隐藏。 - 网关只接受特定订单币种时实现
GatewayCurrencySupport,并仍在StartPayment内再次校验币种。 - 把稳定的支付/退款请求键传给服务商;每个结算事件的
IdempotencyKey也要稳定且唯一。 - 如声明支持自动退款,实现
IdempotentRefunder并返回持久、非空的服务商退款 id。 - 验 webhook 签名;绝不存 PAN/CVV —— 只走托管重定向、展示型或 tokenization。
- 在
Activate注册、Deactivate移除句柄,发布default_inactive = true,并在internal/autoload/autoload_gen.go加一条空白 import。 - 加
LogoSVG()(static/logo.svg)用于后台插件卡片。
下一页:主题接入。
