支付相关
This commit is contained in:
+60
-22
@@ -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 的配置驱动聚合/小游戏渠道最少需要:
|
||||
|
||||
|
||||
+32
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+141
-16
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"]) != "<nil>" {
|
||||
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 == "<nil>" || 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+233
-48
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
-24
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+217
-32
@@ -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 "响应为空"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+185
-22
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+141
-14
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 回调为空")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+83
-12
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user