diff --git a/docs/PAYMENT.md b/docs/PAYMENT.md index ac3266f..5ef1228 100644 --- a/docs/PAYMENT.md +++ b/docs/PAYMENT.md @@ -47,15 +47,16 @@ provider,因此渠道族仍然是九个,而不是把微信重复计算成两 | GoPay 渠道族 | 本地 provider | 当前适配的 GoPay 能力 | 回调/退款边界 | | --- | --- | --- | --- | -| Alipay | `alipay` | `TradeCreate`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、通知验签 | 支持服务端验签后主动查单 | -| WeChat | `wechat-v2`、`wechat-v3` | v2 `UnifiedOrder`/`QueryOrder`/`Refund`;v3 JSAPI、App、Native、H5 下单、查单、退款、通知验签/解密 | v2 使用商户密钥;v3 使用平台证书和 API v3 key | +| Alipay(旧网关协议) | `alipay` | `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、通知验签 | 支持服务端验签后主动查单 | +| Alipay V3 | `alipay-v3` | GoPay V3 `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、证书响应验签、通知证书验签 | 必须配置应用公钥证书、支付宝根证书和支付宝公钥证书;确认仍以主动查单为准 | +| WeChat | `wechat-v2`、`wechat-v3` | v2 `UnifiedOrder`/`Micropay`/`QueryOrder`/`Refund`;v3 JSAPI、App、Native、H5、CodePay 付款码下单、查单、退款、通知验签/解密 | v2 使用商户密钥;v3 使用平台证书和 API v3 key | | Apple | `apple-iap` | App Store Server API 交易查询、JWS/证书链处理 | 下单由客户端 StoreKit 驱动;服务端不提供主动退款 | -| PayPal | `paypal` | `CreateOrder`、`OrderDetail`、`PaymentCaptureRefund`、Webhook 验签 | 退款需要已持久化的 capture ID | +| PayPal | `paypal` | `CreateOrder`、`OrderDetail`、`OrderCapture`、`PaymentCaptureRefund`、Webhook 验签 | 买家批准后由查单链路捕获;退款需要已持久化的 capture ID | | Douyin | `douyin` | App、JSAPI、H5、Native 下单,按商户订单号查单、退款、通知验签和解密 | 平台证书必须配置 | | QQ | `qq` | `UnifiedOrder`、`OrderQuery`、`Refund`、通知解析和验签 | 退款需要商户证书、私钥或 PKCS#12 | -| AllinPay | `allinpay` | `Pay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | -| Lakala | `lakala` | `Pay`、`OrderStatus`、`Refund`、通知解析和验签 | 回调字段以 GoPay 返回模型为准 | -| Saobei | `saobei` | `Pay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | +| AllinPay | `allinpay` | `Pay`、`ScanPay`、`NativePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | +| Lakala | `lakala` | JSAPI、H5、小程序、Native/二维码、Native JSAPI、SDK、Web Gateway、线下条码/二维码下单,`OrderStatus`、`Refund`、通知解析和验签 | 回调字段以 GoPay 返回模型为准 | +| Saobei | `saobei` | `MiniPay`、`BarcodePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 | `CMB`(招商银行)不在固定的 v1.5.122 模块中,不能作为本分支已接入渠道; 上游未发布版本中的目录或提交不构成稳定依赖,故这里明确排除。 @@ -68,11 +69,16 @@ AllinPay 和 Saobei 的 adapter 会拒绝回调入口,因为固定版本没有 当前仓库仅做单元测试和本地 mock/HTTP 响应验证;上线前仍须使用实际商户凭证、 证书、平台回调和退款报文逐项联调。 +统一 adapter 面向普通商户的核心下单、查单、退款和回调流程。微信服务商/合单、 +PayPal `AUTHORIZE` 意图后的授权捕获,以及账单、分账、转账等能力需要不同的业务 +状态机和持久化字段,不能仅靠透传 `extra` 安全接入;这些扩展应按实际业务合同 +单独建模,不属于“稳定渠道族已接入”的含义。 + ## 其他已保留渠道 | Provider | 实现方式 | | --- | --- | -| `alipay`、`wechat-v2`、`wechat-v3`、`apple-iap`、`paypal`、`douyin`、`qq`、`allinpay`、`lakala`、`saobei` | 统一走上面的 GoPay v1.5.122 adapter;业务层只接收 `biz.PaymentResult` | +| `alipay`、`alipay-v3`、`wechat-v2`、`wechat-v3`、`apple-iap`、`paypal`、`douyin`、`qq`、`allinpay`、`lakala`、`saobei` | 统一走上面的 GoPay v1.5.122 adapter;业务层只接收 `biz.PaymentResult` | | `chinaums` | 配置驱动的银联商务 JSON 签名适配器 | | `sft` | 配置驱动的商福通 JSON/MD5 适配器 | | `supper-pay` | 配置驱动的 Supper Pay HMAC 适配器 | @@ -181,7 +187,7 @@ if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrde - 平台确认:只有验签后的主动查单结果可以把订单推进到 `paid`,状态不会从已支付回退到待支付或失败。 - 多实例发货:数据库行锁、处理令牌和租约保证同一时刻只有一个实例执行发货。 - 最终发货幂等:业务模块必须在自己的事务中对 `confirmation_id` 建唯一约束。 -- 退款:一次只允许一个在途退款;每次退款都有持久化 `refund_no`,网络超时会复用同一个退款号重试,平台接受退款后状态为 `pending`,只有携带匹配 `refund_no` 的退款通知或对账任务调用 `ConfirmRefund` 后才增加 `refunded_amount`。 +- 退款:一次只允许一个在途退款;每次退款都有持久化 `refund_no`,网络超时会复用同一个退款号重试。渠道响应提供退款号回显时,adapter 必须校验其与本地 `refund_no` 一致,并要求独立的平台退款号非空;平台接受退款后状态为 `pending`,只有携带匹配 `refund_no` 的退款通知或对账任务调用 `ConfirmRefund` 后才增加 `refunded_amount`。 - 退款授权:业务模块必须实现 `AuthorizeRefund`,支付模块不会仅凭渠道、订单号和金额执行退款。 - 外部平台调用和本地数据库无法组成单个 ACID 事务,因此采用“本地先落单、平台接口幂等重试、主动查单、数据库状态机、业务最终幂等”的组合保证最终一致性。 @@ -193,8 +199,24 @@ if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrde {"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.create`、`alipay.trade.precreate`、 +`method` 可选 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、 `alipay.trade.app.pay`、`alipay.trade.page.pay` 或 `alipay.trade.wap.pay`。 +付款码支付可使用 `barcode` / `micropay` 别名,并在订单 `extra.auth_code` 中传入付款码。 + +支付宝 V3: + +```json +{"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_cert`、`root_cert` 和 `public_cert` +分别对应 GoPay `ClientV3.SetCert` 的应用公钥证书、支付宝根证书和支付宝公钥证书; +也可使用 `*_content` / `*_path` 以及 `alipay_root_cert*`、`alipay_public_cert*` +兼容别名。V3 HTTP 接口使用 `api_base_url`(测试代理可指向本地 mock),页面/APP +调起参数使用 GoPay 已封装的 `TradeAppPay`、`TradePagePay` 和 `TradeWapPay`; +`method` 支持 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、 +`alipay.trade.app.pay`、`alipay.trade.page.pay` 和 `alipay.trade.wap.pay`。V3 的 +REST 响应只有在证书验签通过后才会进入业务层,通知回调同样使用 GoPay 的证书验签。 微信支付 v2: @@ -204,7 +226,10 @@ if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrde `client_cert` / `client_key` 在退款等双向 TLS 请求中使用;也接受 `appid`、`mch_id`、`api_key` 等兼容别名。自定义测试端点可分别配置 -`create_url`、`query_url` 和 `refund_url`。 +`create_url`、`query_url` 和 `refund_url`。`trade_type` 支持 `JSAPI`、`APP`、 +`NATIVE`、`MWEB`;付款码支付可使用 `micropay` / `barcode` 别名,并在订单 +`extra.auth_code`(或渠道配置同名字段)传入付款码。未知下单方式会直接拒绝; +付款码下单返回的平台交易号和金额会校验,客户端调起数据只保存在 `Payload`。 微信支付 v3: @@ -212,8 +237,9 @@ if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrde {"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` 支持 `jsapi`、`app`、`native` 和 `h5`;JSAPI/小程序下单还需 -在订单 `extra.openid` 中传入用户标识。 +`trade_type` 支持 `jsapi`、`app`、`native`、`h5` 和 `codepay` / `micropay`; +JSAPI/小程序下单还需在订单 `extra.openid` 中传入用户标识,付款码支付则需在 +订单 `extra.auth_code` 中传入用户付款码(兼容 `authcode` / `barcode` 字段名)。 Apple 内购: @@ -225,7 +251,8 @@ Apple Server API 的交易 `price` 使用平台返回的单位;`price_divisor` Apple 多币种可使用 `price_divisors` 对不同 `currency` 分别配置比例;服务端不会把 Apple 的价格字段默认当作人民币分。 -Apple 没有传统服务端“预下单”。创建接口要求 `tradeNo` 是 UUID,并把它作为 `appAccountToken` 返回给客户端;客户端发起 StoreKit 购买时必须原样传入。回调使用 `appAccountToken` 关联本地订单,使用 `transactionId` 调 Apple Server API 主动查单,两者不会混用。缺少 `appAccountToken`、Bundle ID 或环境不一致、JWS 算法不是 ES256、证书链校验失败时均拒绝确认。 +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 的退款流程和 @@ -237,8 +264,11 @@ 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"}} ``` -生产回调验签必须配置 `webhook_id`。默认金额比例按 PayPal 币种处理,也可使用 -`amount_scales`/`currency_scales` 覆盖;退款需要订单状态中已持久化的 capture ID。 +启用 PayPal 配置必须提供 `webhook_id`,回调验签会将其传给 GoPay;默认金额比例按 +PayPal 币种处理,也可使用 `amount_scales`/`currency_scales` 覆盖。`CAPTURE` 意图的 +订单在买家批准后会由查单流程调用 GoPay `OrderCapture` 完成捕获,只有捕获成功才会 +进入支付成功和发货;可显式配置 `auto_capture=false` 关闭。退款需要订单状态中已 +持久化的 capture ID。 抖音支付: @@ -247,7 +277,8 @@ PayPal: ``` 当前 adapter 支持 `app`、`jsapi`、`h5`、`native`;固定版本的抖音客户端按 -生产接口工作,`environment` 只能使用 `production`/`prod`。 +生产接口工作,`environment` 只能使用 `production`/`prod`。平台证书序列号也接受 +GoPay 模型使用的兼容字段 `platform_cert_serial`。 QQ 支付: @@ -261,19 +292,25 @@ QQ 支付: 通联支付(AllinPay): ```json -{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","currency":"CNY","environment":"production"} +{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","query_order_type":"reqsn","currency":"CNY","environment":"production"} ``` -该 provider 只接入下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。 +`query_order_type` 只允许 `reqsn` 或 `trxid`,默认使用商户订单号 `reqsn`。 +选择 `trxid` 时,下单响应必须返回交易号并将其持久化为后续查单、退款标识; +Native 下单不会返回该标识,因此不能与 `trxid` 模式组合。该 provider 只接入 +下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。 拉卡拉(Lakala): ```json -{"partner_code":"","credential_code":"","channel":"Wechat","currency":"JPY","environment":"production"} +{"partner_code":"","credential_code":"","channel":"Wechat","method":"jsapi","currency":"JPY","environment":"production"} ``` -支持 JSAPI/H5 下单、查单、退款和 GoPay 通知验签。当前固定版本客户端只允许 -生产环境配置。 +`method` 支持 `jsapi`、`h5`、`mini`、`native`、`qrcode`、`native_jsapi`、 +`sdk`、`web`、`retail` 和 `retail_qrcode`,并映射到 GoPay v1.5.122 对应的 +创建方法;未知值会直接拒绝。下单响应中的 `order_id` 是持久化查单键,二维码、 +跳转 URL 和 SDK 参数只放在创建结果 `Payload`。支持查单、退款和 GoPay 通知验签。 +当前固定版本客户端只允许生产环境配置。 扫呗(Saobei): @@ -282,7 +319,8 @@ QQ 支付: ``` 该 provider 只接入条码/小程序下单、查单和退款,不接收通知;支付确认由主动 -查单/对账触发。 +查单/对账触发。条码支付在订单 `extra.auth_no` 中传入付款码,也兼容 +`auth_code`、`authcode`、`barcode` 和 `pay_code` 字段名。 非 GoPay 的配置驱动聚合/小游戏渠道最少需要: diff --git a/internal/biz/payment.go b/internal/biz/payment.go index 11d1deb..24f294a 100644 --- a/internal/biz/payment.go +++ b/internal/biz/payment.go @@ -17,6 +17,7 @@ import ( const ( PaymentAlipay = "alipay" + PaymentAlipayV3 = "alipay-v3" PaymentWechatV2 = "wechat-v2" PaymentWechatV3 = "wechat-v3" PaymentApple = "apple-iap" @@ -40,7 +41,7 @@ const ( ) var SupportedPaymentProviders = []string{ - PaymentAlipay, PaymentWechatV2, PaymentWechatV3, PaymentApple, + PaymentAlipay, PaymentAlipayV3, PaymentWechatV2, PaymentWechatV3, PaymentApple, PaymentChinaums, PaymentSFT, PaymentSuperPay, PaymentWechatGame, PaymentDouyinGame, PaymentDouyin, PaymentQQ, PaymentAllinPay, PaymentLakala, PaymentPayPal, PaymentSaobei, } @@ -228,7 +229,7 @@ func DefaultPaymentCallbackAck(provider string, success bool) PaymentCallbackAck status = 500 } switch provider { - case PaymentAlipay: + case PaymentAlipay, PaymentAlipayV3: body := "success" if !success { body = "failure" @@ -1071,6 +1072,35 @@ func validatePaymentConfirmation(intent *PaymentIntent, provider, tradeNo string if strings.TrimSpace(intent.BusinessType) == "" || strings.TrimSpace(intent.BusinessID) == "" { return errors.New("支付业务标识不完整") } + if provider == PaymentApple { + if err := validateAppleProduct(intent.Extra, result.Payload); err != nil { + return err + } + } + return nil +} + +func validateAppleProduct(intentExtra, providerPayload json.RawMessage) error { + var expected struct { + ProductID string `json:"product_id"` + } + if err := json.Unmarshal(intentExtra, &expected); err != nil { + return errors.New("Apple 支付订单商品配置无效") + } + expected.ProductID = strings.TrimSpace(expected.ProductID) + if expected.ProductID == "" { + return errors.New("Apple 支付订单缺少 product_id") + } + var actual struct { + ProductID string `json:"productId"` + } + if err := json.Unmarshal(providerPayload, &actual); err != nil { + return errors.New("Apple 交易载荷无效") + } + actual.ProductID = strings.TrimSpace(actual.ProductID) + if actual.ProductID == "" || actual.ProductID != expected.ProductID { + return errors.New("Apple 交易 productId 与业务订单不匹配") + } return nil } diff --git a/internal/biz/payment_test.go b/internal/biz/payment_test.go index 95698bf..9c6c4fe 100644 --- a/internal/biz/payment_test.go +++ b/internal/biz/payment_test.go @@ -2,6 +2,7 @@ package biz import ( "context" + "encoding/json" "errors" "io" "log/slog" @@ -346,3 +347,23 @@ func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) { t.Fatal("broken discount conservation was accepted") } } + +func TestPaymentConfirmationBindsAppleProductID(t *testing.T) { + intent := &PaymentIntent{ + Provider: PaymentApple, TradeNo: "123e4567-e89b-12d3-a456-426614174000", + Amount: 100, Currency: "USD", BusinessType: "item", BusinessID: "I1", + Extra: json.RawMessage(`{"product_id":"com.example.coin100"}`), + } + result := &PaymentResult{ + Provider: PaymentApple, TradeNo: intent.TradeNo, ProviderTradeNo: "APPLE-TX-1", + Status: "success", Amount: 100, Currency: "USD", + Payload: json.RawMessage(`{"productId":"com.example.coin100"}`), + } + if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err != nil { + t.Fatalf("matching productId rejected: %v", err) + } + result.Payload = json.RawMessage(`{"productId":"com.example.coin200"}`) + if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err == nil { + t.Fatal("mismatched Apple productId was accepted") + } +} diff --git a/internal/data/payment.go b/internal/data/payment.go index b4b65d5..3cf6161 100644 --- a/internal/data/payment.go +++ b/internal/data/payment.go @@ -121,6 +121,9 @@ func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment } func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("支付下单请求为空") + } a, c, err := r.adapter(ctx, req.Provider) if err != nil { return nil, err @@ -128,11 +131,62 @@ func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz effective := *req effective.NotifyURL = strings.TrimSpace(text(c, "notify_url")) effective.ReturnURL = strings.TrimSpace(text(c, "return_url")) - if req.Provider != biz.PaymentApple && effective.NotifyURL == "" { + if paymentCreateRequiresNotifyURL(req.Provider, req.Extra, c) && effective.NotifyURL == "" { return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider) } return a.Create(ctx, &effective, c) } + +func paymentProviderRequiresNotifyURL(provider string) bool { + switch provider { + case biz.PaymentApple, biz.PaymentAllinPay, biz.PaymentSaobei, biz.PaymentPayPal: + return false + default: + return true + } +} + +// paymentCreateRequiresNotifyURL keeps the repository-level URL guard aligned +// with the selected provider operation. Synchronous barcode/retail APIs return +// their execution result directly and do not consume notify_url; redirect and +// client-side prepay APIs still require the configured server callback URL. +func paymentCreateRequiresNotifyURL(provider string, extra, config map[string]any) bool { + if !paymentProviderRequiresNotifyURL(provider) { + return false + } + keys := []string{"method", "pay_method", "trade_type", "pay_type", "channel"} + switch provider { + case biz.PaymentAlipay, biz.PaymentAlipayV3: + keys = []string{"method", "pay_method", "trade_type", "channel"} + case biz.PaymentWechatV2: + keys = []string{"trade_type", "pay_type", "method", "pay_method", "channel"} + case biz.PaymentWechatV3: + keys = []string{"trade_type", "pay_type", "method"} + case biz.PaymentQQ: + keys = []string{"trade_type", "pay_type", "method", "pay_method"} + case biz.PaymentLakala: + keys = []string{"method", "pay_method", "trade_type"} + } + value := firstAny(extra, keys...) + if value == "" { + value = firstAny(config, keys...) + } + normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch provider { + case biz.PaymentAlipay, biz.PaymentAlipayV3: + return !contains([]string{"pay", "trade_pay", "alipay_trade_pay", "barcode", "barcode_pay", "micropay", "face_to_face"}, normalized) + case biz.PaymentWechatV2: + return !contains([]string{"micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code"}, normalized) + case biz.PaymentWechatV3: + return !contains([]string{"micropay", "micro_pay", "codepay", "code_pay", "barcode", "barcode_pay", "facepay", "face_pay"}, normalized) + case biz.PaymentQQ: + return !contains([]string{"micropay", "micro_pay", "barcode", "barcode_pay"}, normalized) + case biz.PaymentLakala: + return !contains([]string{"retail", "retail_pay", "micropay", "barcode"}, normalized) + default: + return true + } +} func (r *paymentRepo) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) { a, c, err := r.adapter(ctx, provider) if err != nil { @@ -172,16 +226,27 @@ func (r *paymentRepo) HandleCallback(ctx context.Context, callback *biz.PaymentC err = errors.New("支付回调渠道不匹配") return nil, &biz.PaymentCallbackError{Cause: err, Ack: result.FailureAck} } - fields := callbackFields(callback) - eventID := first(fields, "event_id", "notify_id", "notificationUUID", "id") - if eventID == "" { - hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...)) - eventID = hex.EncodeToString(hash[:]) - } - result.EventID = eventID + result.EventID = paymentCallbackEventID(callback, result) return result, nil } +func paymentCallbackEventID(callback *biz.PaymentCallback, result *biz.PaymentResult) string { + if result != nil { + if eventID := strings.TrimSpace(result.EventID); eventID != "" { + return eventID + } + } + if callback == nil { + return "" + } + fields := callbackFields(callback) + if eventID := strings.TrimSpace(first(fields, "event_id", "notify_id", "notificationUUID", "id")); eventID != "" { + return eventID + } + hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...)) + return hex.EncodeToString(hash[:]) +} + func paymentCallbackAck(provider string, values map[string]any, success bool) biz.PaymentCallbackAck { ack := biz.DefaultPaymentCallbackAck(provider, success) prefix := "callback_success_" @@ -239,12 +304,14 @@ func first(values map[string]string, keys ...string) string { var paymentSecretKeys = map[string]bool{ "key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true, - "client_key": true, "client_secret": true, "api_key": true, "api_v3_key": true, + "app_key": true, "client_key": true, "client_secret": true, "api_key": true, + "api_v3_key": true, "api_v3key": true, "apiv3_key": true, "mch_key": true, "merchant_key": true, "signing_secret": true, "certificate": true, "cert": true, "root_cert": true, "platform_cert": true, "public_cert": true, - "public_key": true, "client_cert": true, "app_secret": true, "appsecret": true, - "access_token": true, "app_auth_token": true, "credential_code": true, "cus_id": true, "p12": true, - "pkcs12": true, "merchant_private_key": true, "platform_key": true, + "public_key": true, "client_cert": true, "key_content": true, "key_pem": true, "apiclient_key": true, + "app_secret": true, "appsecret": true, "token": true, "access_token": true, "app_auth_token": true, + "credential_code": true, "cus_id": true, "op_user_passwd": true, "p12": true, "pkcs12": true, + "pkcs12_content": true, "merchant_private_key": true, "platform_key": true, } func maskPaymentSecrets(values map[string]any) { @@ -290,11 +357,30 @@ func contains(values []string, value string) bool { func validatePaymentConfig(provider string, values map[string]any) error { required := []string{} + configDriven := false switch provider { case biz.PaymentAlipay: required = []string{"app_id", "private_key", "public_key"} + case biz.PaymentAlipayV3: + required = []string{"app_id"} + for _, group := range []struct { + label string + keys []string + }{ + {label: "private_key", keys: []string{"private_key", "private_key_content", "private_key_path"}}, + {label: "app_cert", keys: []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}}, + {label: "root_cert", keys: []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}}, + {label: "public_cert", keys: []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}}, + } { + if firstAny(values, group.keys...) == "" { + return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label) + } + } case biz.PaymentWechatV2: required = []string{"app_id", "merchant_id", "mch_key"} + if firstAny(values, "client_cert", "cert_pem", "apiclient_cert") == "" || firstAny(values, "client_key", "key_pem", "apiclient_key") == "" { + return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider) + } case biz.PaymentWechatV3: required = []string{"app_id", "merchant_id", "serial_no", "private_key", "api_v3_key", "platform_cert"} case biz.PaymentApple: @@ -305,23 +391,27 @@ func validatePaymentConfig(provider string, values map[string]any) error { } } case biz.PaymentDouyin: - required = []string{"merchant_id", "serial_no", "api_key", "private_key", "platform_cert", "platform_serial_no"} + required = []string{"app_id", "merchant_id", "serial_no", "api_key", "private_key", "platform_cert"} case biz.PaymentQQ: if firstAny(values, "mch_id", "merchant_id") == "" { return fmt.Errorf("%s 缺少配置字段 mch_id", provider) } required = []string{"api_key"} + if !hasQQRefundCertificate(values) { + return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider) + } case biz.PaymentAllinPay: required = []string{"cus_id", "app_id", "private_key", "public_key"} case biz.PaymentLakala: required = []string{"partner_code", "credential_code"} case biz.PaymentPayPal: - required = []string{"client_id", "client_secret"} + required = []string{"client_id", "client_secret", "webhook_id"} case biz.PaymentSaobei: required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"} default: + configDriven = true required = []string{ - "protocol_version", "create_url", "query_url", "query_status_field", "query_success_values", + "protocol_version", "app_id", "merchant_id", "create_url", "query_url", "refund_url", "query_status_field", "query_success_values", "query_trade_no_field", "query_provider_trade_no_field", "query_amount_field", "query_currency_field", "query_amount_scale", "callback_status_field", "callback_success_values", "callback_trade_no_field", "callback_provider_trade_no_field", @@ -334,13 +424,28 @@ func validatePaymentConfig(provider string, values map[string]any) error { } } for _, key := range required { - if text(values, key) == "" { + if configDriven && key == "query_amount_scale" { + if _, exists := values[key]; !exists { + return fmt.Errorf("%s 缺少配置字段 %s", provider, key) + } + continue + } + if strings.TrimSpace(text(values, key)) == "" { return fmt.Errorf("%s 缺少配置字段 %s", provider, key) } } + if configDriven && !validPaymentAmountScale(configuredInt64(values, "query_amount_scale", 0)) { + return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider) + } if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" { return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider) } + if provider == biz.PaymentAllinPay { + orderType := strings.ToLower(strings.TrimSpace(firstAny(values, "query_order_type", "order_type"))) + if orderType != "" && orderType != "reqsn" && orderType != "trxid" { + return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", provider) + } + } if environment := strings.ToLower(strings.TrimSpace(text(values, "environment"))); environment != "" { switch provider { case biz.PaymentQQ, biz.PaymentDouyin, biz.PaymentLakala: @@ -369,6 +474,26 @@ func validatePaymentConfig(provider string, values map[string]any) error { return nil } +func hasQQRefundCertificate(values map[string]any) bool { + if firstAny(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" { + return true + } + if firstAny(values, "cert_file", "cert_path") != "" && firstAny(values, "key_file", "key_path") != "" { + return true + } + return firstAny(values, "cert_content") != "" && firstAny(values, "key_content") != "" +} + +func validPaymentAmountScale(scale int64) bool { + if scale <= 0 { + return false + } + for scale%10 == 0 { + scale /= 10 + } + return scale == 1 +} + func validateConfiguredBreakdownFields(values map[string]any) error { for _, key := range []string{ "query_payer_paid_amount_scale", "query_cash_paid_amount_scale", "query_point_paid_amount_scale", diff --git a/internal/data/payment/adapter.go b/internal/data/payment/adapter.go index 5218184..6526702 100644 --- a/internal/data/payment/adapter.go +++ b/internal/data/payment/adapter.go @@ -21,6 +21,8 @@ func New(provider string) (Adapter, error) { switch provider { case biz.PaymentAlipay: return &alipayAdapter{}, nil + case biz.PaymentAlipayV3: + return &alipayV3Adapter{}, nil case biz.PaymentWechatV2: return &wechatV2Adapter{}, nil case biz.PaymentWechatV3: diff --git a/internal/data/payment/alipay.go b/internal/data/payment/alipay.go index d776acd..8ad00f2 100644 --- a/internal/data/payment/alipay.go +++ b/internal/data/payment/alipay.go @@ -68,6 +68,34 @@ func (a *alipayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m return nil, err } switch method { + case "alipay.trade.pay": + rsp, callErr := sdk.client.TradePay(ctx, bm) + if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { + return nil, err + } + response := rsp.Response + if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo { + return nil, errors.New("支付宝付款码支付响应的 out_trade_no 不匹配") + } + status := "" + switch response.Code { + case "10000": + status = "success" + case "10003": + status = "pending" + default: + return nil, fmt.Errorf("支付宝付款码支付失败: %s", firstNonEmptyAlipay(response.SubMsg, response.Msg, response.SubCode, response.Code)) + } + if strings.TrimSpace(response.OutTradeNo) == "" { + return nil, errors.New("支付宝付款码支付响应缺少 out_trade_no") + } + if status == "success" && strings.TrimSpace(response.TradeNo) == "" { + return nil, errors.New("支付宝付款码支付成功响应缺少 trade_no") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipay, Status: status, TradeNo: req.TradeNo, + ProviderTradeNo: strings.TrimSpace(response.TradeNo), Payload: sdk.payload(rsp), + }, nil case "alipay.trade.precreate": rsp, callErr := sdk.client.TradePrecreate(ctx, bm) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { @@ -227,20 +255,59 @@ func (a *alipayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques return nil, err } response := rsp.Response - if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != tradeNo { + return alipayRefundResult(req, response, sdk.payload(rsp)) +} + +func alipayRefundResult(req *biz.PaymentRefundRequest, response *gopayAlipay.TradeRefund, payload []byte) (*biz.PaymentResult, error) { + if req == nil || response == nil { + return nil, errors.New("支付宝退款响应为空") + } + tradeNo := strings.TrimSpace(req.TradeNo) + returnedTradeNo := strings.TrimSpace(response.OutTradeNo) + if returnedTradeNo == "" { + return nil, errors.New("支付宝退款响应缺少 out_trade_no") + } + if returnedTradeNo != tradeNo { return nil, errors.New("支付宝退款响应的 out_trade_no 不匹配") } providerTradeNo := strings.TrimSpace(response.TradeNo) if providerTradeNo == "" { return nil, errors.New("支付宝退款响应缺少 trade_no") } + if expected := strings.TrimSpace(req.ProviderTradeNo); expected != "" && providerTradeNo != expected { + return nil, errors.New("支付宝退款响应的 trade_no 不匹配") + } + refundFee := strings.TrimSpace(response.RefundFee) + if refundFee == "" { + return nil, errors.New("支付宝退款响应缺少 refund_fee") + } + amount, err := parseDecimalAmount(refundFee, 100) + if err != nil { + return nil, fmt.Errorf("解析支付宝退款金额: %w", err) + } + if amount != req.Amount { + return nil, errors.New("支付宝退款响应金额不匹配") + } + currency := strings.ToUpper(strings.TrimSpace(response.RefundCurrency)) + requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency)) + if currency == "" { + currency = requestCurrency + } else if requestCurrency != "" && currency != requestCurrency { + return nil, errors.New("支付宝退款响应币种不匹配") + } + if currency == "" { + currency = "CNY" + } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: tradeNo, - ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp), + ProviderTradeNo: providerTradeNo, Amount: amount, Currency: currency, Payload: payload, }, nil } func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { + if callback == nil || len(callback.Body) == 0 { + return nil, errors.New("支付宝回调为空") + } fields := callbackFields(callback) if err := verifyAlipay(fields, c); err != nil { return nil, err @@ -374,6 +441,8 @@ func alipayCreateMethod(extra, config map[string]any) (string, error) { switch normalized { case "", "create", "trade_create", "alipay_trade_create", "jsapi", "miniapp", "mini_program": return "alipay.trade.create", nil + case "pay", "trade_pay", "alipay_trade_pay", "barcode", "barcode_pay", "micropay", "face_to_face": + return "alipay.trade.pay", nil case "precreate", "trade_precreate", "alipay_trade_precreate", "native", "qr", "qrcode", "scan": return "alipay.trade.precreate", nil case "app", "app_pay", "trade_app_pay", "alipay_trade_app_pay": diff --git a/internal/data/payment/alipay_v3.go b/internal/data/payment/alipay_v3.go new file mode 100644 index 0000000..af71081 --- /dev/null +++ b/internal/data/payment/alipay_v3.go @@ -0,0 +1,534 @@ +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "kra/internal/biz" + + "github.com/go-pay/gopay" + gopayAlipay "github.com/go-pay/gopay/alipay" + gopayAlipayV3 "github.com/go-pay/gopay/alipay/v3" + "github.com/go-pay/gopay/pkg/xhttp" +) + +type alipayV3Adapter struct{} + +type alipayV3Client struct { + client *gopayAlipayV3.ClientV3 + gateway *url.URL +} + +func (a *alipayV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("支付宝 V3 下单参数为空") + } + amount, err := formatDecimalAmount(req.Amount, 100) + if err != nil { + return nil, err + } + sdk, err := newAlipayV3Client(c) + if err != nil { + return nil, err + } + method, err := alipayCreateMethod(req.Extra, c) + if err != nil { + return nil, err + } + bm := gopay.BodyMap{ + "out_trade_no": req.TradeNo, + "total_amount": amount, + "subject": req.Subject, + } + protected := []string{ + "out_trade_no", "total_amount", "subject", "notify_url", "return_url", + "method", "pay_method", "trade_type", "channel", + } + mergeGoPayConfigExtras(bm, c, "create_extra", protected...) + mergeMap(bm, req.Extra, protected...) + if req.NotifyURL != "" { + bm.Set("notify_url", req.NotifyURL) + } + if req.ReturnURL != "" { + bm.Set("return_url", req.ReturnURL) + } + + switch method { + case "alipay.trade.pay": + if bm.GetString("scene") == "" { + bm.Set("scene", "bar_code") + } + rsp, callErr := sdk.client.TradePay(ctx, bm) + if callErr != nil { + return nil, callErr + } + if rsp == nil { + return nil, errors.New("支付宝 V3 付款码支付响应为空") + } + if err = alipayV3CheckResponse("付款码支付", rsp.StatusCode, rsp.ErrResponse); err != nil { + return nil, err + } + return alipayV3TradePayResult(req, rsp) + case "alipay.trade.precreate": + rsp, callErr := sdk.client.TradePrecreate(ctx, bm) + if callErr != nil { + return nil, callErr + } + if rsp == nil { + return nil, errors.New("支付宝 V3 预创建响应为空") + } + if err = alipayV3CheckResponse("预创建", rsp.StatusCode, rsp.ErrResponse); err != nil { + return nil, err + } + outTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if outTradeNo == "" || outTradeNo != req.TradeNo { + return nil, errors.New("支付宝 V3 预创建响应的 out_trade_no 缺失或不匹配") + } + if strings.TrimSpace(rsp.QrCode) == "" { + return nil, errors.New("支付宝 V3 预创建响应缺少 qr_code") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo, + QueryID: outTradeNo, Payload: mustJSON(rsp), + }, nil + case "alipay.trade.app.pay": + orderString, callErr := sdk.client.TradeAppPay(ctx, bm) + if callErr != nil { + return nil, callErr + } + if strings.TrimSpace(orderString) == "" { + return nil, errors.New("支付宝 V3 APP 支付参数为空") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo, + Payload: mustJSON(map[string]string{"order_string": orderString}), + }, nil + case "alipay.trade.page.pay": + payURL, callErr := sdk.client.TradePagePay(ctx, bm) + if callErr != nil { + return nil, callErr + } + payURL, err = sdk.rewriteGeneratedURL(payURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(payURL) == "" { + return nil, errors.New("支付宝 V3 电脑网站支付 URL 为空") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo, + Payload: mustJSON(map[string]string{"pay_url": payURL}), + }, nil + case "alipay.trade.wap.pay": + payURL, callErr := sdk.client.TradeWapPay(ctx, bm) + if callErr != nil { + return nil, callErr + } + payURL, err = sdk.rewriteGeneratedURL(payURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(payURL) == "" { + return nil, errors.New("支付宝 V3 手机网站支付 URL 为空") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo, + Payload: mustJSON(map[string]string{"pay_url": payURL}), + }, nil + default: + if bm.GetString("product_code") == "" { + bm.Set("product_code", firstAnyOr(c, "JSAPI_PAY", "product_code")) + } + if bm.GetString("op_app_id") == "" { + bm.Set("op_app_id", firstAnyOr(c, text(c, "app_id"), "op_app_id")) + } + rsp, callErr := sdk.client.TradeCreate(ctx, bm) + if callErr != nil { + return nil, callErr + } + if rsp == nil { + return nil, errors.New("支付宝 V3 创建响应为空") + } + if err = alipayV3CheckResponse("创建", rsp.StatusCode, rsp.ErrResponse); err != nil { + return nil, err + } + outTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if outTradeNo == "" || outTradeNo != req.TradeNo { + return nil, errors.New("支付宝 V3 创建响应的 out_trade_no 缺失或不匹配") + } + providerTradeNo := strings.TrimSpace(rsp.TradeNo) + if providerTradeNo == "" { + return nil, errors.New("支付宝 V3 创建响应缺少 trade_no") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: req.TradeNo, + ProviderTradeNo: providerTradeNo, QueryID: outTradeNo, Payload: mustJSON(rsp), + }, nil + } +} + +func (a *alipayV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("支付宝 V3 查单缺少 out_trade_no") + } + sdk, err := newAlipayV3Client(c) + if err != nil { + return nil, err + } + rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo}) + if callErr != nil { + return nil, callErr + } + if rsp == nil { + return nil, errors.New("支付宝 V3 查询响应为空") + } + if err = alipayV3CheckResponse("查单", rsp.StatusCode, rsp.ErrResponse); err != nil { + return nil, err + } + outTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if outTradeNo == "" || outTradeNo != tradeNo { + return nil, errors.New("支付宝 V3 查询响应的 out_trade_no 缺失或不匹配") + } + result := &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: normalizePaymentStatus(rsp.TradeStatus, "pending"), + TradeNo: outTradeNo, ProviderTradeNo: strings.TrimSpace(rsp.TradeNo), + Currency: alipayV3Currency(c, ""), Payload: mustJSON(rsp), + } + if result.Status != "success" { + return result, nil + } + if result.ProviderTradeNo == "" { + return nil, errors.New("支付宝 V3 查询响应缺少 trade_no") + } + result.Amount, err = parseDecimalAmount(rsp.TotalAmount, 100) + if err != nil { + return nil, fmt.Errorf("解析支付宝 V3 订单金额: %w", err) + } + values, err := alipayResponseValues(rsp) + if err != nil { + return nil, err + } + if err = populateAlipayBreakdown(result, values); err != nil { + return nil, err + } + return result, nil +} + +func (a *alipayV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("支付宝 V3 退款请求为空") + } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" { + return nil, errors.New("支付宝 V3 退款缺少 out_trade_no 或 out_request_no") + } + amount, err := formatDecimalAmount(req.Amount, 100) + if err != nil { + return nil, err + } + sdk, err := newAlipayV3Client(c) + if err != nil { + return nil, err + } + bm := gopay.BodyMap{ + "out_trade_no": tradeNo, + "out_request_no": refundNo, + "refund_amount": amount, + } + mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount") + rsp, callErr := sdk.client.TradeRefund(ctx, bm) + if callErr != nil { + return nil, callErr + } + if rsp == nil { + return nil, errors.New("支付宝 V3 退款响应为空") + } + if err = alipayV3CheckResponse("退款", rsp.StatusCode, rsp.ErrResponse); err != nil { + return nil, err + } + outTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if outTradeNo == "" || outTradeNo != tradeNo { + return nil, errors.New("支付宝 V3 退款响应的 out_trade_no 缺失或不匹配") + } + providerTradeNo := strings.TrimSpace(rsp.TradeNo) + if providerTradeNo == "" { + return nil, errors.New("支付宝 V3 退款响应缺少 trade_no") + } + if expected := strings.TrimSpace(req.ProviderTradeNo); expected != "" && providerTradeNo != expected { + return nil, errors.New("支付宝 V3 退款响应的 trade_no 不匹配") + } + refundedAmount, err := parseDecimalAmount(rsp.RefundFee, 100) + if err != nil { + return nil, fmt.Errorf("解析支付宝 V3 退款金额: %w", err) + } + if refundedAmount != req.Amount { + return nil, errors.New("支付宝 V3 退款响应金额不匹配") + } + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "created", TradeNo: tradeNo, + ProviderTradeNo: providerTradeNo, Amount: refundedAmount, + Currency: alipayV3Currency(c, req.Currency), Payload: mustJSON(rsp), + }, nil +} + +func (a *alipayV3Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { + if callback == nil || len(callback.Body) == 0 { + return nil, errors.New("支付宝 V3 回调为空") + } + fields := callbackFields(callback) + if fields["sign"] == "" { + if values, parseErr := url.ParseQuery(string(callback.Body)); parseErr == nil { + for key, value := range values { + if len(value) > 0 { + fields[key] = value[0] + } + } + } + } + if err := verifyAlipayV3Callback(fields, c); err != nil { + return nil, err + } + if fields["app_id"] != text(c, "app_id") { + return nil, errors.New("支付宝 V3 回调 app_id 不匹配") + } + tradeNo := strings.TrimSpace(fields["out_trade_no"]) + providerTradeNo := strings.TrimSpace(fields["trade_no"]) + if tradeNo == "" || providerTradeNo == "" { + return nil, errors.New("支付宝 V3 回调缺少 out_trade_no 或 trade_no") + } + payload, _ := json.Marshal(fields) + return &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: normalizePaymentStatus(fields["trade_status"], "pending"), + TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Payload: payload, + }, nil +} + +func alipayV3TradePayResult(req *biz.PaymentRequest, rsp *gopayAlipayV3.TradePayRsp) (*biz.PaymentResult, error) { + if req == nil || rsp == nil { + return nil, errors.New("支付宝 V3 付款码支付响应为空") + } + outTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if outTradeNo == "" || outTradeNo != req.TradeNo { + return nil, errors.New("支付宝 V3 付款码支付响应的 out_trade_no 缺失或不匹配") + } + providerTradeNo := strings.TrimSpace(rsp.TradeNo) + if providerTradeNo == "" { + return nil, errors.New("支付宝 V3 付款码支付响应缺少 trade_no") + } + amount, err := parseDecimalAmount(rsp.TotalAmount, 100) + if err != nil { + return nil, fmt.Errorf("解析支付宝 V3 付款码支付金额: %w", err) + } + if amount != req.Amount { + return nil, errors.New("支付宝 V3 付款码支付响应金额不匹配") + } + result := &biz.PaymentResult{ + Provider: biz.PaymentAlipayV3, Status: "success", TradeNo: outTradeNo, + ProviderTradeNo: providerTradeNo, Amount: amount, + Currency: strings.ToUpper(strings.TrimSpace(req.Currency)), Payload: mustJSON(rsp), + } + if result.Currency == "" { + result.Currency = "CNY" + } + values, err := alipayResponseValues(rsp) + if err != nil { + return nil, err + } + if err = populateAlipayBreakdown(result, values); err != nil { + return nil, err + } + return result, nil +} + +func newAlipayV3Client(c map[string]any) (*alipayV3Client, error) { + appID := strings.TrimSpace(text(c, "app_id")) + privateKey, err := alipayV3PrivateKey(c) + if err != nil { + return nil, err + } + if appID == "" { + return nil, errors.New("支付宝 V3 缺少 app_id") + } + production := !strings.EqualFold(strings.TrimSpace(text(c, "environment")), "sandbox") + client, err := gopayAlipayV3.NewClientV3(appID, goPayAlipayKey(privateKey, false), production) + if err != nil { + return nil, err + } + appCert, err := alipayV3AppCertificate(c) + if err != nil { + return nil, err + } + rootCert, err := alipayV3RootCertificate(c) + if err != nil { + return nil, err + } + publicCert, err := alipayV3PublicCertificate(c) + if err != nil { + return nil, err + } + if err = client.SetCert(appCert, rootCert, publicCert); err != nil { + return nil, fmt.Errorf("配置支付宝 V3 证书: %w", err) + } + if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" { + client.SetAppAuthToken(token) + } + client.SetHttpClient(xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8)) + + sdk := &alipayV3Client{client: client} + if endpoint := strings.TrimSpace(text(c, "gateway_url")); endpoint != "" { + sdk.gateway, err = url.Parse(endpoint) + if err != nil || sdk.gateway.Scheme == "" || sdk.gateway.Host == "" { + return nil, fmt.Errorf("支付宝 V3 gateway_url 无效: %s", endpoint) + } + } + apiEndpoint := strings.TrimSpace(firstAny(c, "api_base_url", "proxy_host", "base_url")) + if apiEndpoint == "" && sdk.gateway != nil && (sdk.gateway.Path == "" || sdk.gateway.Path == "/") { + apiEndpoint = sdk.gateway.String() + } + if apiEndpoint != "" { + parsed, parseErr := url.Parse(apiEndpoint) + if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("支付宝 V3 api_base_url 无效: %s", apiEndpoint) + } + client.SetProxyHost(strings.TrimRight(apiEndpoint, "/")) + } + return sdk, nil +} + +func (c *alipayV3Client) rewriteGeneratedURL(value string) (string, error) { + if strings.TrimSpace(value) == "" || c.gateway == nil { + return value, nil + } + source, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("解析支付宝 V3 支付 URL: %w", err) + } + return alipayTargetURL(source, c.gateway).String(), nil +} + +func verifyAlipayV3Callback(fields map[string]string, c map[string]any) error { + if text(c, "app_id") == "" || fields["sign"] == "" { + return errors.New("支付宝 V3 回调验签配置或 sign 缺失") + } + values := url.Values{} + for key, value := range fields { + values.Set(key, value) + } + body, err := gopayAlipay.ParseNotifyByURLValues(values) + if err != nil { + return err + } + publicCert, err := alipayV3PublicCertificate(c) + if err != nil { + return err + } + ok, err := gopayAlipay.VerifySignWithCert(publicCert, cloneGoPayBodyMap(body)) + if err != nil { + return err + } + if !ok { + return errors.New("支付宝 V3 回调签名校验失败") + } + return nil +} + +func alipayV3CheckResponse(operation string, status int, response gopayAlipayV3.ErrResponse) error { + if status == http.StatusOK { + return nil + } + message := strings.TrimSpace(response.Message) + if code := strings.TrimSpace(response.Code); code != "" { + if message == "" { + message = code + } else { + message = code + ": " + message + } + } + if message == "" { + return fmt.Errorf("支付宝 V3 %s HTTP %d", operation, status) + } + return fmt.Errorf("支付宝 V3 %s HTTP %d: %s", operation, status, message) +} + +func alipayV3Currency(c map[string]any, fallback string) string { + currency := strings.ToUpper(strings.TrimSpace(firstAny(c, "currency", "default_currency"))) + if currency == "" { + currency = strings.ToUpper(strings.TrimSpace(fallback)) + } + if currency == "" { + currency = "CNY" + } + return currency +} + +func alipayV3PrivateKey(c map[string]any) (string, error) { + if value := strings.TrimSpace(firstAny(c, "private_key_content", "private_key")); value != "" { + return value, nil + } + path := strings.TrimSpace(text(c, "private_key_path")) + if path == "" { + return "", errors.New("支付宝 V3 缺少 private_key") + } + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("读取支付宝 V3 private_key: %w", err) + } + return string(content), nil +} + +func alipayV3AppCertificate(c map[string]any) ([]byte, error) { + return alipayV3Certificate(c, "app_cert", + []string{"app_cert_content", "app_public_cert_content"}, + []string{"app_cert_path", "app_public_cert_path"}, + []string{"app_cert", "app_public_cert"}, + ) +} + +func alipayV3RootCertificate(c map[string]any) ([]byte, error) { + return alipayV3Certificate(c, "root_cert", + []string{"alipay_root_cert_content", "root_cert_content"}, + []string{"alipay_root_cert_path", "root_cert_path"}, + []string{"alipay_root_cert", "root_cert"}, + ) +} + +func alipayV3PublicCertificate(c map[string]any) ([]byte, error) { + return alipayV3Certificate(c, "public_cert", + []string{"alipay_public_cert_content", "public_cert_content"}, + []string{"alipay_public_cert_path", "public_cert_path"}, + []string{"alipay_public_cert", "public_cert"}, + ) +} + +func alipayV3Certificate(c map[string]any, label string, contentKeys, pathKeys, flexibleKeys []string) ([]byte, error) { + if value := strings.TrimSpace(firstAny(c, contentKeys...)); value != "" { + return []byte(value), nil + } + if path := strings.TrimSpace(firstAny(c, pathKeys...)); path != "" { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取支付宝 V3 %s: %w", label, err) + } + return content, nil + } + if value := strings.TrimSpace(firstAny(c, flexibleKeys...)); value != "" { + if strings.Contains(value, "-----BEGIN CERTIFICATE-----") { + return []byte(value), nil + } + content, err := os.ReadFile(value) + if err != nil { + return nil, fmt.Errorf("读取支付宝 V3 %s: %w", label, err) + } + return content, nil + } + return nil, fmt.Errorf("支付宝 V3 缺少 %s", label) +} diff --git a/internal/data/payment/alipay_v3_test.go b/internal/data/payment/alipay_v3_test.go new file mode 100644 index 0000000..37edf0d --- /dev/null +++ b/internal/data/payment/alipay_v3_test.go @@ -0,0 +1,333 @@ +package payment + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "kra/internal/biz" + + gopayAlipay "github.com/go-pay/gopay/alipay" + gopayAlipayV3 "github.com/go-pay/gopay/alipay/v3" +) + +func testAlipayV3Config(t *testing.T) (*rsa.PrivateKey, map[string]any) { + t.Helper() + key, privatePEM, _ := testRSAKeyPair(t) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1001), + Subject: pkixName("GoPay Alipay V3 Test"), + Issuer: pkixName("GoPay Alipay V3 Test"), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certificate := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + return key, map[string]any{ + "app_id": "app-v3-test", + "private_key": privatePEM, + "app_cert": certificate, + "root_cert": certificate, + "public_cert": certificate, + "environment": "sandbox", + } +} + +func pkixName(commonName string) pkix.Name { + return pkix.Name{CommonName: commonName, Organization: []string{"GoPay Tests"}} +} + +func cloneAlipayV3Config(values map[string]any) map[string]any { + clone := make(map[string]any, len(values)+1) + for key, value := range values { + clone[key] = value + } + return clone +} + +func alipayV3ResponseSignature(t *testing.T, key *rsa.PrivateKey, body string) (string, string, string) { + t.Helper() + timestamp := "1660000000000" + nonce := "alipay-v3-test-nonce" + signature := testRSA2Sign(t, key, []byte(timestamp+"\n"+nonce+"\n"+body+"\n")) + return timestamp, nonce, signature +} + +func writeAlipayV3Response(w http.ResponseWriter, timestamp, nonce, signature, body string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(gopayAlipayV3.HeaderTimestamp, timestamp) + w.Header().Set(gopayAlipayV3.HeaderNonce, nonce) + w.Header().Set(gopayAlipayV3.HeaderSignature, signature) + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, body) +} + +func TestAlipayV3CreateRESTMethodsUseGoPay(t *testing.T) { + platformKey, baseConfig := testAlipayV3Config(t) + for _, tc := range []struct { + name string + method string + path string + response string + extra map[string]any + wantStatus string + wantTrade string + }{ + { + name: "trade create", path: "/v3/alipay/trade/create", + response: `{"trade_no":"P-V3-CREATE","out_trade_no":"T-V3-CREATE"}`, + extra: map[string]any{"buyer_open_id": "buyer-open-id"}, wantStatus: "created", wantTrade: "P-V3-CREATE", + }, + { + name: "trade pay", method: "barcode", path: "/v3/alipay/trade/pay", + response: `{"trade_no":"P-V3-PAY","out_trade_no":"T-V3-PAY","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00"}`, + extra: map[string]any{"auth_code": "BARCODE-V3"}, wantStatus: "success", wantTrade: "P-V3-PAY", + }, + { + name: "precreate", method: "native", path: "/v3/alipay/trade/precreate", + response: `{"out_trade_no":"T-V3-PRECREATE","qr_code":"https://qr.example/v3"}`, + wantStatus: "created", + }, + } { + t.Run(tc.name, func(t *testing.T) { + tradeNo := "T-V3-CREATE" + if tc.method == "barcode" { + tradeNo = "T-V3-PAY" + } else if tc.method == "native" { + tradeNo = "T-V3-PRECREATE" + } + timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, tc.response) + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tc.path { + t.Errorf("request path = %q, want %q", r.URL.Path, tc.path) + } + if !strings.HasPrefix(r.Header.Get(gopayAlipayV3.HeaderAuthorization), gopayAlipayV3.SignTypeRSA+" ") { + t.Errorf("authorization = %q", r.Header.Get(gopayAlipayV3.HeaderAuthorization)) + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Errorf("decode request: %v", err) + } + writeAlipayV3Response(w, timestamp, nonce, signature, tc.response) + })) + defer server.Close() + + config := cloneAlipayV3Config(baseConfig) + config["api_base_url"] = server.URL + extra := cloneAlipayV3Config(tc.extra) + if tc.method != "" { + extra["method"] = tc.method + } + result, err := (&alipayV3Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAlipayV3, TradeNo: tradeNo, Subject: "subject", Amount: 1000, + Currency: "CNY", NotifyURL: "https://merchant.example/alipay-v3/notify", Extra: extra, + }, config) + if err != nil { + t.Fatal(err) + } + if requestBody == nil || requestBody["out_trade_no"] != tradeNo || requestBody["notify_url"] == "" { + t.Fatalf("request body = %#v", requestBody) + } + if result.Provider != biz.PaymentAlipayV3 || result.Status != tc.wantStatus || result.TradeNo != tradeNo || result.ProviderTradeNo != tc.wantTrade { + t.Fatalf("result = %+v", result) + } + if tc.method == "barcode" { + if requestBody["scene"] != "bar_code" || requestBody["auth_code"] != "BARCODE-V3" { + t.Fatalf("trade pay request = %#v", requestBody) + } + if !result.AmountBreakdownKnown || result.Amount != 1000 || result.PayerPaidAmount != 900 || result.PointPaidAmount != 100 { + t.Fatalf("trade pay amount breakdown = %+v", result) + } + } + if tc.method == "" && (requestBody["product_code"] != "JSAPI_PAY" || requestBody["op_app_id"] != "app-v3-test") { + t.Fatalf("trade create defaults = %#v", requestBody) + } + }) + } +} + +func TestAlipayV3GeneratedPaymentMethodsUseGoPay(t *testing.T) { + _, config := testAlipayV3Config(t) + config["gateway_url"] = "https://merchant-gateway.example/gateway.do" + for _, method := range []string{"app", "page", "wap"} { + t.Run(method, func(t *testing.T) { + result, err := (&alipayV3Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAlipayV3, TradeNo: "T-V3-" + strings.ToUpper(method), Subject: "subject", + Amount: 1000, Currency: "CNY", NotifyURL: "https://merchant.example/alipay-v3/notify", + ReturnURL: "https://merchant.example/alipay-v3/return", Extra: map[string]any{"method": method}, + }, config) + if err != nil { + t.Fatal(err) + } + var payload map[string]string + if err = json.Unmarshal(result.Payload, &payload); err != nil { + t.Fatal(err) + } + generated := payload["order_string"] + if generated == "" { + generated = payload["pay_url"] + } + if !strings.Contains(generated, "alipay.trade."+method+".pay") || !strings.Contains(generated, "notify_url=") { + t.Fatalf("generated payment data = %q", generated) + } + if method != "app" && !strings.HasPrefix(generated, "https://merchant-gateway.example/gateway.do?") { + t.Fatalf("rewritten gateway URL = %q", generated) + } + }) + } +} + +func TestAlipayV3QueryVerifiesSignedIdentityAndAmount(t *testing.T) { + platformKey, config := testAlipayV3Config(t) + body := `{"trade_no":"P-V3-QUERY","out_trade_no":"T-V3-QUERY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00"}` + timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, body) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/alipay/trade/query" { + t.Errorf("request path = %q", r.URL.Path) + } + writeAlipayV3Response(w, timestamp, nonce, signature, body) + })) + defer server.Close() + config["api_base_url"] = server.URL + + result, err := (&alipayV3Adapter{}).Query(context.Background(), "T-V3-QUERY", config) + if err != nil { + t.Fatal(err) + } + if result.Status != "success" || result.TradeNo != "T-V3-QUERY" || result.ProviderTradeNo != "P-V3-QUERY" || result.Amount != 1000 || result.Currency != "CNY" { + t.Fatalf("result = %+v", result) + } + if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 { + t.Fatalf("amount breakdown = %+v", result) + } +} + +func TestAlipayV3QueryRejectsInvalidSignatureAndIdentity(t *testing.T) { + platformKey, baseConfig := testAlipayV3Config(t) + for _, tc := range []struct { + name string + body string + signature string + want string + }{ + {name: "mismatched order", body: `{"trade_no":"P-V3","out_trade_no":"OTHER","trade_status":"TRADE_SUCCESS","total_amount":"10.00"}`, want: "out_trade_no"}, + {name: "invalid signature", body: `{"trade_no":"P-V3","out_trade_no":"T-V3-QUERY","trade_status":"TRADE_SUCCESS","total_amount":"10.00"}`, signature: "invalid", want: "signature"}, + } { + t.Run(tc.name, func(t *testing.T) { + timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, tc.body) + if tc.signature != "" { + signature = tc.signature + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeAlipayV3Response(w, timestamp, nonce, signature, tc.body) + })) + defer server.Close() + config := cloneAlipayV3Config(baseConfig) + config["api_base_url"] = server.URL + _, err := (&alipayV3Adapter{}).Query(context.Background(), "T-V3-QUERY", config) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestAlipayV3RefundValidatesResponse(t *testing.T) { + platformKey, baseConfig := testAlipayV3Config(t) + for _, tc := range []struct { + name string + refundFee string + wantError bool + }{ + {name: "success", refundFee: "1.23"}, + {name: "amount mismatch", refundFee: "1.22", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + body := `{"trade_no":"P-V3-REFUND","out_trade_no":"T-V3-REFUND","fund_change":"Y","refund_fee":"` + tc.refundFee + `"}` + timestamp, nonce, signature := alipayV3ResponseSignature(t, platformKey, body) + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Errorf("decode request: %v", err) + } + writeAlipayV3Response(w, timestamp, nonce, signature, body) + })) + defer server.Close() + config := cloneAlipayV3Config(baseConfig) + config["api_base_url"] = server.URL + result, err := (&alipayV3Adapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ + Provider: biz.PaymentAlipayV3, TradeNo: "T-V3-REFUND", ProviderTradeNo: "P-V3-REFUND", + RefundNo: "R-V3-REFUND", Amount: 123, Currency: "CNY", + }, config) + if tc.wantError { + if err == nil || !strings.Contains(err.Error(), "金额不匹配") { + t.Fatalf("error = %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if requestBody["out_request_no"] != "R-V3-REFUND" || requestBody["refund_amount"] != "1.23" { + t.Fatalf("refund request = %#v", requestBody) + } + if result.Amount != 123 || result.ProviderTradeNo != "P-V3-REFUND" || result.Status != "created" { + t.Fatalf("refund result = %+v", result) + } + }) + } +} + +func TestAlipayV3CallbackUsesGoPayCertificateVerification(t *testing.T) { + platformKey, config := testAlipayV3Config(t) + fields := map[string]string{ + "app_id": "app-v3-test", + "trade_no": "P-V3-CALLBACK", + "out_trade_no": "T-V3-CALLBACK", + "trade_status": "TRADE_SUCCESS", + "sign_type": "RSA2", + } + bodyMap := toGoPayBodyMap(fields) + bodyMap.Remove("sign_type") + signature, err := gopayAlipay.GetRsaSign(bodyMap, gopayAlipay.RSA2, platformKey) + if err != nil { + t.Fatal(err) + } + fields["sign"] = signature + values := url.Values{} + for key, value := range fields { + values.Set(key, value) + } + callback := &biz.PaymentCallback{ + Body: []byte(values.Encode()), Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + } + result, err := (&alipayV3Adapter{}).Callback(context.Background(), callback, config) + if err != nil { + t.Fatal(err) + } + if result.Provider != biz.PaymentAlipayV3 || result.Status != "success" || result.TradeNo != "T-V3-CALLBACK" || result.ProviderTradeNo != "P-V3-CALLBACK" { + t.Fatalf("callback result = %+v", result) + } + callback.Body = []byte(strings.ReplaceAll(values.Encode(), "P-V3-CALLBACK", "P-V3-TAMPERED")) + if _, err = (&alipayV3Adapter{}).Callback(context.Background(), callback, config); err == nil { + t.Fatal("tampered callback unexpectedly verified") + } +} diff --git a/internal/data/payment/allinpay.go b/internal/data/payment/allinpay.go index 784c10d..37ffe10 100644 --- a/internal/data/payment/allinpay.go +++ b/internal/data/payment/allinpay.go @@ -1,18 +1,115 @@ package payment import ( + "bytes" "context" + "crypto" + "crypto/rsa" + "crypto/sha1" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "io" + "net/http" "strings" + "sync" "kra/internal/biz" + "github.com/go-pay/crypto/xpem" + "github.com/go-pay/crypto/xrsa" "github.com/go-pay/gopay" "github.com/go-pay/gopay/allinpay" ) type allinpayAdapter struct{} +type allinpayRoundTripperFunc func(*http.Request) (*http.Response, error) + +func (f allinpayRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type allinpayResponseCapture struct { + mu sync.Mutex + body []byte +} + +func (c *allinpayResponseCapture) set(body []byte) { + c.mu.Lock() + c.body = append(c.body[:0], body...) + c.mu.Unlock() +} + +func (c *allinpayResponseCapture) get() []byte { + c.mu.Lock() + defer c.mu.Unlock() + return append([]byte(nil), c.body...) +} + +// captureAllinpayResponse preserves the exact JSON returned by the gateway. +// Query and Refund in GoPay v1.5.122 unmarshal and return without invoking its +// internal verifySign, so adapter-level verification must use the raw bytes +// rather than re-marshalling a response struct. +func captureAllinpayResponse(client *allinpay.Client) (*allinpayResponseCapture, error) { + if client == nil || client.GetHttpClient() == nil || client.GetHttpClient().HttpClient == nil { + return nil, errors.New("通联支付 HTTP 客户端为空") + } + hc := client.GetHttpClient() + base := hc.HttpClient.Transport + if base == nil { + base = http.DefaultTransport + } + capture := &allinpayResponseCapture{} + hc.SetTransport(allinpayRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + res, err := base.RoundTrip(req) + if err != nil { + return nil, err + } + if res == nil || res.Body == nil { + return res, nil + } + body, readErr := io.ReadAll(res.Body) + _ = res.Body.Close() + if readErr != nil { + return nil, readErr + } + capture.set(body) + res.Body = io.NopCloser(bytes.NewReader(body)) + return res, nil + })) + return capture, nil +} + +func verifyAllinpayResponse(publicKey string, raw []byte) error { + if len(bytes.TrimSpace(raw)) == 0 { + return errors.New("通联支付响应签名校验失败: 响应为空") + } + values := gopay.BodyMap{} + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("通联支付响应签名校验失败: %w", err) + } + sign := strings.TrimSpace(values.GetString("sign")) + if sign == "" { + return errors.New("通联支付响应签名校验失败: 缺少 sign") + } + values.Remove("sign") + key, err := xpem.DecodePublicKey([]byte(xrsa.FormatAlipayPublicKey(publicKey))) + if err != nil { + return fmt.Errorf("通联支付公钥解析失败: %w", err) + } + signature, err := base64.StdEncoding.DecodeString(sign) + if err != nil { + return fmt.Errorf("通联支付响应签名编码无效: %w", err) + } + digest := sha1.Sum([]byte(values.EncodeAliPaySignParams())) + if err = rsa.VerifyPKCS1v15(key, crypto.SHA1, digest[:], signature); err != nil { + return fmt.Errorf("通联支付响应签名校验失败: %w", err) + } + return nil +} + func (a *allinpayAdapter) client(c map[string]any) (*allinpay.Client, error) { client, err := allinpay.NewClient(text(c, "cus_id"), text(c, "app_id"), text(c, "private_key"), text(c, "public_key"), !strings.EqualFold(text(c, "environment"), "sandbox")) if err != nil { @@ -25,14 +122,24 @@ func (a *allinpayAdapter) client(c map[string]any) (*allinpay.Client, error) { } func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("通联支付下单请求为空") + } + method, err := allinpayCreateMethod(req.Extra, c) + if err != nil { + return nil, err + } + orderType, err := allinpayOrderType(c) + if err != nil { + return nil, err + } client, err := a.client(c) if err != nil { return nil, err } bm := gopay.BodyMap{"reqsn": req.TradeNo, "trxamt": req.Amount, "body": req.Subject} mergeGoPayExtras(bm, req.Extra, "reqsn", "trxamt", "body", "method", "pay_method", "trade_type") - method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type")) - if method == "scan" || method == "micropay" || method == "barcode" { + if method == "scan" { bm.Set("authcode", firstAny(req.Extra, "authcode", "auth_code")) bm.SetBodyMap("terminfo", func(info gopay.BodyMap) { info.Set("devicetype", firstAnyOr(req.Extra, "10", "device_type", "devicetype")) @@ -45,9 +152,19 @@ func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c if rsp == nil { return nil, errors.New("通联支付扫码下单响应为空") } - return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Payload: mustJSON(rsp)}, nil + queryID, err := allinpayCreateQueryID(orderType, rsp.Trxid) + if err != nil { + return nil, err + } + if returnedTradeNo := strings.TrimSpace(rsp.Reqsn); returnedTradeNo != "" && returnedTradeNo != strings.TrimSpace(req.TradeNo) { + return nil, errors.New("通联支付扫码响应的 reqsn 不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: strings.TrimSpace(rsp.Trxid), QueryID: queryID, Payload: mustJSON(rsp)}, nil } - if method == "native" || method == "qrcode" { + if method == "native" { + if orderType == allinpay.OrderTypeTrxId { + return nil, errors.New("通联支付 Native 下单不支持 query_order_type=trxid") + } bm.Set("expiretime", firstAny(req.Extra, "expiretime", "expire_time")) rsp, callErr := client.NativePay(ctx, bm) if callErr != nil { @@ -56,7 +173,10 @@ func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c if rsp == nil { return nil, errors.New("通联支付下单响应为空") } - return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.ReqSn, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil + if returnedTradeNo := strings.TrimSpace(rsp.ReqSn); returnedTradeNo != "" && returnedTradeNo != strings.TrimSpace(req.TradeNo) { + return nil, errors.New("通联支付 Native 响应的 reqsn 不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, Payload: mustJSON(rsp)}, nil } payType := firstAny(req.Extra, "paytype", "pay_type") if payType == "" { @@ -73,28 +193,77 @@ func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c if rsp == nil { return nil, errors.New("通联支付下单响应为空") } - return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil + if returnedTradeNo := strings.TrimSpace(rsp.Reqsn); returnedTradeNo != "" && returnedTradeNo != strings.TrimSpace(req.TradeNo) { + return nil, errors.New("通联支付下单响应的 reqsn 不匹配") + } + queryID, err := allinpayCreateQueryID(orderType, rsp.Trxid) + if err != nil { + return nil, err + } + return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: strings.TrimSpace(rsp.Trxid), QueryID: queryID, Payload: mustJSON(rsp)}, nil +} + +func allinpayCreateMethod(extra, config map[string]any) (string, error) { + value := firstAny(extra, "method", "pay_method", "trade_type") + if value == "" { + value = firstAny(config, "method", "pay_method", "trade_type") + } + normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "", "pay", "unified", "unified_pay": + return "pay", nil + case "scan", "scan_pay", "micropay", "micro_pay", "barcode", "barcode_pay": + return "scan", nil + case "native", "native_pay", "qr", "qrcode": + return "native", nil + default: + return "", fmt.Errorf("通联支付不支持的下单方式: %s", value) + } } func (a *allinpayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + orderType, err := allinpayOrderType(c) + if err != nil { + return nil, err + } + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("通联支付查单缺少订单号") + } client, err := a.client(c) if err != nil { return nil, err } - orderType := firstAny(c, "query_order_type", "order_type") - if orderType == "" { - orderType = allinpay.OrderTypeReqSN + capture, err := captureAllinpayResponse(client) + if err != nil { + return nil, err } rsp, err := client.Query(ctx, orderType, tradeNo) if err != nil { return nil, err } + if err = verifyAllinpayResponse(text(c, "public_key"), capture.get()); err != nil { + return nil, err + } if rsp == nil { return nil, errors.New("通联支付查单响应为空") } - result := &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: rsp.Reqsn, ProviderTradeNo: rsp.Trxid, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)} - if result.TradeNo == "" { + result := &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: strings.TrimSpace(rsp.Reqsn), ProviderTradeNo: strings.TrimSpace(rsp.Trxid), Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)} + if strings.EqualFold(orderType, allinpay.OrderTypeTrxId) { + if result.ProviderTradeNo == "" { + return nil, errors.New("通联支付查单响应缺少 trxid") + } + if result.ProviderTradeNo != tradeNo { + return nil, errors.New("通联支付查单响应的 trxid 不匹配") + } + result.QueryID = tradeNo + if strings.TrimSpace(result.TradeNo) == "" { + return nil, errors.New("通联支付查单响应缺少商户订单号") + } + } else if result.TradeNo == "" { result.TradeNo = tradeNo + } else if result.TradeNo != tradeNo { + return nil, errors.New("通联支付查单响应的 reqsn 不匹配") } if rsp.TrxAmt != "" { result.Amount, err = parseIntegerAmount(rsp.TrxAmt) @@ -110,28 +279,97 @@ func (a *allinpayAdapter) Query(ctx context.Context, tradeNo string, c map[strin return result, err } +func allinpayOrderType(c map[string]any) (string, error) { + orderType := strings.ToLower(strings.TrimSpace(firstAny(c, "query_order_type", "order_type"))) + if orderType == "" { + return allinpay.OrderTypeReqSN, nil + } + if orderType != allinpay.OrderTypeReqSN && orderType != allinpay.OrderTypeTrxId { + return "", fmt.Errorf("通联支付 query_order_type 必须是 %s 或 %s", allinpay.OrderTypeReqSN, allinpay.OrderTypeTrxId) + } + return orderType, nil +} + +func allinpayCreateQueryID(orderType, transactionID string) (string, error) { + if orderType == allinpay.OrderTypeTrxId { + queryID := strings.TrimSpace(transactionID) + if queryID == "" { + return "", errors.New("通联支付下单响应缺少 trxid,无法按 trxid 查单") + } + return queryID, nil + } + return "", nil +} + func (a *allinpayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("通联支付退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" || req.Amount <= 0 { + return nil, errors.New("通联支付退款缺少商户订单号、退款单号或有效金额") + } + orderType, err := allinpayOrderType(c) + if err != nil { + return nil, err + } + queryID := strings.TrimSpace(req.QueryID) + if orderType == allinpay.OrderTypeTrxId && queryID == "" { + return nil, errors.New("通联支付按 trxid 退款缺少持久化 QueryID") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{"reqsn": req.RefundNo, "trxamt": req.Amount, "oldreqsn": req.TradeNo, "remark": firstAny(c, "refund_remark", "remark")} - mergeGoPayConfigExtras(bm, c, "refund_extra", "reqsn", "trxamt", "oldreqsn", "remark") + capture, err := captureAllinpayResponse(client) + if err != nil { + return nil, err + } + bm := gopay.BodyMap{"reqsn": refundNo, "trxamt": req.Amount, "remark": firstAny(c, "refund_remark", "remark")} + if orderType == allinpay.OrderTypeTrxId { + bm.Set("oldtrxid", queryID) + } else { + bm.Set("oldreqsn", tradeNo) + } + mergeGoPayConfigExtras(bm, c, "refund_extra", "reqsn", "trxamt", "oldreqsn", "oldtrxid", "remark") rsp, err := client.Refund(ctx, bm) if err != nil { return nil, err } + if err = verifyAllinpayResponse(text(c, "public_key"), capture.get()); err != nil { + return nil, err + } + effective := *req + effective.TradeNo = tradeNo + effective.RefundNo = refundNo + return allinpayRefundResult(&effective, rsp, firstAny(c, "currency")) +} + +func allinpayRefundResult(req *biz.PaymentRefundRequest, rsp *allinpay.RefundRsp, currency string) (*biz.PaymentResult, error) { if rsp == nil { return nil, errors.New("通联支付退款响应为空") } - refundAmount, _ := parseIntegerAmount(rsp.Fee) - if refundAmount == 0 { - refundAmount = req.Amount + if returnedRefundNo := strings.TrimSpace(rsp.Reqsn); returnedRefundNo == "" { + return nil, errors.New("通联支付退款响应缺少 reqsn") + } else if returnedRefundNo != req.RefundNo { + return nil, errors.New("通联支付退款响应的 reqsn 不匹配") } - return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeRefundState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Amount: refundAmount, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)}, nil + providerRefundID := strings.TrimSpace(rsp.Trxid) + if providerRefundID == "" { + return nil, errors.New("通联支付退款响应缺少 trxid") + } + if strings.TrimSpace(rsp.Fee) == "" { + return nil, errors.New("通联支付退款响应缺少 fee") + } + refundAmount, err := parseIntegerAmount(rsp.Fee) + if err != nil { + return nil, fmt.Errorf("解析通联支付退款金额: %w", err) + } + if refundAmount != req.Amount { + return nil, errors.New("通联支付退款响应金额不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeRefundState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: providerRefundID, Amount: refundAmount, Currency: strings.ToUpper(strings.TrimSpace(currency)), Payload: mustJSON(rsp)}, nil } func (a *allinpayAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) { diff --git a/internal/data/payment/allinpay_test.go b/internal/data/payment/allinpay_test.go new file mode 100644 index 0000000..7a09ce6 --- /dev/null +++ b/internal/data/payment/allinpay_test.go @@ -0,0 +1,291 @@ +package payment + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "kra/internal/biz" + + "github.com/go-pay/gopay" + "github.com/go-pay/gopay/allinpay" +) + +func allinPayTestKeys(t *testing.T) (*rsa.PrivateKey, string, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privateKey := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(key)) + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + publicKey := base64.StdEncoding.EncodeToString(publicDER) + return key, privateKey, publicKey +} + +func allinPayTestConfig(privateKey, publicKey string, orderType string) map[string]any { + config := map[string]any{ + "cus_id": "CUS-TEST", + "app_id": "APP-TEST", + "private_key": privateKey, + "public_key": publicKey, + "environment": "sandbox", + } + if orderType != "" { + config["query_order_type"] = orderType + } + return config +} + +func redirectAllinPayHTTPS(t *testing.T, handler http.Handler) { + t.Helper() + server := httptest.NewTLSServer(handler) + oldDefault := http.DefaultTransport + base, ok := oldDefault.(*http.Transport) + if !ok { + server.Close() + t.Fatal("http.DefaultTransport is not *http.Transport") + } + transport := base.Clone() + transport.Proxy = nil + transport.DisableKeepAlives = true + transport.ForceAttemptHTTP2 = false + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // local test server + target := server.Listener.Addr().String() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, target) + } + http.DefaultTransport = transport + t.Cleanup(func() { + http.DefaultTransport = oldDefault + transport.CloseIdleConnections() + server.Close() + }) +} + +func signedAllinPayResponse(t *testing.T, key *rsa.PrivateKey, fields gopay.BodyMap) []byte { + t.Helper() + signData := fields.EncodeAliPaySignParams() + digest := sha1.Sum([]byte(signData)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, digest[:]) + if err != nil { + t.Fatal(err) + } + fields.Set("sign", base64.StdEncoding.EncodeToString(signature)) + body, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + return body +} + +func TestAllinPayTrxIDCreateRejectsResponseWithoutTransactionID(t *testing.T) { + key, privateKey, publicKey := allinPayTestKeys(t) + redirectAllinPayHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/apiweb/unitorder/pay" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + values, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("parse request form: %v", err) + } + if values.Get("reqsn") != "MERCHANT-TRXID-MISSING" || values.Get("paytype") == "" { + t.Errorf("request fields = %v", values) + } + response := signedAllinPayResponse(t, key, gopay.BodyMap{ + "retcode": "SUCCESS", "retmsg": "ok", "reqsn": "MERCHANT-TRXID-MISSING", + "trxstatus": "SUCCESS", "payinfo": "client-only-payinfo", + }) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + + _, err := (&allinpayAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAllinPay, TradeNo: "MERCHANT-TRXID-MISSING", Subject: "subject", + Amount: 100, Currency: "CNY", + }, allinPayTestConfig(privateKey, publicKey, allinpay.OrderTypeTrxId)) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "trxid") { + t.Fatalf("missing trxid error = %v", err) + } +} + +func TestAllinPayNativeCreateRejectsTrxIDLookupMode(t *testing.T) { + _, privateKey, publicKey := allinPayTestKeys(t) + _, err := (&allinpayAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAllinPay, TradeNo: "MERCHANT-NATIVE-TRXID", Subject: "subject", + Amount: 100, Currency: "CNY", Extra: map[string]any{"method": "native", "expiretime": "20261231235959"}, + }, allinPayTestConfig(privateKey, publicKey, allinpay.OrderTypeTrxId)) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "native") || !strings.Contains(strings.ToLower(err.Error()), "trxid") { + t.Fatalf("native trxid error = %v", err) + } +} + +func TestAllinPayCreateRejectsUnknownMethodBeforeClient(t *testing.T) { + _, err := (&allinpayAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAllinPay, TradeNo: "MERCHANT-UNKNOWN-METHOD", Subject: "subject", + Amount: 100, Currency: "CNY", Extra: map[string]any{"method": "unsupported"}, + }, nil) + if err == nil || !strings.Contains(err.Error(), "不支持的下单方式") { + t.Fatalf("unknown method error = %v", err) + } +} + +func TestNormalizeAllinState(t *testing.T) { + for _, tc := range []struct { + state string + want string + }{ + {state: "SUCCESS", want: "success"}, + {state: "0000", want: "success"}, + {state: "2000", want: "pending"}, + {state: "2008", want: "pending"}, + {state: "3000", want: "failed"}, + {state: "3040", want: "failed"}, + {state: "3045", want: "failed"}, + {state: "3999", want: "failed"}, + } { + t.Run(tc.state, func(t *testing.T) { + if got := normalizeAllinState(tc.state); got != tc.want { + t.Fatalf("normalizeAllinState(%q) = %q, want %q", tc.state, got, tc.want) + } + }) + } +} + +func TestAllinPayTrxIDQueryRejectsMismatchedResponseTransactionID(t *testing.T) { + key, privateKey, publicKey := allinPayTestKeys(t) + redirectAllinPayHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + if r.Form.Get("trxid") != "TRX-REQUESTED" { + t.Errorf("query form = %v", r.Form) + } + response := signedAllinPayResponse(t, key, gopay.BodyMap{ + "retcode": "SUCCESS", "retmsg": "ok", "reqsn": "MERCHANT-1", + "trxid": "TRX-OTHER", "trxstatus": "0000", "trxamt": "100", + }) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + + _, err := (&allinpayAdapter{}).Query(context.Background(), "TRX-REQUESTED", allinPayTestConfig(privateKey, publicKey, allinpay.OrderTypeTrxId)) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "trxid") || !strings.Contains(err.Error(), "不匹配") { + t.Fatalf("mismatched trxid error = %v", err) + } +} + +func TestAllinPayRefundUsesConfiguredLookupIdentity(t *testing.T) { + for _, tc := range []struct { + name string + orderType string + tradeNo string + queryID string + wantOldTrxID string + wantOldReqSN string + }{ + { + name: "trxid uses durable query id", + orderType: allinpay.OrderTypeTrxId, + tradeNo: "MERCHANT-REFUND-TRXID", + queryID: "TRX-ORIGINAL-1", + wantOldTrxID: "TRX-ORIGINAL-1", + }, + { + name: "reqsn uses merchant trade number", + orderType: allinpay.OrderTypeReqSN, + tradeNo: "MERCHANT-REFUND-REQSN", + queryID: "SHOULD-NOT-BE-USED", + wantOldReqSN: "MERCHANT-REFUND-REQSN", + }, + } { + t.Run(tc.name, func(t *testing.T) { + key, privateKey, publicKey := allinPayTestKeys(t) + redirectAllinPayHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/apiweb/tranx/refund" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + values, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("parse request form: %v", err) + } + if values.Get("oldtrxid") != tc.wantOldTrxID || values.Get("oldreqsn") != tc.wantOldReqSN { + t.Errorf("refund identity fields = %v", values) + } + if values.Get("reqsn") != "REFUND-1" || values.Get("trxamt") != "40" { + t.Errorf("refund fields = %v", values) + } + response := signedAllinPayResponse(t, key, gopay.BodyMap{ + "retcode": "SUCCESS", "retmsg": "ok", "reqsn": "REFUND-1", + "trxid": "REFUND-TRX-1", "trxstatus": "SUCCESS", "fee": "40", + }) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + + result, err := (&allinpayAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ + Provider: biz.PaymentAllinPay, TradeNo: tc.tradeNo, QueryID: tc.queryID, + RefundNo: "REFUND-1", Amount: 40, TotalAmount: 100, Currency: "CNY", + }, allinPayTestConfig(privateKey, publicKey, tc.orderType)) + if err != nil { + t.Fatal(err) + } + if result.ProviderTradeNo != "REFUND-TRX-1" || result.Amount != 40 { + t.Fatalf("refund result = %+v", result) + } + }) + } +} + +func TestAllinPayRefundResultBindsRefundIdentity(t *testing.T) { + req := &biz.PaymentRefundRequest{TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 40, Currency: "CNY"} + base := func() *allinpay.RefundRsp { + return &allinpay.RefundRsp{Reqsn: "REFUND-1", Trxid: "ALLIN-REFUND-1", TrxStatus: "SUCCESS", Fee: "40"} + } + + for _, tc := range []struct { + name string + mutate func(*allinpay.RefundRsp) + }{ + {name: "missing merchant refund number", mutate: func(rsp *allinpay.RefundRsp) { rsp.Reqsn = "" }}, + {name: "mismatched merchant refund number", mutate: func(rsp *allinpay.RefundRsp) { rsp.Reqsn = "OTHER" }}, + {name: "missing provider refund id", mutate: func(rsp *allinpay.RefundRsp) { rsp.Trxid = "" }}, + {name: "missing amount", mutate: func(rsp *allinpay.RefundRsp) { rsp.Fee = "" }}, + {name: "invalid amount", mutate: func(rsp *allinpay.RefundRsp) { rsp.Fee = "invalid" }}, + {name: "mismatched amount", mutate: func(rsp *allinpay.RefundRsp) { rsp.Fee = "41" }}, + } { + t.Run(tc.name, func(t *testing.T) { + rsp := base() + tc.mutate(rsp) + if _, err := allinpayRefundResult(req, rsp, "CNY"); err == nil { + t.Fatal("allinpayRefundResult() accepted an unbound refund response") + } + }) + } +} diff --git a/internal/data/payment/apple.go b/internal/data/payment/apple.go index 7c64437..c62e1c7 100644 --- a/internal/data/payment/apple.go +++ b/internal/data/payment/apple.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strings" "kra/internal/biz" @@ -16,6 +17,9 @@ import ( type appleAdapter struct{} func (a *appleAdapter) Create(_ context.Context, req *biz.PaymentRequest, _ map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("Apple 内购下单参数为空") + } productID := "" if req.Extra != nil { productID, _ = req.Extra["product_id"].(string) @@ -51,6 +55,9 @@ func (a *appleAdapter) Query(ctx context.Context, transactionID string, c map[st if response == nil || response.SignedTransactionInfo == "" { return nil, errors.New("Apple 查询响应缺少 signedTransactionInfo") } + if err = validateAppleJWSChain(response.SignedTransactionInfo); err != nil { + return nil, err + } transaction, err := response.DecodeSignedTransaction() if err != nil { return nil, err @@ -91,6 +98,9 @@ func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback if body.SignedPayload == "" { return nil, errors.New("Apple 回调缺少 signedPayload") } + if err := validateAppleJWSChain(body.SignedPayload); err != nil { + return nil, err + } notification, err := gopayApple.DecodeSignedPayload(body.SignedPayload) if err != nil { return nil, err @@ -104,6 +114,9 @@ func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback if notification.Data == nil { return nil, errors.New("Apple 回调缺少 data") } + if err = validateAppleJWSChain(notification.Data.SignedTransactionInfo); err != nil { + return nil, err + } transaction, err := notification.DecodeTransactionInfo() if err != nil { return nil, err @@ -143,8 +156,12 @@ func appleClaims(value any) (map[string]any, error) { } func applePaymentResult(transactionID string, claims map[string]any, payload []byte, c map[string]any) (*biz.PaymentResult, error) { + revoked, err := appleRevoked(claims) + if err != nil { + return nil, err + } status := "success" - if fmt.Sprint(claims["revocationDate"]) != "" && fmt.Sprint(claims["revocationDate"]) != "" { + if revoked { status = "failed" } providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"])) @@ -186,6 +203,45 @@ func applePaymentResult(transactionID string, claims map[string]any, payload []b return result, nil } +func appleRevoked(claims map[string]any) (bool, error) { + value, exists := claims["revocationDate"] + if !exists || value == nil { + return false, nil + } + switch number := value.(type) { + case float64: + if math.IsNaN(number) || math.IsInf(number, 0) || number < 0 || math.Trunc(number) != number { + return false, errors.New("Apple revocationDate 无效") + } + return number > 0, nil + case json.Number: + parsed, err := number.Int64() + if err != nil || parsed < 0 { + return false, errors.New("Apple revocationDate 无效") + } + return parsed > 0, nil + case int64: + if number < 0 { + return false, errors.New("Apple revocationDate 无效") + } + return number > 0, nil + case int: + if number < 0 { + return false, errors.New("Apple revocationDate 无效") + } + return number > 0, nil + } + textValue := strings.TrimSpace(fmt.Sprint(value)) + if textValue == "" || textValue == "" || textValue == "0" { + return false, nil + } + parsed, err := parseIntegerAmount(textValue) + if err != nil { + return false, fmt.Errorf("解析 Apple revocationDate: %w", err) + } + return parsed > 0, nil +} + func applePriceDivisor(c map[string]any, currency string) int64 { if values, ok := c["price_divisors"].(map[string]any); ok { for key, value := range values { diff --git a/internal/data/payment/apple_jws.go b/internal/data/payment/apple_jws.go new file mode 100644 index 0000000..d58f642 --- /dev/null +++ b/internal/data/payment/apple_jws.go @@ -0,0 +1,210 @@ +package payment + +import ( + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const ( + maxAppleJWSHeaderSize = 256 << 10 + maxAppleJWSPayloadSize = 1 << 20 + maxAppleJWSTimeDepth = 2 +) + +var ( + appleJWSLeafExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 11, 1} + appleJWSIntermediateExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1} +) + +// validateAppleJWSChain binds the leaf certificate used by GoPay for JWS +// signature verification to the intermediate and root certificates that +// GoPay validates against its embedded Apple trust anchor. +func validateAppleJWSChain(signedPayload string) error { + headerSegment, payloadSegment, signatureSegment, err := splitAppleJWS(signedPayload) + if err != nil { + return err + } + verificationTime, hasVerificationTime, err := appleJWSVerificationTime(payloadSegment) + if err != nil { + return err + } + if signatureSegment == "" { + return errors.New("Apple JWS 格式无效") + } + if len(headerSegment) > base64.RawURLEncoding.EncodedLen(maxAppleJWSHeaderSize) { + return errors.New("Apple JWS header 过大") + } + headerBytes, err := base64.RawURLEncoding.DecodeString(headerSegment) + if err != nil { + return fmt.Errorf("解析 Apple JWS header: %w", err) + } + if len(headerBytes) > maxAppleJWSHeaderSize { + return errors.New("Apple JWS header 过大") + } + var header struct { + Algorithm string `json:"alg"` + Chain []string `json:"x5c"` + } + if err = json.Unmarshal(headerBytes, &header); err != nil { + return fmt.Errorf("解析 Apple JWS header: %w", err) + } + if header.Algorithm != "ES256" { + return errors.New("Apple JWS 算法必须是 ES256") + } + if len(header.Chain) != 3 { + return errors.New("Apple JWS x5c 必须包含叶子、中间和根证书") + } + + certificates := make([]*x509.Certificate, 0, len(header.Chain)) + for index, encoded := range header.Chain { + der, decodeErr := base64.StdEncoding.DecodeString(encoded) + if decodeErr != nil { + return fmt.Errorf("解析 Apple JWS x5c[%d]: %w", index, decodeErr) + } + certificate, parseErr := x509.ParseCertificate(der) + if parseErr != nil { + return fmt.Errorf("解析 Apple JWS x5c[%d] 证书: %w", index, parseErr) + } + certificates = append(certificates, certificate) + } + if err = certificates[0].CheckSignatureFrom(certificates[1]); err != nil { + return fmt.Errorf("Apple JWS 叶子证书不属于声明的证书链: %w", err) + } + if err = certificates[1].CheckSignatureFrom(certificates[2]); err != nil { + return fmt.Errorf("Apple JWS 中间证书不属于声明的证书链: %w", err) + } + if err = certificates[2].CheckSignatureFrom(certificates[2]); err != nil { + return fmt.Errorf("Apple JWS 根证书不是自签名证书: %w", err) + } + if !certificates[1].IsCA || !certificates[1].BasicConstraintsValid { + return errors.New("Apple JWS 中间证书不是有效 CA") + } + if !certificates[2].IsCA || !certificates[2].BasicConstraintsValid { + return errors.New("Apple JWS 根证书不是有效 CA") + } + if certificates[1].KeyUsage != 0 && certificates[1].KeyUsage&x509.KeyUsageCertSign == 0 { + return errors.New("Apple JWS 中间证书不允许签发证书") + } + if certificates[2].KeyUsage != 0 && certificates[2].KeyUsage&x509.KeyUsageCertSign == 0 { + return errors.New("Apple JWS 根证书不允许签发证书") + } + if !hasAppleCertificateExtension(certificates[0], appleJWSLeafExtensionOID) { + return errors.New("Apple JWS 叶子证书缺少 App Store 签名扩展") + } + if !hasAppleCertificateExtension(certificates[1], appleJWSIntermediateExtensionOID) { + return errors.New("Apple JWS 中间证书缺少 Apple 签名扩展") + } + + if hasVerificationTime { + roots := x509.NewCertPool() + roots.AddCert(certificates[2]) + intermediates := x509.NewCertPool() + intermediates.AddCert(certificates[1]) + if _, err = certificates[0].Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + CurrentTime: verificationTime, + }); err != nil { + return fmt.Errorf("验证 Apple JWS 证书链: %w", err) + } + } + if certificates[0].IsCA { + return errors.New("Apple JWS 叶子证书不能是 CA") + } + if certificates[0].KeyUsage != 0 && certificates[0].KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return errors.New("Apple JWS 叶子证书不允许数字签名") + } + return nil +} + +func splitAppleJWS(signedPayload string) (header, payload, signature string, err error) { + header, remainder, ok := strings.Cut(strings.TrimSpace(signedPayload), ".") + if !ok || header == "" { + return "", "", "", errors.New("Apple JWS 格式无效") + } + payload, signature, ok = strings.Cut(remainder, ".") + if !ok || payload == "" || signature == "" || strings.Contains(signature, ".") { + return "", "", "", errors.New("Apple JWS 格式无效") + } + return header, payload, signature, nil +} + +func appleJWSVerificationTime(payloadSegment string) (time.Time, bool, error) { + return appleJWSVerificationTimeAtDepth(payloadSegment, 0) +} + +// appleJWSVerificationTimeAtDepth extracts a signing time from the current +// payload, then follows the nested signed transaction/renewal JWS used by +// App Store Server Notifications. The outer notification often has no date of +// its own, while its nested transaction does. +func appleJWSVerificationTimeAtDepth(payloadSegment string, depth int) (time.Time, bool, error) { + if len(payloadSegment) > base64.RawURLEncoding.EncodedLen(maxAppleJWSPayloadSize) { + return time.Time{}, false, errors.New("Apple JWS payload 过大") + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSegment) + if err != nil { + return time.Time{}, false, fmt.Errorf("解析 Apple JWS payload: %w", err) + } + if len(payloadBytes) > maxAppleJWSPayloadSize { + return time.Time{}, false, errors.New("Apple JWS payload 过大") + } + var payload struct { + SignedDate json.Number `json:"signedDate"` + ReceiptCreationDate json.Number `json:"receiptCreationDate"` + Data struct { + SignedTransactionInfo string `json:"signedTransactionInfo"` + SignedRenewalInfo string `json:"signedRenewalInfo"` + } `json:"data"` + } + if err = json.Unmarshal(payloadBytes, &payload); err != nil { + return time.Time{}, false, fmt.Errorf("解析 Apple JWS payload: %w", err) + } + date := payload.SignedDate + if date == "" { + date = payload.ReceiptCreationDate + } + if date != "" { + milliseconds, parseErr := date.Int64() + if parseErr != nil || milliseconds <= 0 { + return time.Time{}, false, errors.New("Apple JWS signedDate 无效") + } + return time.UnixMilli(milliseconds), true, nil + } + if depth >= maxAppleJWSTimeDepth { + return time.Time{}, false, nil + } + for _, nested := range []string{payload.Data.SignedTransactionInfo, payload.Data.SignedRenewalInfo} { + if strings.TrimSpace(nested) == "" { + continue + } + _, nestedPayload, _, splitErr := splitAppleJWS(nested) + if splitErr != nil { + continue + } + if nestedTime, found, nestedErr := appleJWSVerificationTimeAtDepth(nestedPayload, depth+1); nestedErr != nil { + return time.Time{}, false, nestedErr + } else if found { + return nestedTime, true, nil + } + } + return time.Time{}, false, nil +} + +func hasAppleCertificateExtension(certificate *x509.Certificate, oid asn1.ObjectIdentifier) bool { + if certificate == nil { + return false + } + for _, extension := range certificate.Extensions { + if extension.Id.Equal(oid) { + return true + } + } + return false +} diff --git a/internal/data/payment/apple_jws_test.go b/internal/data/payment/apple_jws_test.go new file mode 100644 index 0000000..bac0aa6 --- /dev/null +++ b/internal/data/payment/apple_jws_test.go @@ -0,0 +1,221 @@ +package payment + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "math/big" + "testing" + "time" +) + +func TestValidateAppleJWSChain(t *testing.T) { + chain := testAppleCertificateChain(t) + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err != nil { + t.Fatalf("validateAppleJWSChain() error = %v", err) + } +} + +func TestValidateAppleJWSChainRejectsUnrelatedLeaf(t *testing.T) { + chain := testAppleCertificateChain(t) + attackerChain := testAppleCertificateChain(t) + chain[0] = attackerChain[0] + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + t.Fatal("validateAppleJWSChain() accepted a leaf outside the declared chain") + } +} + +func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) { + chain := testAppleCertificateChain(t) + if err := validateAppleJWSChain(testAppleJWS(t, "ES384", chain, time.Now())); err == nil { + t.Fatal("validateAppleJWSChain() accepted a non-ES256 algorithm") + } + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain[:2], time.Now())); err == nil { + t.Fatal("validateAppleJWSChain() accepted an incomplete x5c chain") + } +} + +func TestValidateAppleJWSChainRejectsAdditionalCertificates(t *testing.T) { + chain := testAppleCertificateChain(t) + chain = append(chain, chain[2]) + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + t.Fatal("validateAppleJWSChain() accepted additional x5c certificates") + } +} + +func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) { + chain := testAppleCertificateChainWithoutAppleExtensions(t) + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil { + t.Fatal("validateAppleJWSChain() accepted certificates without Apple signing extensions") + } +} + +func TestValidateAppleJWSChainUsesSignedDateForHistoricalPayload(t *testing.T) { + signedAt := time.Now().AddDate(-2, 0, 0) + chain := testAppleCertificateChainAt(t, signedAt, true) + if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, signedAt)); err != nil { + t.Fatalf("validateAppleJWSChain() rejected historical payload: %v", err) + } +} + +func TestValidateAppleJWSChainUsesNestedSignedDateForNotification(t *testing.T) { + signedAt := time.Now().AddDate(-2, 0, 0) + chain := testAppleCertificateChainAt(t, signedAt, true) + inner := testAppleJWS(t, "ES256", chain, signedAt) + header, err := json.Marshal(map[string]any{"alg": "ES256", "x5c": chain}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(map[string]any{ + "notificationType": "DID_RENEW", + "data": map[string]any{"signedTransactionInfo": inner}, + }) + if err != nil { + t.Fatal(err) + } + outer := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + if err := validateAppleJWSChain(outer); err != nil { + t.Fatalf("validateAppleJWSChain() rejected historical notification: %v", err) + } +} + +func TestValidateAppleJWSChainAcceptsExpiredChainWithoutSignedDate(t *testing.T) { + signedAt := time.Now().AddDate(-2, 0, 0) + chain := testAppleCertificateChainAt(t, signedAt, true) + if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err != nil { + t.Fatalf("validateAppleJWSChain() rejected an expired chain without signedDate: %v", err) + } +} + +func TestValidateAppleJWSChainRejectsBrokenChainWithoutSignedDate(t *testing.T) { + chain := testAppleCertificateChain(t) + attackerChain := testAppleCertificateChain(t) + chain[0] = attackerChain[0] + if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err == nil { + t.Fatal("validateAppleJWSChain() accepted a broken chain without signedDate") + } +} + +func testAppleCertificateChain(t *testing.T) []string { + return testAppleCertificateChainWithAppleExtensions(t, true) +} + +func testAppleCertificateChainWithoutAppleExtensions(t *testing.T) []string { + return testAppleCertificateChainWithAppleExtensions(t, false) +} + +func testAppleCertificateChainWithAppleExtensions(t *testing.T, includeAppleExtensions bool) []string { + return testAppleCertificateChainAt(t, time.Now(), includeAppleExtensions) +} + +func testAppleCertificateChainAt(t *testing.T, validAt time.Time, includeAppleExtensions bool) []string { + t.Helper() + rootKey := testAppleECDSAKey(t) + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test Apple root"}, + NotBefore: validAt.Add(-time.Hour), + NotAfter: validAt.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + rootDER := testAppleCertificate(t, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey) + root, err := x509.ParseCertificate(rootDER) + if err != nil { + t.Fatal(err) + } + + intermediateKey := testAppleECDSAKey(t) + intermediateTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "test Apple intermediate"}, + NotBefore: validAt.Add(-time.Hour), + NotAfter: validAt.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + if includeAppleExtensions { + intermediateTemplate.ExtraExtensions = []pkix.Extension{{ + Id: asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1}, + Value: []byte{0x05, 0x00}, + }} + } + intermediateDER := testAppleCertificate(t, intermediateTemplate, root, &intermediateKey.PublicKey, rootKey) + intermediate, err := x509.ParseCertificate(intermediateDER) + if err != nil { + t.Fatal(err) + } + + leafKey := testAppleECDSAKey(t) + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "test Apple signing leaf"}, + NotBefore: validAt.Add(-time.Hour), + NotAfter: validAt.Add(time.Hour), + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature, + } + if includeAppleExtensions { + leafTemplate.ExtraExtensions = []pkix.Extension{{ + Id: asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 11, 1}, + Value: []byte{0x05, 0x00}, + }} + } + leafDER := testAppleCertificate(t, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey) + return []string{ + base64.StdEncoding.EncodeToString(leafDER), + base64.StdEncoding.EncodeToString(intermediateDER), + base64.StdEncoding.EncodeToString(rootDER), + } +} + +func testAppleECDSAKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return key +} + +func testAppleCertificate(t *testing.T, template, parent *x509.Certificate, publicKey any, signer any) []byte { + t.Helper() + der, err := x509.CreateCertificate(rand.Reader, template, parent, publicKey, signer) + if err != nil { + t.Fatal(err) + } + return der +} + +func testAppleJWS(t *testing.T, algorithm string, chain []string, signedAt time.Time) string { + t.Helper() + header, err := json.Marshal(map[string]any{"alg": algorithm, "x5c": chain}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(map[string]any{"signedDate": signedAt.UnixMilli()}) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" +} + +func testAppleJWSWithoutDate(t *testing.T, chain []string) string { + t.Helper() + header, err := json.Marshal(map[string]any{"alg": "ES256", "x5c": chain}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(map[string]any{"bundleId": "com.example"}) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" +} diff --git a/internal/data/payment/douyin.go b/internal/data/payment/douyin.go index ae1387e..748d187 100644 --- a/internal/data/payment/douyin.go +++ b/internal/data/payment/douyin.go @@ -36,6 +36,9 @@ func (a *douyinAdapter) client(c map[string]any) (*douyin.Client, error) { } func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("抖音支付下单请求为空") + } client, err := a.client(c) if err != nil { return nil, err @@ -49,12 +52,12 @@ func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency)) }) mergeGoPayExtras(bm, req.Extra, "appid", "description", "out_trade_no", "notify_url", "amount", "mchid", "method", "pay_method", "trade_type", "channel") - tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "channel")) - if tradeType == "" { - tradeType = strings.ToLower(text(c, "trade_type")) + tradeType, err := douyinCreateMethod(req.Extra, c) + if err != nil { + return nil, err } switch tradeType { - case "app", "app_pay", "apporder": + case "app": rsp, callErr := client.AppOrder(ctx, bm) if callErr != nil { return nil, callErr @@ -62,9 +65,8 @@ func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success { return nil, douyinError(rsp) } - payload, _ := json.Marshal(rsp.Response) - return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil - case "h5", "h5_pay": + return douyinPrepayResult(client, text(c, "app_id"), req.TradeNo, rsp.Response.PrepayId, true) + case "h5": rsp, callErr := client.H5Order(ctx, bm) if callErr != nil { return nil, callErr @@ -72,9 +74,12 @@ func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success { return nil, douyinError(rsp) } + if strings.TrimSpace(rsp.Response.H5Url) == "" { + return nil, errors.New("抖音支付 H5 下单响应缺少 h5_url") + } payload, _ := json.Marshal(rsp.Response) - return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.H5Url, Payload: payload}, nil - case "native", "qr", "qrcode": + return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil + case "native": rsp, callErr := client.NativeOrder(ctx, bm) if callErr != nil { return nil, callErr @@ -82,9 +87,12 @@ func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success { return nil, douyinError(rsp) } + if strings.TrimSpace(rsp.Response.CodeUrl) == "" { + return nil, errors.New("抖音支付 Native 下单响应缺少 code_url") + } payload, _ := json.Marshal(rsp.Response) - return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.CodeUrl, Payload: payload}, nil - default: + return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil + case "jsapi": rsp, callErr := client.JsapiOrder(ctx, bm) if callErr != nil { return nil, callErr @@ -92,12 +100,61 @@ func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success { return nil, douyinError(rsp) } - payload, _ := json.Marshal(rsp.Response) - return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil + return douyinPrepayResult(client, text(c, "app_id"), req.TradeNo, rsp.Response.PrepayId, false) + default: + return nil, fmt.Errorf("抖音支付不支持的下单方式: %s", tradeType) } } +func douyinCreateMethod(extra, config map[string]any) (string, error) { + value := firstAny(extra, "method", "pay_method", "trade_type", "pay_type", "channel") + if value == "" { + value = firstAny(config, "method", "pay_method", "trade_type", "pay_type", "channel") + } + normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "", "jsapi", "js_api", "mini", "mini_program", "miniprogram", "applet": + return "jsapi", nil + case "app", "app_pay", "apporder": + return "app", nil + case "h5", "h5_pay": + return "h5", nil + case "native", "qr", "qrcode": + return "native", nil + default: + return "", fmt.Errorf("抖音支付不支持的下单方式: %s", value) + } +} + +func douyinPrepayResult(client *douyin.Client, appID, tradeNo, prepayID string, app bool) (*biz.PaymentResult, error) { + if client == nil || strings.TrimSpace(prepayID) == "" { + return nil, errors.New("抖音支付下单响应缺少 prepay_id") + } + var ( + params any + err error + ) + if app { + params, err = client.PaySignOfApp(appID, prepayID) + } else { + params, err = client.PaySignOfJSAPI(appID, prepayID) + } + if err != nil { + return nil, fmt.Errorf("生成抖音支付客户端调起参数: %w", err) + } + return &biz.PaymentResult{ + Provider: biz.PaymentDouyin, + Status: "client_pending", + TradeNo: tradeNo, + Payload: mustJSON(params), + }, nil +} + func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("抖音支付查单缺少 out_trade_no") + } client, err := a.client(c) if err != nil { return nil, err @@ -110,23 +167,25 @@ func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string] return nil, douyinError(rsp) } order := rsp.Response - result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(order.TradeState), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: mustJSON(order)} + if err = validateDouyinMerchantIdentity(order.Mchid, order.Appid, c, false); err != nil { + return nil, err + } + payload := mustJSON(order) + var signedBody []byte + if rsp.SignInfo != nil { + signedBody = []byte(rsp.SignInfo.SignBody) + if json.Valid(signedBody) { + payload = append([]byte(nil), signedBody...) + } + } + result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(order.TradeState), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: payload} if result.TradeNo == "" { result.TradeNo = tradeNo + } else if result.TradeNo != tradeNo { + return nil, errors.New("抖音支付查单响应的 out_trade_no 不匹配") } - if order.Amount != nil { - result.Amount = int64(order.Amount.Total) - result.Currency = strings.ToUpper(order.Amount.Currency) - result.PayerPaidAmount = int64(order.Amount.PayerTotal) - result.PayerCurrency = strings.ToUpper(order.Amount.PayerCurrency) - if result.PayerCurrency == "" { - result.PayerCurrency = result.Currency - } - if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount { - result.CashPaidAmount = result.PayerPaidAmount - result.DiscountAmount = result.Amount - result.PayerPaidAmount - result.AmountBreakdownKnown = true - } + if err = populateDouyinPaymentAmount(result, order.Amount, signedBody); err != nil { + return nil, err } return result, nil } @@ -135,12 +194,23 @@ func (a *douyinAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques if req == nil { return nil, errors.New("抖音支付退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + if tradeNo == "" { + return nil, errors.New("抖音支付退款请求缺少 out_trade_no") + } + refundNo := strings.TrimSpace(req.RefundNo) + if refundNo == "" { + return nil, errors.New("抖音支付退款请求缺少 out_refund_no") + } + if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { + return nil, errors.New("抖音支付退款金额无效") + } client, err := a.client(c) if err != nil { return nil, err } bm := gopay.BodyMap{} - bm.Set("out_trade_no", req.TradeNo).Set("out_refund_no", req.RefundNo) + bm.Set("out_trade_no", tradeNo).Set("out_refund_no", refundNo) bm.SetBodyMap("amount", func(m gopay.BodyMap) { m.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency)) }) @@ -153,17 +223,61 @@ func (a *douyinAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques return nil, douyinError(rsp) } r := rsp.Response - result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeRefundState(r.Status), TradeNo: r.OutTradeNo, ProviderTradeNo: r.RefundId, Payload: mustJSON(r)} - if result.TradeNo == "" { - result.TradeNo = req.TradeNo + refundID, err := validateDouyinRefundIdentity(r, tradeNo, refundNo) + if err != nil { + return nil, err } - if r.Amount != nil { - result.Amount = int64(r.Amount.Refund) - result.Currency = strings.ToUpper(r.Amount.Currency) + result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeRefundState(r.Status), TradeNo: tradeNo, ProviderTradeNo: refundID, Payload: mustJSON(r)} + if r.Amount == nil { + return nil, errors.New("抖音支付退款响应缺少金额") + } + result.Amount = int64(r.Amount.Refund) + result.Currency = strings.ToUpper(strings.TrimSpace(r.Amount.Currency)) + if result.Amount != req.Amount { + return nil, errors.New("抖音支付退款响应金额不匹配") + } + if result.Currency == "" { + return nil, errors.New("抖音支付退款响应缺少币种") + } + if req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { + return nil, errors.New("抖音支付退款响应币种不匹配") } return result, nil } +func validateDouyinRefundIdentity(refund *douyin.Refund, tradeNo, refundNo string) (string, error) { + if refund == nil { + return "", errors.New("抖音支付退款响应缺少退款单") + } + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return "", errors.New("抖音支付退款请求缺少 out_trade_no") + } + refundNo = strings.TrimSpace(refundNo) + if refundNo == "" { + return "", errors.New("抖音支付退款请求缺少 out_refund_no") + } + returnedTradeNo := strings.TrimSpace(refund.OutTradeNo) + if returnedTradeNo == "" { + return "", errors.New("抖音支付退款响应缺少 out_trade_no") + } + if returnedTradeNo != tradeNo { + return "", errors.New("抖音支付退款响应的 out_trade_no 不匹配") + } + returnedRefundNo := strings.TrimSpace(refund.OutRefundNo) + if returnedRefundNo == "" { + return "", errors.New("抖音支付退款响应缺少 out_refund_no") + } + if returnedRefundNo != refundNo { + return "", errors.New("抖音支付退款响应的 out_refund_no 不匹配") + } + refundID := strings.TrimSpace(refund.RefundId) + if refundID == "" { + return "", errors.New("抖音支付退款响应缺少 refund_id") + } + return refundID, nil +} + func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { if callback == nil { return nil, errors.New("抖音支付回调为空") @@ -186,25 +300,26 @@ func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallb if err := notify.VerifySignByPKMap(client.PlatformCertMap()); err != nil { return nil, err } + if notify.Resource != nil && strings.TrimSpace(notify.Resource.Mchid) != "" { + if err = validateDouyinMerchantIdentity(notify.Resource.Mchid, "", c, false); err != nil { + return nil, err + } + } if strings.EqualFold(notify.EventType, douyin.EventTransactionSuccess) { pay, err := notify.DecryptPayCipherText(text(c, "api_key")) if err != nil { return nil, err } - result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(pay.TradeState), TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: mustJSON(pay), EventID: notify.Id} - if pay.Amount != nil { - result.Amount = int64(pay.Amount.Total) - result.Currency = strings.ToUpper(pay.Amount.Currency) - result.PayerPaidAmount = int64(pay.Amount.PayerTotal) - result.PayerCurrency = strings.ToUpper(pay.Amount.PayerCurrency) - if result.PayerCurrency == "" { - result.PayerCurrency = result.Currency - } - if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount { - result.CashPaidAmount = result.PayerPaidAmount - result.DiscountAmount = result.Amount - result.PayerPaidAmount - result.AmountBreakdownKnown = true - } + if err = validateDouyinMerchantIdentity(pay.Mchid, pay.Appid, c, true); err != nil { + return nil, err + } + plain, err := douyinNotifyPlaintext(notify, text(c, "api_key")) + if err != nil { + return nil, err + } + result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(pay.TradeState), TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: append([]byte(nil), plain...), EventID: notify.Id} + if err = populateDouyinPaymentAmount(result, pay.Amount, plain); err != nil { + return nil, err } return result, nil } @@ -213,6 +328,11 @@ func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallb if err != nil { return nil, err } + if strings.TrimSpace(refund.Mchid) != "" { + if err = validateDouyinMerchantIdentity(refund.Mchid, "", c, false); err != nil { + return nil, err + } + } status := "pending" if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusSuccess) { status = "success" @@ -228,3 +348,68 @@ func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallb } return nil, fmt.Errorf("抖音支付不支持回调事件 %q", notify.EventType) } + +func validateDouyinMerchantIdentity(merchantID, appID string, c map[string]any, requireAppID bool) error { + merchantID = strings.TrimSpace(merchantID) + expectedMerchantID := strings.TrimSpace(text(c, "merchant_id")) + if merchantID == "" || expectedMerchantID == "" || merchantID != expectedMerchantID { + return errors.New("抖音支付响应商户号不匹配") + } + appID = strings.TrimSpace(appID) + expectedAppID := strings.TrimSpace(text(c, "app_id")) + if requireAppID && appID == "" { + return errors.New("抖音支付响应缺少 appid") + } + if appID != "" && (expectedAppID == "" || appID != expectedAppID) { + return errors.New("抖音支付响应 appid 不匹配") + } + return nil +} + +func populateDouyinPaymentAmount(result *biz.PaymentResult, amount *douyin.AmountInfo, raw []byte) error { + if result == nil || amount == nil { + return nil + } + result.Amount = int64(amount.Total) + result.Currency = strings.ToUpper(strings.TrimSpace(amount.Currency)) + result.PayerCurrency = strings.ToUpper(strings.TrimSpace(amount.PayerCurrency)) + if result.PayerCurrency == "" { + result.PayerCurrency = result.Currency + } + + var amountObject map[string]any + if object := jsonObject(raw); object != nil { + amountObject, _ = valueAtPath(object, "amount").(map[string]any) + } + payerTotal, present, err := parseJSONInteger(amountObject, "payer_total") + if err != nil { + return fmt.Errorf("解析抖音支付 amount.payer_total: %w", err) + } + if !present && len(raw) == 0 && amount.PayerTotal > 0 { + payerTotal = int64(amount.PayerTotal) + present = true + } + if !present { + return nil + } + if payerTotal < 0 || payerTotal > result.Amount { + return errors.New("抖音支付用户实付金额无效") + } + result.PayerPaidAmount = payerTotal + result.CashPaidAmount = payerTotal + result.DiscountAmount = result.Amount - payerTotal + result.AmountBreakdownKnown = true + return nil +} + +func douyinNotifyPlaintext(notify *douyin.NotifyReq, apiKey string) ([]byte, error) { + if notify == nil || notify.Resource == nil { + return nil, errors.New("抖音支付回调缺少 resource") + } + return douyin.DecryptNotifyCipherTextToBytes( + notify.Resource.Ciphertext, + notify.Resource.Nonce, + notify.Resource.AssociatedData, + apiKey, + ) +} diff --git a/internal/data/payment/gopay_helpers.go b/internal/data/payment/gopay_helpers.go index 3b71825..62bfd80 100644 --- a/internal/data/payment/gopay_helpers.go +++ b/internal/data/payment/gopay_helpers.go @@ -74,7 +74,62 @@ func douyinError(rsp any) error { } func qqOK(returnCode, resultCode string) bool { - return strings.EqualFold(returnCode, "SUCCESS") && (resultCode == "" || strings.EqualFold(resultCode, "SUCCESS")) + return strings.EqualFold(strings.TrimSpace(returnCode), "SUCCESS") && strings.EqualFold(strings.TrimSpace(resultCode), "SUCCESS") +} + +// saobeiOK accepts both the documented numeric success code (01) and the +// textual SUCCESS form used by some gateway test/proxy responses. Saobei's +// communication and business codes are independent, so both must explicitly +// report success. +func saobeiOK(returnCode, resultCode string) bool { + return saobeiSuccessCode(returnCode) && saobeiSuccessCode(resultCode) +} + +func saobeiSuccessCode(code string) bool { + code = strings.TrimSpace(code) + return strings.EqualFold(code, "01") || strings.EqualFold(code, "SUCCESS") +} + +func saobeiProcessingOK(returnCode, resultCode string) bool { + return saobeiSuccessCode(returnCode) && strings.TrimSpace(resultCode) == "03" +} + +func saobeiResultOK(returnCode, resultCode string) bool { + return saobeiOK(returnCode, resultCode) || saobeiProcessingOK(returnCode, resultCode) +} + +func lakalaResponseOK(returnCode, resultCode string, accepted ...string) bool { + if !strings.EqualFold(strings.TrimSpace(returnCode), "SUCCESS") { + return false + } + resultCode = strings.ToUpper(strings.TrimSpace(resultCode)) + if resultCode == "" { + return false + } + for _, code := range accepted { + if resultCode == strings.ToUpper(strings.TrimSpace(code)) { + return true + } + } + return false +} + +func lakalaCreateResponseOK(returnCode, resultCode string) bool { + return lakalaResponseOK(returnCode, resultCode, "SUCCESS", "EXISTS") +} + +func lakalaRetailCreateResponseOK(returnCode, resultCode string) bool { + return lakalaResponseOK(returnCode, resultCode, "PAYING", "PAY_SUCCESS") +} + +func lakalaQueryResponseOK(returnCode, resultCode string) bool { + return lakalaResponseOK(returnCode, resultCode, + "PAYING", "CREATE_FAIL", "CLOSED", "PAY_FAIL", "PAY_SUCCESS", "PARTIAL_REFUND", "FULL_REFUND") +} + +func lakalaRefundResponseOK(returnCode, resultCode string) bool { + return lakalaResponseOK(returnCode, resultCode, + "WAITING", "CREATE_FAILED", "SUCCESS", "FAILED", "FINISHED", "CHANGE") } func rspMap(value any) map[string]string { @@ -114,9 +169,9 @@ func normalizeRefundState(state string) string { func normalizeQQState(state string) string { switch strings.ToUpper(strings.TrimSpace(state)) { - case "SUCCESS", "REFUND": + case "SUCCESS": return "success" - case "CLOSED", "PAYERROR", "REVOKED", "NOPAY": + case "REFUND", "CLOSED", "PAYERROR", "REVOKED", "NOPAY": return "failed" default: return "pending" @@ -124,21 +179,35 @@ func normalizeQQState(state string) string { } func normalizeAllinState(state string) string { - switch strings.ToUpper(strings.TrimSpace(state)) { - case "SUCCESS", "0000", "2000", "3040": + state = strings.ToUpper(strings.TrimSpace(state)) + switch state { + case "SUCCESS", "0000": return "success" - case "FAIL", "FAILED", "3000", "3045": + case "FAIL", "FAILED": return "failed" - default: - return "pending" } + if len(state) == 4 && state >= "3000" && state <= "3999" { + return "failed" + } + return "pending" } func normalizeLakalaState(state string) string { switch strings.ToUpper(strings.TrimSpace(state)) { case "SUCCESS", "PAID", "PAY_SUCCESS", "1": return "success" - case "CLOSED", "FAILED", "FAIL", "CANCELLED": + case "CLOSED", "FAILED", "FAIL", "CANCELLED", "CREATE_FAIL", "PAY_FAIL", "PARTIAL_REFUND", "FULL_REFUND": + return "failed" + default: + return "pending" + } +} + +func normalizeLakalaRefundState(state string) string { + switch strings.ToUpper(strings.TrimSpace(state)) { + case "SUCCESS", "FINISHED": + return "success" + case "CREATE_FAILED", "FAILED", "CHANGE": return "failed" default: return "pending" diff --git a/internal/data/payment/gopay_test.go b/internal/data/payment/gopay_test.go index 5bbc635..2887248 100644 --- a/internal/data/payment/gopay_test.go +++ b/internal/data/payment/gopay_test.go @@ -24,6 +24,8 @@ import ( "kra/pkg/paymentkit" gopayAlipay "github.com/go-pay/gopay/alipay" + gopayDouyin "github.com/go-pay/gopay/douyin" + gopayWechat "github.com/go-pay/gopay/wechat" gopayWechatV3 "github.com/go-pay/gopay/wechat/v3" ) @@ -100,6 +102,43 @@ func TestAlipayGoPayRequestParamHonorsGatewayAndVerifiesResponse(t *testing.T) { } } +func TestAlipayTradePayUsesGoPaySDK(t *testing.T) { + platformKey, privatePEM, publicPEM := testRSAKeyPair(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("parse request: %v", err) + return + } + if form.Get("method") != "alipay.trade.pay" || !strings.Contains(form.Get("biz_content"), `"auth_code":"BARCODE-1"`) { + t.Errorf("request form = %v", form) + } + response := []byte(`{"code":"10000","msg":"Success","trade_no":"P-ALIPAY-PAY","out_trade_no":"T-ALIPAY-PAY","total_amount":"10.00","buyer_pay_amount":"10.00"}`) + payload := fmt.Sprintf(`{"alipay_trade_pay_response":%s,"sign":%q}`, response, testRSA2Sign(t, platformKey, response)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, payload) + })) + defer server.Close() + + result, err := (&alipayAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentAlipay, TradeNo: "T-ALIPAY-PAY", Subject: "subject", Amount: 1000, + Currency: "CNY", Extra: map[string]any{"method": "barcode", "auth_code": "BARCODE-1"}, + }, map[string]any{ + "app_id": "app-test", "private_key": privatePEM, "public_key": publicPEM, "gateway_url": server.URL, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "success" || result.TradeNo != "T-ALIPAY-PAY" || result.ProviderTradeNo != "P-ALIPAY-PAY" || result.QueryID != "" { + t.Fatalf("result = %+v", result) + } +} + func TestAlipayGoPayCallbackVerificationDoesNotMutateFields(t *testing.T) { key, _, publicPEM := testRSAKeyPair(t) fields := map[string]string{ @@ -192,6 +231,146 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) { } } +func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) { + const key = "wechat-secret" + var requestValues map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pay/micropay" { + t.Errorf("request path = %q, want /pay/micropay", r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + requestValues, err = paymentkit.XMLValues(body) + if err != nil { + t.Errorf("parse request: %v", err) + return + } + if requestValues["auth_code"] != "AUTH-CODE-1" || requestValues["out_trade_no"] != "T-WECHAT-MICRO" { + t.Errorf("micropay request identity = %#v", requestValues) + } + if requestValues["trade_type"] != "" || requestValues["notify_url"] != "" { + t.Errorf("micropay request contains unified-order fields: %#v", requestValues) + } + if err = verifyWechatV2(requestValues, key); err != nil { + t.Errorf("request signature: %v", err) + } + response := map[string]string{ + "return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", + "appid": "wx-test", "mch_id": "mch-test", "out_trade_no": "T-WECHAT-MICRO", + "transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY", + } + response["sign"] = wechatV2Sign(response, key, "MD5") + _, _ = w.Write(paymentkit.XMLEncode(response)) + })) + defer server.Close() + + result, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentWechatV2, TradeNo: "T-WECHAT-MICRO", Subject: "subject", Amount: 123, + Currency: "CNY", NotifyURL: "http://notify.local", + Extra: map[string]any{"method": "barcode", "auth_code": "AUTH-CODE-1"}, + }, map[string]any{ + "app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key, + "create_url": server.URL + "/pay/micropay", + }) + if err != nil { + t.Fatal(err) + } + if requestValues == nil || result.Status != "success" || result.TradeNo != "T-WECHAT-MICRO" || result.ProviderTradeNo != "WX-TRANSACTION-1" || result.QueryID != "" { + t.Fatalf("unexpected micropay result: %+v, request: %#v", result, requestValues) + } + if result.Amount != 123 || result.Currency != "CNY" { + t.Fatalf("unexpected micropay amount: %+v", result) + } +} + +func TestWechatV2MicropayRejectsMissingAuthCodeAndUnknownMode(t *testing.T) { + _, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentWechatV2, TradeNo: "T-WECHAT-MICRO-MISSING", Subject: "subject", Amount: 1, + Currency: "CNY", NotifyURL: "http://notify.local", Extra: map[string]any{"method": "micropay"}, + }, map[string]any{"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": "wechat-secret"}) + if err == nil || !strings.Contains(err.Error(), "auth_code") { + t.Fatalf("missing auth_code error = %v", err) + } + if _, err = wechatV2CreateMethod(map[string]any{"method": "unsupported"}, nil); err == nil { + t.Fatal("unknown v2 mode unexpectedly accepted") + } +} + +func TestWechatV2CreateMethodMapsSupportedModes(t *testing.T) { + for _, tc := range []struct { + name string + extra map[string]any + config map[string]any + want string + }{ + {name: "default", want: gopayWechat.TradeType_JsApi}, + {name: "mini program", extra: map[string]any{"method": "mini-program"}, want: gopayWechat.TradeType_JsApi}, + {name: "app", extra: map[string]any{"trade_type": "APP"}, want: gopayWechat.TradeType_App}, + {name: "native", extra: map[string]any{"method": "qrcode"}, want: gopayWechat.TradeType_Native}, + {name: "h5", extra: map[string]any{"pay_method": "h5"}, want: gopayWechat.TradeType_H5}, + {name: "micropay", extra: map[string]any{"method": "barcode"}, want: "MICROPAY"}, + {name: "config fallback", config: map[string]any{"trade_type": "micropay"}, want: "MICROPAY"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := wechatV2CreateMethod(tc.extra, tc.config) + if err != nil || got != tc.want { + t.Fatalf("mode = %q, err = %v, want %q", got, err, tc.want) + } + }) + } +} + +func TestWechatV2MicropayRejectsInvalidSuccessResponse(t *testing.T) { + const key = "wechat-secret" + for _, tc := range []struct { + name string + mutate func(map[string]string) + want string + }{ + {name: "missing merchant order", mutate: func(values map[string]string) { delete(values, "out_trade_no") }, want: "out_trade_no"}, + {name: "mismatched merchant order", mutate: func(values map[string]string) { values["out_trade_no"] = "OTHER" }, want: "不匹配"}, + {name: "missing transaction", mutate: func(values map[string]string) { delete(values, "transaction_id") }, want: "transaction_id"}, + {name: "mismatched amount", mutate: func(values map[string]string) { values["total_fee"] = "124" }, want: "金额不匹配"}, + {name: "mismatched currency", mutate: func(values map[string]string) { values["fee_type"] = "USD" }, want: "币种不匹配"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + response := map[string]string{ + "return_code": "SUCCESS", "result_code": "SUCCESS", "out_trade_no": "T-WECHAT-MICRO", + "transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "fee_type": "CNY", + } + tc.mutate(response) + response["sign"] = wechatV2Sign(response, key, "MD5") + _, _ = w.Write(paymentkit.XMLEncode(response)) + })) + defer server.Close() + + _, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentWechatV2, TradeNo: "T-WECHAT-MICRO", Subject: "subject", Amount: 123, + Currency: "CNY", Extra: map[string]any{"method": "micropay", "auth_code": "AUTH-CODE-1"}, + }, map[string]any{ + "app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key, + "create_url": server.URL + "/pay/micropay", + }) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid response error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestWechatV2RejectsUnsignedProviderResponse(t *testing.T) { + if err := verifyWechatV2Response(map[string]string{ + "return_code": "SUCCESS", + "result_code": "SUCCESS", + }, "wechat-secret", "MD5"); err == nil || !strings.Contains(err.Error(), "缺少签名") { + t.Fatalf("unsigned response error = %v", err) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) { @@ -295,6 +474,85 @@ func TestWechatV3PrepayResultUsesGoPayClientSigning(t *testing.T) { } } +func TestWechatV3CodePayResultKeepsPlatformIdentityAndBreakdown(t *testing.T) { + result, err := wechatV3CodePayResult("T-WECHAT-CODE", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{ + OutTradeNo: "T-WECHAT-CODE", + TransactionId: "WX-CODE-1", + TradeState: gopayWechatV3.TradeStateSuccess, + Amount: &gopayWechatV3.Amount{Total: 100, PayerTotal: 90, Currency: "CNY"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "success" || result.TradeNo != "T-WECHAT-CODE" || result.ProviderTradeNo != "WX-CODE-1" || result.QueryID != "" { + t.Fatalf("result = %+v", result) + } + if result.Amount != 100 || result.PayerPaidAmount != 90 || result.DiscountAmount != 10 || !result.AmountBreakdownKnown { + t.Fatalf("amount breakdown = %+v", result) + } +} + +func TestWechatV3CodePayResultRejectsMismatchedOrder(t *testing.T) { + _, err := wechatV3CodePayResult("T-WECHAT-CODE", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{OutTradeNo: "OTHER", TransactionId: "WX-CODE-1", TradeState: gopayWechatV3.TradeStateSuccess, Amount: &gopayWechatV3.Amount{Total: 1, Currency: "CNY"}}, + }) + if err == nil || !strings.Contains(err.Error(), "商户订单号") { + t.Fatalf("mismatched order error = %v", err) + } +} + +func TestDouyinPrepayResultUsesGoPayClientSigning(t *testing.T) { + _, privatePEM, _ := testRSAKeyPair(t) + client, err := gopayDouyin.NewClient("mch-test", "merchant-serial", "01234567890123456789012345678901", privatePEM) + if err != nil { + t.Fatal(err) + } + for _, app := range []bool{false, true} { + result, err := douyinPrepayResult(client, "douyin-app", "T-DOUYIN", "PREPAY-1", app) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err = json.Unmarshal(result.Payload, &payload); err != nil { + t.Fatal(err) + } + if result.Provider != biz.PaymentDouyin || result.TradeNo != "T-DOUYIN" || strings.TrimSpace(fmt.Sprint(payload["sign"])) == "" && strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" { + t.Fatalf("app=%v result=%+v payload=%#v", app, result, payload) + } + if app { + if payload["appid"] != "douyin-app" || payload["prepayid"] != "PREPAY-1" { + t.Fatalf("app payload = %#v", payload) + } + } else if payload["appId"] != "douyin-app" || payload["package"] != "prepay_id=PREPAY-1" { + t.Fatalf("JSAPI payload = %#v", payload) + } + } +} + +func TestDouyinCreateMethodRejectsUnknownMode(t *testing.T) { + if got, err := douyinCreateMethod(map[string]any{"method": "unsupported"}, nil); err == nil || got != "" { + t.Fatalf("unknown mode = %q, err = %v", got, err) + } + for _, tc := range []struct { + value string + want string + }{ + {value: "app_pay", want: "app"}, + {value: "h5", want: "h5"}, + {value: "qrcode", want: "native"}, + {value: "jsapi", want: "jsapi"}, + } { + got, err := douyinCreateMethod(map[string]any{"method": tc.value}, nil) + if err != nil || got != tc.want { + t.Fatalf("mode %q = %q, err = %v", tc.value, got, err) + } + } +} + func TestWechatV3GoPayCallbackVerificationAndDecryption(t *testing.T) { platformKey, merchantPrivatePEM, platformPublicPEM := testRSAKeyPair(t) const ( diff --git a/internal/data/payment/identity_test.go b/internal/data/payment/identity_test.go new file mode 100644 index 0000000..31b6cb7 --- /dev/null +++ b/internal/data/payment/identity_test.go @@ -0,0 +1,386 @@ +package payment + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "kra/internal/biz" + + "github.com/go-pay/gopay" + "github.com/go-pay/gopay/allinpay" + "github.com/go-pay/gopay/douyin" + "github.com/go-pay/gopay/lakala" +) + +func paymentIdentityRSAKeyPair(t *testing.T) (*rsa.PrivateKey, string, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}) + return key, string(privatePEM), string(publicPEM) +} + +// GoPay clients use fixed provider hosts. Redirect their HTTPS transport to a +// local server so adapter tests still exercise the SDK request and response path. +func redirectPaymentHTTPS(t *testing.T, handler http.Handler) { + t.Helper() + server := httptest.NewTLSServer(handler) + oldDefault := http.DefaultTransport + base, ok := oldDefault.(*http.Transport) + if !ok { + server.Close() + t.Fatal("http.DefaultTransport is not *http.Transport") + } + transport := base.Clone() + transport.Proxy = nil + transport.DisableKeepAlives = true + transport.ForceAttemptHTTP2 = false + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // local test server + target := server.Listener.Addr().String() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, target) + } + http.DefaultTransport = transport + t.Cleanup(func() { + http.DefaultTransport = oldDefault + transport.CloseIdleConnections() + server.Close() + }) +} + +func TestLakalaKeepsQueryAndFinalTradeIdentitiesSeparate(t *testing.T) { + created, err := lakalaCreateResult("MERCHANT-LAKALA-1", &lakala.PaymentRsp{ + ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: "SUCCESS"}, + OrderId: " LAKALA-ORDER-1 ", + PartnerOrderId: "MERCHANT-LAKALA-1", + CodeUrl: "https://pay.example/qr", + SdkParams: `{"prepay":"client-only"}`, + }) + if err != nil { + t.Fatal(err) + } + if created.QueryID != "LAKALA-ORDER-1" || created.ProviderTradeNo != "" { + t.Fatalf("create identities = query %q, provider trade %q", created.QueryID, created.ProviderTradeNo) + } + + redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-1" { + t.Errorf("query path = %q", r.URL.Path) + } + _, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"PAY_SUCCESS","order_id":"LAKALA-ORDER-1","partner_order_id":"MERCHANT-LAKALA-1","channel_order_id":"CHANNEL-LAKALA-1","total_fee":100,"real_fee":100,"currency":"JPY"}`) + case http.MethodPut: + if r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-1/refunds/REFUND-LAKALA-1" { + t.Errorf("refund path = %q", r.URL.Path) + } + _, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"SUCCESS","refund_id":"LAKALA-REFUND-1","partner_refund_id":"REFUND-LAKALA-1","amount":40,"currency":"JPY"}`) + default: + t.Errorf("unexpected method %s", r.Method) + http.Error(w, "unexpected method", http.StatusMethodNotAllowed) + } + })) + + config := map[string]any{ + "partner_code": "PART", "credential_code": "credential", + "order_id": "STATIC-CONFIG-MUST-NOT-BE-USED", "currency": "JPY", + } + result, err := (&lakalaAdapter{}).Query(context.Background(), created.QueryID, config) + if err != nil { + t.Fatal(err) + } + if result.TradeNo != "MERCHANT-LAKALA-1" || result.QueryID != "LAKALA-ORDER-1" || result.ProviderTradeNo != "CHANNEL-LAKALA-1" { + t.Fatalf("query identities = %+v", result) + } + if result.Status != "success" || result.Amount != 100 || result.Currency != "JPY" { + t.Fatalf("query result = %+v", result) + } + + refund, err := (&lakalaAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{ + TradeNo: "MERCHANT-LAKALA-1", QueryID: created.QueryID, + RefundNo: "REFUND-LAKALA-1", Amount: 40, Currency: "JPY", + }, config) + if err != nil { + t.Fatal(err) + } + if refund.TradeNo != "MERCHANT-LAKALA-1" || refund.ProviderTradeNo != "LAKALA-REFUND-1" { + t.Fatalf("refund result = %+v", refund) + } +} + +func TestLakalaCreateRejectsMissingOrderID(t *testing.T) { + _, err := lakalaCreateResult("MERCHANT-LAKALA-MISSING", &lakala.PaymentRsp{ + ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: "SUCCESS"}, + PartnerOrderId: "MERCHANT-LAKALA-MISSING", + CodeUrl: "https://pay.example/qr", + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "order_id") { + t.Fatalf("missing order_id error = %v", err) + } +} + +func TestLakalaQueryRejectsMissingMerchantOrderID(t *testing.T) { + redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1.0/gateway/partners/PART/orders/LAKALA-ORDER-MISSING-MERCHANT" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = fmt.Fprint(w, `{"return_code":"SUCCESS","result_code":"PAY_SUCCESS","order_id":"LAKALA-ORDER-MISSING-MERCHANT","channel_order_id":"CHANNEL-LAKALA-1","total_fee":100,"currency":"JPY"}`) + })) + + _, err := (&lakalaAdapter{}).Query(context.Background(), "LAKALA-ORDER-MISSING-MERCHANT", map[string]any{ + "partner_code": "PART", "credential_code": "credential", + }) + if err == nil || !strings.Contains(err.Error(), "商户订单号") { + t.Fatalf("missing merchant order id error = %v", err) + } +} + +func TestLakalaCreateRoutesSupportedMethods(t *testing.T) { + type testCase struct { + name string + method string + configMethod string + tradeNo string + path string + appID string + } + cases := []testCase{ + {name: "default jsapi", tradeNo: "L-JSAPI-DEFAULT", path: "/api/v1.0/jsapi_gateway/partners/PART/orders/L-JSAPI-DEFAULT"}, + {name: "jsapi", method: "jsapi", tradeNo: "L-JSAPI", path: "/api/v1.0/jsapi_gateway/partners/PART/orders/L-JSAPI"}, + {name: "h5", method: "h5", tradeNo: "L-H5", path: "/api/v1.0/h5_payment/partners/PART/orders/L-H5"}, + {name: "mini", method: "mini", tradeNo: "L-MINI", path: "/api/v1.0/gateway/partners/PART/microapp_orders/L-MINI"}, + {name: "native", method: "native", tradeNo: "L-NATIVE", path: "/api/v1.0/gateway/partners/PART/native_orders/L-NATIVE"}, + {name: "qrcode", method: "qrcode", tradeNo: "L-QRCODE", path: "/api/v1.0/gateway/partners/PART/orders/L-QRCODE"}, + {name: "native jsapi", method: "native_jsapi", tradeNo: "L-NATIVE-JSAPI", path: "/api/v1.0/gateway/partners/PART/native_jsapi/L-NATIVE-JSAPI", appID: "wx-test-app"}, + {name: "sdk", method: "sdk", tradeNo: "L-SDK", path: "/api/v1.0/gateway/partners/PART/app_orders/L-SDK", appID: "wx-test-app"}, + {name: "web", method: "web", tradeNo: "L-WEB", path: "/api/v1.0/web_gateway/partners/PART/orders/L-WEB"}, + {name: "config method", configMethod: "h5", tradeNo: "L-CONFIG-H5", path: "/api/v1.0/h5_payment/partners/PART/orders/L-CONFIG-H5"}, + } + expected := make(map[string]string, len(cases)) + for _, tc := range cases { + expected[tc.path] = tc.tradeNo + } + redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("create method = %s, want PUT", r.Method) + } + tradeNo, ok := expected[r.URL.Path] + if !ok { + t.Errorf("unexpected Lakala create path = %q", r.URL.Path) + http.Error(w, "unexpected path", http.StatusNotFound) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode create body: %v", err) + } + if body["description"] != "subject" || fmt.Sprint(body["price"]) != "100" || body["channel"] != "Wechat" { + t.Errorf("create body = %#v", body) + } + _, _ = fmt.Fprintf(w, `{"return_code":"SUCCESS","result_code":"SUCCESS","order_id":"ORDER-%s","partner_order_id":"%s","code_url":"https://pay.example/%s","pay_url":"https://pay.example/pay/%s","sdk_params":"{\"prepay\":\"%s\"}"}`, tradeNo, tradeNo, tradeNo, tradeNo, tradeNo) + })) + + adapter := &lakalaAdapter{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + extra := map[string]any{} + if tc.method != "" { + extra["method"] = tc.method + } + if tc.appID != "" { + extra["appid"] = tc.appID + } + config := map[string]any{ + "partner_code": "PART", "credential_code": "credential", "channel": "Wechat", "currency": "JPY", + } + if tc.configMethod != "" { + config["method"] = tc.configMethod + } + result, err := adapter.Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentLakala, TradeNo: tc.tradeNo, Subject: "subject", Amount: 100, + Currency: "JPY", Extra: extra, + }, config) + if err != nil { + t.Fatal(err) + } + if result.QueryID != "ORDER-"+tc.tradeNo || result.ProviderTradeNo != "" { + t.Fatalf("identities = %+v", result) + } + var payload lakala.PaymentRsp + if err := json.Unmarshal(result.Payload, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload.OrderId != "ORDER-"+tc.tradeNo || payload.PartnerOrderId != tc.tradeNo { + t.Fatalf("payload = %+v", payload) + } + }) + } +} + +func TestLakalaCreateRejectsUnknownMethod(t *testing.T) { + _, err := (&lakalaAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentLakala, TradeNo: "L-UNKNOWN", Subject: "subject", Amount: 100, + Currency: "JPY", Extra: map[string]any{"method": "unsupported"}, + }, map[string]any{"partner_code": "PART", "credential_code": "credential", "channel": "Wechat"}) + if err == nil || !strings.Contains(err.Error(), "不支持的下单方式") { + t.Fatalf("unknown method error = %v", err) + } +} + +func TestAllinPayPersistsQueryIDOnlyForTransactionLookupMode(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]any + want string + }{ + {name: "default reqsn", config: map[string]any{}}, + {name: "explicit reqsn", config: map[string]any{"query_order_type": allinpay.OrderTypeReqSN}}, + {name: "trxid", config: map[string]any{"query_order_type": strings.ToUpper(allinpay.OrderTypeTrxId)}, want: "TRX-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + orderType, err := allinpayOrderType(tc.config) + if err != nil { + t.Fatal(err) + } + got, err := allinpayCreateQueryID(orderType, " TRX-1 ") + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("query id = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAllinPayQueryIdentityMatchesConfiguredLookupMode(t *testing.T) { + key, _, _ := paymentIdentityRSAKeyPair(t) + privateKey := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(key)) + publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + publicKey := base64.StdEncoding.EncodeToString(publicDER) + for _, tc := range []struct { + name string + orderType string + queryID string + merchantNo string + wantResultKey string + }{ + {name: "merchant order number", queryID: "MERCHANT-ALLIN-1", merchantNo: "MERCHANT-ALLIN-1"}, + {name: "provider transaction number", orderType: allinpay.OrderTypeTrxId, queryID: "TRX-ALLIN-1", merchantNo: "MERCHANT-ALLIN-1", wantResultKey: "TRX-ALLIN-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + redirectPaymentHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/apiweb/tranx/query" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + if tc.orderType == allinpay.OrderTypeTrxId { + if r.Form.Get("trxid") != tc.queryID || r.Form.Get("reqsn") != "" { + t.Errorf("transaction query form = %v", r.Form) + } + } else if r.Form.Get("reqsn") != tc.queryID || r.Form.Get("trxid") != "" { + t.Errorf("merchant query form = %v", r.Form) + } + response := signedAllinPayResponse(t, key, gopay.BodyMap{ + "retcode": "SUCCESS", "retmsg": "ok", "reqsn": tc.merchantNo, + "trxid": "TRX-ALLIN-1", "trxstatus": "SUCCESS", "trxamt": "100", + }) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + + config := map[string]any{ + "cus_id": "customer", "app_id": "app", "private_key": privateKey, + "public_key": publicKey, "environment": "sandbox", "currency": "CNY", + } + if tc.orderType != "" { + config["query_order_type"] = tc.orderType + } + result, err := (&allinpayAdapter{}).Query(context.Background(), tc.queryID, config) + if err != nil { + t.Fatal(err) + } + if result.TradeNo != tc.merchantNo || result.ProviderTradeNo != "TRX-ALLIN-1" || result.QueryID != tc.wantResultKey { + t.Fatalf("query identities = %+v", result) + } + if result.Status != "success" || result.Amount != 100 || result.Currency != "CNY" { + t.Fatalf("query result = %+v", result) + } + }) + } +} + +func TestDouyinPrepayPayloadSignaturesAreVerifiable(t *testing.T) { + key, privatePEM, _ := paymentIdentityRSAKeyPair(t) + client, err := douyin.NewClient("merchant-douyin", "merchant-serial", "01234567890123456789012345678901", privatePEM) + if err != nil { + t.Fatal(err) + } + + for _, app := range []bool{false, true} { + result, err := douyinPrepayResult(client, "douyin-app", "MERCHANT-DOUYIN-1", "PREPAY-DOUYIN-1", app) + if err != nil { + t.Fatal(err) + } + if result.QueryID != "" || result.ProviderTradeNo != "" { + t.Fatalf("prepay id leaked into durable identity: %+v", result) + } + + var content, signature string + if app { + var params douyin.AppPayParams + if err = json.Unmarshal(result.Payload, ¶ms); err != nil { + t.Fatal(err) + } + if params.AppId != "douyin-app" || params.PartnerId != "merchant-douyin" || params.PrepayId != "PREPAY-DOUYIN-1" { + t.Fatalf("app params = %+v", params) + } + content = params.AppId + "\n" + params.Timestamp + "\n" + params.NonceStr + "\n" + params.PrepayId + "\n" + signature = params.Sign + } else { + var params douyin.JSAPIPayParams + if err = json.Unmarshal(result.Payload, ¶ms); err != nil { + t.Fatal(err) + } + if params.AppId != "douyin-app" || params.Package != "prepay_id=PREPAY-DOUYIN-1" || params.SignType != douyin.SignTypeRSA { + t.Fatalf("JSAPI params = %+v", params) + } + content = params.AppId + "\n" + params.TimeStamp + "\n" + params.NonceStr + "\n" + params.Package + "\n" + signature = params.PaySign + } + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(content)) + if err = rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signatureBytes); err != nil { + t.Fatalf("app=%v signature verification: %v", app, err) + } + } +} diff --git a/internal/data/payment/lakala.go b/internal/data/payment/lakala.go index 442dc41..6eae7a3 100644 --- a/internal/data/payment/lakala.go +++ b/internal/data/payment/lakala.go @@ -19,6 +19,9 @@ func (a *lakalaAdapter) client(c map[string]any) (*lakala.Client, error) { } func (a *lakalaAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("拉卡拉支付下单请求为空") + } client, err := a.client(c) if err != nil { return nil, err @@ -28,26 +31,81 @@ func (a *lakalaAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m bm.Set("channel", firstAnyOr(c, "Wechat", "channel", "pay_channel")) } mergeGoPayExtras(bm, req.Extra, "description", "price", "channel", "pay_channel", "method", "pay_method", "trade_type") - method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type")) - if method == "h5" { - rsp, callErr := client.CreateH5PayOrder(ctx, req.TradeNo, bm) - if callErr != nil { - return nil, callErr + method := lakalaCreateMethod(req.Extra, c) + var rsp *lakala.PaymentRsp + switch method { + case "", "jsapi", "js_api", "jsapi_order": + rsp, err = client.CreateJSAPIOrder(ctx, req.TradeNo, bm) + case "h5", "h5_pay", "mobile_h5", "mobile_h5_pay": + rsp, err = client.CreateH5PayOrder(ctx, req.TradeNo, bm) + case "mini", "miniapp", "mini_program", "miniprogram", "microapp": + rsp, err = client.CreateMiniProgramOrder(ctx, req.TradeNo, bm) + case "native", "native_qrcode", "native_qr": + rsp, err = client.CreateNativeQRCodeOrder(ctx, req.TradeNo, bm) + case "qrcode", "qr", "qr_code": + rsp, err = client.CreateQRCodeOrder(ctx, req.TradeNo, bm) + case "native_jsapi", "native_js_api", "offline_jsapi", "offline_js_api": + rsp, err = client.CreateNativeJSApiOrder(ctx, req.TradeNo, bm) + case "sdk", "sdk_payment", "app", "app_pay": + rsp, err = client.CreateSDKPaymentOrder(ctx, req.TradeNo, bm) + case "web", "web_gateway", "gateway": + rsp, err = client.CreateWebGatewayOrder(ctx, req.TradeNo, bm) + case "retail", "retail_pay", "micropay", "barcode": + retailRsp, retailErr := client.CreateRetailOrder(ctx, req.TradeNo, bm) + if retailErr != nil { + return nil, retailErr } - return lakalaCreateResult(req.TradeNo, rsp) + return lakalaRetailCreateResult(req.TradeNo, retailRsp) + case "retail_qrcode", "retail_qr", "retail_qr_code": + rsp, err = client.CreateRetailQRCodeOrder(ctx, req.TradeNo, bm) + default: + return nil, fmt.Errorf("拉卡拉支付不支持的下单方式: %s", method) } - rsp, err := client.CreateJSAPIOrder(ctx, req.TradeNo, bm) if err != nil { return nil, err } return lakalaCreateResult(req.TradeNo, rsp) } +func lakalaCreateMethod(extra, config map[string]any) string { + method := firstAny(extra, "method", "pay_method", "trade_type") + if method == "" { + method = firstAny(config, "method", "pay_method", "trade_type") + } + return strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(method))) +} + func lakalaCreateResult(tradeNo string, rsp *lakala.PaymentRsp) (*biz.PaymentResult, error) { - if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") { + if rsp == nil || !lakalaCreateResponseOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("拉卡拉支付下单失败: %v", rsp) } - return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "created", TradeNo: tradeNo, ProviderTradeNo: rsp.OrderId, QueryID: first(rspMap(rsp), "code_url", "pay_url", "sdk_params"), Payload: mustJSON(rsp)}, nil + orderID := strings.TrimSpace(rsp.OrderId) + if orderID == "" { + return nil, errors.New("拉卡拉支付下单响应缺少 order_id") + } + if partnerOrderID := strings.TrimSpace(rsp.PartnerOrderId); partnerOrderID != "" && partnerOrderID != tradeNo { + return nil, errors.New("拉卡拉支付下单响应的商户订单号不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "created", TradeNo: tradeNo, QueryID: orderID, Payload: mustJSON(rsp)}, nil +} + +func lakalaRetailCreateResult(tradeNo string, rsp *lakala.RetailPayRsp) (*biz.PaymentResult, error) { + if rsp == nil || !lakalaRetailCreateResponseOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("拉卡拉线下支付下单失败: %v", rsp) + } + orderID := strings.TrimSpace(rsp.OrderId) + if orderID == "" { + return nil, errors.New("拉卡拉线下支付下单响应缺少 order_id") + } + if partnerOrderID := strings.TrimSpace(rsp.PartnerOrderId); partnerOrderID != "" && partnerOrderID != tradeNo { + return nil, errors.New("拉卡拉线下支付下单响应的商户订单号不匹配") + } + result := &biz.PaymentResult{ + Provider: biz.PaymentLakala, Status: "created", TradeNo: tradeNo, + QueryID: orderID, Amount: int64(rsp.TotalFee), Currency: strings.ToUpper(rsp.Currency), + Payload: mustJSON(rsp), + } + return result, nil } func (a *lakalaAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { @@ -55,26 +113,26 @@ func (a *lakalaAdapter) Query(ctx context.Context, tradeNo string, c map[string] if err != nil { return nil, err } - orderID := firstAny(c, "order_id", "provider_order_id") + orderID := strings.TrimSpace(tradeNo) if orderID == "" { - orderID = tradeNo + return nil, errors.New("拉卡拉支付查单缺少 order_id") } rsp, err := client.OrderStatus(ctx, orderID) if err != nil { return nil, err } - if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") { + if rsp == nil || !lakalaQueryResponseOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("拉卡拉支付查单失败: %v", rsp) } - status := normalizeLakalaState(first(rspMap(rsp), "status", "channel_error_code")) - if rsp.ChannelOrderId != "" && status == "pending" { - status = "success" + if responseOrderID := strings.TrimSpace(rsp.OrderId); responseOrderID != "" && responseOrderID != orderID { + return nil, errors.New("拉卡拉支付查单响应的 order_id 不匹配") } - returnedTradeNo := rsp.PartnerOrderId + status := normalizeLakalaState(first(rspMap(rsp), "result_code", "status", "channel_error_code")) + returnedTradeNo := strings.TrimSpace(rsp.PartnerOrderId) if returnedTradeNo == "" { - returnedTradeNo = tradeNo + return nil, errors.New("拉卡拉支付查单响应缺少商户订单号") } - result := &biz.PaymentResult{Provider: biz.PaymentLakala, Status: status, TradeNo: returnedTradeNo, ProviderTradeNo: rsp.ChannelOrderId, Amount: int64(rsp.TotalFee), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)} + result := &biz.PaymentResult{Provider: biz.PaymentLakala, Status: status, TradeNo: returnedTradeNo, ProviderTradeNo: strings.TrimSpace(rsp.ChannelOrderId), QueryID: orderID, Amount: int64(rsp.TotalFee), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)} if result.Currency == "" { result.Currency = strings.ToUpper(firstAny(c, "currency", "default_currency")) } @@ -96,27 +154,58 @@ func (a *lakalaAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques if req == nil { return nil, errors.New("拉卡拉支付退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" || req.Amount <= 0 { + return nil, errors.New("拉卡拉支付退款缺少商户订单号、退款单号或有效金额") + } client, err := a.client(c) if err != nil { return nil, err } - orderID := firstAny(c, "order_id", "provider_order_id") + orderID := strings.TrimSpace(req.QueryID) if orderID == "" { - orderID = req.TradeNo + orderID = tradeNo } bm := gopay.BodyMap{"fee": req.Amount} if req.Currency != "" { bm.Set("currency", strings.ToUpper(req.Currency)) } mergeGoPayConfigExtras(bm, c, "refund_extra", "fee", "currency") - rsp, err := client.ApplyRefund(ctx, orderID, req.RefundNo, bm) + rsp, err := client.ApplyRefund(ctx, orderID, refundNo, bm) if err != nil { return nil, err } - if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") { + effective := *req + effective.TradeNo = tradeNo + effective.RefundNo = refundNo + return lakalaRefundResult(&effective, rsp) +} + +func lakalaRefundResult(req *biz.PaymentRefundRequest, rsp *lakala.RefundRsp) (*biz.PaymentResult, error) { + if rsp == nil || !lakalaRefundResponseOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("拉卡拉支付退款失败: %v", rsp) } - return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.RefundId, Amount: int64(rsp.Amount), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)}, nil + if partnerRefundID := strings.TrimSpace(rsp.PartnerRefundId); partnerRefundID == "" { + return nil, errors.New("拉卡拉支付退款响应缺少 partner_refund_id") + } else if partnerRefundID != req.RefundNo { + return nil, errors.New("拉卡拉支付退款响应的 partner_refund_id 不匹配") + } + providerRefundID := strings.TrimSpace(rsp.RefundId) + if providerRefundID == "" { + return nil, errors.New("拉卡拉支付退款响应缺少 refund_id") + } + if int64(rsp.Amount) != req.Amount { + return nil, errors.New("拉卡拉支付退款响应金额不匹配") + } + currency := strings.ToUpper(strings.TrimSpace(rsp.Currency)) + requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency)) + if currency == "" { + currency = requestCurrency + } else if requestCurrency != "" && currency != requestCurrency { + return nil, errors.New("拉卡拉支付退款响应币种不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: normalizeLakalaRefundState(rsp.ResultCode), TradeNo: req.TradeNo, ProviderTradeNo: providerRefundID, Amount: int64(rsp.Amount), Currency: currency, Payload: mustJSON(rsp)}, nil } func (a *lakalaAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { @@ -134,5 +223,5 @@ func (a *lakalaAdapter) Callback(_ context.Context, callback *biz.PaymentCallbac if err := lakala.VerifySign(notify, text(c, "partner_code"), text(c, "credential_code")); err != nil { return nil, err } - return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "success", TradeNo: notify.PartnerOrderId, ProviderTradeNo: notify.ChannelOrderId, Amount: int64(notify.TotalFee), Currency: strings.ToUpper(notify.Currency), Payload: mustJSON(notify)}, nil + return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "success", TradeNo: notify.PartnerOrderId, ProviderTradeNo: strings.TrimSpace(notify.ChannelOrderId), QueryID: strings.TrimSpace(notify.OrderId), Amount: int64(notify.TotalFee), Currency: strings.ToUpper(notify.Currency), Payload: mustJSON(notify)}, nil } diff --git a/internal/data/payment/lakala_test.go b/internal/data/payment/lakala_test.go new file mode 100644 index 0000000..2972435 --- /dev/null +++ b/internal/data/payment/lakala_test.go @@ -0,0 +1,130 @@ +package payment + +import ( + "testing" + + "kra/internal/biz" + + "github.com/go-pay/gopay/lakala" +) + +func TestLakalaEndpointResponseCodes(t *testing.T) { + for _, tc := range []struct { + name string + check func(string, string) bool + returnCode string + resultCode string + want bool + }{ + {name: "create success", check: lakalaCreateResponseOK, returnCode: "SUCCESS", resultCode: "SUCCESS", want: true}, + {name: "create idempotent", check: lakalaCreateResponseOK, returnCode: "SUCCESS", resultCode: "EXISTS", want: true}, + {name: "create business failure", check: lakalaCreateResponseOK, returnCode: "SUCCESS", resultCode: "FAILED"}, + {name: "create transport failure", check: lakalaCreateResponseOK, returnCode: "FAILED", resultCode: "SUCCESS"}, + {name: "create missing result", check: lakalaCreateResponseOK, returnCode: "SUCCESS"}, + {name: "retail paying", check: lakalaRetailCreateResponseOK, returnCode: "SUCCESS", resultCode: "PAYING", want: true}, + {name: "retail paid", check: lakalaRetailCreateResponseOK, returnCode: "SUCCESS", resultCode: "PAY_SUCCESS", want: true}, + {name: "retail business failure", check: lakalaRetailCreateResponseOK, returnCode: "SUCCESS", resultCode: "PAY_FAIL"}, + {name: "query state", check: lakalaQueryResponseOK, returnCode: "SUCCESS", resultCode: "PAY_SUCCESS", want: true}, + {name: "query endpoint rejects generic success", check: lakalaQueryResponseOK, returnCode: "SUCCESS", resultCode: "SUCCESS"}, + {name: "query transport failure", check: lakalaQueryResponseOK, returnCode: "FAILED", resultCode: "PAY_SUCCESS"}, + {name: "refund state", check: lakalaRefundResponseOK, returnCode: "SUCCESS", resultCode: "WAITING", want: true}, + {name: "refund endpoint rejects payment state", check: lakalaRefundResponseOK, returnCode: "SUCCESS", resultCode: "PAY_SUCCESS"}, + {name: "refund transport failure", check: lakalaRefundResponseOK, returnCode: "FAILED", resultCode: "SUCCESS"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.check(tc.returnCode, tc.resultCode); got != tc.want { + t.Fatalf("response check(%q, %q) = %v, want %v", tc.returnCode, tc.resultCode, got, tc.want) + } + }) + } +} + +func TestLakalaQueryStateMapping(t *testing.T) { + for _, tc := range []struct { + code string + want string + }{ + {code: "PAYING", want: "pending"}, + {code: "PAY_SUCCESS", want: "success"}, + {code: "CREATE_FAIL", want: "failed"}, + {code: "CLOSED", want: "failed"}, + {code: "PAY_FAIL", want: "failed"}, + {code: "PARTIAL_REFUND", want: "failed"}, + {code: "FULL_REFUND", want: "failed"}, + } { + if got := normalizeLakalaState(tc.code); got != tc.want { + t.Errorf("normalizeLakalaState(%q) = %q, want %q", tc.code, got, tc.want) + } + } +} + +func TestLakalaRefundResultBindsRefundIdentity(t *testing.T) { + req := &biz.PaymentRefundRequest{TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 50, Currency: "JPY"} + base := func() *lakala.RefundRsp { + return &lakala.RefundRsp{ + ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: "SUCCESS"}, + RefundId: "LK-REFUND-1", + PartnerRefundId: "REFUND-1", + Amount: 50, + Currency: "JPY", + } + } + + result, err := lakalaRefundResult(req, base()) + if err != nil { + t.Fatalf("lakalaRefundResult() error = %v", err) + } + if result.ProviderTradeNo != "LK-REFUND-1" || result.TradeNo != "LOCAL-1" || result.Amount != 50 || result.Currency != "JPY" { + t.Fatalf("lakalaRefundResult() = %+v", result) + } + + for _, tc := range []struct { + name string + mutate func(*lakala.RefundRsp) + }{ + {name: "missing merchant refund id", mutate: func(rsp *lakala.RefundRsp) { rsp.PartnerRefundId = "" }}, + {name: "mismatched merchant refund id", mutate: func(rsp *lakala.RefundRsp) { rsp.PartnerRefundId = "OTHER" }}, + {name: "missing provider refund id", mutate: func(rsp *lakala.RefundRsp) { rsp.RefundId = "" }}, + {name: "mismatched amount", mutate: func(rsp *lakala.RefundRsp) { rsp.Amount++ }}, + {name: "mismatched currency", mutate: func(rsp *lakala.RefundRsp) { rsp.Currency = "USD" }}, + } { + t.Run(tc.name, func(t *testing.T) { + rsp := base() + tc.mutate(rsp) + if _, err := lakalaRefundResult(req, rsp); err == nil { + t.Fatal("lakalaRefundResult() accepted an unbound refund response") + } + }) + } +} + +func TestLakalaRefundResultMapsProviderState(t *testing.T) { + req := &biz.PaymentRefundRequest{TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 50, Currency: "JPY"} + for _, tc := range []struct { + code string + want string + }{ + {code: "WAITING", want: "pending"}, + {code: "SUCCESS", want: "success"}, + {code: "FINISHED", want: "success"}, + {code: "CREATE_FAILED", want: "failed"}, + {code: "FAILED", want: "failed"}, + {code: "CHANGE", want: "failed"}, + } { + t.Run(tc.code, func(t *testing.T) { + result, err := lakalaRefundResult(req, &lakala.RefundRsp{ + ErrorCode: lakala.ErrorCode{ReturnCode: "SUCCESS", ResultCode: tc.code}, + RefundId: "LK-REFUND-1", + PartnerRefundId: "REFUND-1", + Amount: 50, + Currency: "JPY", + }) + if err != nil { + t.Fatalf("lakalaRefundResult() error = %v", err) + } + if result.Status != tc.want { + t.Fatalf("lakalaRefundResult() status = %q, want %q", result.Status, tc.want) + } + }) + } +} diff --git a/internal/data/payment/native_test.go b/internal/data/payment/native_test.go index 272573c..2c488da 100644 --- a/internal/data/payment/native_test.go +++ b/internal/data/payment/native_test.go @@ -41,6 +41,29 @@ func TestApplePaymentResultSeparatesMerchantAndProviderTradeNumbers(t *testing.T } } +func TestApplePaymentResultTreatsZeroRevocationDateAsNotRevoked(t *testing.T) { + result, err := applePaymentResult("1000000000001", map[string]any{ + "transactionId": "1000000000001", + "appAccountToken": "123e4567-e89b-12d3-a456-426614174000", + "currency": "CNY", + "price": "1000", + "revocationDate": float64(0), + }, nil, map[string]any{"price_divisor": "10"}) + if err != nil || result.Status != "success" || result.Amount != 100 { + t.Fatalf("zero revocationDate result = %+v, err = %v", result, err) + } + result, err = applePaymentResult("1000000000001", map[string]any{ + "transactionId": "1000000000001", + "appAccountToken": "123e4567-e89b-12d3-a456-426614174000", + "currency": "CNY", + "price": "1000", + "revocationDate": float64(1700000000000), + }, nil, map[string]any{"price_divisor": "10"}) + if err != nil || result.Status != "failed" { + t.Fatalf("positive revocationDate result = %+v, err = %v", result, err) + } +} + func TestWechatV3Decrypt(t *testing.T) { key := "01234567890123456789012345678901" nonce := "0123456789ab" diff --git a/internal/data/payment/paypal.go b/internal/data/payment/paypal.go index abe489c..58c38d5 100644 --- a/internal/data/payment/paypal.go +++ b/internal/data/payment/paypal.go @@ -72,12 +72,7 @@ func (a *paypalAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m if strings.TrimSpace(order.Id) == "" { return nil, errors.New("PayPal 下单成功但缺少 order ID") } - result, err := paypalOrderResult(order, order.Id, req.TradeNo, c) - if err != nil { - return nil, err - } - result.TradeNo = req.TradeNo - return result, nil + return paypalCreateResult(req, order, c) } func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string]any) (*biz.PaymentResult, error) { @@ -99,13 +94,79 @@ func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string] if rsp.Response.Id != "" && rsp.Response.Id != orderID { return nil, errors.New("PayPal 查单响应的 order ID 不匹配") } - return paypalOrderResult(rsp.Response, orderID, "", c) + order := rsp.Response + if _, err = paypalOrderIdentity(order); err != nil { + return nil, err + } + if paypalAutoCaptureEnabled(c) && strings.EqualFold(strings.TrimSpace(order.Status), "APPROVED") { + order, err = paypalCaptureOrder(ctx, client, orderID, c) + if err != nil { + return nil, err + } + } + return paypalOrderResult(order, orderID, "", c) +} + +// paypalCaptureOrder completes an order after the buyer approves it. PayPal's +// CAPTURE intent deliberately separates approval from settlement; treating an +// APPROVED order as paid would allow fulfillment before funds are captured. +func paypalCaptureOrder(ctx context.Context, client *paypal.Client, orderID string, c map[string]any) (*paypal.OrderDetail, error) { + if client == nil || strings.TrimSpace(orderID) == "" { + return nil, errors.New("PayPal 捕获订单参数为空") + } + bm := gopay.BodyMap{} + mergeGoPayConfigExtras(bm, c, "capture_extra") + client.SetRequestHeader("PayPal-Request-Id", strings.TrimSpace(orderID)+"-capture") + rsp, err := client.OrderCapture(ctx, orderID, bm) + if err != nil { + return nil, err + } + if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { + return nil, paypalResponseError("捕获订单", paypalResponseCode(rsp), paypalCaptureError(rsp)) + } + if returnedID := strings.TrimSpace(rsp.Response.Id); returnedID != "" && returnedID != strings.TrimSpace(orderID) { + return nil, errors.New("PayPal 捕获响应的 order ID 不匹配") + } + return rsp.Response, nil +} + +func paypalAutoCaptureEnabled(c map[string]any) bool { + value, ok := c["auto_capture"] + if !ok || value == nil { + return true + } + switch parsed := value.(type) { + case bool: + return parsed + case string: + if result, err := strconv.ParseBool(strings.TrimSpace(parsed)); err == nil { + return result + } + case json.Number: + return parsed != "0" + case float64: + return parsed != 0 + case int: + return parsed != 0 + case int64: + return parsed != 0 + } + return true } func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("PayPal 退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" || req.Amount <= 0 { + return nil, errors.New("PayPal 退款缺少商户订单号、退款单号或有效金额") + } + effective := *req + effective.TradeNo = tradeNo + effective.RefundNo = refundNo + req = &effective client, err := a.client(c) if err != nil { return nil, err @@ -120,7 +181,7 @@ func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques return nil, err } bm := gopay.BodyMap{ - "invoice_id": req.RefundNo, + "invoice_id": refundNo, "amount": &paypal.Amount{ CurrencyCode: currency, Value: value, @@ -130,7 +191,7 @@ func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques bm["note_to_payer"] = note } mergeGoPayConfigExtras(bm, c, "refund_extra", "amount", "invoice_id", "capture_id", "order_id", "query_id", "provider_trade_no") - client.SetRequestHeader("PayPal-Request-Id", req.RefundNo) + client.SetRequestHeader("PayPal-Request-Id", refundNo) rsp, err := client.PaymentCaptureRefund(ctx, captureID, bm) if err != nil { return nil, err @@ -138,24 +199,82 @@ func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil { return nil, paypalResponseError("退款", paypalResponseCode(rsp), paypalRefundError(rsp)) } - refund := rsp.Response + return paypalRefundResult(req, rsp.Response, c) +} + +func paypalRefundResult(req *biz.PaymentRefundRequest, refund *paypal.PaymentCaptureRefund, c map[string]any) (*biz.PaymentResult, error) { + if req == nil || refund == nil { + return nil, errors.New("PayPal 退款响应为空") + } + if invoiceID := strings.TrimSpace(refund.InvoiceId); invoiceID == "" { + return nil, errors.New("PayPal 退款响应缺少 invoice_id") + } else if invoiceID != strings.TrimSpace(req.RefundNo) { + return nil, errors.New("PayPal 退款响应的 invoice_id 不匹配") + } + providerRefundID := strings.TrimSpace(refund.Id) + if providerRefundID == "" { + return nil, errors.New("PayPal 退款响应缺少 refund ID") + } + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) result := &biz.PaymentResult{ Provider: biz.PaymentPayPal, Status: normalizePayPalRefundState(refund.Status), TradeNo: req.TradeNo, - ProviderTradeNo: refund.Id, + ProviderTradeNo: providerRefundID, Amount: req.Amount, Currency: currency, Payload: mustJSON(refund), } - if refund.Amount != nil { - parsed, parseErr := paypalParseAmount(refund.Amount, c) - if parseErr != nil { - return nil, parseErr - } - result.Amount = parsed - result.Currency = strings.ToUpper(refund.Amount.CurrencyCode) + if refund.Amount == nil { + return nil, errors.New("PayPal 退款响应缺少金额") } + parsed, err := paypalParseAmount(refund.Amount, c) + if err != nil { + return nil, err + } + if parsed != req.Amount { + return nil, errors.New("PayPal 退款响应金额不匹配") + } + responseCurrency := strings.ToUpper(strings.TrimSpace(refund.Amount.CurrencyCode)) + if responseCurrency == "" { + return nil, errors.New("PayPal 退款响应缺少币种") + } + if currency != "" && responseCurrency != currency { + return nil, errors.New("PayPal 退款响应币种不匹配") + } + result.Amount = parsed + result.Currency = responseCurrency + return result, nil +} + +func paypalCreateResult(req *biz.PaymentRequest, order *paypal.OrderDetail, c map[string]any) (*biz.PaymentResult, error) { + if req == nil || order == nil { + return nil, errors.New("PayPal 下单响应为空") + } + tradeNo, err := paypalOrderIdentity(order) + if err != nil { + return nil, err + } + if tradeNo != "" && tradeNo != strings.TrimSpace(req.TradeNo) { + return nil, errors.New("PayPal 下单响应的商户订单号不匹配") + } + amount, currency, found, err := paypalOrderAmount(order, c) + if err != nil { + return nil, err + } + if found { + if amount != req.Amount { + return nil, errors.New("PayPal 下单响应金额不匹配") + } + if expected := strings.ToUpper(strings.TrimSpace(req.Currency)); currency == "" || currency != expected { + return nil, errors.New("PayPal 下单响应币种不匹配") + } + } + result, err := paypalOrderResult(order, order.Id, req.TradeNo, c) + if err != nil { + return nil, err + } + result.TradeNo = req.TradeNo return result, nil } @@ -238,14 +357,17 @@ func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo strin if order == nil { return nil, errors.New("PayPal 订单结果为空") } - tradeNo := paypalOrderTradeNo(order) + tradeNo, err := paypalOrderIdentity(order) + if err != nil { + return nil, err + } if tradeNo == "" { tradeNo = fallbackTradeNo } capture := paypalOrderCapture(order) result := &biz.PaymentResult{ Provider: biz.PaymentPayPal, - Status: normalizePayPalOrderState(order.Status), + Status: paypalOrderCaptureState(order), TradeNo: tradeNo, QueryID: queryID, Payload: mustJSON(order), @@ -265,22 +387,37 @@ func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo strin } func paypalOrderTradeNo(order *paypal.OrderDetail) string { + tradeNo, _ := paypalOrderIdentity(order) + return tradeNo +} + +func paypalOrderIdentity(order *paypal.OrderDetail) (string, error) { if order == nil { - return "" + return "", nil } + identity := "" for _, unit := range order.PurchaseUnits { if unit == nil { continue } - if value := firstNonEmpty(unit.InvoiceId, unit.CustomId, unit.ReferenceId); value != "" { - return value + for _, value := range []string{unit.InvoiceId, unit.CustomId, unit.ReferenceId} { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if identity == "" { + identity = value + continue + } + if value != identity { + return "", errors.New("PayPal 订单响应包含冲突的商户订单号") + } } } - return "" + return identity, nil } func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture { - var firstCapture *paypal.Capture if order == nil { return nil } @@ -292,15 +429,48 @@ func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture { if capture == nil || strings.TrimSpace(capture.Id) == "" { continue } - if firstCapture == nil { - firstCapture = capture - } if strings.EqualFold(capture.Status, "COMPLETED") { return capture } } } - return firstCapture + return nil +} + +func paypalOrderCaptureState(order *paypal.OrderDetail) string { + if order == nil { + return "pending" + } + pending, failed, seen := false, false, false + for _, unit := range order.PurchaseUnits { + if unit == nil || unit.Payments == nil { + continue + } + for _, capture := range unit.Payments.Captures { + if capture == nil || strings.TrimSpace(capture.Id) == "" { + continue + } + seen = true + switch normalizePayPalCaptureState(capture.Status) { + case "success": + return "success" + case "pending": + pending = true + case "failed": + failed = true + } + } + } + if pending || !seen { + if strings.EqualFold(order.Status, "VOIDED") { + return "failed" + } + return "pending" + } + if failed { + return "failed" + } + return "pending" } func paypalOrderAmount(order *paypal.OrderDetail, c map[string]any) (int64, string, bool, error) { @@ -350,7 +520,11 @@ func paypalRefundCaptureID(ctx context.Context, client *paypal.Client, req *biz. if rsp.Response.Id != "" && rsp.Response.Id != orderID { return "", errors.New("PayPal 退款查单响应的 order ID 不匹配") } - if returnedTradeNo := paypalOrderTradeNo(rsp.Response); returnedTradeNo == "" || returnedTradeNo != req.TradeNo { + returnedTradeNo, identityErr := paypalOrderIdentity(rsp.Response) + if identityErr != nil { + return "", identityErr + } + if returnedTradeNo == "" || returnedTradeNo != req.TradeNo { return "", errors.New("PayPal 退款订单与本地商户订单号不匹配") } capture := paypalOrderCapture(rsp.Response) @@ -375,12 +549,16 @@ func paypalWebhookResult(event *paypal.WebhookEvent) (*biz.PaymentResult, error) if err := json.Unmarshal(event.Resource, &order); err != nil { return nil, err } - tradeNo = paypalOrderTradeNo(&order) + identity, identityErr := paypalOrderIdentity(&order) + if identityErr != nil { + return nil, identityErr + } + tradeNo = identity queryID = order.Id if capture := paypalOrderCapture(&order); capture != nil { providerTradeNo = capture.Id } - status = normalizePayPalOrderState(order.Status) + status = paypalOrderCaptureState(&order) } else { providerTradeNo = paypalMapString(resource, "id") } @@ -579,6 +757,13 @@ func paypalOrderError(rsp *paypal.OrderDetailRsp) string { return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) } +func paypalCaptureError(rsp *paypal.OrderCaptureRsp) string { + if rsp == nil { + return "响应为空" + } + return paypalErrorDetail(rsp.Error, rsp.ErrorResponse) +} + func paypalRefundError(rsp *paypal.PaymentCaptureRefundRsp) string { if rsp == nil { return "响应为空" diff --git a/internal/data/payment/paypal_test.go b/internal/data/payment/paypal_test.go index d127c36..a5c4c38 100644 --- a/internal/data/payment/paypal_test.go +++ b/internal/data/payment/paypal_test.go @@ -5,9 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "kra/internal/biz" + + "github.com/go-pay/gopay/paypal" ) func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) { @@ -85,6 +88,136 @@ func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) { } } +func TestPayPalCreateRejectsMismatchedResponseIdentityAndAmount(t *testing.T) { + for _, tc := range []struct { + name string + response string + want string + }{ + { + name: "merchant identity", + response: `{ + "id":"ORDER-MISMATCH","status":"CREATED", + "purchase_units":[{"reference_id":"OTHER","invoice_id":"OTHER","custom_id":"OTHER","amount":{"currency_code":"USD","value":"10.99"}}] + }`, + want: "商户订单号不匹配", + }, + { + name: "conflicting merchant identities", + response: `{ + "id":"ORDER-CONFLICT","status":"CREATED", + "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"OTHER","amount":{"currency_code":"USD","value":"10.99"}}] + }`, + want: "冲突的商户订单号", + }, + { + name: "amount", + response: `{ + "id":"ORDER-AMOUNT","status":"CREATED", + "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"11.00"}}] + }`, + want: "金额不匹配", + }, + { + name: "currency", + response: `{ + "id":"ORDER-CURRENCY","status":"CREATED", + "purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"EUR","value":"10.99"}}] + }`, + want: "币种不匹配", + }, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(tc.response)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + _, err := (&paypalAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "LOCAL-1", Subject: "Order one", Amount: 1099, Currency: "USD", + }, paypalTestConfig(server.URL)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Create() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestPayPalQueryCapturesApprovedOrderBeforeFulfillment(t *testing.T) { + var captureCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-APPROVED": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"ORDER-APPROVED","status":"APPROVED", + "purchase_units":[{"reference_id":"LOCAL-APPROVED","invoice_id":"LOCAL-APPROVED","custom_id":"LOCAL-APPROVED","amount":{"currency_code":"USD","value":"10.99"}}] + }`)) + case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders/ORDER-APPROVED/capture": + captureCalled = true + if got := r.Header.Get("PayPal-Request-Id"); got != "ORDER-APPROVED-capture" { + t.Errorf("capture PayPal-Request-Id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "id":"ORDER-APPROVED","status":"COMPLETED", + "purchase_units":[{"reference_id":"LOCAL-APPROVED","invoice_id":"LOCAL-APPROVED","custom_id":"LOCAL-APPROVED","amount":{"currency_code":"USD","value":"10.99"}, + "payments":{"captures":[{"id":"CAPTURE-APPROVED","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]}}] + }`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + result, err := (&paypalAdapter{}).Query(context.Background(), "ORDER-APPROVED", paypalTestConfig(server.URL)) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if !captureCalled { + t.Fatal("Query() did not capture approved order") + } + if result.Status != "success" || result.TradeNo != "LOCAL-APPROVED" || result.ProviderTradeNo != "CAPTURE-APPROVED" || result.Amount != 1099 || result.Currency != "USD" { + t.Fatalf("captured result = %+v", result) + } +} + +func TestPayPalOrderDoesNotFulfillPendingCapture(t *testing.T) { + order := &paypal.OrderDetail{ + Id: "ORDER-PENDING", + Status: "COMPLETED", + PurchaseUnits: []*paypal.PurchaseUnit{{ + InvoiceId: "LOCAL-PENDING", + Amount: &paypal.Amount{CurrencyCode: "USD", Value: "10.99"}, + Payments: &paypal.Payments{Captures: []*paypal.Capture{{ + Id: "CAPTURE-PENDING", Status: "PENDING", + Amount: &paypal.Amount{CurrencyCode: "USD", Value: "10.99"}, + }}}, + }}, + } + result, err := paypalOrderResult(order, order.Id, "", map[string]any{"amount_scales": map[string]any{"USD": "100"}}) + if err != nil { + t.Fatal(err) + } + if result.Status != "pending" || result.ProviderTradeNo != "" { + t.Fatalf("pending capture result = %+v", result) + } +} + func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) { var refundBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -101,7 +234,7 @@ func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-1","status":"PENDING","amount":{"currency_code":"USD","value":"1.23"}}`)) + _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-1","invoice_id":"REFUND-1","status":"PENDING","amount":{"currency_code":"USD","value":"1.23"}}`)) default: http.NotFound(w, r) } @@ -145,7 +278,7 @@ func TestPayPalRefundResolvesCaptureFromPersistedQueryID(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-FROM-ORDER/refund": w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-2","status":"COMPLETED","amount":{"currency_code":"USD","value":"1.23"}}`)) + _, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-2","invoice_id":"REFUND-2","status":"COMPLETED","amount":{"currency_code":"USD","value":"1.23"}}`)) default: http.NotFound(w, r) } @@ -177,6 +310,36 @@ func TestPayPalRefundRejectsStaticCaptureIDWithoutDurableIdentity(t *testing.T) } } +func TestPayPalRefundResultBindsRefundIdentity(t *testing.T) { + req := &biz.PaymentRefundRequest{TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD"} + base := func() *paypal.PaymentCaptureRefund { + return &paypal.PaymentCaptureRefund{ + Id: "PAYPAL-REFUND-1", InvoiceId: "REFUND-1", Status: "PENDING", + Amount: &paypal.Amount{CurrencyCode: "USD", Value: "1.23"}, + } + } + + for _, tc := range []struct { + name string + mutate func(*paypal.PaymentCaptureRefund) + }{ + {name: "missing invoice id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.InvoiceId = "" }}, + {name: "mismatched invoice id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.InvoiceId = "OTHER" }}, + {name: "missing provider refund id", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Id = "" }}, + {name: "missing amount", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount = nil }}, + {name: "mismatched amount", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount.Value = "1.24" }}, + {name: "mismatched currency", mutate: func(refund *paypal.PaymentCaptureRefund) { refund.Amount.CurrencyCode = "EUR" }}, + } { + t.Run(tc.name, func(t *testing.T) { + refund := base() + tc.mutate(refund) + if _, err := paypalRefundResult(req, refund, nil); err == nil { + t.Fatal("paypalRefundResult() accepted an unbound refund response") + } + }) + } +} + func TestPayPalCallbackUsesGoPayWebhookVerification(t *testing.T) { var verifyBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/data/payment/qq.go b/internal/data/payment/qq.go index 1c3e79c..4477efb 100644 --- a/internal/data/payment/qq.go +++ b/internal/data/payment/qq.go @@ -24,50 +24,165 @@ func (a *qqAdapter) client(c map[string]any) (*gopayQQ.Client, error) { return client, nil } +// qqSignType resolves the algorithm used both for request signing and for +// validating synchronous API responses. QQ defaults to MD5 when sign_type is +// omitted, but an explicit unsupported value must never silently downgrade to +// a weaker or different algorithm. +func qqSignType(extra, config map[string]any) (string, error) { + value := firstAny(extra, "sign_type", "signature_type", "signType") + if value == "" { + value = firstAny(config, "sign_type", "signature_type", "signType") + } + if value == "" { + return gopayQQ.SignType_MD5, nil + } + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "_", "-")) + switch normalized { + case gopayQQ.SignType_MD5: + return gopayQQ.SignType_MD5, nil + case gopayQQ.SignType_HMAC_SHA256: + return gopayQQ.SignType_HMAC_SHA256, nil + default: + return "", fmt.Errorf("QQ 支付不支持的签名类型: %s", value) + } +} + +func verifyQQResponse(apiKey, signType string, response any) error { + if strings.TrimSpace(apiKey) == "" { + return errors.New("QQ 支付响应验签缺少 api_key") + } + ok, err := gopayQQ.VerifySign(apiKey, signType, response) + if err != nil { + return fmt.Errorf("QQ 支付响应签名校验失败: %w", err) + } + if !ok { + return errors.New("QQ 支付响应签名校验失败") + } + return nil +} + func (a *qqAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("QQ 支付下单请求为空") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{} - bm.Set("nonce_str", nonce()).Set("body", req.Subject).Set("out_trade_no", req.TradeNo).Set("total_fee", req.Amount).Set("spbill_create_ip", req.ClientIP).Set("notify_url", req.NotifyURL) - tradeType := firstAny(req.Extra, "trade_type", "pay_type") - if tradeType == "" { - tradeType = firstAny(c, "trade_type", "pay_type") + signType, err := qqSignType(req.Extra, c) + if err != nil { + return nil, err } - if tradeType == "" { - tradeType = gopayQQ.TradeType_Native + bm := gopay.BodyMap{} + bm.Set("nonce_str", nonce()).Set("body", req.Subject).Set("out_trade_no", req.TradeNo).Set("total_fee", req.Amount).Set("spbill_create_ip", req.ClientIP).Set("notify_url", req.NotifyURL).Set("sign_type", signType) + tradeType, err := qqCreateMethod(req.Extra, c) + if err != nil { + return nil, err + } + if tradeType == gopayQQ.TradeType_MicroPay { + bm.Set("device_info", firstAnyOr(req.Extra, firstAnyOr(c, "WEB", "device_info"), "device_info")) + authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code") + if strings.TrimSpace(authCode) == "" { + return nil, errors.New("QQ 付款码支付缺少 auth_code") + } + bm.Set("auth_code", strings.TrimSpace(authCode)) + mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "device_info", "auth_code", "authcode", "mch_id", "sign_type") + rsp, callErr := client.MicroPay(ctx, bm) + if callErr != nil { + return nil, callErr + } + if callErr = verifyQQResponse(text(c, "api_key"), signType, rsp); callErr != nil { + return nil, callErr + } + if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("QQ 付款码支付失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des")) + } + if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != req.TradeNo { + return nil, errors.New("QQ 付款码支付响应的 out_trade_no 不匹配") + } + status := normalizeQQState(rsp.TradeState) + if status == "pending" && strings.EqualFold(rsp.ResultCode, "SUCCESS") && strings.TrimSpace(rsp.TransactionId) != "" { + status = "success" + } + return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: firstNonEmptyQQ(rsp.OutTradeNo, req.TradeNo), ProviderTradeNo: strings.TrimSpace(rsp.TransactionId), Payload: mustJSON(rsp)}, nil } bm.Set("trade_type", tradeType) - mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id") + mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id", "sign_type") rsp, err := client.UnifiedOrder(ctx, bm) if err != nil { return nil, err } + if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil { + return nil, err + } if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("QQ 支付下单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des")) } - result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "created", TradeNo: req.TradeNo, QueryID: first(rspMap(rsp), "prepay_id", "code_url"), Payload: mustJSON(rsp)} + result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "created", TradeNo: req.TradeNo, Payload: mustJSON(rsp)} return result, nil } +func qqCreateMethod(extra, config map[string]any) (string, error) { + value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method") + if value == "" { + value = firstAny(config, "trade_type", "pay_type", "method", "pay_method") + } + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "-", "_")) + switch normalized { + case "", "NATIVE", "QR", "QRCODE": + return gopayQQ.TradeType_Native, nil + case "MICROPAY", "MICRO_PAY", "BARCODE", "BARCODE_PAY": + return gopayQQ.TradeType_MicroPay, nil + case "JSAPI", "JS_API": + return gopayQQ.TradeType_JsApi, nil + case "MINIAPP", "MINI_APP", "MINI_PROGRAM", "MINIPROGRAM", "MINI": + return gopayQQ.TradeType_Mini, nil + case "APP", "APP_PAY": + return gopayQQ.TradeType_App, nil + default: + return "", fmt.Errorf("QQ 支付不支持的下单方式: %s", value) + } +} + +func firstNonEmptyQQ(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("QQ 支付查单缺少 out_trade_no") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo} - mergeGoPayConfigExtras(bm, c, "query_extra", "nonce_str", "out_trade_no", "mch_id") + signType, err := qqSignType(nil, c) + if err != nil { + return nil, err + } + bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo, "sign_type": signType} + mergeGoPayConfigExtras(bm, c, "query_extra", "nonce_str", "out_trade_no", "mch_id", "sign_type") rsp, err := client.OrderQuery(ctx, bm) if err != nil { return nil, err } + if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil { + return nil, err + } if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("QQ 支付查单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des")) } result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: normalizeQQState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.TransactionId, Payload: mustJSON(rsp)} if result.TradeNo == "" { result.TradeNo = tradeNo + } else if strings.TrimSpace(result.TradeNo) != tradeNo { + return nil, errors.New("QQ 支付查单响应的 out_trade_no 不匹配") } result.Currency = strings.ToUpper(rsp.FeeType) if result.Currency == "" { @@ -80,7 +195,10 @@ func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) } } if rsp.CashFee != "" { - result.PayerPaidAmount, _ = parseIntegerAmount(rsp.CashFee) + result.PayerPaidAmount, err = parseIntegerAmount(rsp.CashFee) + if err != nil { + return nil, fmt.Errorf("解析 QQ 支付实付金额: %w", err) + } result.CashPaidAmount = result.PayerPaidAmount result.AmountBreakdownKnown = true } @@ -91,18 +209,30 @@ func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c if req == nil { return nil, errors.New("QQ 支付退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" { + return nil, errors.New("QQ 支付退款缺少 out_trade_no 或 out_refund_no") + } + if req.Amount <= 0 || req.TotalAmount < 0 || req.TotalAmount > 0 && req.Amount > req.TotalAmount { + return nil, errors.New("QQ 支付退款金额无效") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo, "refund_fee": req.Amount, "op_user_id": firstAny(c, "op_user_id", "merchant_id", "mch_id"), "op_user_passwd": text(c, "op_user_passwd")} + signType, err := qqSignType(nil, c) + if err != nil { + return nil, err + } + bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo, "out_refund_no": refundNo, "refund_fee": req.Amount, "op_user_id": firstAny(c, "op_user_id", "merchant_id", "mch_id"), "op_user_passwd": text(c, "op_user_passwd"), "sign_type": signType} if req.TotalAmount > 0 { bm.Set("total_fee", req.TotalAmount) } if req.Currency != "" { bm.Set("refund_fee_type", strings.ToUpper(req.Currency)) } - mergeGoPayConfigExtras(bm, c, "refund_extra", "nonce_str", "out_trade_no", "out_refund_no", "refund_fee", "total_fee", "op_user_id", "op_user_passwd", "mch_id") + mergeGoPayConfigExtras(bm, c, "refund_extra", "nonce_str", "out_trade_no", "out_refund_no", "refund_fee", "total_fee", "op_user_id", "op_user_passwd", "mch_id", "sign_type") cert, key, p12 := anyConfig(c, "cert_file", "cert_path"), anyConfig(c, "key_file", "key_path"), anyConfig(c, "pkcs12_file", "p12_file") if (cert == nil || key == nil) && p12 == nil { if text(c, "cert_content") != "" || text(c, "key_content") != "" || text(c, "pkcs12_content") != "" { @@ -116,15 +246,48 @@ func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c if err != nil { return nil, err } + if err = verifyQQResponse(text(c, "api_key"), signType, rsp); err != nil { + return nil, err + } if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) { return nil, fmt.Errorf("QQ 支付退款失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des")) } - refundAmount, _ := parseIntegerAmount(rsp.RefundFee) - returnedTradeNo := rsp.OutTradeNo - if returnedTradeNo == "" { - returnedTradeNo = req.TradeNo + if err = validateQQRefundIdentity(tradeNo, refundNo, rsp); err != nil { + return nil, err } - return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: rsp.RefundId, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil + refundAmount := req.Amount + if strings.TrimSpace(rsp.RefundFee) != "" { + refundAmount, err = parseIntegerAmount(rsp.RefundFee) + if err != nil { + return nil, fmt.Errorf("解析 QQ 支付退款金额: %w", err) + } + if refundAmount != req.Amount { + return nil, errors.New("QQ 支付退款响应金额不匹配") + } + } + returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo) + if returnedTradeNo == "" { + returnedTradeNo = tradeNo + } + return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: strings.TrimSpace(rsp.RefundId), Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil +} + +func validateQQRefundIdentity(tradeNo, refundNo string, rsp *gopayQQ.RefundResponse) error { + if rsp == nil { + return errors.New("QQ 支付退款响应为空") + } + if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo { + return errors.New("QQ 支付退款响应的 out_trade_no 不匹配") + } + if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" { + return errors.New("QQ 支付退款响应缺少 out_refund_no") + } else if returnedRefundNo != refundNo { + return errors.New("QQ 支付退款响应的 out_refund_no 不匹配") + } + if strings.TrimSpace(rsp.RefundId) == "" { + return errors.New("QQ 支付退款响应缺少 refund_id") + } + return nil } func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { @@ -139,9 +302,9 @@ func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c if err != nil { return nil, err } - signType := firstAny(c, "sign_type", "signature_type", "signType") - if signType == "" { - signType = gopayQQ.SignType_MD5 + signType, err := qqSignType(nil, c) + if err != nil { + return nil, err } ok, err := gopayQQ.VerifySign(text(c, "api_key"), signType, cloneGoPayBodyMap(bm)) if err != nil { diff --git a/internal/data/payment/qq_test.go b/internal/data/payment/qq_test.go new file mode 100644 index 0000000..8a3b3df --- /dev/null +++ b/internal/data/payment/qq_test.go @@ -0,0 +1,141 @@ +package payment + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + + "kra/internal/biz" + "kra/pkg/paymentkit" + + "github.com/go-pay/gopay" + gopayQQ "github.com/go-pay/gopay/qq" +) + +func redirectQQHTTPS(t *testing.T, handler http.Handler) { + t.Helper() + server := httptest.NewTLSServer(handler) + oldDefault := http.DefaultTransport + base, ok := oldDefault.(*http.Transport) + if !ok { + server.Close() + t.Fatal("http.DefaultTransport is not *http.Transport") + } + transport := base.Clone() + transport.Proxy = nil + transport.DisableKeepAlives = true + transport.ForceAttemptHTTP2 = false + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true} //nolint:gosec // local test server + target := server.Listener.Addr().String() + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, target) + } + http.DefaultTransport = transport + t.Cleanup(func() { + http.DefaultTransport = oldDefault + transport.CloseIdleConnections() + server.Close() + }) +} + +func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) { + redirectQQHTTPS(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/cgi-bin/pay/qpay_unified_order.cgi" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request XML: %v", err) + http.Error(w, "invalid XML", http.StatusBadRequest) + return + } + values, err := paymentkit.XMLValues(body) + if err != nil { + t.Errorf("parse request XML: %v", err) + http.Error(w, "invalid XML", http.StatusBadRequest) + return + } + if values["mch_id"] != "QQ-MERCHANT" || values["out_trade_no"] != "MERCHANT-QQ-1" { + t.Errorf("request identities = %#v", values) + } + if values["trade_type"] != "NATIVE" || values["total_fee"] != "100" || values["sign"] == "" { + t.Errorf("request fields = %#v", values) + } + w.Header().Set("Content-Type", "application/xml") + response := gopay.BodyMap{ + "return_code": "SUCCESS", + "result_code": "SUCCESS", + "mch_id": "QQ-MERCHANT", + "trade_type": "NATIVE", + "prepay_id": "QQ-PREPAY-1", + "code_url": "https://qpay.qq.com/qr/QQ-PREPAY-1", + } + response.Set("sign", gopayQQ.GetReleaseSign("qq-api-key", gopayQQ.SignType_MD5, response)) + encoded := make(map[string]string, len(response)) + for key, value := range response { + encoded[key] = fmt.Sprint(value) + } + _, _ = w.Write(paymentkit.XMLEncode(encoded)) + })) + + result, err := (&qqAdapter{}).Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "MERCHANT-QQ-1", Subject: "subject", Amount: 100, + Currency: "CNY", ClientIP: "127.0.0.1", NotifyURL: "https://merchant.example/qq/notify", + }, map[string]any{"mch_id": "QQ-MERCHANT", "api_key": "qq-api-key"}) + if err != nil { + t.Fatal(err) + } + if result.Provider != biz.PaymentQQ || result.Status != "created" || result.TradeNo != "MERCHANT-QQ-1" { + t.Fatalf("create result = %+v", result) + } + if result.QueryID != "" || result.ProviderTradeNo != "" { + t.Fatalf("client payment data leaked into durable identities: %+v", result) + } + + var payload struct { + PrepayID string `json:"prepay_id"` + CodeURL string `json:"code_url"` + } + if err = json.Unmarshal(result.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.PrepayID != "QQ-PREPAY-1" || payload.CodeURL != "https://qpay.qq.com/qr/QQ-PREPAY-1" { + t.Fatalf("client payment payload = %+v", payload) + } +} + +func TestQQCreateMethodMapsSupportedModes(t *testing.T) { + for _, tc := range []struct { + value string + want string + }{ + {value: "", want: gopayQQ.TradeType_Native}, + {value: "MICROPAY", want: gopayQQ.TradeType_MicroPay}, + {value: "barcode", want: gopayQQ.TradeType_MicroPay}, + {value: "JSAPI", want: gopayQQ.TradeType_JsApi}, + {value: "APP", want: gopayQQ.TradeType_App}, + {value: "MINIAPP", want: gopayQQ.TradeType_Mini}, + } { + if got, err := qqCreateMethod(map[string]any{"trade_type": tc.value}, nil); err != nil || got != tc.want { + t.Fatalf("mode %q = %q, err = %v, want %q", tc.value, got, err, tc.want) + } + } +} + +func TestQQCreateMethodRejectsUnknownMode(t *testing.T) { + if got, err := qqCreateMethod(map[string]any{"method": "unsupported"}, nil); err == nil || got != "" { + t.Fatalf("unknown mode = %q, err = %v", got, err) + } +} + +func TestNormalizeQQStateTreatsRefundAsFailed(t *testing.T) { + if got := normalizeQQState("REFUND"); got != "failed" { + t.Fatalf("normalizeQQState(REFUND) = %q, want failed", got) + } +} diff --git a/internal/data/payment/refund_boundary_test.go b/internal/data/payment/refund_boundary_test.go new file mode 100644 index 0000000..f098458 --- /dev/null +++ b/internal/data/payment/refund_boundary_test.go @@ -0,0 +1,154 @@ +package payment + +import ( + "context" + "strings" + "testing" + + "kra/internal/biz" + + "github.com/go-pay/gopay" + gopayQQ "github.com/go-pay/gopay/qq" + "github.com/go-pay/gopay/saobei" + gopayWechat "github.com/go-pay/gopay/wechat" +) + +func TestWechatV2RefundRejectsInvalidRequestBeforeClient(t *testing.T) { + for _, tc := range []struct { + name string + req *biz.PaymentRefundRequest + want string + }{ + {name: "blank trade number", req: &biz.PaymentRefundRequest{TradeNo: " ", RefundNo: "R1", Amount: 1, TotalAmount: 1}, want: "out_trade_no"}, + {name: "blank refund number", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: " ", Amount: 1, TotalAmount: 1}, want: "out_refund_no"}, + {name: "invalid amount", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 2, TotalAmount: 1}, want: "金额"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := (&wechatV2Adapter{}).Refund(context.Background(), tc.req, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Refund() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestWechatV2RefundIdentityValidation(t *testing.T) { + valid := func() *gopayWechat.RefundResponse { + return &gopayWechat.RefundResponse{OutTradeNo: "T1", OutRefundNo: "R1", RefundId: "WX-R1"} + } + if err := validateWechatV2RefundIdentity("T1", "R1", valid()); err != nil { + t.Fatalf("valid refund response: %v", err) + } + for _, tc := range []struct { + name string + edit func(*gopayWechat.RefundResponse) + }{ + {name: "trade mismatch", edit: func(r *gopayWechat.RefundResponse) { r.OutTradeNo = "OTHER" }}, + {name: "refund missing", edit: func(r *gopayWechat.RefundResponse) { r.OutRefundNo = "" }}, + {name: "refund mismatch", edit: func(r *gopayWechat.RefundResponse) { r.OutRefundNo = "OTHER" }}, + {name: "provider refund missing", edit: func(r *gopayWechat.RefundResponse) { r.RefundId = "" }}, + } { + t.Run(tc.name, func(t *testing.T) { + rsp := valid() + tc.edit(rsp) + if err := validateWechatV2RefundIdentity("T1", "R1", rsp); err == nil { + t.Fatal("accepted invalid refund identity") + } + }) + } +} + +func TestQQRefundRejectsInvalidRequestBeforeClient(t *testing.T) { + for _, tc := range []struct { + name string + req *biz.PaymentRefundRequest + want string + }{ + {name: "blank trade number", req: &biz.PaymentRefundRequest{TradeNo: " ", RefundNo: "R1", Amount: 1}, want: "out_trade_no"}, + {name: "blank refund number", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: " ", Amount: 1}, want: "out_refund_no"}, + {name: "invalid amount", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 0}, want: "金额"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := (&qqAdapter{}).Refund(context.Background(), tc.req, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Refund() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestQQRefundIdentityValidation(t *testing.T) { + valid := func() *gopayQQ.RefundResponse { + return &gopayQQ.RefundResponse{OutTradeNo: "T1", OutRefundNo: "R1", RefundId: "QQ-R1"} + } + if err := validateQQRefundIdentity("T1", "R1", valid()); err != nil { + t.Fatalf("valid refund response: %v", err) + } + for _, tc := range []struct { + name string + edit func(*gopayQQ.RefundResponse) + }{ + {name: "trade mismatch", edit: func(r *gopayQQ.RefundResponse) { r.OutTradeNo = "OTHER" }}, + {name: "refund missing", edit: func(r *gopayQQ.RefundResponse) { r.OutRefundNo = "" }}, + {name: "refund mismatch", edit: func(r *gopayQQ.RefundResponse) { r.OutRefundNo = "OTHER" }}, + {name: "provider refund missing", edit: func(r *gopayQQ.RefundResponse) { r.RefundId = "" }}, + } { + t.Run(tc.name, func(t *testing.T) { + rsp := valid() + tc.edit(rsp) + if err := validateQQRefundIdentity("T1", "R1", rsp); err == nil { + t.Fatal("accepted invalid refund identity") + } + }) + } +} + +func TestSaobeiRefundRejectsInvalidRequestBeforeClient(t *testing.T) { + for _, tc := range []struct { + name string + req *biz.PaymentRefundRequest + want string + }{ + {name: "blank trade number", req: &biz.PaymentRefundRequest{TradeNo: " ", RefundNo: "R1", Amount: 1}, want: "商户订单号"}, + {name: "blank refund number", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: " ", Amount: 1}, want: "退款单号"}, + {name: "invalid amount", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 0}, want: "金额"}, + } { + t.Run(tc.name, func(t *testing.T) { + clientCreated := false + adapter := &saobeiAdapter{newClient: func(map[string]any) (saobeiClient, error) { + clientCreated = true + return &saobeiClientStub{}, nil + }} + _, err := adapter.Refund(context.Background(), tc.req, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Refund() error = %v, want %q", err, tc.want) + } + if clientCreated { + t.Fatal("Refund() initialized client before validating request") + } + }) + } +} + +func TestSaobeiRefundRejectsMismatchedRefundNumber(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) { + return &saobei.RefundRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, TerminalTrace: "OTHER", OutRefundNo: "SAOBEI-REFUND-1", RefundFee: "25"}, nil + }}) + _, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 25}, nil) + if err == nil || !strings.Contains(err.Error(), "terminal_trace") { + t.Fatalf("Refund() error = %v", err) + } +} + +func TestSaobeiBarcodeCreateTreatsPayingAsPending(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{barcodePay: func(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error) { + return &saobei.BarcodePayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: saobei.ResultCodePaying}, OutTradeNo: "SAOBEI-ORDER-1"}, nil + }}) + result, err := adapter.Create(context.Background(), &biz.PaymentRequest{TradeNo: "LOCAL-1", Amount: 10, Extra: map[string]any{"method": "barcode", "auth_no": "AUTH-1"}}, nil) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.Status != "pending" { + t.Fatalf("Create() status = %q, want pending", result.Status) + } +} diff --git a/internal/data/payment/response_validation_test.go b/internal/data/payment/response_validation_test.go new file mode 100644 index 0000000..4a1bd13 --- /dev/null +++ b/internal/data/payment/response_validation_test.go @@ -0,0 +1,115 @@ +package payment + +import ( + "strings" + "testing" + + "kra/internal/biz" + + gopayAlipay "github.com/go-pay/gopay/alipay" + "github.com/go-pay/gopay/douyin" +) + +func TestWechatV2ResponseRequiresBusinessSuccessCode(t *testing.T) { + for _, values := range []map[string]string{ + {"return_code": "SUCCESS"}, + {"return_code": "SUCCESS", "result_code": "FAIL"}, + {"return_code": "FAIL", "result_code": "SUCCESS"}, + } { + if err := validateWechatV2Response(values); err == nil { + t.Fatalf("accepted unsuccessful response: %#v", values) + } + } + if err := validateWechatV2Response(map[string]string{ + "return_code": "SUCCESS", "result_code": "SUCCESS", + }); err != nil { + t.Fatalf("valid response rejected: %v", err) + } +} + +func TestPopulateDouyinPaymentAmountUsesSignedFieldPresence(t *testing.T) { + amount := &douyin.AmountInfo{Total: 100, PayerTotal: 0, Currency: "CNY", PayerCurrency: "CNY"} + result := &biz.PaymentResult{} + if err := populateDouyinPaymentAmount(result, amount, []byte(`{"amount":{"total":100,"payer_total":0,"currency":"CNY","payer_currency":"CNY"}}`)); err != nil { + t.Fatal(err) + } + if !result.AmountBreakdownKnown || result.PayerPaidAmount != 0 || result.CashPaidAmount != 0 || result.DiscountAmount != 100 { + t.Fatalf("explicit zero payer_total was not preserved: %+v", result) + } + + result = &biz.PaymentResult{} + if err := populateDouyinPaymentAmount(result, amount, []byte(`{"amount":{"total":100,"currency":"CNY"}}`)); err != nil { + t.Fatal(err) + } + if result.AmountBreakdownKnown || result.PayerPaidAmount != 0 || result.DiscountAmount != 0 { + t.Fatalf("missing payer_total was treated as authoritative: %+v", result) + } + + if err := populateDouyinPaymentAmount(&biz.PaymentResult{}, &douyin.AmountInfo{Total: 100, PayerTotal: 101}, []byte(`{"amount":{"total":100,"payer_total":101}}`)); err == nil { + t.Fatal("accepted payer_total greater than total") + } +} + +func TestValidateDouyinMerchantIdentity(t *testing.T) { + config := map[string]any{"merchant_id": "douyin-merchant", "app_id": "douyin-app"} + if err := validateDouyinMerchantIdentity("douyin-merchant", "douyin-app", config, true); err != nil { + t.Fatalf("valid identity rejected: %v", err) + } + for _, tc := range []struct { + merchantID string + appID string + requireApp bool + }{ + {merchantID: "other", appID: "douyin-app", requireApp: true}, + {merchantID: "douyin-merchant", appID: "other", requireApp: true}, + {merchantID: "douyin-merchant", requireApp: true}, + } { + if err := validateDouyinMerchantIdentity(tc.merchantID, tc.appID, config, tc.requireApp); err == nil { + t.Fatalf("accepted invalid identity: %+v", tc) + } + } + if err := validateDouyinMerchantIdentity("douyin-merchant", "", config, false); err != nil { + t.Fatalf("optional query appid rejected: %v", err) + } +} + +func TestAlipayRefundResultValidatesIdentityAndAmount(t *testing.T) { + req := &biz.PaymentRefundRequest{ + TradeNo: "T-ALIPAY", ProviderTradeNo: "P-ALIPAY", RefundNo: "R-ALIPAY", + Amount: 123, Currency: "CNY", + } + valid := func() *gopayAlipay.TradeRefund { + return &gopayAlipay.TradeRefund{ + TradeNo: "P-ALIPAY", OutTradeNo: "T-ALIPAY", RefundFee: "1.23", RefundCurrency: "CNY", + } + } + result, err := alipayRefundResult(req, valid(), []byte(`{"ok":true}`)) + if err != nil { + t.Fatal(err) + } + if result.Amount != 123 || result.Currency != "CNY" || result.ProviderTradeNo != "P-ALIPAY" { + t.Fatalf("unexpected refund result: %+v", result) + } + + for _, tc := range []struct { + name string + mutate func(*gopayAlipay.TradeRefund) + want string + }{ + {name: "missing merchant order", mutate: func(r *gopayAlipay.TradeRefund) { r.OutTradeNo = "" }, want: "out_trade_no"}, + {name: "mismatched merchant order", mutate: func(r *gopayAlipay.TradeRefund) { r.OutTradeNo = "OTHER" }, want: "out_trade_no"}, + {name: "mismatched provider order", mutate: func(r *gopayAlipay.TradeRefund) { r.TradeNo = "OTHER" }, want: "trade_no"}, + {name: "missing refund fee", mutate: func(r *gopayAlipay.TradeRefund) { r.RefundFee = "" }, want: "refund_fee"}, + {name: "mismatched refund fee", mutate: func(r *gopayAlipay.TradeRefund) { r.RefundFee = "1.22" }, want: "金额不匹配"}, + {name: "mismatched currency", mutate: func(r *gopayAlipay.TradeRefund) { r.RefundCurrency = "USD" }, want: "币种不匹配"}, + } { + t.Run(tc.name, func(t *testing.T) { + response := valid() + tc.mutate(response) + _, err := alipayRefundResult(req, response, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid response error = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/data/payment/result.go b/internal/data/payment/result.go index 9e284c3..c349b6c 100644 --- a/internal/data/payment/result.go +++ b/internal/data/payment/result.go @@ -19,7 +19,13 @@ func callbackFields(callback *biz.PaymentCallback) map[string]string { for key, value := range callback.Query { fields[key] = value } - contentType := strings.ToLower(callback.Headers["Content-Type"]) + contentType := "" + for key, value := range callback.Headers { + if strings.EqualFold(key, "Content-Type") { + contentType = strings.ToLower(value) + break + } + } if strings.Contains(contentType, "application/x-www-form-urlencoded") { if values, err := url.ParseQuery(string(callback.Body)); err == nil { for key, value := range values { diff --git a/internal/data/payment/result_test.go b/internal/data/payment/result_test.go new file mode 100644 index 0000000..bfb0812 --- /dev/null +++ b/internal/data/payment/result_test.go @@ -0,0 +1,18 @@ +package payment + +import ( + "testing" + + "kra/internal/biz" +) + +func TestCallbackFieldsAcceptsCaseInsensitiveContentType(t *testing.T) { + callback := &biz.PaymentCallback{ + Headers: map[string]string{"content-type": "Application/X-WWW-Form-Urlencoded"}, + Body: []byte("out_trade_no=ORDER-1&sign=signature"), + } + fields := callbackFields(callback) + if fields["out_trade_no"] != "ORDER-1" || fields["sign"] != "signature" { + t.Fatalf("callback fields = %#v", fields) + } +} diff --git a/internal/data/payment/saobei.go b/internal/data/payment/saobei.go index c33e0f4..7102a58 100644 --- a/internal/data/payment/saobei.go +++ b/internal/data/payment/saobei.go @@ -3,6 +3,7 @@ package payment import ( "context" "errors" + "fmt" "strings" "time" @@ -12,13 +13,36 @@ import ( "github.com/go-pay/gopay/saobei" ) -type saobeiAdapter struct{} +type saobeiClient interface { + MiniPay(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) + BarcodePay(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error) + Query(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) + Refund(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) +} -func (a *saobeiAdapter) client(c map[string]any) (*saobei.Client, error) { +type saobeiAdapter struct { + newClient func(map[string]any) (saobeiClient, error) +} + +func saobeiBarcodeOK(returnCode, resultCode string) bool { + return saobeiResultOK(returnCode, resultCode) +} + +func (a *saobeiAdapter) client(c map[string]any) (saobeiClient, error) { + if a.newClient != nil { + return a.newClient(c) + } return saobei.NewClient(text(c, "inst_no"), text(c, "key"), text(c, "merchant_no"), text(c, "terminal_id"), text(c, "access_token"), !strings.EqualFold(text(c, "environment"), "sandbox")) } func (a *saobeiAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("扫呗支付下单请求为空") + } + method, err := saobeiCreateMethod(req.Extra, c) + if err != nil { + return nil, err + } client, err := a.client(c) if err != nil { return nil, err @@ -26,41 +50,109 @@ func (a *saobeiAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c m now := time.Now().Format("20060102150405") bm := gopay.BodyMap{"pay_type": firstAnyOr(req.Extra, "010", "pay_type", "payment_type"), "terminal_ip": req.ClientIP, "terminal_trace": req.TradeNo, "terminal_time": now, "total_fee": req.Amount} mergeGoPayExtras(bm, req.Extra, "pay_type", "payment_type", "terminal_ip", "terminal_trace", "terminal_time", "total_fee", "method", "pay_method", "trade_type") - method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type")) - if method == "barcode" || method == "micropay" { + if method == "barcode" { + authNo := strings.TrimSpace(firstAny(req.Extra, "auth_no", "auth_code", "authcode", "barcode", "pay_code")) + if authNo == "" { + return nil, errors.New("扫呗付款码支付缺少 auth_no") + } + bm.Set("auth_no", authNo) rsp, callErr := client.BarcodePay(ctx, bm) if callErr != nil { return nil, callErr } - return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Payload: mustJSON(rsp)}, nil + if rsp == nil || !saobeiBarcodeOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("扫呗付款码支付失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"])) + } + if strings.TrimSpace(rsp.OutTradeNo) == "" { + return nil, errors.New("扫呗支付下单响应缺少 out_trade_no") + } + status := "created" + if strings.TrimSpace(rsp.ResultCode) == saobei.ResultCodePaying { + status = "pending" + } + return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: status, TradeNo: req.TradeNo, QueryID: strings.TrimSpace(rsp.OutTradeNo), Payload: mustJSON(rsp)}, nil } rsp, err := client.MiniPay(ctx, bm) if err != nil { return nil, err } - return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "client_pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, QueryID: rsp.PackageStr, Payload: mustJSON(rsp)}, nil + if rsp == nil || !saobeiOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("扫呗支付下单失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"])) + } + if strings.TrimSpace(rsp.OutTradeNo) == "" { + return nil, errors.New("扫呗支付下单响应缺少 out_trade_no") + } + return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "client_pending", TradeNo: req.TradeNo, QueryID: strings.TrimSpace(rsp.OutTradeNo), Payload: mustJSON(rsp)}, nil +} + +func saobeiCreateMethod(extra, config map[string]any) (string, error) { + value := firstAny(extra, "method", "pay_method", "trade_type") + if value == "" { + value = firstAny(config, "method", "pay_method", "trade_type") + } + normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "", "mini", "mini_pay", "miniapp", "mini_app", "mini_program", "miniprogram", "jsapi", "js_api": + return "mini", nil + case "barcode", "barcode_pay", "micropay", "micro_pay", "scan", "scan_pay", "pay_code", "payment_code": + return "barcode", nil + default: + return "", fmt.Errorf("扫呗支付不支持的下单方式: %s", value) + } } func (a *saobeiAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("扫呗支付查单缺少 out_trade_no") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": tradeNo, "terminal_time": time.Now().Format("20060102150405"), "out_trade_no": tradeNo} + // terminal_trace is the query request's own trace ID. The durable platform + // order identity is carried by out_trade_no and comes from PaymentResult.QueryID. + bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": nonce(), "terminal_time": time.Now().Format("20060102150405"), "out_trade_no": tradeNo} mergeGoPayConfigExtras(bm, c, "query_extra", "pay_type", "terminal_trace", "terminal_time", "out_trade_no") rsp, err := client.Query(ctx, bm) if err != nil { return nil, err } - result := &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: normalizeSaobeiState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.ChannelTradeNo, Payload: mustJSON(rsp), Currency: strings.ToUpper(firstAnyOr(c, "CNY", "currency", "default_currency"))} - if result.TradeNo == "" { - result.TradeNo = tradeNo + if rsp == nil { + return nil, errors.New("扫呗支付查单响应为空") } + if !saobeiResultOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("扫呗支付查单失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"])) + } + queryID := strings.TrimSpace(tradeNo) + if responseQueryID := strings.TrimSpace(rsp.OutTradeNo); responseQueryID == "" { + return nil, errors.New("扫呗支付查单响应缺少 out_trade_no") + } else if responseQueryID != queryID { + return nil, errors.New("扫呗支付查单平台订单号不匹配") + } + tradeNoResult := strings.TrimSpace(rsp.TerminalTrace) + if tradeNoResult == "" { + tradeNoResult = strings.TrimSpace(rsp.PayTrace) + } + if tradeNoResult == "" { + return nil, errors.New("扫呗支付查单响应缺少商户订单号") + } + status := normalizeSaobeiState(rsp.TradeState) + if strings.TrimSpace(rsp.ResultCode) == saobei.ResultCodePaying { + status = "pending" + } + result := &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: status, TradeNo: tradeNoResult, ProviderTradeNo: strings.TrimSpace(rsp.ChannelTradeNo), QueryID: queryID, Payload: mustJSON(rsp), Currency: strings.ToUpper(firstAnyOr(c, "CNY", "currency", "default_currency"))} if rsp.TotalFee != "" { result.Amount, err = parseIntegerAmount(rsp.TotalFee) + if err != nil { + return nil, fmt.Errorf("解析扫呗订单金额: %w", err) + } } if rsp.BuyerPayFee != "" { - result.PayerPaidAmount, _ = parseIntegerAmount(rsp.BuyerPayFee) + result.PayerPaidAmount, err = parseIntegerAmount(rsp.BuyerPayFee) + if err != nil { + return nil, fmt.Errorf("解析扫呗买家实付金额: %w", err) + } result.CashPaidAmount = result.PayerPaidAmount if result.Amount >= result.PayerPaidAmount { result.DiscountAmount = result.Amount - result.PayerPaidAmount @@ -74,11 +166,23 @@ func (a *saobeiAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques if req == nil { return nil, errors.New("扫呗支付退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" { + return nil, errors.New("扫呗支付退款缺少商户订单号或退款单号") + } + if req.Amount <= 0 { + return nil, errors.New("扫呗支付退款金额无效") + } client, err := a.client(c) if err != nil { return nil, err } - bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": req.TradeNo, "terminal_time": time.Now().Format("20060102150405"), "refund_fee": req.Amount, "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo} + outTradeNo := strings.TrimSpace(req.QueryID) + if outTradeNo == "" { + outTradeNo = tradeNo + } + bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": refundNo, "terminal_time": time.Now().Format("20060102150405"), "refund_fee": req.Amount, "out_trade_no": outTradeNo} mergeGoPayConfigExtras(bm, c, "refund_extra", "pay_type", "terminal_trace", "terminal_time", "refund_fee", "out_trade_no", "out_refund_no") rsp, err := client.Refund(ctx, bm) if err != nil { @@ -87,8 +191,31 @@ func (a *saobeiAdapter) Refund(ctx context.Context, req *biz.PaymentRefundReques if rsp == nil { return nil, errors.New("扫呗支付退款响应为空") } - refundAmount, _ := parseIntegerAmount(rsp.RefundFee) - return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil + if !saobeiResultOK(rsp.ReturnCode, rsp.ResultCode) { + return nil, fmt.Errorf("扫呗支付退款失败: %s", firstNonEmpty(rspMap(rsp)["return_msg"], rspMap(rsp)["result_code"])) + } + if responseOrderID := strings.TrimSpace(rsp.OutTradeNo); responseOrderID != "" && responseOrderID != outTradeNo { + return nil, errors.New("扫呗支付退款响应平台订单号不匹配") + } + returnedTrace := strings.TrimSpace(rsp.TerminalTrace) + if returnedTrace == "" { + return nil, errors.New("扫呗支付退款响应缺少 terminal_trace") + } + if returnedTrace != refundNo { + return nil, errors.New("扫呗支付退款响应的 terminal_trace 不匹配") + } + providerRefundID := strings.TrimSpace(rsp.OutRefundNo) + if providerRefundID == "" { + return nil, errors.New("扫呗支付退款响应缺少 out_refund_no") + } + refundAmount, err := parseIntegerAmount(rsp.RefundFee) + if err != nil { + return nil, fmt.Errorf("解析扫呗退款金额: %w", err) + } + if refundAmount != req.Amount { + return nil, errors.New("扫呗支付退款响应金额不匹配") + } + return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: providerRefundID, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil } func (a *saobeiAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) { diff --git a/internal/data/payment/saobei_test.go b/internal/data/payment/saobei_test.go new file mode 100644 index 0000000..c58b8ff --- /dev/null +++ b/internal/data/payment/saobei_test.go @@ -0,0 +1,311 @@ +package payment + +import ( + "context" + "testing" + + "kra/internal/biz" + + "github.com/go-pay/gopay" + "github.com/go-pay/gopay/saobei" +) + +type saobeiClientStub struct { + miniPay func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) + barcodePay func(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error) + query func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) + refund func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) +} + +func (s *saobeiClientStub) MiniPay(ctx context.Context, bm gopay.BodyMap) (*saobei.MiniPayRsp, error) { + return s.miniPay(ctx, bm) +} + +func (s *saobeiClientStub) BarcodePay(ctx context.Context, bm gopay.BodyMap) (*saobei.BarcodePayRsp, error) { + return s.barcodePay(ctx, bm) +} + +func (s *saobeiClientStub) Query(ctx context.Context, bm gopay.BodyMap) (*saobei.QueryRsp, error) { + return s.query(ctx, bm) +} + +func (s *saobeiClientStub) Refund(ctx context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) { + return s.refund(ctx, bm) +} + +func newSaobeiTestAdapter(client saobeiClient) *saobeiAdapter { + return &saobeiAdapter{newClient: func(map[string]any) (saobeiClient, error) { + return client, nil + }} +} + +func TestSaobeiOKRequiresExplicitSuccess(t *testing.T) { + for _, tc := range []struct { + name string + returnCode string + resultCode string + want bool + }{ + {name: "documented success", returnCode: "01", resultCode: "01", want: true}, + {name: "text success", returnCode: "SUCCESS", resultCode: "SUCCESS", want: true}, + {name: "missing result code", returnCode: "01", want: false}, + {name: "empty response"}, + {name: "business failure", returnCode: "01", resultCode: "02"}, + {name: "communication failure", returnCode: "02", resultCode: "01"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := saobeiOK(tc.returnCode, tc.resultCode); got != tc.want { + t.Fatalf("saobeiOK(%q, %q) = %v, want %v", tc.returnCode, tc.resultCode, got, tc.want) + } + }) + } +} + +func TestSaobeiCreateKeepsQueryAndProviderTradeIDsSeparate(t *testing.T) { + t.Run("mini pay", func(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{miniPay: func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) { + return &saobei.MiniPayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-1"}, nil + }}) + result, err := adapter.Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "LOCAL-1", + Amount: 100, + Extra: map[string]any{"sub_appid": "APP-1", "open_id": "OPEN-1"}, + }, nil) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.TradeNo != "LOCAL-1" || result.QueryID != "SAOBEI-ORDER-1" || result.ProviderTradeNo != "" { + t.Fatalf("Create() result = %+v", result) + } + }) + + t.Run("barcode pay", func(t *testing.T) { + var request gopay.BodyMap + adapter := newSaobeiTestAdapter(&saobeiClientStub{barcodePay: func(_ context.Context, bm gopay.BodyMap) (*saobei.BarcodePayRsp, error) { + request = bm + return &saobei.BarcodePayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-2", ChannelTradeNo: "CHANNEL-2"}, nil + }}) + result, err := adapter.Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "LOCAL-2", + Amount: 200, + Extra: map[string]any{"method": "barcode", "auth_code": "AUTH-2"}, + }, nil) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.TradeNo != "LOCAL-2" || result.QueryID != "SAOBEI-ORDER-2" || result.ProviderTradeNo != "" { + t.Fatalf("Create() result = %+v", result) + } + if request.GetString("auth_no") != "AUTH-2" { + t.Fatalf("Create() auth_no = %q", request.GetString("auth_no")) + } + }) +} + +func TestSaobeiCreateRejectsMissingPlatformOrderID(t *testing.T) { + for _, method := range []string{"", "barcode"} { + t.Run(method, func(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{ + miniPay: func(context.Context, gopay.BodyMap) (*saobei.MiniPayRsp, error) { + return &saobei.MiniPayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}}, nil + }, + barcodePay: func(context.Context, gopay.BodyMap) (*saobei.BarcodePayRsp, error) { + return &saobei.BarcodePayRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}}, nil + }, + }) + extra := map[string]any{} + if method != "" { + extra["method"] = method + } + if _, err := adapter.Create(context.Background(), &biz.PaymentRequest{TradeNo: "LOCAL-MISSING", Amount: 1, Extra: extra}, nil); err == nil { + t.Fatal("Create() accepted response without out_trade_no") + } + }) + } +} + +func TestSaobeiCreateRejectsUnknownMethodBeforeClient(t *testing.T) { + clientCreated := false + adapter := &saobeiAdapter{newClient: func(map[string]any) (saobeiClient, error) { + clientCreated = true + return &saobeiClientStub{}, nil + }} + + _, err := adapter.Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "LOCAL-UNKNOWN-METHOD", Amount: 1, Extra: map[string]any{"method": "unsupported"}, + }, nil) + if err == nil { + t.Fatal("Create() accepted an unknown method") + } + if clientCreated { + t.Fatal("Create() initialized the GoPay client before rejecting the unknown method") + } +} + +func TestSaobeiQueryRestoresMerchantTradeNoAndKeepsQueryID(t *testing.T) { + var queryBody gopay.BodyMap + adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(_ context.Context, bm gopay.BodyMap) (*saobei.QueryRsp, error) { + queryBody = bm + return &saobei.QueryRsp{ + RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, + PayTrace: "LOCAL-1", + OutTradeNo: "SAOBEI-ORDER-1", + ChannelTradeNo: "CHANNEL-1", + TradeState: "SUCCESS", + TotalFee: "100", + BuyerPayFee: "90", + }, nil + }}) + + result, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", map[string]any{"currency": "cny"}) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if queryBody.GetString("out_trade_no") != "SAOBEI-ORDER-1" { + t.Fatalf("Query() out_trade_no = %q", queryBody.GetString("out_trade_no")) + } + if trace := queryBody.GetString("terminal_trace"); trace == "" || trace == "SAOBEI-ORDER-1" || len(trace) > 32 { + t.Fatalf("Query() terminal_trace = %q, want an independent query流水号", trace) + } + if result.TradeNo != "LOCAL-1" || result.QueryID != "SAOBEI-ORDER-1" || result.ProviderTradeNo != "CHANNEL-1" { + t.Fatalf("Query() result = %+v", result) + } + if result.Status != "success" || result.Amount != 100 || result.PayerPaidAmount != 90 || result.Currency != "CNY" { + t.Fatalf("Query() payment fields = %+v", result) + } +} + +func TestSaobeiQueryRejectsMissingPaymentTrace(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) { + return &saobei.QueryRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, OutTradeNo: "SAOBEI-ORDER-1", TradeState: "NOTPAY"}, nil + }}) + + if _, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil); err == nil { + t.Fatal("Query() accepted a response without pay_trace") + } +} + +func TestSaobeiQueryRejectsInvalidTotalFee(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) { + return &saobei.QueryRsp{ + RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, + PayTrace: "LOCAL-1", + OutTradeNo: "SAOBEI-ORDER-1", + TradeState: "SUCCESS", + TotalFee: "invalid", + BuyerPayFee: "90", + }, nil + }}) + + if _, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil); err == nil { + t.Fatal("Query() accepted an invalid total_fee") + } +} + +func TestSaobeiQueryTreatsPayingAsPending(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{query: func(context.Context, gopay.BodyMap) (*saobei.QueryRsp, error) { + return &saobei.QueryRsp{ + RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: saobei.ResultCodePaying}, + TerminalTrace: "LOCAL-QUERY-1", + OutTradeNo: "SAOBEI-ORDER-1", + TradeState: "SUCCESS", + TotalFee: "50", + ChannelTradeNo: "CHANNEL-1", + }, nil + }}) + + result, err := adapter.Query(context.Background(), "SAOBEI-ORDER-1", nil) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if result.Status != "pending" { + t.Fatalf("Query() status = %q, want pending", result.Status) + } +} + +func TestSaobeiRefundPrefersPersistedQueryID(t *testing.T) { + var refundBody gopay.BodyMap + adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(_ context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) { + refundBody = bm + return &saobei.RefundRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, TerminalTrace: "LOCAL-REFUND-1", OutTradeNo: "SAOBEI-ORDER-1", OutRefundNo: "SAOBEI-REFUND-1", RefundFee: "50"}, nil + }}) + + result, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{ + TradeNo: "LOCAL-1", QueryID: "SAOBEI-ORDER-1", ProviderTradeNo: "CHANNEL-1", + RefundNo: "LOCAL-REFUND-1", Amount: 50, Currency: "cny", + }, map[string]any{"refund_extra": map[string]any{"out_refund_no": "MUST-NOT-BE-SENT"}}) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if refundBody.GetString("out_trade_no") != "SAOBEI-ORDER-1" { + t.Fatalf("Refund() out_trade_no = %q, want persisted QueryID", refundBody.GetString("out_trade_no")) + } + if refundBody.GetString("terminal_trace") != "LOCAL-REFUND-1" { + t.Fatalf("Refund() terminal_trace = %q, want refund number", refundBody.GetString("terminal_trace")) + } + if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "SAOBEI-REFUND-1" || result.Amount != 50 || result.Currency != "CNY" { + t.Fatalf("Refund() result = %+v", result) + } + if _, ok := refundBody["out_refund_no"]; ok { + t.Fatal("Refund() sent unsupported out_refund_no request field") + } +} + +func TestSaobeiRefundFallsBackToMerchantTradeNo(t *testing.T) { + var refundBody gopay.BodyMap + adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(_ context.Context, bm gopay.BodyMap) (*saobei.RefundRsp, error) { + refundBody = bm + return &saobei.RefundRsp{RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, TerminalTrace: "LOCAL-REFUND-2", OutRefundNo: "SAOBEI-REFUND-2", RefundFee: "25"}, nil + }}) + + _, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{ + TradeNo: "LOCAL-2", RefundNo: "LOCAL-REFUND-2", Amount: 25, Currency: "CNY", + }, nil) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if refundBody.GetString("out_trade_no") != "LOCAL-2" { + t.Fatalf("Refund() out_trade_no = %q, want merchant trade number fallback", refundBody.GetString("out_trade_no")) + } + if refundBody.GetString("terminal_trace") != "LOCAL-REFUND-2" { + t.Fatalf("Refund() terminal_trace = %q, want refund number", refundBody.GetString("terminal_trace")) + } +} + +func TestSaobeiRefundTreatsPayingAsPending(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) { + return &saobei.RefundRsp{ + RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: saobei.ResultCodePaying}, + TerminalTrace: "LOCAL-REFUND-3", + OutRefundNo: "SAOBEI-REFUND-3", + RefundFee: "25", + }, nil + }}) + + result, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{ + TradeNo: "LOCAL-3", RefundNo: "LOCAL-REFUND-3", Amount: 25, Currency: "CNY", + }, nil) + if err != nil { + t.Fatalf("Refund() error = %v", err) + } + if result.Status != "pending" || result.ProviderTradeNo != "SAOBEI-REFUND-3" { + t.Fatalf("Refund() result = %+v", result) + } +} + +func TestSaobeiRefundRejectsMismatchedAmount(t *testing.T) { + adapter := newSaobeiTestAdapter(&saobeiClientStub{refund: func(context.Context, gopay.BodyMap) (*saobei.RefundRsp, error) { + return &saobei.RefundRsp{ + RspBase: saobei.RspBase{ReturnCode: "01", ResultCode: "01"}, + TerminalTrace: "LOCAL-REFUND-1", + OutRefundNo: "SAOBEI-REFUND-1", + RefundFee: "51", + }, nil + }}) + + if _, err := adapter.Refund(context.Background(), &biz.PaymentRefundRequest{ + TradeNo: "LOCAL-1", RefundNo: "LOCAL-REFUND-1", Amount: 50, Currency: "CNY", + }, nil); err == nil { + t.Fatal("Refund() accepted a mismatched refund amount") + } +} diff --git a/internal/data/payment/vendor.go b/internal/data/payment/vendor.go index 93d6fd8..75c0780 100644 --- a/internal/data/payment/vendor.go +++ b/internal/data/payment/vendor.go @@ -38,6 +38,9 @@ func newVendorAdapter(provider string, profile vendorProfile) Adapter { } func (a *vendorPaymentAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("配置驱动支付下单请求为空") + } payload := map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "subject": req.Subject, "amount": req.Amount, "currency": req.Currency, "notify_url": req.NotifyURL, "client_ip": req.ClientIP, "timestamp": time.Now().Unix(), "nonce": nonce()} mergeMap(payload, req.Extra, "merchant_id", "app_id", "trade_no", "subject", "amount", "currency", "notify_url", "client_ip", "timestamp", "nonce") return a.call(ctx, "create_url", payload, req.TradeNo, c) @@ -52,6 +55,9 @@ func (a *vendorPaymentAdapter) Refund(ctx context.Context, req *biz.PaymentRefun return a.call(ctx, "refund_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "refund_no": req.RefundNo, "amount": req.Amount, "total_amount": req.TotalAmount, "currency": req.Currency, "timestamp": time.Now().Unix(), "nonce": nonce()}, req.TradeNo, c) } func (a *vendorPaymentAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { + if callback == nil || len(callback.Body) == 0 { + return nil, errors.New("配置驱动支付回调为空") + } fields := callbackFields(callback) if err := a.verify(fields, callback.Body, callback.Headers, c); err != nil { return nil, err @@ -259,10 +265,32 @@ func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, head } expected := first(fields, "sign", "signature") if expected == "" { - expected = first(headers, "X-Payment-Sign", "X-SFT-Sign", "X-Wechat-Game-Sign", "X-TT-Pay-Sign") + expected = firstVendorHeader(headers, "X-Payment-Sign", "X-SFT-Sign", "X-Wechat-Game-Sign", "X-TT-Pay-Sign") } var actual string switch a.profile { + case vendorChinaums: + // Chinaums uses the OPEN-BODY-SIG Authorization contract rather than + // the HMAC header used by the other configurable profiles. Keep the + // header parsing case-insensitive because net/http canonicalizes names + // but test/proxy callers do not necessarily do so. + auth := parseChinaumsAuthorization(firstVendorHeader(headers, "Authorization")) + if len(auth) == 0 { + return errors.New("Chinaums 回调缺少有效 Authorization") + } + expected = auth["signature"] + appID := strings.TrimSpace(auth["appid"]) + configuredAppID := strings.TrimSpace(text(c, "app_id")) + if appID == "" || configuredAppID == "" || appID != configuredAppID { + return errors.New("Chinaums 回调 AppId 不匹配") + } + timestamp := strings.TrimSpace(auth["timestamp"]) + nonceValue := strings.TrimSpace(auth["nonce"]) + if expected == "" || timestamp == "" || nonceValue == "" { + return errors.New("Chinaums 回调签名信息不完整") + } + digest := sha256.Sum256([]byte(configuredAppID + timestamp + nonceValue + string(raw) + secret)) + actual = hex.EncodeToString(digest[:]) case vendorSFT, vendorDouyinGame: values := map[string]any{} for key, value := range fields { @@ -280,6 +308,46 @@ func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, head return nil } +func firstVendorHeader(headers map[string]string, names ...string) string { + for _, name := range names { + for key, value := range headers { + if strings.EqualFold(strings.TrimSpace(key), name) { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + } + return "" +} + +func parseChinaumsAuthorization(value string) map[string]string { + result := map[string]string{} + value = strings.TrimSpace(value) + if value == "" { + return result + } + parts := strings.Fields(value) + if len(parts) < 2 || !strings.EqualFold(parts[0], "OPEN-BODY-SIG") { + return result + } + if index := strings.IndexAny(value, " \t"); index >= 0 { + value = strings.TrimSpace(value[index+1:]) + } + for _, item := range strings.Split(value, ",") { + key, rawValue, ok := strings.Cut(strings.TrimSpace(item), "=") + if !ok { + continue + } + key = strings.ToLower(strings.TrimSpace(key)) + rawValue = strings.Trim(strings.TrimSpace(rawValue), "\"") + if key != "" && rawValue != "" { + result[key] = rawValue + } + } + return result +} + func ensureJSON(raw []byte) []byte { if json.Valid(raw) { return raw diff --git a/internal/data/payment/vendor_test.go b/internal/data/payment/vendor_test.go new file mode 100644 index 0000000..29e74c5 --- /dev/null +++ b/internal/data/payment/vendor_test.go @@ -0,0 +1,33 @@ +package payment + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestChinaumsCallbackAuthorizationVerification(t *testing.T) { + profile := &vendorPaymentAdapter{profile: vendorChinaums} + config := map[string]any{"app_id": "china-app", "app_key": "china-secret"} + raw := []byte(`{"status":"SUCCESS","trade_no":"T-1"}`) + timestamp, nonceValue := "1700000000", "nonce-1" + digest := sha256.Sum256([]byte("china-app" + timestamp + nonceValue + string(raw) + "china-secret")) + headers := map[string]string{ + "authorization": "OPEN-BODY-SIG AppId=china-app, Timestamp=" + timestamp + ", Nonce=" + nonceValue + ", Signature=" + hex.EncodeToString(digest[:]), + } + if err := profile.verify(nil, raw, headers, config); err != nil { + t.Fatalf("valid Chinaums callback rejected: %v", err) + } + if err := profile.verify(nil, []byte(`{"status":"FAILED","trade_no":"T-1"}`), headers, config); err == nil { + t.Fatal("tampered Chinaums callback accepted") + } + missingNonce := map[string]string{"Authorization": "OPEN-BODY-SIG AppId=china-app, Timestamp=" + timestamp + ", Signature=" + hex.EncodeToString(digest[:])} + if err := profile.verify(nil, raw, missingNonce, config); err == nil || !strings.Contains(err.Error(), "信息不完整") { + t.Fatalf("missing Chinaums signature component error = %v", err) + } + mismatchedApp := map[string]string{"Authorization": "OPEN-BODY-SIG AppId=other-app, Timestamp=" + timestamp + ", Nonce=" + nonceValue + ", Signature=" + hex.EncodeToString(digest[:])} + if err := profile.verify(nil, raw, mismatchedApp, config); err == nil || !strings.Contains(err.Error(), "AppId") { + t.Fatalf("mismatched Chinaums AppId error = %v", err) + } +} diff --git a/internal/data/payment/wechat_v2.go b/internal/data/payment/wechat_v2.go index e5d60e6..904b291 100644 --- a/internal/data/payment/wechat_v2.go +++ b/internal/data/payment/wechat_v2.go @@ -50,10 +50,17 @@ func wechatV2Sign(values map[string]string, key, signType string) string { } func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("微信支付 v2 下单请求为空") + } client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url")) if err != nil { return nil, err } + tradeType, err := wechatV2CreateMethod(req.Extra, c) + if err != nil { + return nil, err + } values := map[string]string{ "appid": client.AppId, "mch_id": client.MchId, @@ -64,7 +71,7 @@ func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c "fee_type": strings.ToUpper(req.Currency), "spbill_create_ip": req.ClientIP, "notify_url": req.NotifyURL, - "trade_type": stringOr(req.Extra, "trade_type", firstAny(c, "trade_type", "pay_type")), + "trade_type": tradeType, "sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")), } if values["spbill_create_ip"] == "" { @@ -73,10 +80,11 @@ func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c if values["spbill_create_ip"] == "" { values["spbill_create_ip"] = "127.0.0.1" } - if values["trade_type"] == "" { - values["trade_type"] = "JSAPI" + if tradeType == "MICROPAY" { + return a.createMicropay(ctx, client, req, c, values) } mergeWechatV2Extras(values, req.Extra) + values["trade_type"] = tradeType rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values)) if err != nil { return nil, err @@ -99,7 +107,106 @@ func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c }, nil } +func (a *wechatV2Adapter) createMicropay(ctx context.Context, client *gopayWechat.Client, req *biz.PaymentRequest, c map[string]any, values map[string]string) (*biz.PaymentResult, error) { + authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code", "payment_code") + if authCode == "" { + authCode = firstAny(c, "auth_code", "authcode", "barcode", "pay_code", "payment_code") + } + if strings.TrimSpace(authCode) == "" { + return nil, errors.New("微信支付 v2 付款码支付缺少 auth_code") + } + delete(values, "trade_type") + delete(values, "notify_url") + mergeWechatV2Extras(values, req.Extra) + values["auth_code"] = strings.TrimSpace(authCode) + rsp, err := client.Micropay(ctx, toGoPayBodyMap(values)) + if err != nil { + return nil, err + } + if rsp == nil { + return nil, errors.New("微信支付 v2 付款码支付响应为空") + } + resultValues, err := wechatV2ResponseValues(rsp) + if err != nil { + return nil, err + } + if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil { + return nil, err + } + if resultValues["return_code"] != "SUCCESS" { + return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "return_msg", "err_code_des", "err_code")) + } + if resultValues["result_code"] != "SUCCESS" && !strings.EqualFold(resultValues["err_code"], "USERPAYING") { + return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "err_code_des", "return_msg", "err_code")) + } + if resultValues["out_trade_no"] == "" { + return nil, errors.New("微信支付 v2 付款码支付响应缺少 out_trade_no") + } + if resultValues["out_trade_no"] != req.TradeNo { + return nil, errors.New("微信支付 v2 付款码支付响应的 out_trade_no 不匹配") + } + if strings.EqualFold(resultValues["err_code"], "USERPAYING") { + return &biz.PaymentResult{Provider: biz.PaymentWechatV2, Status: "pending", TradeNo: req.TradeNo, Payload: wechatV2Payload(resultValues)}, nil + } + providerTradeNo := strings.TrimSpace(resultValues["transaction_id"]) + if providerTradeNo == "" { + return nil, errors.New("微信支付 v2 付款码支付响应缺少 transaction_id") + } + result := &biz.PaymentResult{ + Provider: biz.PaymentWechatV2, Status: "success", TradeNo: req.TradeNo, + ProviderTradeNo: providerTradeNo, Payload: wechatV2Payload(resultValues), + } + amount := strings.TrimSpace(resultValues["total_fee"]) + if amount == "" { + return nil, errors.New("微信支付 v2 付款码支付响应缺少 total_fee") + } + result.Amount, err = parseIntegerAmount(amount) + if err != nil { + return nil, fmt.Errorf("解析微信支付 v2 付款码金额: %w", err) + } + if result.Amount != req.Amount { + return nil, errors.New("微信支付 v2 付款码支付响应金额不匹配") + } + result.Currency = strings.ToUpper(resultValues["fee_type"]) + requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency)) + if result.Currency == "" { + result.Currency = requestCurrency + } else if requestCurrency != "" && result.Currency != requestCurrency { + return nil, errors.New("微信支付 v2 付款码支付响应币种不匹配") + } + if err = populateWechatV2Breakdown(result, resultValues); err != nil { + return nil, err + } + return result, nil +} + +func wechatV2CreateMethod(extra, config map[string]any) (string, error) { + value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method", "channel") + if value == "" { + value = firstAny(config, "trade_type", "pay_type", "method", "pay_method", "channel") + } + normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "", "jsapi", "js_api", "mini", "miniapp", "mini_program", "miniprogram", "applet": + return gopayWechat.TradeType_JsApi, nil + case "app", "app_pay": + return gopayWechat.TradeType_App, nil + case "native", "qr", "qrcode": + return gopayWechat.TradeType_Native, nil + case "h5", "h5_pay", "mweb", "wap", "wap_pay": + return gopayWechat.TradeType_H5, nil + case "micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code": + return "MICROPAY", nil + default: + return "", fmt.Errorf("微信支付 v2 不支持的下单方式: %s", value) + } +} + func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("微信支付 v2 查单缺少 out_trade_no") + } client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url")) if err != nil { return nil, err @@ -128,6 +235,8 @@ func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[strin } if result.TradeNo == "" { result.TradeNo = tradeNo + } else if result.TradeNo != tradeNo { + return nil, errors.New("微信支付 v2 查询响应的 out_trade_no 不匹配") } if result.Status != "success" { return result, nil @@ -154,6 +263,14 @@ func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ if req == nil { return nil, errors.New("微信支付 v2 退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + refundNo := strings.TrimSpace(req.RefundNo) + if tradeNo == "" || refundNo == "" { + return nil, errors.New("微信支付 v2 退款缺少 out_trade_no 或 out_refund_no") + } + if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { + return nil, errors.New("微信支付 v2 退款金额无效") + } client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url")) if err != nil { return nil, err @@ -162,8 +279,8 @@ func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ "appid": client.AppId, "mch_id": client.MchId, "nonce_str": nonce(), - "out_trade_no": req.TradeNo, - "out_refund_no": req.RefundNo, + "out_trade_no": tradeNo, + "out_refund_no": refundNo, "total_fee": fmt.Sprint(req.TotalAmount), "refund_fee": fmt.Sprint(req.Amount), "sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")), @@ -186,11 +303,14 @@ func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil { return nil, err } + if err = validateWechatV2RefundIdentity(tradeNo, refundNo, rsp); err != nil { + return nil, err + } result := &biz.PaymentResult{ Provider: biz.PaymentWechatV2, Status: "created", - TradeNo: req.TradeNo, - ProviderTradeNo: rsp.RefundId, + TradeNo: tradeNo, + ProviderTradeNo: strings.TrimSpace(rsp.RefundId), Currency: currency, Payload: wechatV2Payload(resultValues), } @@ -199,11 +319,43 @@ func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ if err != nil { return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err) } + if result.Amount != req.Amount { + return nil, errors.New("微信支付 v2 退款响应金额不匹配") + } + } else { + result.Amount = req.Amount + } + if responseCurrency := strings.ToUpper(strings.TrimSpace(rsp.FeeType)); responseCurrency != "" { + if currency != "" && responseCurrency != currency { + return nil, errors.New("微信支付 v2 退款响应币种不匹配") + } + result.Currency = responseCurrency } return result, nil } +func validateWechatV2RefundIdentity(tradeNo, refundNo string, rsp *gopayWechat.RefundResponse) error { + if rsp == nil { + return errors.New("微信支付 v2 退款响应为空") + } + if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo { + return errors.New("微信支付 v2 退款响应的 out_trade_no 不匹配") + } + if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" { + return errors.New("微信支付 v2 退款响应缺少 out_refund_no") + } else if returnedRefundNo != refundNo { + return errors.New("微信支付 v2 退款响应的 out_refund_no 不匹配") + } + if strings.TrimSpace(rsp.RefundId) == "" { + return errors.New("微信支付 v2 退款响应缺少 refund_id") + } + return nil +} + func (a *wechatV2Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { + if callback == nil || len(callback.Body) == 0 { + return nil, errors.New("微信支付 v2 回调为空") + } request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body)) if err != nil { return nil, err @@ -289,7 +441,8 @@ func wechatV2ResponseValues(value any) (map[string]string, error) { } func validateWechatV2Response(values map[string]string) error { - if values["return_code"] == "SUCCESS" && (values["result_code"] == "" || values["result_code"] == "SUCCESS") { + if strings.EqualFold(strings.TrimSpace(values["return_code"]), "SUCCESS") && + strings.EqualFold(strings.TrimSpace(values["result_code"]), "SUCCESS") { return nil } message := first(values, "err_code_des", "return_msg", "err_code") @@ -301,7 +454,7 @@ func validateWechatV2Response(values map[string]string) error { func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error { if values["sign"] == "" { - return nil + return errors.New("微信支付 v2 响应缺少签名") } signType := values["sign_type"] if signType == "" { @@ -367,7 +520,8 @@ func parseWechatInteger(values map[string]string, key string) (int64, bool, erro func mergeWechatV2Extras(dst map[string]string, extra map[string]any) { protected := map[string]struct{}{ "appid": {}, "mch_id": {}, "nonce_str": {}, "body": {}, "out_trade_no": {}, - "total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "sign": {}, + "total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "trade_type": {}, + "auth_code": {}, "sign": {}, } for key, value := range stringMap(extra) { if _, exists := protected[key]; exists { diff --git a/internal/data/payment/wechat_v3.go b/internal/data/payment/wechat_v3.go index 26bb1c2..9d49b55 100644 --- a/internal/data/payment/wechat_v3.go +++ b/internal/data/payment/wechat_v3.go @@ -22,6 +22,9 @@ import ( type wechatV3Adapter struct{} func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { + if req == nil { + return nil, errors.New("微信支付 v3 下单请求为空") + } client, err := newWechatV3Client(c, "create_url") if err != nil { return nil, err @@ -38,12 +41,12 @@ func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c }) mergeGoPayExtras(bm, req.Extra, "appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer", - "trade_type", "pay_type", "method", "openid", "open_id", + "trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode", ) - tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "method")) + tradeType := normalizeWechatV3TradeType(firstAny(req.Extra, "trade_type", "pay_type", "method")) if tradeType == "" { - tradeType = strings.ToLower(firstAny(c, "trade_type", "pay_type", "method")) + tradeType = normalizeWechatV3TradeType(firstAny(c, "trade_type", "pay_type", "method")) } switch tradeType { case "", "jsapi", "mini", "miniprogram", "mini_program", "applet": @@ -85,12 +88,103 @@ func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c return nil, errors.New("微信支付 v3 下单响应为空") } return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error) + case "micropay", "micro_pay", "codepay", "code_pay", "barcode", "barcode_pay", "facepay", "face_pay": + authCode := strings.TrimSpace(firstAny(req.Extra, "auth_code", "authcode", "barcode")) + if authCode == "" { + return nil, errors.New("微信支付 v3 付款码支付缺少 auth_code") + } + bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("auth_code", authCode) }) + rsp, callErr := client.V3TransactionCodePay(ctx, bm) + if callErr != nil { + return nil, callErr + } + result, err := wechatV3CodePayResult(req.TradeNo, rsp) + if err != nil { + return nil, err + } + if result.Amount > 0 && result.Amount != req.Amount { + return nil, errors.New("微信支付 v3 付款码响应金额不匹配") + } + if result.Currency != "" && req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { + return nil, errors.New("微信支付 v3 付款码响应币种不匹配") + } + if rsp.Response.Appid != "" && rsp.Response.Appid != firstAny(c, "app_id", "appid") { + return nil, errors.New("微信支付 v3 付款码响应 appid 不匹配") + } + if rsp.Response.Mchid != "" && rsp.Response.Mchid != firstAny(c, "merchant_id", "mch_id", "mchid") { + return nil, errors.New("微信支付 v3 付款码响应商户号不匹配") + } + return result, nil default: return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType) } } +func normalizeWechatV3TradeType(value string) string { + return strings.NewReplacer(".", "", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +} + +func wechatV3CodePayResult(tradeNo string, rsp *gopayWechatV3.CodePayRsp) (*biz.PaymentResult, error) { + if rsp == nil { + return nil, errors.New("微信支付 v3 付款码响应为空") + } + if rsp.Code != gopayWechatV3.Success { + return nil, wechatV3APIError("付款码支付", rsp.Code, rsp.ErrResponse, rsp.Error) + } + if rsp.Response == nil { + return nil, errors.New("微信支付 v3 付款码响应缺少订单") + } + order := rsp.Response + if returnedTradeNo := strings.TrimSpace(order.OutTradeNo); returnedTradeNo == "" { + return nil, errors.New("微信支付 v3 付款码响应缺少 out_trade_no") + } else if returnedTradeNo != tradeNo { + return nil, errors.New("微信支付 v3 付款码响应的商户订单号不匹配") + } + result := &biz.PaymentResult{ + Provider: biz.PaymentWechatV3, + Status: normalizePaymentStatus(order.TradeState, "pending"), + TradeNo: tradeNo, + ProviderTradeNo: strings.TrimSpace(order.TransactionId), + Payload: wechatV3ResponsePayload(rsp.SignInfo, order), + } + if order.Amount != nil { + result.Amount = int64(order.Amount.Total) + result.Currency = strings.ToUpper(order.Amount.Currency) + result.PayerCurrency = strings.ToUpper(order.Amount.PayerCurrency) + if result.PayerCurrency == "" { + result.PayerCurrency = result.Currency + } + // GoPay's Amount.PayerTotal is an int, so a missing JSON field and an + // explicit zero are otherwise indistinguishable. Inspect the signed + // response body before marking the amount breakdown as authoritative. + payloadObject := jsonObject(wechatV3ResponsePayload(rsp.SignInfo, order)) + if amountObject, ok := valueAtPath(payloadObject, "amount").(map[string]any); ok { + if payerTotal, payerOK, payerErr := parseJSONInteger(amountObject, "payer_total"); payerErr != nil { + return nil, fmt.Errorf("解析微信支付 v3 付款码 amount.payer_total: %w", payerErr) + } else if payerOK && payerTotal >= 0 && payerTotal <= result.Amount { + result.PayerPaidAmount = payerTotal + result.CashPaidAmount = payerTotal + result.DiscountAmount = result.Amount - payerTotal + result.AmountBreakdownKnown = true + } + } + } + if result.Status == "success" { + if result.ProviderTradeNo == "" { + return nil, errors.New("微信支付 v3 付款码响应缺少 transaction_id") + } + if result.Amount <= 0 || result.Currency == "" { + return nil, errors.New("微信支付 v3 付款码响应缺少有效金额") + } + } + return result, nil +} + func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return nil, errors.New("微信支付 v3 查单缺少 out_trade_no") + } client, err := newWechatV3Client(c, "query_url") if err != nil { return nil, err @@ -120,6 +214,8 @@ func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[strin } if result.TradeNo == "" { result.TradeNo = tradeNo + } else if result.TradeNo != tradeNo { + return nil, errors.New("微信支付 v3 查询响应的 out_trade_no 不匹配") } if result.Status != "success" { return result, nil @@ -145,11 +241,22 @@ func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ if req == nil { return nil, errors.New("微信支付 v3 退款请求为空") } + tradeNo := strings.TrimSpace(req.TradeNo) + if tradeNo == "" { + return nil, errors.New("微信支付 v3 退款请求缺少 out_trade_no") + } + refundNo := strings.TrimSpace(req.RefundNo) + if refundNo == "" { + return nil, errors.New("微信支付 v3 退款请求缺少 out_refund_no") + } + if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { + return nil, errors.New("微信支付 v3 退款金额无效") + } client, err := newWechatV3Client(c, "refund_url") if err != nil { return nil, err } - bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo} + bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_refund_no": refundNo} bm.SetBodyMap("amount", func(values gopay.BodyMap) { values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency)) }) @@ -169,29 +276,67 @@ func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequ } refund := rsp.Response + refundID, err := validateWechatV3RefundIdentity(refund, tradeNo, refundNo) + if err != nil { + return nil, err + } result := &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: normalizeRefundState(refund.Status), - TradeNo: refund.OutTradeNo, - ProviderTradeNo: refund.RefundId, + TradeNo: tradeNo, + ProviderTradeNo: refundID, Payload: wechatV3ResponsePayload(rsp.SignInfo, refund), } - if result.TradeNo == "" { - result.TradeNo = req.TradeNo + if refund.Amount == nil { + return nil, errors.New("微信支付 v3 退款响应缺少金额") } - if refund.Amount != nil { - result.Amount = int64(refund.Amount.Refund) - result.Currency = strings.ToUpper(refund.Amount.Currency) - } - if result.Amount == 0 { - result.Amount = req.Amount + result.Amount = int64(refund.Amount.Refund) + result.Currency = strings.ToUpper(strings.TrimSpace(refund.Amount.Currency)) + if result.Amount != req.Amount { + return nil, errors.New("微信支付 v3 退款响应金额不匹配") } if result.Currency == "" { - result.Currency = strings.ToUpper(req.Currency) + return nil, errors.New("微信支付 v3 退款响应缺少币种") + } + if req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { + return nil, errors.New("微信支付 v3 退款响应币种不匹配") } return result, nil } +func validateWechatV3RefundIdentity(refund *gopayWechatV3.RefundOrderResponse, tradeNo, refundNo string) (string, error) { + if refund == nil { + return "", errors.New("微信支付 v3 退款响应缺少退款单") + } + tradeNo = strings.TrimSpace(tradeNo) + if tradeNo == "" { + return "", errors.New("微信支付 v3 退款请求缺少 out_trade_no") + } + refundNo = strings.TrimSpace(refundNo) + if refundNo == "" { + return "", errors.New("微信支付 v3 退款请求缺少 out_refund_no") + } + returnedTradeNo := strings.TrimSpace(refund.OutTradeNo) + if returnedTradeNo == "" { + return "", errors.New("微信支付 v3 退款响应缺少 out_trade_no") + } + if returnedTradeNo != tradeNo { + return "", errors.New("微信支付 v3 退款响应的 out_trade_no 不匹配") + } + returnedRefundNo := strings.TrimSpace(refund.OutRefundNo) + if returnedRefundNo == "" { + return "", errors.New("微信支付 v3 退款响应缺少 out_refund_no") + } + if returnedRefundNo != refundNo { + return "", errors.New("微信支付 v3 退款响应的 out_refund_no 不匹配") + } + refundID := strings.TrimSpace(refund.RefundId) + if refundID == "" { + return "", errors.New("微信支付 v3 退款响应缺少 refund_id") + } + return refundID, nil +} + func (a *wechatV3Adapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { if callback == nil { return nil, errors.New("微信支付 v3 回调为空") diff --git a/internal/data/payment/wechat_v3_codepay_test.go b/internal/data/payment/wechat_v3_codepay_test.go new file mode 100644 index 0000000..64e6019 --- /dev/null +++ b/internal/data/payment/wechat_v3_codepay_test.go @@ -0,0 +1,204 @@ +package payment + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "kra/internal/biz" + + gopayWechatV3 "github.com/go-pay/gopay/wechat/v3" +) + +func TestWechatV3CodePayUsesGoPaySDK(t *testing.T) { + platformKey, _, platformPublicPEM := testRSAKeyPair(t) + _, merchantPrivatePEM, _ := testRSAKeyPair(t) + const ( + platformSerial = "PLATFORM-SERIAL" + timestamp = "1787068800" + nonce = "codepay-response-nonce" + ) + response := []byte(`{"appid":"wx-test","mchid":"mch-test","out_trade_no":"T-WECHAT-CODEPAY","transaction_id":"WX-CODEPAY-1","trade_type":"MICROPAY","trade_state":"SUCCESS","amount":{"total":123,"payer_total":100,"currency":"CNY","payer_currency":"CNY"},"promotion_detail":[{"coupon_id":"COUPON-1","amount":23}]}`) + signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+nonce+"\n"+string(response)+"\n")) + + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v3/pay/transactions/codepay" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if strings.TrimSpace(r.Header.Get("Authorization")) == "" { + t.Error("missing GoPay authorization header") + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request: %v", err) + return + } + if err = json.Unmarshal(body, &requestBody); err != nil { + t.Errorf("decode request: %v", err) + return + } + + w.Header().Set(gopayWechatV3.HeaderTimestamp, timestamp) + w.Header().Set(gopayWechatV3.HeaderNonce, nonce) + w.Header().Set(gopayWechatV3.HeaderSerial, platformSerial) + w.Header().Set(gopayWechatV3.HeaderSignature, signature) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + })) + defer server.Close() + + result, err := (&wechatV3Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + Provider: biz.PaymentWechatV3, + TradeNo: "T-WECHAT-CODEPAY", + Subject: "subject", + Amount: 123, + Currency: "CNY", + NotifyURL: "https://merchant.test/payment/callback", + Extra: map[string]any{ + "method": "codepay", + "auth_code": "AUTH-CODE-1", + "payer": map[string]any{"auth_code": "OVERRIDE"}, + }, + }, map[string]any{ + "app_id": "wx-test", + "merchant_id": "mch-test", + "serial_no": "merchant-serial", + "private_key": merchantPrivatePEM, + "api_v3_key": "01234567890123456789012345678901", + "platform_cert": platformPublicPEM, + "platform_serial_no": platformSerial, + "create_url": server.URL, + }) + if err != nil { + t.Fatal(err) + } + + payer, _ := requestBody["payer"].(map[string]any) + amount, _ := requestBody["amount"].(map[string]any) + if payer["auth_code"] != "AUTH-CODE-1" || requestBody["out_trade_no"] != "T-WECHAT-CODEPAY" { + t.Fatalf("request identity = %#v", requestBody) + } + if amount["total"] != float64(123) || amount["currency"] != "CNY" { + t.Fatalf("request amount = %#v", amount) + } + if result.Provider != biz.PaymentWechatV3 || result.Status != "success" || result.TradeNo != "T-WECHAT-CODEPAY" || result.ProviderTradeNo != "WX-CODEPAY-1" || result.QueryID != "" { + t.Fatalf("result identity = %+v", result) + } + if result.Amount != 123 || result.Currency != "CNY" || result.PayerCurrency != "CNY" || !result.AmountBreakdownKnown || result.PayerPaidAmount != 100 || result.CashPaidAmount != 100 || result.DiscountAmount != 23 { + t.Fatalf("result amount = %+v", result) + } +} + +func TestWechatV3CodePayAliasesRequireAuthCode(t *testing.T) { + _, merchantPrivatePEM, _ := testRSAKeyPair(t) + _, _, platformPublicPEM := testRSAKeyPair(t) + config := map[string]any{ + "app_id": "wx-test", + "merchant_id": "mch-test", + "serial_no": "merchant-serial", + "private_key": merchantPrivatePEM, + "api_v3_key": "01234567890123456789012345678901", + "platform_cert": platformPublicPEM, + "platform_serial_no": "platform-serial", + } + + for _, method := range []string{"micropay", "micro_pay", "micro-pay", "codepay", "code_pay", "code-pay", "barcode", "barcode_pay", "facepay", "face_pay"} { + t.Run(method, func(t *testing.T) { + _, err := (&wechatV3Adapter{}).Create(context.Background(), &biz.PaymentRequest{ + TradeNo: "T-WECHAT-CODEPAY", Subject: "subject", Amount: 1, Currency: "CNY", + Extra: map[string]any{"method": method}, + }, config) + if err == nil || !strings.Contains(err.Error(), "auth_code") { + t.Fatalf("method %q error = %v", method, err) + } + }) + } +} + +func TestWechatV3CodePayResultRejectsInvalidIdentity(t *testing.T) { + _, err := wechatV3CodePayResult("T-WECHAT-CODEPAY", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{ + OutTradeNo: "OTHER-ORDER", + TradeState: "SUCCESS", + Amount: &gopayWechatV3.Amount{Total: 1, Currency: "CNY"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "商户订单号不匹配") { + t.Fatalf("identity mismatch error = %v", err) + } +} + +func TestWechatV3CodePayResultRequiresMerchantOrderNumber(t *testing.T) { + _, err := wechatV3CodePayResult("T-WECHAT-CODEPAY", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{ + TradeState: "SUCCESS", + TransactionId: "WX-CODEPAY-1", + Amount: &gopayWechatV3.Amount{Total: 1, Currency: "CNY"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "out_trade_no") { + t.Fatalf("missing out_trade_no error = %v", err) + } +} + +func TestWechatV3CodePayResultPreservesFullyDiscountedBreakdown(t *testing.T) { + result, err := wechatV3CodePayResult("T-WECHAT-CODEPAY", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{ + OutTradeNo: "T-WECHAT-CODEPAY", + TransactionId: "WX-CODEPAY-1", + TradeState: "SUCCESS", + Amount: &gopayWechatV3.Amount{ + Total: 100, PayerTotal: 0, Currency: "CNY", PayerCurrency: "CNY", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if !result.AmountBreakdownKnown || result.PayerPaidAmount != 0 || result.CashPaidAmount != 0 || result.DiscountAmount != 100 || result.PayerCurrency != "CNY" { + t.Fatalf("fully discounted result = %+v", result) + } +} + +func TestWechatV3CodePayDoesNotInferMissingPayerTotal(t *testing.T) { + result, err := wechatV3CodePayResult("T-WECHAT-CODEPAY", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + SignInfo: &gopayWechatV3.SignInfo{SignBody: `{"out_trade_no":"T-WECHAT-CODEPAY","transaction_id":"WX-CODEPAY-2","trade_state":"SUCCESS","amount":{"total":100,"currency":"CNY"}}`}, + Response: &gopayWechatV3.CodePay{ + OutTradeNo: "T-WECHAT-CODEPAY", + TransactionId: "WX-CODEPAY-2", + TradeState: "SUCCESS", + Amount: &gopayWechatV3.Amount{Total: 100, Currency: "CNY"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.AmountBreakdownKnown || result.PayerPaidAmount != 0 || result.DiscountAmount != 0 { + t.Fatalf("missing payer_total was treated as a complete breakdown: %+v", result) + } +} + +func TestWechatV3CodePayUserPayingRemainsPending(t *testing.T) { + result, err := wechatV3CodePayResult("T-WECHAT-CODEPAY", &gopayWechatV3.CodePayRsp{ + Code: gopayWechatV3.Success, + Response: &gopayWechatV3.CodePay{ + OutTradeNo: "T-WECHAT-CODEPAY", + TradeState: "USERPAYING", + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "pending" || result.TradeNo != "T-WECHAT-CODEPAY" || result.ProviderTradeNo != "" || result.QueryID != "" { + t.Fatalf("pending result = %+v", result) + } +} diff --git a/internal/data/payment/wechat_v3_douyin_refund_test.go b/internal/data/payment/wechat_v3_douyin_refund_test.go new file mode 100644 index 0000000..966c0b8 --- /dev/null +++ b/internal/data/payment/wechat_v3_douyin_refund_test.go @@ -0,0 +1,120 @@ +package payment + +import ( + "context" + "strings" + "testing" + + "kra/internal/biz" + + gopayDouyin "github.com/go-pay/gopay/douyin" + gopayWechatV3 "github.com/go-pay/gopay/wechat/v3" +) + +func TestWechatV3RefundRejectsBlankIdentityBeforeClient(t *testing.T) { + for _, tc := range []struct { + name string + req *biz.PaymentRefundRequest + want string + }{ + {name: "blank trade number", req: &biz.PaymentRefundRequest{TradeNo: " ", RefundNo: "R1"}, want: "out_trade_no"}, + {name: "blank refund number", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: " "}, want: "out_refund_no"}, + {name: "invalid amount", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 2, TotalAmount: 1}, want: "金额"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := (&wechatV3Adapter{}).Refund(context.Background(), tc.req, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Refund() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestWechatV3RefundIdentityValidation(t *testing.T) { + valid := func() *gopayWechatV3.RefundOrderResponse { + return &gopayWechatV3.RefundOrderResponse{ + OutTradeNo: " T1 ", + OutRefundNo: " R1 ", + RefundId: " WX-R1 ", + } + } + refundID, err := validateWechatV3RefundIdentity(valid(), " T1 ", " R1 ") + if err != nil { + t.Fatalf("valid refund response: %v", err) + } + if refundID != "WX-R1" { + t.Fatalf("refund id = %q, want %q", refundID, "WX-R1") + } + for _, tc := range []struct { + name string + edit func(*gopayWechatV3.RefundOrderResponse) + }{ + {name: "trade number missing", edit: func(r *gopayWechatV3.RefundOrderResponse) { r.OutTradeNo = " " }}, + {name: "trade number mismatch", edit: func(r *gopayWechatV3.RefundOrderResponse) { r.OutTradeNo = "OTHER" }}, + {name: "refund number missing", edit: func(r *gopayWechatV3.RefundOrderResponse) { r.OutRefundNo = " " }}, + {name: "refund number mismatch", edit: func(r *gopayWechatV3.RefundOrderResponse) { r.OutRefundNo = "OTHER" }}, + {name: "provider refund id missing", edit: func(r *gopayWechatV3.RefundOrderResponse) { r.RefundId = " " }}, + } { + t.Run(tc.name, func(t *testing.T) { + refund := valid() + tc.edit(refund) + if _, err := validateWechatV3RefundIdentity(refund, "T1", "R1"); err == nil { + t.Fatal("accepted invalid refund identity") + } + }) + } +} + +func TestDouyinRefundRejectsBlankIdentityBeforeClient(t *testing.T) { + for _, tc := range []struct { + name string + req *biz.PaymentRefundRequest + want string + }{ + {name: "blank trade number", req: &biz.PaymentRefundRequest{TradeNo: " ", RefundNo: "R1"}, want: "out_trade_no"}, + {name: "blank refund number", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: " "}, want: "out_refund_no"}, + {name: "invalid amount", req: &biz.PaymentRefundRequest{TradeNo: "T1", RefundNo: "R1", Amount: 2, TotalAmount: 1}, want: "金额"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := (&douyinAdapter{}).Refund(context.Background(), tc.req, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Refund() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestDouyinRefundIdentityValidation(t *testing.T) { + valid := func() *gopayDouyin.Refund { + return &gopayDouyin.Refund{ + OutTradeNo: " T1 ", + OutRefundNo: " R1 ", + RefundId: " DY-R1 ", + } + } + refundID, err := validateDouyinRefundIdentity(valid(), " T1 ", " R1 ") + if err != nil { + t.Fatalf("valid refund response: %v", err) + } + if refundID != "DY-R1" { + t.Fatalf("refund id = %q, want %q", refundID, "DY-R1") + } + for _, tc := range []struct { + name string + edit func(*gopayDouyin.Refund) + }{ + {name: "trade number missing", edit: func(r *gopayDouyin.Refund) { r.OutTradeNo = " " }}, + {name: "trade number mismatch", edit: func(r *gopayDouyin.Refund) { r.OutTradeNo = "OTHER" }}, + {name: "refund number missing", edit: func(r *gopayDouyin.Refund) { r.OutRefundNo = " " }}, + {name: "refund number mismatch", edit: func(r *gopayDouyin.Refund) { r.OutRefundNo = "OTHER" }}, + {name: "provider refund id missing", edit: func(r *gopayDouyin.Refund) { r.RefundId = " " }}, + } { + t.Run(tc.name, func(t *testing.T) { + refund := valid() + tc.edit(refund) + if _, err := validateDouyinRefundIdentity(refund, "T1", "R1"); err == nil { + t.Fatal("accepted invalid refund identity") + } + }) + } +} diff --git a/internal/data/payment_config_test.go b/internal/data/payment_config_test.go new file mode 100644 index 0000000..42668c3 --- /dev/null +++ b/internal/data/payment_config_test.go @@ -0,0 +1,300 @@ +package data + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "kra/internal/biz" +) + +func TestSavePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) { + db := openIntegrationConfigTestDB(t) + repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}} + values := map[string]any{ + "merchant_id": "merchant-douyin", + "serial_no": "merchant-serial", + "api_key": "01234567890123456789012345678901", + "private_key": "merchant-private-key", + "platform_cert": "platform-public-key", + "platform_serial_no": "platform-serial", + } + encode := func() json.RawMessage { + raw, err := json.Marshal(values) + if err != nil { + t.Fatal(err) + } + return raw + } + + err := repo.SaveConfig(context.Background(), &biz.PaymentConfig{ + Provider: biz.PaymentDouyin, + Enabled: true, + Values: encode(), + }) + if err == nil || !strings.Contains(err.Error(), "app_id") { + t.Fatalf("missing app_id error = %v", err) + } + var count int64 + if err = db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("invalid enabled configuration was persisted: count=%d", count) + } + + values["app_id"] = "douyin-app" + if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{ + Provider: biz.PaymentDouyin, + Enabled: true, + Values: encode(), + }); err != nil { + t.Fatalf("save complete Douyin configuration: %v", err) + } + var stored integrationConfigPO + if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).First(&stored).Error; err != nil { + t.Fatal(err) + } + if !stored.Enabled || !strings.Contains(stored.Config, `"app_id":"douyin-app"`) { + t.Fatalf("stored configuration = %+v", stored) + } +} + +func TestValidateDouyinPaymentConfigAcceptsPlatformCertificateSerialAlias(t *testing.T) { + err := validatePaymentConfig(biz.PaymentDouyin, map[string]any{ + "app_id": "douyin-app", + "merchant_id": "merchant-douyin", + "serial_no": "merchant-serial", + "api_key": "01234567890123456789012345678901", + "private_key": "merchant-private-key", + "platform_cert": "platform-public-key", + "platform_cert_serial": "platform-serial", + }) + if err != nil { + t.Fatalf("platform_cert_serial alias rejected: %v", err) + } +} + +func TestValidateAllinPayPaymentConfigRejectsUnknownQueryOrderType(t *testing.T) { + err := validatePaymentConfig(biz.PaymentAllinPay, map[string]any{ + "cus_id": "customer", + "app_id": "app", + "private_key": "private-key", + "public_key": "public-key", + "query_order_type": "payinfo", + }) + if err == nil || !strings.Contains(err.Error(), "reqsn") || !strings.Contains(err.Error(), "trxid") { + t.Fatalf("unknown query_order_type error = %v", err) + } +} + +func TestValidateAlipayV3PaymentConfigRequiresCertificates(t *testing.T) { + values := map[string]any{ + "app_id": "alipay-v3-app", + "private_key": "private-key", + } + if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "app_cert") { + t.Fatalf("missing app certificate error = %v", err) + } + + values["app_cert_content"] = "app-certificate" + if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "root_cert") { + t.Fatalf("missing root certificate error = %v", err) + } + + values["alipay_root_cert_path"] = "alipayRootCert.crt" + if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "public_cert") { + t.Fatalf("missing public certificate error = %v", err) + } + + values["alipay_public_cert_content"] = "alipay-public-certificate" + if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err != nil { + t.Fatalf("valid Alipay V3 configuration rejected: %v", err) + } +} + +func TestValidatePayPalPaymentConfigRequiresWebhookID(t *testing.T) { + values := map[string]any{ + "client_id": "client-id", + "client_secret": "client-secret", + } + if err := validatePaymentConfig(biz.PaymentPayPal, values); err == nil || !strings.Contains(err.Error(), "webhook_id") { + t.Fatalf("missing webhook_id error = %v", err) + } + + values["webhook_id"] = "webhook-1" + if err := validatePaymentConfig(biz.PaymentPayPal, values); err != nil { + t.Fatalf("valid PayPal configuration rejected: %v", err) + } +} + +func TestValidateWechatV2PaymentConfigRequiresRefundCertificatePair(t *testing.T) { + base := map[string]any{ + "app_id": "wechat-app", + "merchant_id": "wechat-merchant", + "mch_key": "merchant-key", + } + if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_cert") { + t.Fatalf("missing refund certificate error = %v", err) + } + + base["client_cert"] = "certificate-pem" + if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_key") { + t.Fatalf("missing refund private key error = %v", err) + } + + base["client_key"] = "private-key-pem" + if err := validatePaymentConfig(biz.PaymentWechatV2, base); err != nil { + t.Fatalf("valid WeChat v2 configuration rejected: %v", err) + } +} + +func TestValidateQQPaymentConfigRequiresRefundCertificate(t *testing.T) { + base := map[string]any{ + "mch_id": "qq-merchant", + "api_key": "merchant-key", + } + if err := validatePaymentConfig(biz.PaymentQQ, base); err == nil || !strings.Contains(err.Error(), "cert_file") { + t.Fatalf("missing refund certificate error = %v", err) + } + + for _, tc := range []struct { + name string + values map[string]any + }{ + {name: "certificate files", values: map[string]any{"cert_file": "cert.pem", "key_file": "key.pem"}}, + {name: "pkcs12 file", values: map[string]any{"pkcs12_file": "merchant.p12"}}, + {name: "certificate content", values: map[string]any{"cert_content": "certificate-pem", "key_content": "private-key-pem"}}, + {name: "pkcs12 content", values: map[string]any{"pkcs12_content": "base64-pkcs12"}}, + } { + t.Run(tc.name, func(t *testing.T) { + values := map[string]any{} + for key, value := range base { + values[key] = value + } + for key, value := range tc.values { + values[key] = value + } + if err := validatePaymentConfig(biz.PaymentQQ, values); err != nil { + t.Fatalf("valid QQ configuration rejected: %v", err) + } + }) + } +} + +func TestPaymentProviderNotifyURLRequirementMatchesCallbackSupport(t *testing.T) { + for _, tc := range []struct { + provider string + want bool + }{ + {provider: biz.PaymentApple, want: false}, + {provider: biz.PaymentAllinPay, want: false}, + {provider: biz.PaymentSaobei, want: false}, + {provider: biz.PaymentAlipay, want: true}, + {provider: biz.PaymentAlipayV3, want: true}, + {provider: biz.PaymentWechatV3, want: true}, + } { + t.Run(tc.provider, func(t *testing.T) { + if got := paymentProviderRequiresNotifyURL(tc.provider); got != tc.want { + t.Fatalf("requires notify_url = %v, want %v", got, tc.want) + } + }) + } +} + +func TestPaymentCreateNotifyURLRequirementMatchesSynchronousMethods(t *testing.T) { + for _, tc := range []struct { + name string + provider string + extra map[string]any + config map[string]any + want bool + }{ + {name: "paypal create", provider: biz.PaymentPayPal, want: false}, + {name: "alipay barcode", provider: biz.PaymentAlipay, extra: map[string]any{"method": "barcode"}, want: false}, + {name: "alipay v3 barcode", provider: biz.PaymentAlipayV3, extra: map[string]any{"method": "barcode"}, want: false}, + {name: "alipay ignores unsupported pay type selector", provider: biz.PaymentAlipay, extra: map[string]any{"pay_type": "barcode"}, want: true}, + {name: "wechat v2 micropay", provider: biz.PaymentWechatV2, config: map[string]any{"trade_type": "MICROPAY"}, want: false}, + {name: "wechat v3 codepay", provider: biz.PaymentWechatV3, extra: map[string]any{"method": "codepay"}, want: false}, + {name: "qq micropay", provider: biz.PaymentQQ, config: map[string]any{"trade_type": "MICROPAY"}, want: false}, + {name: "lakala retail", provider: biz.PaymentLakala, extra: map[string]any{"method": "retail"}, want: false}, + {name: "alipay native", provider: biz.PaymentAlipay, config: map[string]any{"method": "native"}, want: true}, + {name: "wechat v3 jsapi", provider: biz.PaymentWechatV3, extra: map[string]any{"trade_type": "jsapi"}, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := paymentCreateRequiresNotifyURL(tc.provider, tc.extra, tc.config); got != tc.want { + t.Fatalf("paymentCreateRequiresNotifyURL() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestPaymentCallbackEventIDPreservesVerifiedAdapterIdentity(t *testing.T) { + callback := &biz.PaymentCallback{ + Provider: biz.PaymentDouyin, + Body: []byte(`{"id":"untrusted-flat-id","data":{"event_id":"nested-id"}}`), + } + result := &biz.PaymentResult{EventID: " verified-sdk-event "} + if got := paymentCallbackEventID(callback, result); got != "verified-sdk-event" { + t.Fatalf("paymentCallbackEventID() = %q, want verified adapter ID", got) + } + result.EventID = "" + if got := paymentCallbackEventID(callback, result); got != "untrusted-flat-id" { + t.Fatalf("paymentCallbackEventID() fallback = %q, want flat callback ID", got) + } +} + +func TestValidateGenericPaymentConfigRequiresRuntimeEndpointsAndIdentity(t *testing.T) { + base := map[string]any{ + "protocol_version": "v1", + "app_id": "app", + "merchant_id": "merchant", + "create_url": "https://pay.test/create", + "query_url": "https://pay.test/query", + "refund_url": "https://pay.test/refund", + "app_key": "secret", + "query_status_field": "data.status", + "query_success_values": "SUCCESS", + "query_trade_no_field": "data.trade_no", + "query_provider_trade_no_field": "data.provider_trade_no", + "query_amount_field": "data.amount", + "query_currency_field": "data.currency", + "query_amount_scale": "100", + "callback_status_field": "data.status", + "callback_success_values": "SUCCESS", + "callback_trade_no_field": "data.trade_no", + "callback_provider_trade_no_field": "data.provider_trade_no", + } + if err := validatePaymentConfig(biz.PaymentChinaums, base); err != nil { + t.Fatalf("valid generic config rejected: %v", err) + } + for _, tc := range []struct { + name string + key string + value any + want string + }{ + {name: "missing app id", key: "app_id", want: "app_id"}, + {name: "missing merchant id", key: "merchant_id", want: "merchant_id"}, + {name: "missing refund endpoint", key: "refund_url", want: "refund_url"}, + {name: "zero amount scale", key: "query_amount_scale", value: "0", want: "query_amount_scale"}, + {name: "non power amount scale", key: "query_amount_scale", value: "3", want: "query_amount_scale"}, + } { + t.Run(tc.name, func(t *testing.T) { + values := map[string]any{} + for key, value := range base { + values[key] = value + } + if tc.value == nil { + values[tc.key] = "" + } else { + values[tc.key] = tc.value + } + err := validatePaymentConfig(biz.PaymentChinaums, values) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("validation error = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/data/payment_native_test.go b/internal/data/payment_native_test.go index 65c7d7c..cfc9eba 100644 --- a/internal/data/payment_native_test.go +++ b/internal/data/payment_native_test.go @@ -1,6 +1,8 @@ package data import ( + "context" + "encoding/json" "testing" "kra/internal/biz" @@ -40,3 +42,78 @@ func TestPaymentSecretMaskAndMerge(t *testing.T) { t.Fatalf("not merged: %#v", values) } } + +func TestPaymentConfigListMasksAndSavePreservesSecretAliases(t *testing.T) { + db := openIntegrationConfigTestDB(t) + repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}} + secrets := map[string]string{ + "app_key": "app-key-secret", + "token": "signing-token", + "key_content": "private-key-content", + "pkcs12_content": "pkcs12-content", + "op_user_passwd": "operator-password", + "api_v3key": "api-v3-key-alias", + "apiv3_key": "api-v3-key-compact-alias", + "key_pem": "key-pem-content", + "apiclient_key": "api-client-key-content", + } + storedValues := map[string]any{"app_id": "public-app-id"} + for key, value := range secrets { + storedValues[key] = value + } + storedValues["nested"] = map[string]any{"token": "nested-token"} + raw, err := json.Marshal(storedValues) + if err != nil { + t.Fatal(err) + } + if err = db.Create(&integrationConfigPO{ + Kind: integrationKindPayment, Provider: biz.PaymentQQ, Config: string(raw), + }).Error; err != nil { + t.Fatal(err) + } + + configs, err := repo.ListConfigs(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(configs) != 1 { + t.Fatalf("payment configs = %d, want 1", len(configs)) + } + masked := map[string]any{} + if err = json.Unmarshal(configs[0].Values, &masked); err != nil { + t.Fatal(err) + } + if masked["app_id"] != "public-app-id" { + t.Fatalf("non-secret app_id = %#v, want public-app-id", masked["app_id"]) + } + for key := range secrets { + if masked[key] != "******" { + t.Errorf("secret %s = %#v, want masked value", key, masked[key]) + } + } + if nested, ok := masked["nested"].(map[string]any); !ok || nested["token"] != "******" { + t.Errorf("nested token was not masked: %#v", masked["nested"]) + } + + if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{ + Provider: biz.PaymentQQ, Values: configs[0].Values, + }); err != nil { + t.Fatal(err) + } + var row integrationConfigPO + if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentQQ).First(&row).Error; err != nil { + t.Fatal(err) + } + preserved := map[string]any{} + if err = json.Unmarshal([]byte(row.Config), &preserved); err != nil { + t.Fatal(err) + } + for key, want := range secrets { + if preserved[key] != want { + t.Errorf("preserved secret %s = %#v, want %q", key, preserved[key], want) + } + } + if nested, ok := preserved["nested"].(map[string]any); !ok || nested["token"] != "nested-token" { + t.Errorf("nested token was not preserved: %#v", preserved["nested"]) + } +} diff --git a/internal/server/handler/payment.go b/internal/server/handler/payment.go index fa3a47b..53115b0 100644 --- a/internal/server/handler/payment.go +++ b/internal/server/handler/payment.go @@ -2,11 +2,11 @@ package handler import ( "io" - "kra/internal/biz" + "net/http" + "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" - "net/http" "github.com/gin-gonic/gin" ) @@ -28,7 +28,7 @@ func (h *Payment) SaveConfig(c *gin.Context) { httpx.Fail(c, err.Error()) return } - if err := h.service.SaveConfig(c.Request.Context(), &biz.PaymentConfig{Provider: req.Provider, Enabled: req.Enabled, Values: req.Config}); err != nil { + if err := h.service.SaveConfig(c.Request.Context(), &req); err != nil { httpx.Fail(c, err.Error()) return } @@ -54,12 +54,7 @@ func (h *Payment) Create(c *gin.Context) { return } req.ClientIP = c.ClientIP() - result, err := h.service.Create(c.Request.Context(), &biz.PaymentRequest{ - Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject, - Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency, NotifyURL: req.NotifyURL, - ReturnURL: req.ReturnURL, ClientIP: req.ClientIP, Extra: req.Extra, - BusinessType: req.BusinessType, BusinessID: req.BusinessID, PaymentMode: req.PaymentMode, - }) + result, err := h.service.Create(c.Request.Context(), &req) if err != nil { httpx.Fail(c, err.Error()) return @@ -72,7 +67,7 @@ func (h *Payment) Query(c *gin.Context) { httpx.Fail(c, err.Error()) return } - result, err := h.service.Query(c.Request.Context(), req.Provider, req.TradeNo) + result, err := h.service.Query(c.Request.Context(), &req) if err != nil { httpx.Fail(c, err.Error()) return @@ -85,7 +80,7 @@ func (h *Payment) Refund(c *gin.Context) { httpx.Fail(c, err.Error()) return } - result, err := h.service.Refund(c.Request.Context(), req.Provider, req.TradeNo, req.Amount) + result, err := h.service.Refund(c.Request.Context(), &req) if err != nil { httpx.Fail(c, err.Error()) return @@ -96,7 +91,7 @@ func (h *Payment) Callback(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<20) raw, err := io.ReadAll(c.Request.Body) if err != nil { - writePaymentCallbackAck(c, biz.DefaultPaymentCallbackAck(c.Param("provider"), false)) + writePaymentCallbackAck(c, service.DefaultPaymentCallbackAck(c.Param("provider"), false)) return } headers := map[string]string{} @@ -111,19 +106,12 @@ func (h *Payment) Callback(c *gin.Context) { query[k] = v[0] } } - result, err := h.service.Callback(c.Request.Context(), &biz.PaymentCallback{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query}) - if err != nil { - writePaymentCallbackAck(c, biz.CallbackFailure(err, biz.DefaultPaymentCallbackAck(c.Param("provider"), false))) - return - } - ack := biz.DefaultPaymentCallbackAck(c.Param("provider"), true) - if result != nil && result.SuccessAck.StatusCode != 0 { - ack = result.SuccessAck - } + ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query}) writePaymentCallbackAck(c, ack) + _ = err } -func writePaymentCallbackAck(c *gin.Context, ack biz.PaymentCallbackAck) { +func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) { if ack.StatusCode == 0 { ack.StatusCode = http.StatusInternalServerError } diff --git a/internal/server/handler/payment_test.go b/internal/server/handler/payment_test.go index 314c80b..6f0ed50 100644 --- a/internal/server/handler/payment_test.go +++ b/internal/server/handler/payment_test.go @@ -5,14 +5,14 @@ import ( "testing" "github.com/gin-gonic/gin" - "kra/internal/biz" + "kra/internal/service/dto" ) func TestWritePaymentCallbackAckDoesNotExposeError(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() context, _ := gin.CreateTestContext(recorder) - writePaymentCallbackAck(context, biz.PaymentCallbackAck{ + writePaymentCallbackAck(context, dto.PaymentCallbackAck{ StatusCode: 500, ContentType: "text/plain; charset=utf-8", Body: []byte("failure"), diff --git a/internal/service/dto/payment.go b/internal/service/dto/payment.go index 7d33100..4082b67 100644 --- a/internal/service/dto/payment.go +++ b/internal/service/dto/payment.go @@ -10,6 +10,16 @@ type PaymentConfigRequest struct { Enabled bool `json:"enabled"` Config json.RawMessage `json:"config"` } + +// PaymentConfigResponse is the transport representation returned by the +// payment configuration endpoint. Values are already masked by the usecase's +// repository boundary. +type PaymentConfigResponse struct { + Provider string `json:"Provider"` + Enabled bool `json:"Enabled"` + Values json.RawMessage `json:"Values"` +} + type PaymentRequest struct { Provider string `json:"provider"` TradeNo string `json:"tradeNo"` @@ -35,6 +45,44 @@ type PaymentRefundRequest struct { Amount int64 `json:"amount"` } +// PaymentCallbackRequest is populated by the HTTP transport and converted to +// the biz callback object inside service. +type PaymentCallbackRequest struct { + Provider string + Headers map[string]string + Body []byte + Query map[string]string +} + +type PaymentCallbackAck struct { + StatusCode int + ContentType string + Body []byte +} + +type PaymentResultResponse struct { + Provider string `json:"provider"` + Status string `json:"status"` + TradeNo string `json:"tradeNo"` + ProviderTradeNo string `json:"providerTradeNo"` + Amount int64 `json:"amount"` + PayerPaidAmount int64 `json:"payerPaidAmount,omitempty"` + CashPaidAmount int64 `json:"cashPaidAmount,omitempty"` + PointPaidAmount int64 `json:"pointPaidAmount,omitempty"` + DiscountAmount int64 `json:"discountAmount,omitempty"` + ProviderDiscountAmount int64 `json:"providerDiscountAmount,omitempty"` + MerchantDiscountAmount int64 `json:"merchantDiscountAmount,omitempty"` + SettlementAmount int64 `json:"settlementAmount,omitempty"` + Currency string `json:"currency"` + PayerCurrency string `json:"payerCurrency,omitempty"` + AmountBreakdownKnown bool `json:"amountBreakdownKnown"` + Duplicate bool `json:"duplicate"` + Payload json.RawMessage `json:"payload"` + OrderStatus string `json:"orderStatus,omitempty"` + FulfillmentStatus string `json:"fulfillmentStatus,omitempty"` + RefundStatus string `json:"refundStatus,omitempty"` +} + type PaymentOrderResponse struct { Provider string `json:"provider"` TradeNo string `json:"tradeNo"` diff --git a/internal/service/payment.go b/internal/service/payment.go index 3722391..e7ab80a 100644 --- a/internal/service/payment.go +++ b/internal/service/payment.go @@ -2,6 +2,8 @@ package service import ( "context" + "errors" + "kra/internal/biz" "kra/internal/service/dto" ) @@ -9,11 +11,25 @@ import ( type PaymentService struct{ uc *biz.PaymentUsecase } func NewPaymentService(uc *biz.PaymentUsecase) *PaymentService { return &PaymentService{uc: uc} } -func (s *PaymentService) Configs(ctx context.Context) ([]*biz.PaymentConfig, error) { - return s.uc.Configs(ctx) +func (s *PaymentService) Configs(ctx context.Context) ([]*dto.PaymentConfigResponse, error) { + configs, err := s.uc.Configs(ctx) + if err != nil { + return nil, err + } + result := make([]*dto.PaymentConfigResponse, 0, len(configs)) + for _, config := range configs { + if config == nil { + continue + } + result = append(result, &dto.PaymentConfigResponse{Provider: config.Provider, Enabled: config.Enabled, Values: config.Values}) + } + return result, nil } -func (s *PaymentService) SaveConfig(ctx context.Context, c *biz.PaymentConfig) error { - return s.uc.SaveConfig(ctx, c) +func (s *PaymentService) SaveConfig(ctx context.Context, req *dto.PaymentConfigRequest) error { + if req == nil { + return errors.New("支付配置请求为空") + } + return s.uc.SaveConfig(ctx, &biz.PaymentConfig{Provider: req.Provider, Enabled: req.Enabled, Values: req.Config}) } func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*dto.PaymentOrderResponse, error) { order, err := s.uc.Order(ctx, provider, tradeNo) @@ -35,15 +51,70 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (* FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt, }, nil } -func (s *PaymentService) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) { - return s.uc.Create(ctx, req) +func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (*dto.PaymentResultResponse, error) { + if req == nil { + return nil, errors.New("支付请求为空") + } + result, err := s.uc.Create(ctx, &biz.PaymentRequest{ + Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject, + Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency, + NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP, + Extra: req.Extra, BusinessType: req.BusinessType, BusinessID: req.BusinessID, + PaymentMode: req.PaymentMode, + }) + return paymentResultResponse(result), err } -func (s *PaymentService) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) { - return s.uc.Query(ctx, provider, tradeNo) +func (s *PaymentService) Query(ctx context.Context, req *dto.PaymentQueryRequest) (*dto.PaymentResultResponse, error) { + if req == nil { + return nil, errors.New("支付查询请求为空") + } + result, err := s.uc.Query(ctx, req.Provider, req.TradeNo) + return paymentResultResponse(result), err } -func (s *PaymentService) Refund(ctx context.Context, provider, tradeNo string, amount int64) (*biz.PaymentResult, error) { - return s.uc.Refund(ctx, provider, tradeNo, amount) +func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundRequest) (*dto.PaymentResultResponse, error) { + if req == nil { + return nil, errors.New("支付退款请求为空") + } + result, err := s.uc.Refund(ctx, req.Provider, req.TradeNo, req.Amount) + return paymentResultResponse(result), err } -func (s *PaymentService) Callback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) { - return s.uc.Callback(ctx, callback) +func (s *PaymentService) Callback(ctx context.Context, req *dto.PaymentCallbackRequest) (dto.PaymentCallbackAck, error) { + if req == nil { + return paymentCallbackAckResponse(biz.DefaultPaymentCallbackAck("", false)), errors.New("支付回调请求为空") + } + result, err := s.uc.Callback(ctx, &biz.PaymentCallback{Provider: req.Provider, Headers: req.Headers, Body: req.Body, Query: req.Query}) + if err != nil { + return paymentCallbackAckResponse(biz.CallbackFailure(err, biz.DefaultPaymentCallbackAck(req.Provider, false))), err + } + ack := biz.DefaultPaymentCallbackAck(req.Provider, true) + if result != nil && result.SuccessAck.StatusCode != 0 { + ack = result.SuccessAck + } + return paymentCallbackAckResponse(ack), nil +} + +func DefaultPaymentCallbackAck(provider string, success bool) dto.PaymentCallbackAck { + return paymentCallbackAckResponse(biz.DefaultPaymentCallbackAck(provider, success)) +} + +func paymentResultResponse(result *biz.PaymentResult) *dto.PaymentResultResponse { + if result == nil { + return nil + } + return &dto.PaymentResultResponse{ + Provider: result.Provider, Status: result.Status, TradeNo: result.TradeNo, + ProviderTradeNo: result.ProviderTradeNo, Amount: result.Amount, + PayerPaidAmount: result.PayerPaidAmount, CashPaidAmount: result.CashPaidAmount, + PointPaidAmount: result.PointPaidAmount, DiscountAmount: result.DiscountAmount, + ProviderDiscountAmount: result.ProviderDiscountAmount, + MerchantDiscountAmount: result.MerchantDiscountAmount, SettlementAmount: result.SettlementAmount, + Currency: result.Currency, PayerCurrency: result.PayerCurrency, + AmountBreakdownKnown: result.AmountBreakdownKnown, Duplicate: result.Duplicate, + Payload: result.Payload, OrderStatus: result.OrderStatus, + FulfillmentStatus: result.FulfillmentStatus, RefundStatus: result.RefundStatus, + } +} + +func paymentCallbackAckResponse(ack biz.PaymentCallbackAck) dto.PaymentCallbackAck { + return dto.PaymentCallbackAck{StatusCode: ack.StatusCode, ContentType: ack.ContentType, Body: ack.Body} }