kra-new/docs/PAYMENT.md

25 KiB
Raw Blame History

支付接入设计

代码分层

支付渠道适配器统一放在 internal/data/payment,订单仓储和运行时入口保留在 internal/data;这与 internal/data/storage 集中管理 OSS provider 的方式一致, 避免各渠道协议散落在 data 根目录。它们仍属于 data 层,因为需要读取 sys_integration_configs、调用运行时客户端、转换 biz 对象并在数据库事务中推进 pay_orders 状态。无状态的协议基础能力已下沉到领域化公共包:

  • pkg/paymentkit金额整数化、支付状态归一化、JSON 路径读取、微信 v2 XML、 微信 v2/聚合渠道签名等纯函数;不依赖 bizdata 或数据库。
  • internal/data/paymentGoPay 和配置驱动渠道的 adapter、SDK 配置映射、 回调验签/解密以及 DO 结果归一化GoPay/driver 类型不越过 data 边界。
  • pkg/osskit:跨对象存储 provider 的流式合并和 MD5 计算;不依赖具体 OSS SDK。
  • internal/data/storage:本地磁盘、七牛、阿里云 OSS、华为 OBS、腾讯 COS、 S3/MinIO/R2 的客户端创建、配置映射和 biz.FileStorage 适配仍保留在 data。

因此不能把整个 internal/data 或所有 OSS provider 直接移动到 pkg:那会让公共包 反向依赖内部领域模型和运行时配置,破坏分层。后续新增 provider 时,优先把纯签名、 金额和报文转换放入 pkg/paymentkit把配置、HTTP 客户端、数据库和业务结果转换留在 internal/data

支付模块创建一张 pay_orders 表,用于记录一次支付尝试的金额、渠道状态、发货状态、退款状态和并发租约。业务模块继续拥有自己的业务订单,通过 business_type + business_id 关联支付订单。渠道配置继续使用已有的 sys_integration_configskind = payment;不创建独立回调事件表或支付日志表。

订单表边界

pay_orders 一行代表一次支付尝试,而不是业务订单:

  • (provider, trade_no) 唯一,同一个业务订单可以创建多个不同渠道或不同批次的支付尝试。
  • (provider, provider_trade_no) 唯一,禁止一个平台交易绑定两个本地订单。
  • request_fingerprint 防止相同商户订单号被不同金额、币种或业务对象重复使用。
  • payment_statusfulfillment_statusrefund_status 相互独立,退款不会抹掉原始支付和发货事实。
  • original_amount 是业务原价;amount 是提交给第三方平台的订单总额,二者均使用币种最小单位整数。
  • payer_paid_amount 是用户实际支付金额;cash_paid_amount / point_paid_amount 分别表示现金和积分/平台资产部分;discount_amount 是订单总额减用户实付。
  • provider_discount_amount / merchant_discount_amount 只有渠道明确返回出资拆分时才填写;未知字段不能推测。settlement_amount 是渠道最终结算商户金额,不等于用户实付。
  • amount_breakdown_known 只有在上述核心金额满足守恒校验时才为 true,否则仍保留总额校验,但不伪造优惠拆分。
  • confirmation_id 根据 provider + trade_no 稳定生成,是业务发货最终幂等键。
  • 只保存平台响应哈希和必要状态;不保存完整回调原文、签名密钥或请求头。
  • fulfillment_token / refund_token 和租约时间用于跨实例互斥,进程退出后租约到期可安全重试。

GoPay 渠道矩阵v1.5.122

本分支固定使用 github.com/go-pay/gopay v1.5.122。下面的九行是 GoPay 稳定版本中可复用的渠道族;微信在本地拆成 wechat-v2wechat-v3 两个 provider因此渠道族仍然是九个而不是把微信重复计算成两个渠道。

GoPay 渠道族 本地 provider 当前适配的 GoPay 能力 回调/退款边界
Alipay旧网关协议 alipay TradeCreateTradePayTradePrecreateTradeAppPayTradePagePayTradeWapPay、查单、退款、通知验签 支持服务端验签后主动查单
Alipay V3 alipay-v3 GoPay V3 TradeCreateTradePayTradePrecreateTradeAppPayTradePagePayTradeWapPay、查单、退款、证书响应验签、通知证书验签 必须配置应用公钥证书、支付宝根证书和支付宝公钥证书;确认仍以主动查单为准
WeChat wechat-v2wechat-v3 v2 UnifiedOrder/Micropay/QueryOrder/Refundv3 JSAPI、App、Native、H5、CodePay 付款码下单、查单、退款、通知验签/解密 v2 使用商户密钥v3 使用平台证书和 API v3 key
Apple apple-iap App Store Server API 交易查询、JWS/证书链处理 下单由客户端 StoreKit 驱动;服务端不提供主动退款
PayPal paypal CreateOrderOrderDetailOrderCapturePaymentCaptureRefund、Webhook 验签 买家批准后由查单链路捕获;退款需要已持久化的 capture ID
Douyin douyin App、JSAPI、H5、Native 下单,按商户订单号查单、退款、通知验签和解密 平台证书必须配置
QQ qq UnifiedOrderOrderQueryRefund、通知解析和验签 退款需要商户证书、私钥或 PKCS#12
AllinPay allinpay PayScanPayNativePayQueryRefund 当前不接收通知;使用主动查单/对账确认
Lakala lakala JSAPI、H5、小程序、Native/二维码、Native JSAPI、SDK、Web Gateway、线下条码/二维码下单,OrderStatusRefund、通知解析和验签 回调字段以 GoPay 返回模型为准
Saobei saobei MiniPayBarcodePayQueryRefund 当前不接收通知;使用主动查单/对账确认

CMB(招商银行)不在固定的 v1.5.122 模块中,不能作为本分支已接入渠道; 上游未发布版本中的目录或提交不构成稳定依赖,故这里明确排除。

AllinPay 和 Saobei 的 adapter 会拒绝回调入口,因为固定版本没有可复用且能在 本项目边界内完成验签的通知路径。对这两个渠道,生产流程必须依赖主动查单、 定时对账和幂等状态推进,不得把未验签的通知当作支付事实。

本矩阵只说明代码和 GoPay 方法的接入情况,不代表真实商户沙箱或生产联调已经完成。 当前仓库仅做单元测试和本地 mock/HTTP 响应验证;上线前仍须使用实际商户凭证、 证书、平台回调和退款报文逐项联调。

统一 adapter 面向普通商户的核心下单、查单、退款和回调流程。微信服务商/合单、 PayPal AUTHORIZE 意图后的授权捕获,以及账单、分账、转账等能力需要不同的业务 状态机和持久化字段,不能仅靠透传 extra 安全接入;这些扩展应按实际业务合同 单独建模,不属于“稳定渠道族已接入”的含义。

其他已保留渠道

Provider 实现方式
alipayalipay-v3wechat-v2wechat-v3apple-iappaypaldouyinqqallinpaylakalasaobei 统一走上面的 GoPay v1.5.122 adapter业务层只接收 biz.PaymentResult
chinaums 配置驱动的银联商务 JSON 签名适配器
sft 配置驱动的商福通 JSON/MD5 适配器
supper-pay 配置驱动的 Supper Pay HMAC 适配器
wechat-game-pay 配置驱动的微信小游戏虚拟支付 2.0 适配器,支持 access token
douyin-game-pay 配置驱动的抖音小游戏支付签名适配器

上表后五个 provider 不是 GoPay v1.5.122 的九个稳定渠道族,仍保留现有的 配置驱动协议适配器。它们的协议字段依商户合同和产品版本不同,因此没有强行 假设某一个固定请求格式,必须在 sys_integration_configs.config 中声明查单 字段和金额单位。

回调安全流程

创建支付按以下顺序处理:

  1. 根据 business_type 调用业务模块注册的 PaymentOrderSource
  2. 业务模块返回可信的金额、币种、标题、业务 ID 和商户订单号;客户端提交的金额不是权威数据。
  3. 先插入 pay_orders,利用唯一索引和请求指纹实现本地下单幂等。
  4. 再调用支付平台创建订单;平台创建响应只会把本地状态推进到 pending,不能直接认定已支付。
  5. 平台调用成功后保存客户端后续支付所需的创建响应。数据库记录失败时,可使用相同商户订单号安全重试平台创建接口。

每次支付回调按以下顺序处理:

  1. 根据渠道配置加载适配器。
  2. 校验渠道签名、证书/JWS/通知解密,并校验应用号、商户号等身份字段。
  3. 从已验签的通知中提取商户订单号。
  4. 主动调用对应平台查单接口,不以回调中的支付状态、金额或币种作为最终依据。
  5. 查单状态只允许 successpendingfailed;未知状态直接失败。成功状态必须同时包含商户订单号、平台交易号、正整数金额和币种。
  6. 查单状态不是成功时,不发货;后续回调或业务主动查询可以再次确认。
  7. pay_orders 读取本地权威金额、币种、业务类型和业务 ID。
  8. 严格比较渠道、商户订单号、平台交易号、金额、币种;任一关键字段缺失或不一致都拒绝更新为已支付。
  9. 在数据库事务中把支付状态推进到 paid,再竞争发货租约。
  10. 根据 business_type 查找 PaymentFulfillmentHandler,使用稳定 confirmation_id 执行业务发货。
  11. 发货成功后将 fulfillment_status 更新为 succeeded;失败记录为 failed 并允许重试。重复回调和主动查询不会重复执行已完成发货。

支付模块不会接受客户端传入的金额、标题或币种作为权威订单数据。持久化流程启用后,未注册 PaymentOrderSource 的业务类型会直接拒绝下单。

业务扩展接口

业务侧必须把同一个实现作为 PaymentBusinessModule 注册。该接口同时要求实现 PaymentOrderSource(返回权威金额、币种和标题)、PaymentFulfillmentHandler (按 confirmation_id 幂等发货)和 PaymentRefundAuthorizer(校验业务订单是否 允许退款)。应用组合阶段调用 PaymentUsecase.RegisterBusinessModule(...) 只注册支付 adapter 而不注册业务模块是不完整的接入。

当前仓库没有注册任何生产业务模块,因此直接调用持久化支付创建流程会返回 支付业务订单来源未注册。这不是渠道配置错误,接入具体商品、订单或订阅业务时 必须在应用启动组装处完成注册。

每个业务模块先实现可信订单来源:

type GameItemPayment struct {
    orders GameItemOrderRepo
}

func (GameItemPayment) Type() string { return "game_item" }

func (p GameItemPayment) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*biz.PaymentIntent, error) {
    order, err := p.orders.FindPayable(ctx, businessID)
    if err != nil {
        return nil, err
    }
    return &biz.PaymentIntent{
        Provider: provider, TradeNo: tradeNo,
        BusinessType: "game_item", BusinessID: businessID,
        Subject: order.Title, Amount: order.PayableAmount, Currency: order.Currency,
    }, nil
}

然后注册按业务类型分发的发货处理器:

func (p GameItemPayment) Fulfill(ctx context.Context, c *biz.PaymentConfirmation) error {
    return p.orders.Transaction(ctx, func(tx GameItemOrderTx) error {
        // confirmation_id 必须有唯一约束。已处理时直接返回 nil。
        if tx.HasPaymentConfirmation(c.ID) {
            return nil
        }
        if err := tx.Deliver(c.BusinessID); err != nil {
            return err
        }
        return tx.SavePaymentConfirmation(c.ID)
    })
}

func (p GameItemPayment) AuthorizeRefund(ctx context.Context, order *biz.PaymentOrder, amount int64) error {
    return p.orders.CheckRefundable(ctx, order.BusinessID, amount)
}

启动时将同一个实现分别注册到 PaymentOrderSourceRegistryPaymentFulfillmentRegistryPaymentConfirmation.ID 同一支付尝试永远不变。业务处理器必须在自己的事务中以该 ID 建唯一约束,才能覆盖“业务已发货但进程在更新 pay_orders 前退出”的极端窗口。

推荐直接注册完整业务模块:

if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrders}); err != nil {
    return err
}

一致性边界

  • 本地下单:唯一索引和 request_fingerprint 保证同一支付号不可换金额或业务对象。
  • 平台确认:只有验签后的主动查单结果可以把订单推进到 paid,状态不会从已支付回退到待支付或失败。
  • 多实例发货:数据库行锁、处理令牌和租约保证同一时刻只有一个实例执行发货。
  • 最终发货幂等:业务模块必须在自己的事务中对 confirmation_id 建唯一约束。
  • 退款:一次只允许一个在途退款;每次退款都有持久化 refund_no网络超时会复用同一个退款号重试。渠道响应提供退款号回显时adapter 必须校验其与本地 refund_no 一致,并要求独立的平台退款号非空;平台接受退款后状态为 pending,只有携带匹配 refund_no 的退款通知或对账任务调用 ConfirmRefund 后才增加 refunded_amount
  • 退款授权:业务模块必须实现 AuthorizeRefund,支付模块不会仅凭渠道、订单号和金额执行退款。
  • 外部平台调用和本地数据库无法组成单个 ACID 事务,因此采用“本地先落单、平台接口幂等重试、主动查单、数据库状态机、业务最终幂等”的组合保证最终一致性。

配置示例

支付宝:

{"app_id":"","private_key":"PEM","public_key":"PEM","environment":"production","sign_type":"RSA2","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"}

method 可选 alipay.trade.createalipay.trade.payalipay.trade.precreatealipay.trade.app.payalipay.trade.page.payalipay.trade.wap.pay。 付款码支付可使用 barcode / micropay 别名,并在订单 extra.auth_code 中传入付款码。

支付宝 V3

{"app_id":"","private_key":"PEM","app_cert":"PEM 或文件路径","root_cert":"PEM 或文件路径","public_cert":"PEM 或文件路径","environment":"production","api_base_url":"https://openapi.alipay.com","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"}

alipay-v3 独立于旧 alipay provider。app_certroot_certpublic_cert 分别对应 GoPay ClientV3.SetCert 的应用公钥证书、支付宝根证书和支付宝公钥证书; 也可使用 *_content / *_path 以及 alipay_root_cert*alipay_public_cert* 兼容别名。V3 HTTP 接口使用 api_base_url(测试代理可指向本地 mock页面/APP 调起参数使用 GoPay 已封装的 TradeAppPayTradePagePayTradeWapPay method 支持 alipay.trade.createalipay.trade.payalipay.trade.precreatealipay.trade.app.payalipay.trade.page.payalipay.trade.wap.pay。V3 的 REST 响应只有在证书验签通过后才会进入业务层,通知回调同样使用 GoPay 的证书验签。

微信支付 v2

{"app_id":"","merchant_id":"","mch_key":"","sign_type":"MD5","trade_type":"NATIVE","client_cert":"PEM","client_key":"PEM"}

client_cert / client_key 在退款等双向 TLS 请求中使用;也接受 appidmch_idapi_key 等兼容别名。自定义测试端点可分别配置 create_urlquery_urlrefund_urltrade_type 支持 JSAPIAPPNATIVEMWEB;付款码支付可使用 micropay / barcode 别名,并在订单 extra.auth_code(或渠道配置同名字段)传入付款码。未知下单方式会直接拒绝; 付款码下单返回的平台交易号和金额会校验,客户端调起数据只保存在 Payload

微信支付 v3

{"app_id":"","merchant_id":"","serial_no":"","private_key":"PEM","api_v3_key":"32-byte key","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi"}

trade_type 支持 jsapiappnativeh5codepay / micropay JSAPI/小程序下单还需在订单 extra.openid 中传入用户标识,付款码支付则需在 订单 extra.auth_code 中传入用户付款码(兼容 authcode / barcode 字段名)。

Apple 内购:

{"issuer_id":"","key_id":"","bundle_id":"","private_key":"PEM","price_divisor":"10","environment":"production"}

Apple Server API 的交易 price 使用平台返回的单位;price_divisor 必须按业务订单使用的最小货币单位配置。金额不能整除时,支付模块拒绝确认。

Apple 多币种可使用 price_divisors 对不同 currency 分别配置比例;服务端不会把 Apple 的价格字段默认当作人民币分。

Apple 没有传统服务端“预下单”。创建接口要求 tradeNo 是 UUID并把它作为 appAccountToken 返回给客户端;客户端发起 StoreKit 购买时必须原样传入。回调使用 appAccountToken 关联本地订单,使用 transactionId 调 Apple Server API 主动查单,两者不会混用。最终确认还会把签名载荷的 productId 与业务订单持久化的 extra.product_id 精确匹配;缺少 appAccountToken、Bundle ID、商品 ID 或环境不一致、JWS 算法不是 ES256、证书链校验失败时均拒绝确认。 adapter 在调用 GoPay 解码前还会绑定 x5c[0] 叶子到 x5c[1]/x5c[2],并检查 Apple App Store 签名证书扩展,避免仅凭叶子公钥验签。

Apple IAP 的购买流程由客户端发起,退款/撤销由 App Store 管理。本项目的 apple-iap adapter 不提供商户服务端主动退款,业务侧应通过 Apple 的退款流程和 后续通知/查询更新状态。

PayPal

{"client_id":"","client_secret":"","webhook_id":"","environment":"sandbox","return_url":"https://merchant.example/paypal/return","cancel_url":"https://merchant.example/paypal/cancel","amount_scales":{"USD":"100","JPY":"1"}}

启用 PayPal 配置必须提供 webhook_id,回调验签会将其传给 GoPay默认金额比例按 PayPal 币种处理,也可使用 amount_scales/currency_scales 覆盖。CAPTURE 意图的 订单在买家批准后会由查单流程调用 GoPay OrderCapture 完成捕获,只有捕获成功才会 进入支付成功和发货;可显式配置 auto_capture=false 关闭。退款需要订单状态中已 持久化的 capture ID。

抖音支付:

{"app_id":"","merchant_id":"","serial_no":"","api_key":"32-byte key","private_key":"PEM","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi","environment":"production"}

当前 adapter 支持 appjsapih5native;固定版本的抖音客户端按 生产接口工作,environment 只能使用 production/prod。平台证书序列号也接受 GoPay 模型使用的兼容字段 platform_cert_serial

QQ 支付:

{"mch_id":"","api_key":"","sign_type":"MD5","trade_type":"NATIVE","cert_file":"/secure/qq/apiclient_cert.pem","key_file":"/secure/qq/apiclient_key.pem","environment":"production"}

sign_type 可用 MD5HMAC-SHA256。退款必须配置 cert_file + key_file,或 pkcs12_file;也可使用对应的 *_content 字段。

通联支付AllinPay

{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","query_order_type":"reqsn","currency":"CNY","environment":"production"}

query_order_type 只允许 reqsntrxid,默认使用商户订单号 reqsn。 选择 trxid 时,下单响应必须返回交易号并将其持久化为后续查单、退款标识; Native 下单不会返回该标识,因此不能与 trxid 模式组合。该 provider 只接入 下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。

拉卡拉Lakala

{"partner_code":"","credential_code":"","channel":"Wechat","method":"jsapi","currency":"JPY","environment":"production"}

method 支持 jsapih5mininativeqrcodenative_jsapisdkwebretailretail_qrcode,并映射到 GoPay v1.5.122 对应的 创建方法;未知值会直接拒绝。下单响应中的 order_id 是持久化查单键,二维码、 跳转 URL 和 SDK 参数只放在创建结果 Payload。支持查单、退款和 GoPay 通知验签。 当前固定版本客户端只允许生产环境配置。

扫呗Saobei

{"inst_no":"","key":"","merchant_no":"","terminal_id":"","access_token":"","pay_type":"010","currency":"CNY","environment":"production"}

该 provider 只接入条码/小程序下单、查单和退款,不接收通知;支付确认由主动 查单/对账触发。条码支付在订单 extra.auth_no 中传入付款码,也兼容 auth_codeauthcodebarcodepay_code 字段名。

非 GoPay 的配置驱动聚合/小游戏渠道最少需要:

{
  "protocol_version":"以商户协议为准",
  "app_id":"",
  "merchant_id":"",
  "create_url":"",
  "query_url":"",
  "refund_url":"",
  "app_key":"",
  "query_status_field":"data.status",
  "query_success_values":"SUCCESS,PAID",
  "query_trade_no_field":"data.trade_no",
  "query_provider_trade_no_field":"data.transaction_id",
  "query_amount_field":"data.amount",
  "query_currency_field":"data.currency",
  "query_amount_scale":"1",
  "callback_status_field":"data.status",
  "callback_success_values":"SUCCESS,PAID",
  "callback_trade_no_field":"data.trade_no",
  "callback_provider_trade_no_field":"data.transaction_id"
}

query_amount_scale = 1 表示响应已经是最小货币单位整数;100 表示响应是元/主货币单位并转换为分;其他值必须是 10 的幂。字段路径使用点号访问 JSON 对象。

聚合/小游戏渠道如需保存优惠和内部资产拆分,可额外配置:

{
  "query_payer_paid_amount_field":"data.payer_total",
  "query_cash_paid_amount_field":"data.cash_fee",
  "query_point_paid_amount_field":"data.point_fee",
  "query_discount_amount_field":"data.discount",
  "query_provider_discount_amount_field":"data.provider_discount",
  "query_merchant_discount_amount_field":"data.merchant_discount",
  "query_settlement_amount_field":"data.settlement_amount",
  "query_payer_currency_field":"data.payer_currency",
  "query_payer_paid_amount_scale":"100",
  "query_cash_paid_amount_scale":"100"
}

金额拆分字段缺失时,适配器把 amount_breakdown_known 设为 false,不会将未知优惠归给平台或商户。字段存在但不能按配置的整数比例精确换算时,查单失败并要求修正渠道配置。

内部支付使用 provider = internalpayment_mode = internal,不调用第三方平台。业务模块实现 PayInternal,以 trade_no 原子、幂等扣减积分、余额或其他内部资产,并返回授权号和完整金额拆分;仍然走同一套 pay_orders、查单/确认、发货和退款状态机。内部退款必须由业务模块以同一订单幂等执行 RefundInternal

Chinaums、SFT、Supper Pay、微信小游戏和抖音小游戏没有在代码中假设所有商户都使用同一合同版本。启用前必须拿实际商户文档和沙箱报文逐项确认请求字段、签名串、金额单位、回调字段和成功状态缺少 protocol_version 或明确回调字段映射时配置校验会拒绝启用。这里提供的是严格失败的适配框架,不应在未完成渠道联调测试时标记为生产可用。

回调 ACK

回调接口不会返回内部错误文本、平台原始查询结果或业务发货信息。支付宝、微信 v2、微信 v3 和 Apple 使用各自固定 ACK聚合渠道默认返回纯文本 success / failure,并可按实际协议配置:

{
  "callback_success_status":"200",
  "callback_success_content_type":"application/json",
  "callback_success_body":"{\"code\":\"SUCCESS\"}",
  "callback_failure_status":"500",
  "callback_failure_content_type":"application/json",
  "callback_failure_body":"{\"code\":\"FAIL\"}"
}

状态码只允许 200-599Content-Type 禁止换行,响应体最大 64KiB。验签、主动查单、订单解析、金额校验或发货失败时返回失败 ACK让支持重试的平台再次通知查单为 pending / failed 时不发货,但通知已被安全处理,因此返回成功 ACK。

HTTP 接口

  • GET /integration/configs/payment
  • GET /integration/configs/payment/:provider
  • PUT /integration/configs/payment/:provider
  • DELETE /integration/configs/payment/:provider
  • POST /payment/order
  • POST /payment/create
  • POST /payment/query
  • POST /payment/refund
  • POST /payment/callback/:provider

amount 使用整数,单位是业务约定的最小货币单位。extra 传递渠道特有字段,例如 openidtrade_typeproduct_id

日志

支付日志在 internal/biz/payment_log.go 通过 PaymentLogger 独立抽象,包含下单、查单失败、回调查单、金额校验、重复回调和发货结果等结构化事件。日志只记录渠道、商户订单号、业务类型、业务 ID、确认 ID 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。