支付相关

This commit is contained in:
Yvan
2026-08-19 18:06:03 +08:00
parent 1ef8ecb7b2
commit e54cbd1207
56 changed files with 8453 additions and 42 deletions
+6 -1
View File
@@ -65,6 +65,11 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
emailUsecase := biz.NewEmailUsecase(emailRepo)
emailService := service.NewEmailService(emailUsecase)
email := handler.NewEmail(emailService)
paymentRepo := data.NewPaymentRepo(dataData)
paymentOrderRepo := data.NewPaymentOrderRepo(dataData)
paymentUsecase := biz.NewPaymentUsecase(paymentRepo, paymentOrderRepo, logger)
paymentService := service.NewPaymentService(paymentUsecase)
payment := handler.NewPayment(paymentService)
taskRepo := data.NewTaskRepo(dataData)
taskUsecase := biz.NewTaskUsecase(taskRepo)
mediaRepo := data.NewMediaRepo(dataData)
@@ -130,7 +135,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
user := handler.NewUser(userService, authService)
navigation := handler.NewNavigation(userService)
session := handler.NewSession(tokenService)
set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, payment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger, string2)
httpServer := server.NewGinServer(confServer, engine)
app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger)
+368
View File
@@ -0,0 +1,368 @@
# 支付接入设计
## 代码分层
支付渠道适配器统一放在 `internal/data/payment`,订单仓储和运行时入口保留在
`internal/data`;这与 `internal/data/storage` 集中管理 OSS provider 的方式一致,
避免各渠道协议散落在 data 根目录。它们仍属于 data 层,因为需要读取
`sys_integration_configs`、调用运行时客户端、转换 `biz` 对象并在数据库事务中推进
`pay_orders` 状态。无状态的协议基础能力已下沉到领域化公共包:
- `pkg/paymentkit`:金额整数化、支付状态归一化、JSON 路径读取、微信 v2 XML、
微信 v2/聚合渠道签名等纯函数;不依赖 `biz`、`data` 或数据库。
- `internal/data/payment`:GoPay 和配置驱动渠道的 adapter、SDK 配置映射、
回调验签/解密以及 DO 结果归一化;GoPay/driver 类型不越过 data 边界。
- `pkg/osskit`:跨对象存储 provider 的流式合并和 MD5 计算;不依赖具体 OSS SDK。
- `internal/data/storage`:本地磁盘、七牛、阿里云 OSS、华为 OBS、腾讯 COS、
S3/MinIO/R2 的客户端创建、配置映射和 `biz.FileStorage` 适配仍保留在 data。
因此不能把整个 `internal/data` 或所有 OSS provider 直接移动到 `pkg`:那会让公共包
反向依赖内部领域模型和运行时配置,破坏分层。后续新增 provider 时,优先把纯签名、
金额和报文转换放入 `pkg/paymentkit`,把配置、HTTP 客户端、数据库和业务结果转换留在
`internal/data`。
支付模块创建一张 `pay_orders` 表,用于记录一次支付尝试的金额、渠道状态、发货状态、退款状态和并发租约。业务模块继续拥有自己的业务订单,通过 `business_type + business_id` 关联支付订单。渠道配置继续使用已有的 `sys_integration_configs`,`kind = payment`;不创建独立回调事件表或支付日志表。
## 订单表边界
`pay_orders` 一行代表一次支付尝试,而不是业务订单:
- `(provider, trade_no)` 唯一,同一个业务订单可以创建多个不同渠道或不同批次的支付尝试。
- `(provider, provider_trade_no)` 唯一,禁止一个平台交易绑定两个本地订单。
- `request_fingerprint` 防止相同商户订单号被不同金额、币种或业务对象重复使用。
- `payment_status`、`fulfillment_status`、`refund_status` 相互独立,退款不会抹掉原始支付和发货事实。
- `original_amount` 是业务原价;`amount` 是提交给第三方平台的订单总额,二者均使用币种最小单位整数。
- `payer_paid_amount` 是用户实际支付金额;`cash_paid_amount` / `point_paid_amount` 分别表示现金和积分/平台资产部分;`discount_amount` 是订单总额减用户实付。
- `provider_discount_amount` / `merchant_discount_amount` 只有渠道明确返回出资拆分时才填写;未知字段不能推测。`settlement_amount` 是渠道最终结算商户金额,不等于用户实付。
- `amount_breakdown_known` 只有在上述核心金额满足守恒校验时才为 `true`,否则仍保留总额校验,但不伪造优惠拆分。
- `confirmation_id` 根据 `provider + trade_no` 稳定生成,是业务发货最终幂等键。
- 只保存平台响应哈希和必要状态;不保存完整回调原文、签名密钥或请求头。
- `fulfillment_token` / `refund_token` 和租约时间用于跨实例互斥,进程退出后租约到期可安全重试。
## GoPay 渠道矩阵(v1.5.122)
本分支固定使用 `github.com/go-pay/gopay v1.5.122`。下面的九行是 GoPay
稳定版本中可复用的渠道族;微信在本地拆成 `wechat-v2` 和 `wechat-v3` 两个
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 |
| Apple | `apple-iap` | App Store Server API 交易查询、JWS/证书链处理 | 下单由客户端 StoreKit 驱动;服务端不提供主动退款 |
| PayPal | `paypal` | `CreateOrder`、`OrderDetail`、`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` | 当前不接收通知;使用主动查单/对账确认 |
`CMB`(招商银行)不在固定的 v1.5.122 模块中,不能作为本分支已接入渠道;
上游未发布版本中的目录或提交不构成稳定依赖,故这里明确排除。
AllinPay 和 Saobei 的 adapter 会拒绝回调入口,因为固定版本没有可复用且能在
本项目边界内完成验签的通知路径。对这两个渠道,生产流程必须依赖主动查单、
定时对账和幂等状态推进,不得把未验签的通知当作支付事实。
本矩阵只说明代码和 GoPay 方法的接入情况,不代表真实商户沙箱或生产联调已经完成。
当前仓库仅做单元测试和本地 mock/HTTP 响应验证;上线前仍须使用实际商户凭证、
证书、平台回调和退款报文逐项联调。
## 其他已保留渠道
| Provider | 实现方式 |
| --- | --- |
| `alipay`、`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 适配器 |
| `wechat-game-pay` | 配置驱动的微信小游戏虚拟支付 2.0 适配器,支持 access token |
| `douyin-game-pay` | 配置驱动的抖音小游戏支付签名适配器 |
上表后五个 provider 不是 GoPay v1.5.122 的九个稳定渠道族,仍保留现有的
配置驱动协议适配器。它们的协议字段依商户合同和产品版本不同,因此没有强行
假设某一个固定请求格式,必须在 `sys_integration_configs.config` 中声明查单
字段和金额单位。
## 回调安全流程
创建支付按以下顺序处理:
1. 根据 `business_type` 调用业务模块注册的 `PaymentOrderSource`。
2. 业务模块返回可信的金额、币种、标题、业务 ID 和商户订单号;客户端提交的金额不是权威数据。
3. 先插入 `pay_orders`,利用唯一索引和请求指纹实现本地下单幂等。
4. 再调用支付平台创建订单;平台创建响应只会把本地状态推进到 `pending`,不能直接认定已支付。
5. 平台调用成功后保存客户端后续支付所需的创建响应。数据库记录失败时,可使用相同商户订单号安全重试平台创建接口。
每次支付回调按以下顺序处理:
1. 根据渠道配置加载适配器。
2. 校验渠道签名、证书/JWS/通知解密,并校验应用号、商户号等身份字段。
3. 从已验签的通知中提取商户订单号。
4. **主动调用对应平台查单接口**,不以回调中的支付状态、金额或币种作为最终依据。
5. 查单状态只允许 `success`、`pending`、`failed`;未知状态直接失败。成功状态必须同时包含商户订单号、平台交易号、正整数金额和币种。
6. 查单状态不是成功时,不发货;后续回调或业务主动查询可以再次确认。
7. 从 `pay_orders` 读取本地权威金额、币种、业务类型和业务 ID。
8. 严格比较渠道、商户订单号、平台交易号、金额、币种;任一关键字段缺失或不一致都拒绝更新为已支付。
9. 在数据库事务中把支付状态推进到 `paid`,再竞争发货租约。
10. 根据 `business_type` 查找 `PaymentFulfillmentHandler`,使用稳定 `confirmation_id` 执行业务发货。
11. 发货成功后将 `fulfillment_status` 更新为 `succeeded`;失败记录为 `failed` 并允许重试。重复回调和主动查询不会重复执行已完成发货。
支付模块不会接受客户端传入的金额、标题或币种作为权威订单数据。持久化流程启用后,未注册 `PaymentOrderSource` 的业务类型会直接拒绝下单。
## 业务扩展接口
业务侧必须把同一个实现作为 `PaymentBusinessModule` 注册。该接口同时要求实现
`PaymentOrderSource`(返回权威金额、币种和标题)、`PaymentFulfillmentHandler`
(按 `confirmation_id` 幂等发货)和 `PaymentRefundAuthorizer`(校验业务订单是否
允许退款)。应用组合阶段调用 `PaymentUsecase.RegisterBusinessModule(...)`;
只注册支付 adapter 而不注册业务模块是不完整的接入。
当前仓库没有注册任何生产业务模块,因此直接调用持久化支付创建流程会返回
`支付业务订单来源未注册`。这不是渠道配置错误,接入具体商品、订单或订阅业务时
必须在应用启动组装处完成注册。
每个业务模块先实现可信订单来源:
```go
type GameItemPayment struct {
orders GameItemOrderRepo
}
func (GameItemPayment) Type() string { return "game_item" }
func (p GameItemPayment) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*biz.PaymentIntent, error) {
order, err := p.orders.FindPayable(ctx, businessID)
if err != nil {
return nil, err
}
return &biz.PaymentIntent{
Provider: provider, TradeNo: tradeNo,
BusinessType: "game_item", BusinessID: businessID,
Subject: order.Title, Amount: order.PayableAmount, Currency: order.Currency,
}, nil
}
```
然后注册按业务类型分发的发货处理器:
```go
func (p GameItemPayment) Fulfill(ctx context.Context, c *biz.PaymentConfirmation) error {
return p.orders.Transaction(ctx, func(tx GameItemOrderTx) error {
// confirmation_id 必须有唯一约束。已处理时直接返回 nil。
if tx.HasPaymentConfirmation(c.ID) {
return nil
}
if err := tx.Deliver(c.BusinessID); err != nil {
return err
}
return tx.SavePaymentConfirmation(c.ID)
})
}
func (p GameItemPayment) AuthorizeRefund(ctx context.Context, order *biz.PaymentOrder, amount int64) error {
return p.orders.CheckRefundable(ctx, order.BusinessID, amount)
}
```
启动时将同一个实现分别注册到 `PaymentOrderSourceRegistry` 和 `PaymentFulfillmentRegistry`。`PaymentConfirmation.ID` 同一支付尝试永远不变。业务处理器必须在自己的事务中以该 ID 建唯一约束,才能覆盖“业务已发货但进程在更新 `pay_orders` 前退出”的极端窗口。
推荐直接注册完整业务模块:
```go
if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrders}); err != nil {
return err
}
```
## 一致性边界
- 本地下单:唯一索引和 `request_fingerprint` 保证同一支付号不可换金额或业务对象。
- 平台确认:只有验签后的主动查单结果可以把订单推进到 `paid`,状态不会从已支付回退到待支付或失败。
- 多实例发货:数据库行锁、处理令牌和租约保证同一时刻只有一个实例执行发货。
- 最终发货幂等:业务模块必须在自己的事务中对 `confirmation_id` 建唯一约束。
- 退款:一次只允许一个在途退款;每次退款都有持久化 `refund_no`,网络超时会复用同一个退款号重试,平台接受退款后状态为 `pending`,只有携带匹配 `refund_no` 的退款通知或对账任务调用 `ConfirmRefund` 后才增加 `refunded_amount`。
- 退款授权:业务模块必须实现 `AuthorizeRefund`,支付模块不会仅凭渠道、订单号和金额执行退款。
- 外部平台调用和本地数据库无法组成单个 ACID 事务,因此采用“本地先落单、平台接口幂等重试、主动查单、数据库状态机、业务最终幂等”的组合保证最终一致性。
## 配置示例
支付宝:
```json
{"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`、
`alipay.trade.app.pay`、`alipay.trade.page.pay` 或 `alipay.trade.wap.pay`。
微信支付 v2:
```json
{"app_id":"","merchant_id":"","mch_key":"","sign_type":"MD5","trade_type":"NATIVE","client_cert":"PEM","client_key":"PEM"}
```
`client_cert` / `client_key` 在退款等双向 TLS 请求中使用;也接受
`appid`、`mch_id`、`api_key` 等兼容别名。自定义测试端点可分别配置
`create_url`、`query_url` 和 `refund_url`。
微信支付 v3:
```json
{"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` 中传入用户标识。
Apple 内购:
```json
{"issuer_id":"","key_id":"","bundle_id":"","private_key":"PEM","price_divisor":"10","environment":"production"}
```
Apple Server API 的交易 `price` 使用平台返回的单位;`price_divisor` 必须按业务订单使用的最小货币单位配置。金额不能整除时,支付模块拒绝确认。
Apple 多币种可使用 `price_divisors` 对不同 `currency` 分别配置比例;服务端不会把 Apple 的价格字段默认当作人民币分。
Apple 没有传统服务端“预下单”。创建接口要求 `tradeNo` 是 UUID,并把它作为 `appAccountToken` 返回给客户端;客户端发起 StoreKit 购买时必须原样传入。回调使用 `appAccountToken` 关联本地订单,使用 `transactionId` 调 Apple Server API 主动查单,两者不会混用。缺少 `appAccountToken`、Bundle ID 或环境不一致、JWS 算法不是 ES256、证书链校验失败时均拒绝确认。
Apple IAP 的购买流程由客户端发起,退款/撤销由 App Store 管理。本项目的
`apple-iap` adapter 不提供商户服务端主动退款,业务侧应通过 Apple 的退款流程和
后续通知/查询更新状态。
PayPal:
```json
{"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。
抖音支付:
```json
{"app_id":"","merchant_id":"","serial_no":"","api_key":"32-byte key","private_key":"PEM","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi","environment":"production"}
```
当前 adapter 支持 `app`、`jsapi`、`h5`、`native`;固定版本的抖音客户端按
生产接口工作,`environment` 只能使用 `production`/`prod`。
QQ 支付:
```json
{"mch_id":"","api_key":"","sign_type":"MD5","trade_type":"NATIVE","cert_file":"/secure/qq/apiclient_cert.pem","key_file":"/secure/qq/apiclient_key.pem","environment":"production"}
```
`sign_type` 可用 `MD5` 或 `HMAC-SHA256`。退款必须配置
`cert_file` + `key_file`,或 `pkcs12_file`;也可使用对应的 `*_content` 字段。
通联支付(AllinPay):
```json
{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","currency":"CNY","environment":"production"}
```
该 provider 只接入下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。
拉卡拉(Lakala):
```json
{"partner_code":"","credential_code":"","channel":"Wechat","currency":"JPY","environment":"production"}
```
支持 JSAPI/H5 下单、查单、退款和 GoPay 通知验签。当前固定版本客户端只允许
生产环境配置。
扫呗(Saobei):
```json
{"inst_no":"","key":"","merchant_no":"","terminal_id":"","access_token":"","pay_type":"010","currency":"CNY","environment":"production"}
```
该 provider 只接入条码/小程序下单、查单和退款,不接收通知;支付确认由主动
查单/对账触发。
非 GoPay 的配置驱动聚合/小游戏渠道最少需要:
```json
{
"protocol_version":"以商户协议为准",
"app_id":"",
"merchant_id":"",
"create_url":"",
"query_url":"",
"refund_url":"",
"app_key":"",
"query_status_field":"data.status",
"query_success_values":"SUCCESS,PAID",
"query_trade_no_field":"data.trade_no",
"query_provider_trade_no_field":"data.transaction_id",
"query_amount_field":"data.amount",
"query_currency_field":"data.currency",
"query_amount_scale":"1",
"callback_status_field":"data.status",
"callback_success_values":"SUCCESS,PAID",
"callback_trade_no_field":"data.trade_no",
"callback_provider_trade_no_field":"data.transaction_id"
}
```
`query_amount_scale = 1` 表示响应已经是最小货币单位整数;`100` 表示响应是元/主货币单位并转换为分;其他值必须是 10 的幂。字段路径使用点号访问 JSON 对象。
聚合/小游戏渠道如需保存优惠和内部资产拆分,可额外配置:
```json
{
"query_payer_paid_amount_field":"data.payer_total",
"query_cash_paid_amount_field":"data.cash_fee",
"query_point_paid_amount_field":"data.point_fee",
"query_discount_amount_field":"data.discount",
"query_provider_discount_amount_field":"data.provider_discount",
"query_merchant_discount_amount_field":"data.merchant_discount",
"query_settlement_amount_field":"data.settlement_amount",
"query_payer_currency_field":"data.payer_currency",
"query_payer_paid_amount_scale":"100",
"query_cash_paid_amount_scale":"100"
}
```
金额拆分字段缺失时,适配器把 `amount_breakdown_known` 设为 `false`,不会将未知优惠归给平台或商户。字段存在但不能按配置的整数比例精确换算时,查单失败并要求修正渠道配置。
内部支付使用 `provider = internal`、`payment_mode = internal`,不调用第三方平台。业务模块实现 `PayInternal`,以 `trade_no` 原子、幂等扣减积分、余额或其他内部资产,并返回授权号和完整金额拆分;仍然走同一套 `pay_orders`、查单/确认、发货和退款状态机。内部退款必须由业务模块以同一订单幂等执行 `RefundInternal`。
Chinaums、SFT、Supper Pay、微信小游戏和抖音小游戏没有在代码中假设所有商户都使用同一合同版本。启用前必须拿实际商户文档和沙箱报文逐项确认请求字段、签名串、金额单位、回调字段和成功状态;缺少 `protocol_version` 或明确回调字段映射时配置校验会拒绝启用。这里提供的是严格失败的适配框架,不应在未完成渠道联调测试时标记为生产可用。
## 回调 ACK
回调接口不会返回内部错误文本、平台原始查询结果或业务发货信息。支付宝、微信 v2、微信 v3 和 Apple 使用各自固定 ACK;聚合渠道默认返回纯文本 `success` / `failure`,并可按实际协议配置:
```json
{
"callback_success_status":"200",
"callback_success_content_type":"application/json",
"callback_success_body":"{\"code\":\"SUCCESS\"}",
"callback_failure_status":"500",
"callback_failure_content_type":"application/json",
"callback_failure_body":"{\"code\":\"FAIL\"}"
}
```
状态码只允许 `200-599`,Content-Type 禁止换行,响应体最大 64KiB。验签、主动查单、订单解析、金额校验或发货失败时返回失败 ACK,让支持重试的平台再次通知;查单为 `pending` / `failed` 时不发货,但通知已被安全处理,因此返回成功 ACK。
## HTTP 接口
- `GET /payment/configs`
- `POST /payment/config`
- `POST /payment/order`
- `POST /payment/create`
- `POST /payment/query`
- `POST /payment/refund`
- `POST /payment/callback/:provider`
`amount` 使用整数,单位是业务约定的最小货币单位。`extra` 传递渠道特有字段,例如 `openid`、`trade_type`、`product_id`。
## 日志
支付日志在 `internal/biz/payment_log.go` 通过 `PaymentLogger` 独立抽象,包含下单、查单失败、回调查单、金额校验、重复回调和发货结果等结构化事件。日志只记录渠道、商户订单号、业务类型、业务 ID、确认 ID 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。
+7
View File
@@ -16,6 +16,7 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59
github.com/go-kratos/kratos/v3 v3.0.0
github.com/go-pay/gopay v1.5.122
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
@@ -94,6 +95,12 @@ require (
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-pay/crypto v0.0.1 // indirect
github.com/go-pay/errgroup v0.0.3 // indirect
github.com/go-pay/smap v0.0.2 // indirect
github.com/go-pay/util v0.0.4 // indirect
github.com/go-pay/xlog v0.0.3 // indirect
github.com/go-pay/xtime v0.0.2 // indirect
github.com/go-playground/form/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
+14
View File
@@ -149,6 +149,20 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-pay/crypto v0.0.1 h1:B6InT8CLfSLc6nGRVx9VMJRBBazFMjr293+jl0lLXUY=
github.com/go-pay/crypto v0.0.1/go.mod h1:41oEIvHMKbNcYlWUlRWtsnC6+ASgh7u29z0gJXe5bes=
github.com/go-pay/errgroup v0.0.3 h1:DB4s8e8oWYDyETKQ1y1riMJ7y29zE1uIsMCSjEOFSbU=
github.com/go-pay/errgroup v0.0.3/go.mod h1:0+4b8mvFMS71MIzsaC+gVvB4x37I93lRb2dqrwuU8x8=
github.com/go-pay/gopay v1.5.122 h1:kuOz2qBQXGF+o4v6IOGfdFB05fquhEa32dVW98gk/bI=
github.com/go-pay/gopay v1.5.122/go.mod h1:RzP9+1bPzIMAXGRL5RvYsZjib7wV2fhA3JxT5sjoi2w=
github.com/go-pay/smap v0.0.2 h1:kKflYor5T5FgZltPFBMTFfjJvqYMHr5VnIFSEyhVTcA=
github.com/go-pay/smap v0.0.2/go.mod h1:HW9oAo0okuyDYsbpbj5fJFxnNj/BZorRGFw26SxrNWw=
github.com/go-pay/util v0.0.4 h1:TuwSU9o3Qd7m9v1PbzFuIA/8uO9FJnA6P7neG/NwPyk=
github.com/go-pay/util v0.0.4/go.mod h1:Tsdhs8Ib9J9b4+NKNO1PHh5hWHhlg98PthsX0ckq6PM=
github.com/go-pay/xlog v0.0.3 h1:avyMhCL/JgBHreoGx/am/kHxfs1udDOAeVqbmzP/Yes=
github.com/go-pay/xlog v0.0.3/go.mod h1:mH47xbobrdsSHWsmFtSF5agWbMHFP+tK0ZbVCk5OAEw=
github.com/go-pay/xtime v0.0.2 h1:7YR4/iuELsEHpJ6LUO0SVK80hQxDO9MLCfuVYIiTCRM=
github.com/go-pay/xtime v0.0.2/go.mod h1:W1yRbJaSt4CSBcdAtLBQ8xajiN/Pl5hquGczUcUE9xE=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+1 -1
View File
@@ -3,4 +3,4 @@ package biz
import "github.com/google/wire"
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase)
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase)
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
package biz
import (
"context"
"log/slog"
)
// PaymentLogger isolates payment audit logging from the payment workflow and
// lets an application replace slog with its own structured logger.
type PaymentLogger interface {
Info(context.Context, string, ...any)
Error(context.Context, string, ...any)
}
type slogPaymentLogger struct {
logger *slog.Logger
}
func NewPaymentLogger(logger *slog.Logger) PaymentLogger {
if logger == nil {
logger = slog.Default()
}
return &slogPaymentLogger{logger: logger.With("mod", "payment")}
}
func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...any) {
l.logger.InfoContext(ctx, message, args...)
}
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
l.logger.ErrorContext(ctx, message, args...)
}
+210
View File
@@ -0,0 +1,210 @@
package biz
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
)
const (
PaymentStatusInitialized = "initialized"
PaymentStatusPending = "pending"
PaymentStatusPaid = "paid"
PaymentStatusFailed = "failed"
PaymentStatusClosed = "closed"
PaymentStatusPartiallyRefunded = "partially_refunded"
PaymentStatusRefunded = "refunded"
FulfillmentStatusPending = "pending"
FulfillmentStatusProcessing = "processing"
FulfillmentStatusSucceeded = "succeeded"
FulfillmentStatusFailed = "failed"
RefundStatusNone = "none"
RefundStatusProcessing = "processing"
RefundStatusPending = "pending"
RefundStatusPartial = "partial"
RefundStatusSucceeded = "succeeded"
RefundStatusFailed = "failed"
)
var (
ErrPaymentOrderNotFound = errors.New("支付订单不存在")
ErrPaymentOrderConflict = errors.New("支付订单参数冲突")
ErrPaymentOrderBusy = errors.New("支付订单正在处理中")
ErrPaymentOrderState = errors.New("支付订单状态不允许当前操作")
ErrPaymentProviderConflict = errors.New("支付平台交易号冲突")
)
// PaymentOrder is one payment attempt. Business modules own their domain
// orders and link to this record through BusinessType and BusinessID.
type PaymentOrder struct {
ID uint64
TradeNo string
Provider string
ProviderTradeNo string
QueryID string
BusinessType string
BusinessID string
Subject string
PaymentMode string
OriginalAmount int64
Amount int64
PaidAmount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
PaymentStatus string
ProviderStatus string
FulfillmentStatus string
RefundStatus string
RefundedAmount int64
RefundRequestedAmount int64
RefundNo string
ConfirmationID string
RequestFingerprint string
CreatePayload json.RawMessage
Extra json.RawMessage
LastEventID string
LastPayloadHash string
LastError string
FulfillmentToken string
RefundToken string
Version uint64
CreatedAt time.Time
UpdatedAt time.Time
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
type PaymentProviderUpdate struct {
Status string
ProviderStatus string
ProviderTradeNo string
QueryID string
Amount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
EventID string
PayloadHash string
CreatePayload json.RawMessage
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
CompletePaymentFulfillment(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (order *PaymentOrder, token string, err error)
CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error)
}
// PaymentOrderSource is implemented by each business module. It returns the
// canonical order facts; client-provided amount, currency and subject are not
// trusted once the persistent payment-order flow is enabled.
type PaymentOrderSource interface {
Type() string
PreparePayment(context.Context, string, string, string) (*PaymentIntent, error)
}
type PaymentBusinessModule interface {
PaymentOrderSource
PaymentFulfillmentHandler
PaymentRefundAuthorizer
}
type PaymentRefundAuthorizer interface {
AuthorizeRefund(context.Context, *PaymentOrder, int64) error
}
type PaymentOrderSourceRegistry struct {
mu sync.RWMutex
sources map[string]PaymentOrderSource
}
func NewPaymentOrderSourceRegistry() *PaymentOrderSourceRegistry {
return &PaymentOrderSourceRegistry{sources: map[string]PaymentOrderSource{}}
}
func (r *PaymentOrderSourceRegistry) Register(source PaymentOrderSource) error {
if source == nil || strings.TrimSpace(source.Type()) == "" {
return errors.New("支付业务订单来源无效")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sources[source.Type()]; exists {
return errors.New("支付业务订单来源已注册: " + source.Type())
}
r.sources[source.Type()] = source
return nil
}
func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.sources[kind]
}
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sources, kind)
}
func (o *PaymentOrder) Intent() *PaymentIntent {
if o == nil {
return nil
}
return &PaymentIntent{
Provider: o.Provider,
TradeNo: o.TradeNo,
BusinessType: o.BusinessType,
BusinessID: o.BusinessID,
Subject: o.Subject,
Amount: o.Amount,
Currency: o.Currency,
Extra: append(json.RawMessage(nil), o.Extra...),
PaymentMode: defaultPaymentMode(o.Provider, o.PaymentMode),
OriginalAmount: o.OriginalAmount,
}
}
func defaultPaymentMode(provider, mode string) string {
if strings.TrimSpace(mode) != "" {
return mode
}
if provider == PaymentInternal {
return PaymentModeInternal
}
return PaymentModeExternal
}
+348
View File
@@ -0,0 +1,348 @@
package biz
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
)
type paymentRepoStub struct {
create *PaymentResult
createdReq *PaymentRequest
refund *PaymentResult
refundErr error
refundedReq *PaymentRefundRequest
callback *PaymentResult
query *PaymentResult
queryCalls int
callbackErr error
}
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
copyReq := *req
r.createdReq = &copyReq
return r.create, nil
}
type paymentOrderRepoStub struct {
order *PaymentOrder
}
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
if r.order != nil {
return r.order, false, nil
}
copyOrder := *order
r.order = &copyOrder
return r.order, true, nil
}
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
if r.order == nil {
return nil, ErrPaymentOrderNotFound
}
return r.order, nil
}
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
return r.order, nil
}
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
if update.Status == "success" {
r.order.PaymentStatus = PaymentStatusPaid
r.order.PaidAmount = r.order.Amount
r.order.ProviderTradeNo = update.ProviderTradeNo
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
return r.order, "", true, nil
}
r.order.FulfillmentStatus = FulfillmentStatusProcessing
return r.order, "token", false, nil
}
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
if success {
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
} else {
r.order.FulfillmentStatus = FulfillmentStatusFailed
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
r.order.RefundNo = "refund-1"
r.order.RefundStatus = RefundStatusProcessing
return r.order, "refund-token", nil
}
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
r.order.RefundStatus = RefundStatusPending
return r.order, nil
}
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
return r.order, nil
}
type paymentSourceStub struct{ intent *PaymentIntent }
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
return s.intent, nil
}
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
r.queryCalls++
return r.query, nil
}
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
if req != nil {
copyReq := *req
r.refundedReq = &copyReq
}
return r.refund, r.refundErr
}
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
return r.callback, r.callbackErr
}
type paymentFulfillmentStub struct {
calls int
failFirst bool
confirmIDs []string
}
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
return nil
}
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
h.calls++
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
if h.failFirst && h.calls == 1 {
return errors.New("temporary fulfillment failure")
}
return nil
}
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
}
func testBizPaymentOrder() *PaymentOrder {
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
}
func successPaymentResult() *PaymentResult {
return &PaymentResult{
Provider: PaymentAlipay,
Status: "success",
TradeNo: "order-1",
ProviderTradeNo: "ali-1",
Amount: 100,
Currency: "CNY",
}
}
func successIntent() *PaymentIntent {
return &PaymentIntent{
Provider: PaymentAlipay,
TradeNo: "order-1",
BusinessType: "game_item",
BusinessID: "item-1",
Amount: 100,
Currency: "CNY",
}
}
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
if repo.queryCalls != 1 {
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
}
}
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
pending := successPaymentResult()
pending.Status = "pending"
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || handler.calls != 0 {
t.Fatalf("result=%+v handler=%d", result, handler.calls)
}
}
func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("amount mismatch was accepted")
}
if handler.calls != 0 {
t.Fatal("handler ran after amount mismatch")
}
}
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
}
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
}
}
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{failFirst: true}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err == nil {
t.Fatal("first fulfillment failure was accepted")
}
result, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
}
}
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
result := successPaymentResult()
result.ProviderTradeNo = ""
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("incomplete successful query was accepted")
}
}
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
result := successPaymentResult()
result.Status = "maybe"
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("unknown payment status was accepted")
}
}
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
callbackErr, ok := err.(*PaymentCallbackError)
if !ok || string(callbackErr.Ack.Body) != "failure" {
t.Fatalf("callback error = %#v", err)
}
}
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
orders := &paymentOrderRepoStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
sources := NewPaymentOrderSourceRegistry()
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
t.Fatal(err)
}
uc.SetOrderSourceRegistry(sources)
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
if err != nil {
t.Fatal(err)
}
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
}
}
func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
handler := &paymentFulfillmentStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
}
}
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
t.Fatal(err)
}
}
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
t.Fatal("overpayment was accepted")
}
}
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
t.Fatal("broken discount conservation was accepted")
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"kra/internal/conf"
)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewPaymentRepo, NewPaymentOrderRepo, NewCache, NewFileStorage)
type Data struct {
initMu sync.Mutex
+4
View File
@@ -25,9 +25,13 @@ func migrateAll(db *gorm.DB) error {
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
&paymentOrderPO{},
); err != nil {
return err
}
if err := ensurePaymentIntegrationConfigs(db); err != nil {
return err
}
if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
return err
}
+410
View File
@@ -0,0 +1,410 @@
package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"kra/internal/biz"
datapayment "kra/internal/data/payment"
"gorm.io/gorm"
)
type paymentRepo struct{ data *Data }
func NewPaymentRepo(data *Data) biz.PaymentRepo { return &paymentRepo{data: data} }
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range biz.SupportedPaymentProviders {
var count int64
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
return err
}
}
}
return nil
}
func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) {
var row integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, biz.ErrPaymentProviderNotFound
}
return nil, nil, err
}
if !row.Enabled {
return nil, nil, fmt.Errorf("支付渠道 %s 未启用", provider)
}
values := map[string]any{}
if err := json.Unmarshal([]byte(row.Config), &values); err != nil {
return nil, nil, fmt.Errorf("支付配置格式错误: %w", err)
}
return &row, values, nil
}
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
var rows []integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*biz.PaymentConfig, 0, len(rows))
for _, row := range rows {
values := map[string]any{}
if json.Unmarshal([]byte(row.Config), &values) == nil {
maskPaymentSecrets(values)
masked, _ := json.Marshal(values)
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: masked})
continue
}
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: json.RawMessage("{}")})
}
return out, nil
}
func (r *paymentRepo) SaveConfig(ctx context.Context, config *biz.PaymentConfig) error {
if config == nil || !contains(biz.SupportedPaymentProviders, config.Provider) {
return errors.New("不支持的支付渠道")
}
if !json.Valid(config.Values) {
return errors.New("支付配置必须是 JSON")
}
values := map[string]any{}
if err := json.Unmarshal(config.Values, &values); err != nil {
return errors.New("支付配置必须是 JSON 对象")
}
db := r.data.gormDB.WithContext(ctx)
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, config.Provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
}
if err != nil {
return err
}
old := map[string]any{}
_ = json.Unmarshal([]byte(row.Config), &old)
mergePaymentSecrets(values, old)
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
}
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
_, values, err := r.row(ctx, provider)
if err != nil {
return nil, nil, err
}
adapter, err := datapayment.New(provider)
return adapter, values, err
}
func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
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 == "" {
return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider)
}
return a.Create(ctx, &effective, c)
}
func (r *paymentRepo) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, provider)
if err != nil {
return nil, err
}
return a.Query(ctx, tradeNo, c)
}
func (r *paymentRepo) Refund(ctx context.Context, req *biz.PaymentRefundRequest) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
return a.Refund(ctx, req, c)
}
func (r *paymentRepo) HandleCallback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("支付回调为空")
}
a, c, err := r.adapter(ctx, callback.Provider)
if err != nil {
return nil, err
}
result, err := a.Callback(ctx, callback, c)
if err != nil {
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
if result == nil {
err = errors.New("支付回调解析结果为空")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
result.SuccessAck = paymentCallbackAck(callback.Provider, c, true)
result.FailureAck = paymentCallbackAck(callback.Provider, c, false)
if result.Provider != callback.Provider {
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
return result, nil
}
func paymentCallbackAck(provider string, values map[string]any, success bool) biz.PaymentCallbackAck {
ack := biz.DefaultPaymentCallbackAck(provider, success)
prefix := "callback_success_"
if !success {
prefix = "callback_failure_"
}
if configured := strings.TrimSpace(text(values, prefix+"status")); configured != "" {
if status, err := strconv.Atoi(configured); err == nil && status >= 200 && status <= 599 {
ack.StatusCode = status
}
}
if contentType := strings.TrimSpace(text(values, prefix+"content_type")); contentType != "" {
ack.ContentType = contentType
}
if body, exists := values[prefix+"body"]; exists {
ack.Body = []byte(fmt.Sprint(body))
}
return ack
}
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if text, ok := value.(string); ok {
fields[key] = text
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
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,
"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,
}
func maskPaymentSecrets(values map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text != "" {
values[key] = "******"
}
continue
}
if nested, ok := value.(map[string]any); ok {
maskPaymentSecrets(nested)
}
}
}
func mergePaymentSecrets(values, old map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text == "******" {
if prior, exists := old[key]; exists {
values[key] = prior
}
}
continue
}
if nested, ok := value.(map[string]any); ok {
if prior, ok := old[key].(map[string]any); ok {
mergePaymentSecrets(nested, prior)
}
}
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func validatePaymentConfig(provider string, values map[string]any) error {
required := []string{}
switch provider {
case biz.PaymentAlipay:
required = []string{"app_id", "private_key", "public_key"}
case biz.PaymentWechatV2:
required = []string{"app_id", "merchant_id", "mch_key"}
case biz.PaymentWechatV3:
required = []string{"app_id", "merchant_id", "serial_no", "private_key", "api_v3_key", "platform_cert"}
case biz.PaymentApple:
required = []string{"issuer_id", "key_id", "bundle_id", "private_key"}
if configuredInt64(values, "price_divisor", 0) <= 0 {
if _, ok := values["price_divisors"].(map[string]any); !ok {
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
}
}
case biz.PaymentDouyin:
required = []string{"merchant_id", "serial_no", "api_key", "private_key", "platform_cert", "platform_serial_no"}
case biz.PaymentQQ:
if firstAny(values, "mch_id", "merchant_id") == "" {
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
}
required = []string{"api_key"}
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"}
case biz.PaymentSaobei:
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
default:
required = []string{
"protocol_version", "create_url", "query_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",
}
if firstAny(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
return fmt.Errorf("%s 缺少签名密钥", provider)
}
if err := validateConfiguredBreakdownFields(values); err != nil {
return fmt.Errorf("%s 金额拆分配置无效: %w", provider, err)
}
}
for _, key := range required {
if text(values, key) == "" {
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
}
}
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
}
if environment := strings.ToLower(strings.TrimSpace(text(values, "environment"))); environment != "" {
switch provider {
case biz.PaymentQQ, biz.PaymentDouyin, biz.PaymentLakala:
if environment != "production" && environment != "prod" {
return fmt.Errorf("%s 仅支持 production 环境", provider)
}
case biz.PaymentPayPal:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 必须是 production 或 sandbox", provider)
}
default:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 配置无效", provider)
}
}
}
if provider == biz.PaymentQQ {
signType := strings.ToUpper(strings.TrimSpace(text(values, "sign_type")))
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
}
}
if err := validatePaymentCallbackAckConfig(values); err != nil {
return fmt.Errorf("%s 回调响应配置无效: %w", provider, err)
}
return nil
}
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",
"query_discount_amount_scale", "query_provider_discount_amount_scale", "query_merchant_discount_amount_scale",
"query_settlement_amount_scale",
} {
if _, exists := values[key]; exists && configuredInt64(values, key, 0) <= 0 {
return fmt.Errorf("%s 必须是正整数", key)
}
}
for _, key := range []string{
"query_payer_paid_amount_field", "query_cash_paid_amount_field", "query_point_paid_amount_field",
"query_discount_amount_field", "query_provider_discount_amount_field", "query_merchant_discount_amount_field",
"query_settlement_amount_field", "query_payer_currency_field",
} {
if value, exists := values[key]; exists && strings.TrimSpace(fmt.Sprint(value)) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validatePaymentCallbackAckConfig(values map[string]any) error {
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
if value := strings.TrimSpace(text(values, prefix+"status")); value != "" {
status, err := strconv.Atoi(value)
if err != nil || status < 200 || status > 599 {
return fmt.Errorf("%sstatus 必须是 200-599", prefix)
}
}
if contentType := text(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
return fmt.Errorf("%scontent_type 非法", prefix)
}
if body, exists := values[prefix+"body"]; exists && len(fmt.Sprint(body)) > 64<<10 {
return fmt.Errorf("%sbody 超过 64KiB", prefix)
}
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"context"
"fmt"
"kra/internal/biz"
)
// Adapter is the provider boundary used by the payment repository. Provider
// SDK types stay in this package and are normalized to biz.PaymentResult.
type Adapter interface {
Create(context.Context, *biz.PaymentRequest, map[string]any) (*biz.PaymentResult, error)
Query(context.Context, string, map[string]any) (*biz.PaymentResult, error)
Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error)
Callback(context.Context, *biz.PaymentCallback, map[string]any) (*biz.PaymentResult, error)
}
// New constructs the SDK-backed adapter for a configured provider.
func New(provider string) (Adapter, error) {
switch provider {
case biz.PaymentAlipay:
return &alipayAdapter{}, nil
case biz.PaymentWechatV2:
return &wechatV2Adapter{}, nil
case biz.PaymentWechatV3:
return &wechatV3Adapter{}, nil
case biz.PaymentApple:
return &appleAdapter{}, nil
case biz.PaymentDouyin:
return &douyinAdapter{}, nil
case biz.PaymentQQ:
return &qqAdapter{}, nil
case biz.PaymentAllinPay:
return &allinpayAdapter{}, nil
case biz.PaymentLakala:
return &lakalaAdapter{}, nil
case biz.PaymentPayPal:
return &paypalAdapter{}, nil
case biz.PaymentSaobei:
return &saobeiAdapter{}, nil
case biz.PaymentChinaums:
return newVendorAdapter(provider, vendorChinaums), nil
case biz.PaymentSFT:
return newVendorAdapter(provider, vendorSFT), nil
case biz.PaymentSuperPay:
return newVendorAdapter(provider, vendorSupperPay), nil
case biz.PaymentWechatGame:
return newVendorAdapter(provider, vendorWechatGame), nil
case biz.PaymentDouyinGame:
return newVendorAdapter(provider, vendorDouyinGame), nil
default:
return nil, fmt.Errorf("支付渠道 %s 没有适配器", provider)
}
}
+21
View File
@@ -0,0 +1,21 @@
package payment
import (
"testing"
"kra/internal/biz"
)
func TestEverySupportedProviderHasAdapter(t *testing.T) {
for _, provider := range biz.SupportedPaymentProviders {
t.Run(provider, func(t *testing.T) {
adapter, err := New(provider)
if err != nil {
t.Fatalf("New(%q): %v", provider, err)
}
if adapter == nil {
t.Fatalf("New(%q) returned a nil adapter", provider)
}
})
}
}
+551
View File
@@ -0,0 +1,551 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayAlipay "github.com/go-pay/gopay/alipay"
"github.com/go-pay/gopay/pkg/xhttp"
)
type alipayAdapter struct{}
type alipayClient struct {
client *gopayAlipay.Client
gateway *url.URL
response bytes.Buffer
}
type alipayTransport struct {
base http.RoundTripper
gateway *url.URL
response *bytes.Buffer
}
type alipayResponseBody struct {
io.Reader
io.Closer
}
func (a *alipayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝下单参数为空")
}
amount, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "total_amount": amount, "subject": req.Subject}
mergeMap(bm, req.Extra,
"out_trade_no", "total_amount", "subject", "notify_url", "return_url",
"method", "pay_method", "trade_type", "channel",
)
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
if req.NotifyURL != "" {
sdk.client.SetNotifyUrl(req.NotifyURL)
}
if req.ReturnURL != "" {
sdk.client.SetReturnUrl(req.ReturnURL)
}
method, err := alipayCreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
switch method {
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 {
return nil, err
}
response := rsp.Response
queryID := strings.TrimSpace(response.OutTradeNo)
if queryID == "" {
queryID = req.TradeNo
} else if queryID != req.TradeNo {
return nil, errors.New("支付宝预创建响应的 out_trade_no 不匹配")
}
if strings.TrimSpace(response.QrCode) == "" {
return nil, errors.New("支付宝预创建响应缺少 qr_code")
}
// Precreate has no Alipay trade number yet. Keep the QR code in Payload
// and query later by the merchant out_trade_no.
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
QueryID: queryID, Payload: sdk.payload(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("支付宝 APP 支付参数为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(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
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝电脑网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(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
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝手机网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}),
}, nil
default:
rsp, callErr := sdk.client.TradeCreate(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 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝创建响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
}
func (a *alipayAdapter) 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")
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
const method = "alipay.trade.query"
rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo})
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: normalizePaymentStatus(response.TradeStatus, "pending"),
TradeNo: strings.TrimSpace(response.OutTradeNo), ProviderTradeNo: strings.TrimSpace(response.TradeNo),
Payload: sdk.payload(rsp),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
} else if result.TradeNo != tradeNo {
return nil, errors.New("支付宝查询响应的 out_trade_no 不匹配")
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("支付宝查询响应缺少 trade_no")
}
result.Amount, err = parseDecimalAmount(response.TotalAmount, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝订单金额: %w", err)
}
result.Currency = strings.ToUpper(firstNonEmptyAlipay(response.TransCurrency, response.PayCurrency))
if result.Currency == "" {
result.Currency = "CNY"
}
values, err := alipayResponseValues(response)
if err != nil {
return nil, err
}
if err = populateAlipayBreakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *alipayAdapter) 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 == "" {
return nil, errors.New("支付宝退款缺少 out_trade_no 或 out_request_no")
}
formatted, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_request_no": refundNo, "refund_amount": formatted}
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount")
const method = "alipay.trade.refund"
rsp, callErr := sdk.client.TradeRefund(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) != tradeNo {
return nil, errors.New("支付宝退款响应的 out_trade_no 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝退款响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
fields := callbackFields(callback)
if err := verifyAlipay(fields, c); err != nil {
return nil, err
}
if fields["app_id"] != text(c, "app_id") {
return nil, errors.New("支付宝回调 app_id 不匹配")
}
status := "pending"
if fields["trade_status"] == "TRADE_SUCCESS" || fields["trade_status"] == "TRADE_FINISHED" {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: status,
TradeNo: first(fields, "out_trade_no"), ProviderTradeNo: first(fields, "trade_no"),
Payload: payload,
}, nil
}
func newAlipayClient(c map[string]any) (*alipayClient, error) {
appID := strings.TrimSpace(text(c, "app_id"))
privateKey := strings.TrimSpace(text(c, "private_key"))
if appID == "" || privateKey == "" {
return nil, errors.New("支付宝缺少 app_id 或 private_key")
}
production := !strings.EqualFold(text(c, "environment"), "sandbox")
client, err := gopayAlipay.NewClient(appID, goPayAlipayKey(privateKey, false), production)
if err != nil {
return nil, err
}
if charset := strings.TrimSpace(text(c, "charset")); charset != "" {
client.SetCharset(charset)
}
if signType := strings.TrimSpace(text(c, "sign_type")); signType != "" {
client.SetSignType(signType)
}
if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" {
client.SetAppAuthToken(token)
}
if location := strings.TrimSpace(text(c, "location")); location != "" {
client.SetLocation(location)
}
sdk := &alipayClient{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("支付宝 gateway_url 无效: %s", endpoint)
}
}
httpClient := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8)
httpClient.SetTransport(&alipayTransport{
base: httpClient.HttpClient.Transport, gateway: sdk.gateway, response: &sdk.response,
})
client.SetHttpClient(httpClient)
return sdk, nil
}
func (c *alipayClient) finish(method string, hasResponse bool, callErr error, config map[string]any) error {
if !hasResponse {
if callErr != nil {
return callErr
}
return fmt.Errorf("支付宝 %s 响应为空", method)
}
if c.response.Len() == 0 {
return fmt.Errorf("支付宝 %s 响应原文为空", method)
}
if err := verifyAlipayResponse(c.response.Bytes(), method, config); err != nil {
return err
}
return callErr
}
func (c *alipayClient) payload(fallback any) []byte {
if c.response.Len() > 0 {
return append([]byte(nil), c.response.Bytes()...)
}
return mustMarshalAlipayPayload(fallback)
}
func (c *alipayClient) rewriteGeneratedURL(value string) (string, error) {
if c.gateway == nil {
return value, nil
}
source, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("解析支付宝支付 URL: %w", err)
}
return alipayTargetURL(source, c.gateway).String(), nil
}
func (t *alipayTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.base == nil {
t.base = http.DefaultTransport
}
target := req
if t.gateway != nil {
clone := req.Clone(req.Context())
clone.URL = alipayTargetURL(req.URL, t.gateway)
clone.Host = ""
target = clone
}
if t.response != nil {
t.response.Reset()
}
resp, err := t.base.RoundTrip(target)
if err != nil || resp == nil || resp.Body == nil || t.response == nil {
return resp, err
}
resp.Body = &alipayResponseBody{Reader: io.TeeReader(resp.Body, t.response), Closer: resp.Body}
return resp, nil
}
func alipayTargetURL(source, gateway *url.URL) *url.URL {
target := *gateway
switch {
case target.RawQuery == "":
target.RawQuery = source.RawQuery
case source.RawQuery != "":
target.RawQuery += "&" + source.RawQuery
}
return &target
}
func alipayCreateMethod(extra, config map[string]any) (string, error) {
value := firstAny(extra, "method", "pay_method", "trade_type", "channel")
if value == "" {
value = firstAny(config, "method", "pay_method", "trade_type", "channel")
}
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
switch normalized {
case "", "create", "trade_create", "alipay_trade_create", "jsapi", "miniapp", "mini_program":
return "alipay.trade.create", 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":
return "alipay.trade.app.pay", nil
case "page", "page_pay", "trade_page_pay", "alipay_trade_page_pay", "web", "pc", "desktop":
return "alipay.trade.page.pay", nil
case "wap", "wap_pay", "trade_wap_pay", "alipay_trade_wap_pay", "h5", "mobile":
return "alipay.trade.wap.pay", nil
default:
return "", fmt.Errorf("支付宝不支持的下单方式: %s", value)
}
}
func alipayResponseValues(value any) (map[string]any, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := map[string]any{}
if err = json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return result, nil
}
func mustMarshalAlipayPayload(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func firstNonEmptyAlipay(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func populateAlipayBreakdown(result *biz.PaymentResult, response map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
// Alipay returns these values in major currency units. Keep all arithmetic
// integer-only and only mark the breakdown complete when the core fields are
// present and internally consistent.
total, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(response["total_amount"])), 100)
if err != nil {
return fmt.Errorf("解析支付宝 total_amount: %w", err)
}
result.Amount = total
payer, payerOK, err := parseAlipayDecimal(response, "buyer_pay_amount")
if err != nil {
return err
}
if !payerOK {
payer, payerOK, err = parseAlipayDecimal(response, "receipt_amount")
if err != nil {
return err
}
}
point, pointOK, err := parseAlipayDecimal(response, "point_amount")
if err != nil {
return err
}
if !payerOK {
return nil
}
if pointOK && point > payer {
return errors.New("支付宝 point_amount 大于用户实付金额")
}
result.PayerPaidAmount = payer
result.PointPaidAmount = point
result.CashPaidAmount = payer - point
result.DiscountAmount = total - payer
if result.DiscountAmount < 0 {
return errors.New("支付宝用户实付金额超过订单总额")
}
// receipt_amount is the merchant receipt amount. It is not always present;
// when it is, use it as settlement amount. Alipay's detailed voucher list
// is retained in Payload for audit and is intentionally not guessed here.
if receipt, ok, receiptErr := parseAlipayDecimal(response, "receipt_amount"); receiptErr != nil {
return receiptErr
} else if ok {
result.SettlementAmount = receipt
}
result.AmountBreakdownKnown = true
return nil
}
func parseAlipayDecimal(values map[string]any, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(fmt.Sprint(value)) == "" || fmt.Sprint(value) == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(value)), 100)
return amount, true, err
}
func verifyAlipayResponse(raw []byte, method string, c map[string]any) error {
publicKeyValue := text(c, "public_key")
if publicKeyValue == "" {
return errors.New("支付宝缺少 public_key")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return err
}
responseKey := strings.ReplaceAll(method, ".", "_") + "_response"
responseRaw := envelope[responseKey]
if len(responseRaw) == 0 {
return errors.New("支付宝响应缺少 " + responseKey)
}
var sign string
if err := json.Unmarshal(envelope["sign"], &sign); err != nil || sign == "" {
return errors.New("支付宝响应缺少 sign")
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicKeyValue, true), string(responseRaw), sign)
if err != nil {
return err
}
if !ok {
return errors.New("支付宝响应签名校验失败")
}
return nil
}
func alipaySignContent(v url.Values) string {
keys := make([]string, 0, len(v))
for key := range v {
if key != "sign" && v.Get(key) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+v.Get(key))
}
return strings.Join(parts, "&")
}
func verifyAlipay(fields map[string]string, c map[string]any) error {
if text(c, "app_id") == "" || text(c, "public_key") == "" || fields["sign"] == "" {
return errors.New("支付宝回调验签配置或 sign 缺失")
}
values := url.Values{}
for key, value := range fields {
values.Set(key, value)
}
body, err := gopayAlipay.ParseNotifyByURLValues(values)
if err != nil {
return err
}
ok, err := gopayAlipay.VerifySign(goPayAlipayKey(text(c, "public_key"), true), cloneGoPayBodyMap(body))
if err != nil {
return err
}
if !ok {
return errors.New("支付宝回调签名校验失败")
}
return nil
}
func mergeMap(dst map[string]any, src map[string]any, protected ...string) {
protectedKeys := make(map[string]struct{}, len(protected))
for _, key := range protected {
protectedKeys[key] = struct{}{}
}
for key, value := range src {
if _, exists := protectedKeys[key]; exists {
continue
}
dst[key] = value
}
}
+139
View File
@@ -0,0 +1,139 @@
package payment
import (
"context"
"errors"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/allinpay"
)
type allinpayAdapter struct{}
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 {
return nil, err
}
if org := text(c, "org_id"); org != "" {
client.SetOrgId(org)
}
return client, nil
}
func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
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" {
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"))
info.Set("termno", firstAnyOr(req.Extra, "00000001", "termno", "terminal_no"))
})
rsp, callErr := client.ScanPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
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
}
if method == "native" || method == "qrcode" {
bm.Set("expiretime", firstAny(req.Extra, "expiretime", "expire_time"))
rsp, callErr := client.NativePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
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
}
payType := firstAny(req.Extra, "paytype", "pay_type")
if payType == "" {
payType = firstAny(c, "paytype", "pay_type")
}
if payType == "" {
payType = allinpay.PayTypeWXJS
}
bm.Set("paytype", payType)
rsp, err := client.Pay(ctx, bm)
if err != nil {
return nil, err
}
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
}
func (a *allinpayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderType := firstAny(c, "query_order_type", "order_type")
if orderType == "" {
orderType = allinpay.OrderTypeReqSN
}
rsp, err := client.Query(ctx, orderType, tradeNo)
if 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.TradeNo = tradeNo
}
if rsp.TrxAmt != "" {
result.Amount, err = parseIntegerAmount(rsp.TrxAmt)
if err != nil {
if scale := configuredInt64(c, "amount_scale", 100); scale != 1 {
result.Amount, err = parseDecimalAmount(rsp.TrxAmt, scale)
}
}
}
if result.Currency == "" {
result.Currency = "CNY"
}
return result, err
}
func (a *allinpayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, 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
}
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")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付退款响应为空")
}
refundAmount, _ := parseIntegerAmount(rsp.Fee)
if refundAmount == 0 {
refundAmount = req.Amount
}
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
}
func (a *allinpayAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("通联支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+213
View File
@@ -0,0 +1,213 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
gopayApple "github.com/go-pay/gopay/apple"
"github.com/google/uuid"
)
type appleAdapter struct{}
func (a *appleAdapter) Create(_ context.Context, req *biz.PaymentRequest, _ map[string]any) (*biz.PaymentResult, error) {
productID := ""
if req.Extra != nil {
productID, _ = req.Extra["product_id"].(string)
}
if productID == "" {
return nil, errors.New("Apple 内购缺少 extra.product_id")
}
if _, err := uuid.Parse(req.TradeNo); err != nil {
return nil, errors.New("Apple 内购 tradeNo 必须是用于 appAccountToken 的 UUID")
}
payload, _ := json.Marshal(map[string]any{"product_id": productID, "app_account_token": req.TradeNo, "client_only": true, "trade_no": req.TradeNo})
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil
}
func (a *appleAdapter) Query(ctx context.Context, transactionID string, c map[string]any) (*biz.PaymentResult, error) {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
return nil, errors.New("Apple 查询缺少 transactionId")
}
client, err := gopayApple.NewClient(
text(c, "issuer_id"),
text(c, "bundle_id"),
text(c, "key_id"),
text(c, "private_key"),
!strings.EqualFold(text(c, "environment"), "sandbox"),
)
if err != nil {
return nil, err
}
response, err := client.GetTransactionInfo(ctx, transactionID)
if err != nil {
return nil, err
}
if response == nil || response.SignedTransactionInfo == "" {
return nil, errors.New("Apple 查询响应缺少 signedTransactionInfo")
}
transaction, err := response.DecodeSignedTransaction()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 交易 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
result, err := applePaymentResult(transactionID, claims, payload, c)
if err != nil {
return nil, err
}
if result.ProviderTradeNo == "" {
return nil, errors.New("Apple 交易缺少 transactionId")
}
return result, nil
}
func (a *appleAdapter) Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("Apple 内购退款由 App Store 管理,服务端不提供主动退款接口")
}
func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("Apple 回调为空")
}
var body struct {
SignedPayload string `json:"signedPayload"`
}
if err := json.Unmarshal(callback.Body, &body); err != nil {
return nil, err
}
if body.SignedPayload == "" {
return nil, errors.New("Apple 回调缺少 signedPayload")
}
notification, err := gopayApple.DecodeSignedPayload(body.SignedPayload)
if err != nil {
return nil, err
}
if notificationID := strings.TrimSpace(notification.NotificationUUID); notificationID != "" {
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notificationID
}
if notification.Data == nil {
return nil, errors.New("Apple 回调缺少 data")
}
transaction, err := notification.DecodeTransactionInfo()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 回调 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
transactionID := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if tradeNo == "" || tradeNo == "<nil>" || transactionID == "" || transactionID == "<nil>" {
return nil, errors.New("Apple 回调缺少 appAccountToken 或 transactionId")
}
if _, parseErr := uuid.Parse(tradeNo); parseErr != nil {
return nil, errors.New("Apple 回调 appAccountToken 不是有效 UUID")
}
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: transactionID, QueryID: transactionID, Payload: payload}, nil
}
func appleClaims(value any) (map[string]any, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
claims := map[string]any{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func applePaymentResult(transactionID string, claims map[string]any, payload []byte, c map[string]any) (*biz.PaymentResult, error) {
status := "success"
if fmt.Sprint(claims["revocationDate"]) != "" && fmt.Sprint(claims["revocationDate"]) != "<nil>" {
status = "failed"
}
providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if providerTradeNo == "" || providerTradeNo == "<nil>" || providerTradeNo != transactionID {
return nil, errors.New("Apple 查询返回的 transactionId 不匹配")
}
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
if tradeNo == "" || tradeNo == "<nil>" {
return nil, errors.New("Apple 交易缺少 appAccountToken")
}
if _, err := uuid.Parse(tradeNo); err != nil {
return nil, errors.New("Apple 交易 appAccountToken 不是有效 UUID")
}
result := &biz.PaymentResult{Provider: biz.PaymentApple, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, QueryID: providerTradeNo, Payload: payload}
if status != "success" {
return result, nil
}
result.Currency = strings.ToUpper(strings.TrimSpace(fmt.Sprint(claims["currency"])))
if result.Currency == "<nil>" {
result.Currency = ""
}
if result.Currency == "" {
return nil, errors.New("Apple 交易缺少 currency")
}
price, err := parseIntegerAmount(strings.TrimSpace(fmt.Sprint(claims["price"])))
if err != nil {
return nil, fmt.Errorf("解析 Apple 交易 price: %w", err)
}
divisor := applePriceDivisor(c, result.Currency)
if divisor <= 0 || price%divisor != 0 {
return nil, errors.New("Apple price_divisor 未配置或交易金额无法精确换算到最小货币单位")
}
result.Amount = price / divisor
result.PayerPaidAmount = result.Amount
result.CashPaidAmount = result.Amount
result.SettlementAmount = result.Amount
result.PayerCurrency = result.Currency
result.AmountBreakdownKnown = true
return result, 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 {
if strings.EqualFold(strings.TrimSpace(key), currency) {
return configuredInt64(map[string]any{"value": value}, "value", 0)
}
}
}
return configuredInt64(c, "price_divisor", 0)
}
func validateAppleEnvironment(claims map[string]any, c map[string]any) error {
actual := strings.ToLower(strings.TrimSpace(fmt.Sprint(claims["environment"])))
if actual == "" || actual == "<nil>" {
return errors.New("Apple 交易缺少 environment")
}
expected := "production"
if strings.EqualFold(text(c, "environment"), "sandbox") {
expected = "sandbox"
}
if actual != expected {
return errors.New("Apple 交易 environment 不匹配")
}
return nil
}
+230
View File
@@ -0,0 +1,230 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
type douyinAdapter struct{}
func (a *douyinAdapter) client(c map[string]any) (*douyin.Client, error) {
client, err := douyin.NewClient(text(c, "merchant_id"), text(c, "serial_no"), text(c, "api_key"), text(c, "private_key"))
if err != nil {
return nil, err
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial")
if cert := text(c, "platform_cert"); cert != "" {
if platformSerial == "" {
return nil, errors.New("抖音支付缺少 platform_serial_no")
}
if err := client.SetPlatformCert([]byte(cert), platformSerial); err != nil {
return nil, err
}
}
if proxy := text(c, "proxy_url"); proxy != "" {
client.SetProxyHost(proxy)
}
return client, nil
}
func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("appid", text(c, "app_id"))
bm.Set("description", req.Subject)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("notify_url", req.NotifyURL)
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
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"))
}
switch tradeType {
case "app", "app_pay", "apporder":
rsp, callErr := client.AppOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
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":
rsp, callErr := client.H5Order(ctx, bm)
if callErr != nil {
return nil, callErr
}
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.H5Url, Payload: payload}, nil
case "native", "qr", "qrcode":
rsp, callErr := client.NativeOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
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.CodeUrl, Payload: payload}, nil
default:
rsp, callErr := client.JsapiOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
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
}
}
func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderQueryByOutTradeNo(ctx, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
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 result.TradeNo == "" {
result.TradeNo = tradeNo
}
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
}
}
return result, nil
}
func (a *douyinAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, 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
}
bm := gopay.BodyMap{}
bm.Set("out_trade_no", req.TradeNo).Set("out_refund_no", req.RefundNo)
bm.SetBodyMap("amount", func(m gopay.BodyMap) {
m.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount", "mchid")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
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
}
if r.Amount != nil {
result.Amount = int64(r.Amount.Refund)
result.Currency = strings.ToUpper(r.Amount.Currency)
}
return result, 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("抖音支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := douyin.ParseNotify(request)
if err != nil {
return nil, err
}
client, err := a.client(c)
if err != nil {
return nil, err
}
if cert := text(c, "platform_cert"); cert == "" {
return nil, errors.New("抖音支付回调缺少 platform_cert")
}
if err := notify.VerifySignByPKMap(client.PlatformCertMap()); 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
}
}
return result, nil
}
if strings.HasPrefix(strings.ToUpper(notify.EventType), "REFUND.") {
refund, err := notify.DecryptRefundCipherText(text(c, "api_key"))
if err != nil {
return nil, err
}
status := "pending"
if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusSuccess) {
status = "success"
} else if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusClosed) || strings.EqualFold(refund.RefundStatus, douyin.RefundStatusAbnormal) {
status = "failed"
}
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: status, TradeNo: refund.OutTradeNo, ProviderTradeNo: refund.RefundId, Payload: mustJSON(refund), EventID: notify.Id}
if refund.Amount != nil {
result.Amount = int64(refund.Amount.Refund)
result.Currency = strings.ToUpper(firstAny(c, "currency"))
}
return result, nil
}
return nil, fmt.Errorf("抖音支付不支持回调事件 %q", notify.EventType)
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"github.com/go-pay/gopay"
)
// toGoPayBodyMap keeps provider protocol types inside the data layer while
// allowing GoPay's signing and notification helpers to be reused.
func toGoPayBodyMap(values map[string]string) gopay.BodyMap {
bm := make(gopay.BodyMap, len(values))
for key, value := range values {
bm[key] = value
}
return bm
}
func cloneGoPayBodyMap(values gopay.BodyMap) gopay.BodyMap {
clone := make(gopay.BodyMap, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}
func fromGoPayBodyMap(values gopay.BodyMap) map[string]string {
result := make(map[string]string, len(values))
for key := range values {
result[key] = values.GetString(key)
}
return result
}
// goPayAlipayKey converts the PEM-friendly configuration accepted by this
// service into GoPay's raw base64 key format. GoPay's Alipay helpers add their
// own PEM envelope internally.
func goPayAlipayKey(value string, public bool) string {
value = strings.TrimSpace(value)
if block, _ := pem.Decode([]byte(value)); block != nil {
der := block.Bytes
if public && block.Type == "CERTIFICATE" {
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
if pub, err := x509.MarshalPKIXPublicKey(cert.PublicKey); err == nil {
der = pub
}
}
}
return base64.StdEncoding.EncodeToString(der)
}
return strings.Join(strings.Fields(value), "")
}
+157
View File
@@ -0,0 +1,157 @@
package payment
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
// The adapters in this package intentionally keep GoPay request/response
// models below the data boundary. The business layer only sees PaymentResult.
func callbackRequest(callback *biz.PaymentCallback) (*http.Request, error) {
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
return request, nil
}
func mergeGoPayExtras(dst gopay.BodyMap, src map[string]any, protected ...string) {
protectedKeys := make(map[string]struct{}, len(protected))
for _, key := range protected {
protectedKeys[key] = struct{}{}
}
for key, value := range src {
if _, ok := protectedKeys[key]; ok {
continue
}
dst[key] = value
}
}
func mergeGoPayConfigExtras(dst gopay.BodyMap, config map[string]any, key string, protected ...string) {
extra, ok := config[key].(map[string]any)
if !ok || extra == nil {
return
}
mergeGoPayExtras(dst, extra, protected...)
}
func anyConfig(c map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := c[key]; ok && fmt.Sprint(value) != "" {
return value
}
}
return nil
}
func firstAnyOr(values map[string]any, fallback string, keys ...string) string {
if value := firstAny(values, keys...); value != "" {
return value
}
return fallback
}
func mustJSON(value any) []byte {
encoded, _ := json.Marshal(value)
return encoded
}
func douyinError(rsp any) error {
return fmt.Errorf("抖音支付请求失败: %v", rsp)
}
func qqOK(returnCode, resultCode string) bool {
return strings.EqualFold(returnCode, "SUCCESS") && (resultCode == "" || strings.EqualFold(resultCode, "SUCCESS"))
}
func rspMap(value any) map[string]string {
encoded, _ := json.Marshal(value)
var object map[string]any
_ = json.Unmarshal(encoded, &object)
result := make(map[string]string, len(object))
for key, val := range object {
result[key] = fmt.Sprint(val)
}
return result
}
func normalizeDouyinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.TradeStateSuccess:
return "success"
case douyin.TradeStatePayErr:
return "failed"
case douyin.TradeStateClosed:
return "failed"
default:
return "pending"
}
}
func normalizeRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.RefundStatusSuccess, "REFUNDED":
return "success"
case douyin.RefundStatusClosed, douyin.RefundStatusAbnormal, "FAILED":
return "failed"
default:
return "pending"
}
}
func normalizeQQState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "REFUND":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "NOPAY":
return "failed"
default:
return "pending"
}
}
func normalizeAllinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "0000", "2000", "3040":
return "success"
case "FAIL", "FAILED", "3000", "3045":
return "failed"
default:
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":
return "failed"
default:
return "pending"
}
}
func normalizeSaobeiState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "FAILED", "NOPAY":
return "failed"
default:
return "pending"
}
}
+349
View File
@@ -0,0 +1,349 @@
package payment
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"kra/internal/biz"
"kra/pkg/paymentkit"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
func testRSAKeyPair(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)
}
func testRSA2Sign(t *testing.T, key *rsa.PrivateKey, data []byte) string {
t.Helper()
digest := sha256.Sum256(data)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(signature)
}
func TestAlipayGoPayRequestParamHonorsGatewayAndVerifiesResponse(t *testing.T) {
platformKey, privatePEM, publicPEM := testRSAKeyPair(t)
var requestForm url.Values
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
}
requestForm, err = url.ParseQuery(string(body))
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestForm.Get("method") != "alipay.trade.query" {
t.Errorf("method = %q", requestForm.Get("method"))
}
if !strings.Contains(requestForm.Get("biz_content"), `"out_trade_no":"T-ALIPAY"`) {
t.Errorf("biz_content = %q", requestForm.Get("biz_content"))
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicPEM, true), alipaySignContent(requestForm), requestForm.Get("sign"))
if err != nil || !ok {
t.Errorf("request signature valid = %v, err = %v", ok, err)
}
response := []byte(`{"code":"10000","msg":"Success","trade_no":"P-ALIPAY","out_trade_no":"T-ALIPAY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00","trans_currency":"CNY"}`)
payload := fmt.Sprintf(`{"alipay_trade_query_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{}).Query(context.Background(), "T-ALIPAY", map[string]any{
"app_id": "app-test",
"private_key": privatePEM,
"public_key": publicPEM,
"gateway_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if requestForm == nil || result.TradeNo != "T-ALIPAY" || result.ProviderTradeNo != "P-ALIPAY" || result.Amount != 1000 || result.Currency != "CNY" {
t.Fatalf("unexpected result: %+v, request: %v", result, requestForm)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 {
t.Fatalf("unexpected amount breakdown: %+v", result)
}
}
func TestAlipayGoPayCallbackVerificationDoesNotMutateFields(t *testing.T) {
key, _, publicPEM := testRSAKeyPair(t)
fields := map[string]string{
"app_id": "app-test",
"trade_no": "P-ALIPAY",
"out_trade_no": "T-ALIPAY",
"trade_status": "TRADE_SUCCESS",
"sign_type": "RSA2",
}
body := toGoPayBodyMap(fields)
body.Remove("sign_type")
sign, err := gopayAlipay.GetRsaSign(body, gopayAlipay.RSA2, key)
if err != nil {
t.Fatal(err)
}
fields["sign"] = sign
config := map[string]any{"app_id": "app-test", "public_key": publicPEM}
if err = verifyAlipay(fields, config); err != nil {
t.Fatal(err)
}
if fields["sign"] == "" || fields["sign_type"] != "RSA2" {
t.Fatalf("verification mutated source fields: %#v", fields)
}
fields["trade_no"] = "tampered"
if err = verifyAlipay(fields, config); err == nil {
t.Fatal("tampered callback unexpectedly verified")
}
}
func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
const key = "wechat-secret"
var requestValues map[string]string
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
}
requestValues, err = paymentkit.XMLValues(body)
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestValues["total_fee"] != "123" {
t.Errorf("total_fee = %q, want 123", requestValues["total_fee"])
}
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", "prepay_id": "prepay"}
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
}))
defer server.Close()
result, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{
TradeNo: "T-WECHAT", Subject: "subject", Amount: 123, Currency: "CNY", NotifyURL: "http://notify.local",
}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
"environment": "sandbox", "create_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if result.Status != "created" || requestValues["total_fee"] != "123" {
t.Fatalf("unexpected create result: %+v, request: %#v", result, requestValues)
}
callbackValues := map[string]string{
"return_code": "SUCCESS", "result_code": "SUCCESS", "appid": "wx-test", "mch_id": "mch-test",
"out_trade_no": "T-WECHAT", "transaction_id": "P-WECHAT", "trade_state": "SUCCESS",
}
callbackValues["sign"] = wechatV2Sign(callbackValues, key, "MD5")
originalSign := callbackValues["sign"]
if err = verifyWechatV2(callbackValues, key); err != nil {
t.Fatal(err)
}
if callbackValues["sign"] != originalSign {
t.Fatal("verification mutated source values")
}
callbackBody := paymentkit.XMLEncode(callbackValues)
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
})
if err != nil {
t.Fatal(err)
}
if callbackResult.Status != "success" || callbackResult.ProviderTradeNo != "P-WECHAT" {
t.Fatalf("unexpected callback result: %+v", callbackResult)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func TestWechatV3EndpointTransportPreservesSignedPathAndQuery(t *testing.T) {
endpoint, err := url.Parse("https://payments.test.local")
if err != nil {
t.Fatal(err)
}
request, err := http.NewRequest(http.MethodGet, "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/T-1?mchid=M-1", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Authorization", `WECHATPAY2-SHA256-RSA2048 mchid="M-1"`)
transport := &wechatV3EndpointTransport{
endpoint: endpoint,
base: roundTripperFunc(func(got *http.Request) (*http.Response, error) {
if got.URL.Scheme != "https" || got.URL.Host != "payments.test.local" {
t.Fatalf("endpoint = %s", got.URL)
}
if got.URL.EscapedPath() != "/v3/pay/transactions/out-trade-no/T-1" || got.URL.RawQuery != "mchid=M-1" {
t.Fatalf("signed target changed to %s", got.URL.RequestURI())
}
if got.Header.Get("Authorization") != request.Header.Get("Authorization") {
t.Fatal("authorization header changed while rewriting endpoint")
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header), Request: got}, nil
}),
}
if _, err = transport.RoundTrip(request); err != nil {
t.Fatal(err)
}
if request.URL.Host != "api.mch.weixin.qq.com" {
t.Fatal("transport mutated the original request")
}
}
func TestWechatV3PrepayResultUsesGoPayClientSigning(t *testing.T) {
_, privatePEM, _ := testRSAKeyPair(t)
client, err := gopayWechatV3.NewClientV3(
"mch-test",
"merchant-serial",
"01234567890123456789012345678901",
privatePEM,
)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
tradeType string
assert func(*testing.T, map[string]any)
}{
{
name: "jsapi", tradeType: "jsapi",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("JSAPI payload = %#v", payload)
}
},
},
{
name: "applet", tradeType: "mini_program",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("applet payload = %#v", payload)
}
},
},
{
name: "app", tradeType: "app",
assert: func(t *testing.T, payload map[string]any) {
if payload["appid"] != "wx-test" || payload["partnerid"] != "mch-test" || payload["prepayid"] != "PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["sign"])) == "" {
t.Fatalf("App payload = %#v", payload)
}
},
},
} {
t.Run(tc.name, func(t *testing.T) {
result, err := wechatV3PrepayCreateResult(client, "wx-test", tc.tradeType, "T-WECHAT-V3", &gopayWechatV3.PrepayRsp{
Code: gopayWechatV3.Success,
Response: &gopayWechatV3.Prepay{PrepayId: "PREPAY-1"},
})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "T-WECHAT-V3" || result.QueryID != "" || result.Status != "created" {
t.Fatalf("result = %+v", result)
}
var payload map[string]any
if err = json.Unmarshal(result.Payload, &payload); err != nil {
t.Fatal(err)
}
tc.assert(t, payload)
})
}
}
func TestWechatV3GoPayCallbackVerificationAndDecryption(t *testing.T) {
platformKey, merchantPrivatePEM, platformPublicPEM := testRSAKeyPair(t)
const (
apiV3Key = "01234567890123456789012345678901"
resourceNonce = "0123456789ab"
headerNonce = "callback-nonce"
timestamp = "1787068800"
serial = "ABCDEF123456"
)
plain := []byte(`{"appid":"wx-test","mchid":"mch-test","out_trade_no":"T-WECHAT-V3","transaction_id":"P-WECHAT-V3","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(apiV3Key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(resourceNonce), plain, []byte("transaction")))
body, err := json.Marshal(map[string]any{
"id": "EVENT-WECHAT-V3", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "summary": "payment success",
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "original_type": "transaction", "nonce": resourceNonce},
})
if err != nil {
t.Fatal(err)
}
signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+headerNonce+"\n"+string(body)+"\n"))
callback := &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
gopayWechatV3.HeaderTimestamp: timestamp,
gopayWechatV3.HeaderNonce: headerNonce,
gopayWechatV3.HeaderSerial: serial,
gopayWechatV3.HeaderSignature: signature,
},
}
result, err := (&wechatV3Adapter{}).Callback(context.Background(), callback, map[string]any{
"api_v3_key": apiV3Key, "platform_cert": platformPublicPEM, "private_key": merchantPrivatePEM,
"platform_serial_no": strings.ToLower(serial), "serial_no": "merchant-serial", "app_id": "wx-test", "merchant_id": "mch-test",
})
if err != nil {
t.Fatal(err)
}
if result.Status != "success" || result.TradeNo != "T-WECHAT-V3" || result.ProviderTradeNo != "P-WECHAT-V3" {
t.Fatalf("unexpected callback result: %+v", result)
}
if callback.Query["event_id"] != "EVENT-WECHAT-V3" {
t.Fatalf("event id = %q", callback.Query["event_id"])
}
}
+138
View File
@@ -0,0 +1,138 @@
package payment
import (
"context"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/lakala"
)
type lakalaAdapter struct{}
func (a *lakalaAdapter) client(c map[string]any) (*lakala.Client, error) {
return lakala.NewClient(text(c, "partner_code"), text(c, "credential_code"), !strings.EqualFold(text(c, "environment"), "sandbox"))
}
func (a *lakalaAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"description": req.Subject, "price": req.Amount, "channel": firstAny(req.Extra, "channel", "pay_channel")}
if bm.GetString("channel") == gopay.NULL {
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
}
return lakalaCreateResult(req.TradeNo, rsp)
}
rsp, err := client.CreateJSAPIOrder(ctx, req.TradeNo, bm)
if err != nil {
return nil, err
}
return lakalaCreateResult(req.TradeNo, rsp)
}
func lakalaCreateResult(tradeNo string, rsp *lakala.PaymentRsp) (*biz.PaymentResult, error) {
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
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
}
func (a *lakalaAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = tradeNo
}
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") {
return nil, fmt.Errorf("拉卡拉支付查单失败: %v", rsp)
}
status := normalizeLakalaState(first(rspMap(rsp), "status", "channel_error_code"))
if rsp.ChannelOrderId != "" && status == "pending" {
status = "success"
}
returnedTradeNo := rsp.PartnerOrderId
if returnedTradeNo == "" {
returnedTradeNo = tradeNo
}
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)}
if result.Currency == "" {
result.Currency = strings.ToUpper(firstAny(c, "currency", "default_currency"))
}
if result.Currency == "" {
result.Currency = "JPY"
}
if rsp.RealFee > 0 {
result.PayerPaidAmount = int64(rsp.RealFee)
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
if result.DiscountAmount >= 0 {
result.AmountBreakdownKnown = true
}
}
return result, nil
}
func (a *lakalaAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, 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
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = req.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)
if err != nil {
return nil, err
}
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
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
}
func (a *lakalaAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("拉卡拉支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := lakala.ParseNotify(request)
if err != nil {
return nil, err
}
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
}
+129
View File
@@ -0,0 +1,129 @@
package payment
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"net/url"
"strings"
"testing"
"kra/internal/biz"
)
func TestWechatV2SignOfficialStyle(t *testing.T) {
values := map[string]string{
"appid": "wxd930ea5d5a258f4f", "mch_id": "10000100", "device_info": "1000",
"body": "test", "nonce_str": "ibuaiVcKdpRxkhJA",
}
got := wechatV2Sign(values, "192006250b4c09247ec02edce69f6a2d", "MD5")
if got != strings.ToUpper(got) || len(got) != 32 {
t.Fatalf("invalid WeChat v2 MD5 signature %q", got)
}
values["sign"] = got
if err := verifyWechatV2(values, "192006250b4c09247ec02edce69f6a2d"); err != nil {
t.Fatal(err)
}
}
func TestApplePaymentResultSeparatesMerchantAndProviderTradeNumbers(t *testing.T) {
result, err := applePaymentResult("1000000000001", map[string]any{
"transactionId": "1000000000001",
"appAccountToken": "123e4567-e89b-12d3-a456-426614174000",
"currency": "CNY",
"price": "1000",
}, nil, map[string]any{"price_divisor": "10"})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "123e4567-e89b-12d3-a456-426614174000" || result.ProviderTradeNo != "1000000000001" || result.QueryID != "1000000000001" {
t.Fatalf("unexpected Apple order identifiers: %+v", result)
}
}
func TestWechatV3Decrypt(t *testing.T) {
key := "01234567890123456789012345678901"
nonce := "0123456789ab"
aad := "transaction"
plain := []byte(`{"out_trade_no":"T1","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plain, []byte(aad)))
got, err := decryptWechatV3(ciphertext, nonce, aad, key)
if err != nil {
t.Fatal(err)
}
if string(got) != string(plain) {
t.Fatalf("plaintext = %q", got)
}
}
func TestAlipaySignContentSortedAndExcludesSign(t *testing.T) {
values := url.Values{"method": {"alipay.trade.query"}, "app_id": {"123"}, "sign": {"ignored"}, "empty": {""}}
if got := alipaySignContent(values); got != "app_id=123&method=alipay.trade.query" {
t.Fatalf("sign content = %q", got)
}
}
func TestAlipayBreakdownWithPointPayment(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateAlipayBreakdown(result, map[string]any{
"total_amount": "10.00", "buyer_pay_amount": "8.00", "point_amount": "2.00", "receipt_amount": "8.00",
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 800 {
t.Fatalf("unexpected Alipay breakdown: %+v", result)
}
}
func TestWechatV2BreakdownWithCoupon(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV2Breakdown(result, map[string]string{"cash_fee": "900", "coupon_fee": "100", "settlement_total_fee": "950"})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.DiscountAmount != 100 || result.SettlementAmount != 950 {
t.Fatalf("unexpected WeChat v2 breakdown: %+v", result)
}
}
func TestWechatV3BreakdownWithPromotion(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV3Breakdown(result, map[string]any{
"amount": map[string]any{"payer_total": float64(800), "settlement_amount": float64(780)},
"promotion_detail": []any{map[string]any{"amount": float64(200)}},
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 800 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected WeChat v3 breakdown: %+v", result)
}
}
func TestVendorBreakdownUsesConfiguredUnits(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000, Currency: "CNY"}
object := map[string]any{"data": map[string]any{
"payer": "8.00", "cash": "6.00", "points": "2.00", "discount": "2.00", "settlement": "7.80",
}}
config := map[string]any{
"query_amount_scale": "100", "query_payer_paid_amount_field": "data.payer", "query_cash_paid_amount_field": "data.cash",
"query_point_paid_amount_field": "data.points", "query_discount_amount_field": "data.discount",
"query_settlement_amount_field": "data.settlement", "query_payer_currency_field": "data.currency",
}
err := populateVendorBreakdown(result, object, config)
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected vendor breakdown: %+v", result)
}
}
+596
View File
@@ -0,0 +1,596 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/paypal"
)
type paypalAdapter struct{}
func (a *paypalAdapter) client(c map[string]any) (*paypal.Client, error) {
environment := strings.ToLower(strings.TrimSpace(text(c, "environment")))
isProd := environment != "sandbox"
options := []paypal.Option{paypal.WithoutAutoRefreshToken()}
if baseURL := strings.TrimRight(firstAny(c, "api_base_url", "base_url", "proxy_url"), "/"); baseURL != "" {
options = append(options, paypal.WithProxyUrl(baseURL, baseURL))
}
client, err := paypal.NewClient(text(c, "client_id"), text(c, "client_secret"), isProd, options...)
if err != nil {
return nil, fmt.Errorf("PayPal 客户端初始化失败: %w", err)
}
client.SetRequestHeader("Prefer", "return=representation")
return client, nil
}
func (a *paypalAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("PayPal 下单参数为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
purchaseUnit := &paypal.PurchaseUnit{
ReferenceId: req.TradeNo,
Description: req.Subject,
CustomId: req.TradeNo,
InvoiceId: req.TradeNo,
Amount: &paypal.Amount{CurrencyCode: currency, Value: value},
}
bm := gopay.BodyMap{
"intent": "CAPTURE",
"purchase_units": []*paypal.PurchaseUnit{purchaseUnit},
}
if applicationContext := paypalApplicationContext(req, c); len(applicationContext) > 0 {
bm["application_context"] = applicationContext
}
mergeGoPayExtras(bm, req.Extra, "intent", "purchase_units", "application_context")
client.SetRequestHeader("PayPal-Request-Id", req.TradeNo)
rsp, err := client.CreateOrder(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("下单", paypalResponseCode(rsp), paypalCreateError(rsp))
}
order := rsp.Response
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
}
func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string]any) (*biz.PaymentResult, error) {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return nil, errors.New("PayPal 查单缺少 order ID")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return nil, errors.New("PayPal 查单响应的 order ID 不匹配")
}
return paypalOrderResult(rsp.Response, orderID, "", c)
}
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 退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
captureID, err := paypalRefundCaptureID(ctx, client, req)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"invoice_id": req.RefundNo,
"amount": &paypal.Amount{
CurrencyCode: currency,
Value: value,
},
}
if note := strings.TrimSpace(text(c, "refund_note")); note != "" {
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)
rsp, err := client.PaymentCaptureRefund(ctx, captureID, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("退款", paypalResponseCode(rsp), paypalRefundError(rsp))
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalRefundState(refund.Status),
TradeNo: req.TradeNo,
ProviderTradeNo: refund.Id,
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)
}
return result, nil
}
func (a *paypalAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("PayPal 回调为空")
}
webhookID := strings.TrimSpace(text(c, "webhook_id"))
if webhookID == "" {
return nil, errors.New("PayPal 回调验签缺少 webhook_id")
}
var event paypal.WebhookEvent
if err := json.Unmarshal(callback.Body, &event); err != nil {
return nil, fmt.Errorf("PayPal 回调 JSON 无效: %w", err)
}
if strings.TrimSpace(event.Id) == "" || len(event.Resource) == 0 {
return nil, errors.New("PayPal 回调事件不完整")
}
var webhookEvent any
if err := json.Unmarshal(callback.Body, &webhookEvent); err != nil {
return nil, err
}
verifyBody := gopay.BodyMap{
"auth_algo": paypalCallbackHeader(callback, "Paypal-Auth-Algo"),
"cert_url": paypalCallbackHeader(callback, "Paypal-Cert-Url"),
"transmission_id": paypalCallbackHeader(callback, "Paypal-Transmission-Id"),
"transmission_sig": paypalCallbackHeader(callback, "Paypal-Transmission-Sig"),
"transmission_time": paypalCallbackHeader(callback, "Paypal-Transmission-Time"),
"webhook_id": webhookID,
"webhook_event": webhookEvent,
}
client, err := a.client(c)
if err != nil {
return nil, err
}
verified, err := client.VerifyWebhookSignature(ctx, verifyBody)
if err != nil {
return nil, err
}
if verified == nil || !strings.EqualFold(verified.VerificationStatus, "SUCCESS") {
return nil, errors.New("PayPal 回调签名校验失败")
}
result, err := paypalWebhookResult(&event)
if err != nil {
return nil, err
}
result.Payload = append([]byte(nil), callback.Body...)
result.EventID = event.Id
return result, nil
}
func paypalApplicationContext(req *biz.PaymentRequest, c map[string]any) map[string]any {
applicationContext := map[string]any{}
if configured, ok := req.Extra["application_context"].(map[string]any); ok {
for key, value := range configured {
applicationContext[key] = value
}
}
for _, key := range []string{"brand_name", "locale", "landing_page", "shipping_preference", "user_action"} {
if _, exists := applicationContext[key]; !exists {
if value := strings.TrimSpace(text(c, key)); value != "" {
applicationContext[key] = value
}
}
}
if returnURL := strings.TrimSpace(req.ReturnURL); returnURL != "" {
applicationContext["return_url"] = returnURL
}
cancelURL := firstAny(c, "cancel_url", "return_url")
if cancelURL == "" {
cancelURL = strings.TrimSpace(req.ReturnURL)
}
if cancelURL != "" {
applicationContext["cancel_url"] = cancelURL
}
return applicationContext
}
func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo string, c map[string]any) (*biz.PaymentResult, error) {
if order == nil {
return nil, errors.New("PayPal 订单结果为空")
}
tradeNo := paypalOrderTradeNo(order)
if tradeNo == "" {
tradeNo = fallbackTradeNo
}
capture := paypalOrderCapture(order)
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalOrderState(order.Status),
TradeNo: tradeNo,
QueryID: queryID,
Payload: mustJSON(order),
}
if capture != nil {
result.ProviderTradeNo = capture.Id
}
amount, currency, found, err := paypalOrderAmount(order, c)
if err != nil {
return nil, err
}
if found {
result.Amount = amount
result.Currency = currency
}
return result, nil
}
func paypalOrderTradeNo(order *paypal.OrderDetail) string {
if order == nil {
return ""
}
for _, unit := range order.PurchaseUnits {
if unit == nil {
continue
}
if value := firstNonEmpty(unit.InvoiceId, unit.CustomId, unit.ReferenceId); value != "" {
return value
}
}
return ""
}
func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture {
var firstCapture *paypal.Capture
if order == nil {
return nil
}
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
}
if firstCapture == nil {
firstCapture = capture
}
if strings.EqualFold(capture.Status, "COMPLETED") {
return capture
}
}
}
return firstCapture
}
func paypalOrderAmount(order *paypal.OrderDetail, c map[string]any) (int64, string, bool, error) {
var total int64
var currency string
found := false
for _, unit := range order.PurchaseUnits {
if unit == nil || unit.Amount == nil || strings.TrimSpace(unit.Amount.Value) == "" {
continue
}
unitCurrency := strings.ToUpper(strings.TrimSpace(unit.Amount.CurrencyCode))
amount, err := paypalParseAmount(unit.Amount, c)
if err != nil {
return 0, "", false, err
}
if found && currency != unitCurrency {
return 0, "", false, errors.New("PayPal 订单包含不同币种的 purchase unit")
}
if amount > math.MaxInt64-total {
return 0, "", false, errors.New("PayPal 订单金额超出范围")
}
currency = unitCurrency
total += amount
found = true
}
return total, currency, found, nil
}
func paypalRefundCaptureID(ctx context.Context, client *paypal.Client, req *biz.PaymentRefundRequest) (string, error) {
if req == nil {
return "", errors.New("PayPal 退款请求为空")
}
if captureID := strings.TrimSpace(req.ProviderTradeNo); captureID != "" {
return captureID, nil
}
orderID := strings.TrimSpace(req.QueryID)
if orderID == "" {
return "", errors.New("PayPal 退款缺少已持久化的 capture ID")
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return "", err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return "", paypalResponseError("退款前查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return "", errors.New("PayPal 退款查单响应的 order ID 不匹配")
}
if returnedTradeNo := paypalOrderTradeNo(rsp.Response); returnedTradeNo == "" || returnedTradeNo != req.TradeNo {
return "", errors.New("PayPal 退款订单与本地商户订单号不匹配")
}
capture := paypalOrderCapture(rsp.Response)
if capture == nil || strings.TrimSpace(capture.Id) == "" {
return "", errors.New("PayPal 退款订单缺少 capture ID")
}
return capture.Id, nil
}
func paypalWebhookResult(event *paypal.WebhookEvent) (*biz.PaymentResult, error) {
var resource map[string]any
if err := json.Unmarshal(event.Resource, &resource); err != nil {
return nil, fmt.Errorf("PayPal 回调 resource 无效: %w", err)
}
eventType := strings.ToUpper(strings.TrimSpace(event.EventType))
tradeNo := firstNonEmpty(paypalMapString(resource, "invoice_id"), paypalMapString(resource, "custom_id"))
queryID := paypalNestedMapString(resource, "supplementary_data", "related_ids", "order_id")
providerTradeNo := ""
status := normalizePayPalCaptureState(paypalMapString(resource, "status"))
if strings.HasPrefix(eventType, "CHECKOUT.ORDER.") {
var order paypal.OrderDetail
if err := json.Unmarshal(event.Resource, &order); err != nil {
return nil, err
}
tradeNo = paypalOrderTradeNo(&order)
queryID = order.Id
if capture := paypalOrderCapture(&order); capture != nil {
providerTradeNo = capture.Id
}
status = normalizePayPalOrderState(order.Status)
} else {
providerTradeNo = paypalMapString(resource, "id")
}
if tradeNo == "" {
tradeNo = paypalPurchaseUnitTradeNo(resource)
}
if tradeNo == "" || queryID == "" {
return nil, errors.New("PayPal 回调缺少商户订单号或 order ID")
}
return &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: status,
TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo,
QueryID: queryID,
}, nil
}
func paypalPurchaseUnitTradeNo(resource map[string]any) string {
units, _ := resource["purchase_units"].([]any)
for _, value := range units {
unit, _ := value.(map[string]any)
if tradeNo := firstNonEmpty(paypalMapString(unit, "invoice_id"), paypalMapString(unit, "custom_id"), paypalMapString(unit, "reference_id")); tradeNo != "" {
return tradeNo
}
}
return ""
}
func paypalCallbackHeader(callback *biz.PaymentCallback, name string) string {
for key, value := range callback.Headers {
if strings.EqualFold(key, name) {
return strings.TrimSpace(value)
}
}
return ""
}
func paypalFormatAmount(amount int64, currency string, c map[string]any) (string, error) {
scale, err := paypalAmountScale(currency, c)
if err != nil {
return "", err
}
return formatDecimalAmount(amount, scale)
}
func paypalParseAmount(amount *paypal.Amount, c map[string]any) (int64, error) {
if amount == nil {
return 0, errors.New("PayPal 金额为空")
}
scale, err := paypalAmountScale(amount.CurrencyCode, c)
if err != nil {
return 0, err
}
return parseDecimalAmount(amount.Value, scale)
}
func paypalAmountScale(currency string, c map[string]any) (int64, error) {
currency = strings.ToUpper(strings.TrimSpace(currency))
for _, key := range []string{"amount_scales", "currency_scales"} {
if scales, ok := c[key].(map[string]any); ok {
for configuredCurrency, raw := range scales {
if !strings.EqualFold(configuredCurrency, currency) {
continue
}
scale, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(raw)), 10, 64)
if err != nil || scale <= 0 {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效", currency)
}
if _, err = formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效: %w", currency, err)
}
return scale, nil
}
}
}
if scale := configuredInt64(c, "amount_scale", 0); scale > 0 {
if _, err := formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal 金额换算比例无效: %w", err)
}
return scale, nil
}
switch currency {
case "HUF", "JPY", "TWD":
return 1, nil
default:
return 100, nil
}
}
func normalizePayPalOrderState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalCaptureState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED", "PARTIALLY_REFUNDED", "REFUNDED":
return "success"
case "DECLINED", "FAILED", "DENIED", "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "CANCELLED", "FAILED":
return "failed"
default:
return "pending"
}
}
func paypalMapString(values map[string]any, key string) string {
value := values[key]
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func paypalNestedMapString(values map[string]any, keys ...string) string {
var current any = values
for _, key := range keys {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
if current == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(current))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" && value != "<nil>" {
return value
}
}
return ""
}
func paypalResponseError(operation string, code int, detail string) error {
detail = strings.TrimSpace(detail)
if detail == "" {
detail = "未知错误"
}
if code == 0 {
return fmt.Errorf("PayPal %s失败: %s", operation, detail)
}
return fmt.Errorf("PayPal %s失败(%d): %s", operation, code, detail)
}
func paypalResponseCode(value any) int {
switch rsp := value.(type) {
case *paypal.CreateOrderRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.OrderDetailRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.PaymentCaptureRefundRsp:
if rsp != nil {
return rsp.Code
}
}
return 0
}
func paypalCreateError(rsp *paypal.CreateOrderRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalOrderError(rsp *paypal.OrderDetailRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalRefundError(rsp *paypal.PaymentCaptureRefundRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalErrorDetail(raw string, response *paypal.ErrorResponse) string {
if response != nil {
if value := firstNonEmpty(response.Message, response.Name); value != "" {
return value
}
}
return raw
}
+241
View File
@@ -0,0 +1,241 @@
package payment
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"kra/internal/biz"
)
func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) {
var createBody map[string]any
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":
if got := r.Header.Get("PayPal-Request-Id"); got != "LOCAL-1" {
t.Errorf("PayPal-Request-Id = %q, want LOCAL-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
t.Errorf("decode create body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"CREATED",
"purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"10.99"}}],
"links":[{"rel":"approve","href":"https://example.test/approve","method":"GET"}]
}`))
case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-1","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]}
}]
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := &paypalAdapter{}
config := paypalTestConfig(server.URL)
created, err := adapter.Create(context.Background(), &biz.PaymentRequest{
TradeNo: "LOCAL-1",
Subject: "Order one",
Amount: 1099,
Currency: "USD",
ReturnURL: "https://merchant.test/return",
Extra: map[string]any{"payment_source": map[string]any{"paypal": map[string]any{}}},
}, config)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.TradeNo != "LOCAL-1" || created.QueryID != "ORDER-1" || created.ProviderTradeNo != "" || created.Status != "pending" {
t.Fatalf("Create() result = %+v", created)
}
units, _ := createBody["purchase_units"].([]any)
if len(units) != 1 {
t.Fatalf("purchase_units = %#v", createBody["purchase_units"])
}
unit, _ := units[0].(map[string]any)
amount, _ := unit["amount"].(map[string]any)
if unit["invoice_id"] != "LOCAL-1" || amount["value"] != "10.99" {
t.Fatalf("purchase unit = %#v", unit)
}
if _, ok := createBody["payment_source"]; !ok {
t.Fatalf("payment_source was not forwarded: %#v", createBody)
}
queried, err := adapter.Query(context.Background(), created.QueryID, config)
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if queried.Status != "success" || queried.TradeNo != "LOCAL-1" || queried.QueryID != "ORDER-1" || queried.ProviderTradeNo != "CAPTURE-1" || queried.Amount != 1099 || queried.Currency != "USD" {
t.Fatalf("Query() result = %+v", queried)
}
}
func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) {
var refundBody map[string]any
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/payments/captures/CAPTURE-1/refund":
if got := r.Header.Get("PayPal-Request-Id"); got != "REFUND-1" {
t.Errorf("PayPal-Request-Id = %q, want REFUND-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&refundBody); err != nil {
t.Errorf("decode refund body: %v", err)
}
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"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["provider_trade_no"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", ProviderTradeNo: "CAPTURE-1",
QueryID: "ORDER-1", RefundNo: "REFUND-1", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "PAYPAL-REFUND-1" || result.Amount != 123 || result.Currency != "USD" || result.Status != "pending" {
t.Fatalf("Refund() result = %+v", result)
}
amount, _ := refundBody["amount"].(map[string]any)
if refundBody["invoice_id"] != "REFUND-1" || amount["value"] != "1.23" {
t.Fatalf("refund body = %#v", refundBody)
}
}
func TestPayPalRefundResolvesCaptureFromPersistedQueryID(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.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"invoice_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-FROM-ORDER","status":"COMPLETED"}]}
}]
}`))
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"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["capture_id"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", QueryID: "ORDER-1",
RefundNo: "REFUND-2", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.ProviderTradeNo != "PAYPAL-REFUND-2" || result.Status != "success" {
t.Fatalf("Refund() result = %+v", result)
}
}
func TestPayPalRefundRejectsStaticCaptureIDWithoutDurableIdentity(t *testing.T) {
config := paypalTestConfig("https://paypal.invalid")
config["capture_id"] = "CONFIG-CAPTURE"
_, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD",
}, config)
if err == nil {
t.Fatal("Refund() accepted a static config capture ID")
}
}
func TestPayPalCallbackUsesGoPayWebhookVerification(t *testing.T) {
var verifyBody map[string]any
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 == "/v1/notifications/verify-webhook-signature":
if err := json.NewDecoder(r.Body).Decode(&verifyBody); err != nil {
t.Errorf("decode verify body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"verification_status":"SUCCESS"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
body := []byte(`{
"id":"WEBHOOK-1","event_type":"PAYMENT.CAPTURE.COMPLETED",
"resource":{
"id":"CAPTURE-1","status":"COMPLETED","invoice_id":"LOCAL-1",
"supplementary_data":{"related_ids":{"order_id":"ORDER-1"}}
}
}`)
config := paypalTestConfig(server.URL)
config["webhook_id"] = "HOOK-1"
result, err := (&paypalAdapter{}).Callback(context.Background(), &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
"Paypal-Auth-Algo": "SHA256withRSA",
"Paypal-Cert-Url": "https://api.paypal.test/cert",
"Paypal-Transmission-Id": "TRANSMISSION-1",
"Paypal-Transmission-Sig": "SIGNATURE",
"Paypal-Transmission-Time": "2026-08-19T00:00:00Z",
},
}, config)
if err != nil {
t.Fatalf("Callback() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.QueryID != "ORDER-1" || result.ProviderTradeNo != "CAPTURE-1" || result.EventID != "WEBHOOK-1" || result.Status != "success" {
t.Fatalf("Callback() result = %+v", result)
}
if verifyBody["webhook_id"] != "HOOK-1" || verifyBody["transmission_id"] != "TRANSMISSION-1" {
t.Fatalf("verify body = %#v", verifyBody)
}
if _, ok := verifyBody["webhook_event"].(map[string]any); !ok {
t.Fatalf("webhook_event was not sent as an object: %#v", verifyBody["webhook_event"])
}
}
func paypalTestConfig(baseURL string) map[string]any {
return map[string]any{
"client_id": "client-id",
"client_secret": "client-secret",
"environment": "sandbox",
"api_base_url": baseURL,
"cancel_url": "https://merchant.test/cancel",
"notify_url": "https://merchant.test/paypal/webhook",
}
}
+160
View File
@@ -0,0 +1,160 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq"
)
type qqAdapter struct{}
func (a *qqAdapter) client(c map[string]any) (*gopayQQ.Client, error) {
mch := firstAny(c, "merchant_id", "mch_id")
if mch == "" || text(c, "api_key") == "" {
return nil, errors.New("QQ 支付缺少 merchant_id 或 api_key")
}
client := gopayQQ.NewClient(mch, text(c, "api_key"))
return client, nil
}
func (a *qqAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
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")
}
if tradeType == "" {
tradeType = gopayQQ.TradeType_Native
}
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")
rsp, err := client.UnifiedOrder(ctx, bm)
if 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)}
return result, nil
}
func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
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")
rsp, err := client.OrderQuery(ctx, bm)
if 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
}
result.Currency = strings.ToUpper(rsp.FeeType)
if result.Currency == "" {
result.Currency = "CNY"
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
if err != nil {
return nil, err
}
}
if rsp.CashFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.CashFee)
result.CashPaidAmount = result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
return result, nil
}
func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, 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{"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")}
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")
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") != "" {
cert, key, p12 = []byte(text(c, "cert_content")), []byte(text(c, "key_content")), []byte(text(c, "pkcs12_content"))
}
}
if (cert == nil || key == nil) && p12 == nil {
return nil, errors.New("QQ 支付退款缺少 cert_file、key_file、pkcs12_file(或 *_content)")
}
rsp, err := client.Refund(ctx, bm, cert, key, p12)
if 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
}
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: rsp.RefundId, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("QQ 支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
bm, err := gopayQQ.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
signType := firstAny(c, "sign_type", "signature_type", "signType")
if signType == "" {
signType = gopayQQ.SignType_MD5
}
ok, err := gopayQQ.VerifySign(text(c, "api_key"), signType, cloneGoPayBodyMap(bm))
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("QQ 支付回调签名校验失败")
}
values := fromGoPayBodyMap(bm)
status := "pending"
if strings.EqualFold(values["return_code"], "SUCCESS") && strings.EqualFold(values["result_code"], "SUCCESS") && strings.EqualFold(values["trade_state"], "SUCCESS") {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: values["out_trade_no"], ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
+149
View File
@@ -0,0 +1,149 @@
package payment
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
if callback == nil {
return fields
}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if stringValue, ok := value.(string); ok {
fields[key] = stringValue
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// Compatibility shims keep channel adapters focused on protocol and
// persistence concerns while stateless parsing rules live in paymentkit.
func normalizePaymentStatus(value, fallback string) string {
return paymentkit.NormalizeStatus(value, fallback)
}
func parseIntegerAmount(value string) (int64, error) {
return paymentkit.ParseIntegerAmount(value)
}
func parseDecimalAmount(value string, scale int64) (int64, error) {
return paymentkit.ParseDecimalAmount(value, scale)
}
func formatDecimalAmount(amount, scale int64) (string, error) {
return paymentkit.FormatDecimalAmount(amount, scale)
}
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
func jsonObject(raw []byte) map[string]any {
return paymentkit.JSONObject(raw)
}
func nestedString(value any, keys ...string) string {
return paymentkit.NestedString(value, keys...)
}
func valueAtPath(value any, path string) any {
return paymentkit.ValueAtPath(value, path)
}
func stringAtPath(value any, path string) string {
return paymentkit.StringAtPath(value, path)
}
func configuredValues(values map[string]any, key string) []string {
return paymentkit.ConfiguredValues(values, key)
}
func containsFold(values []string, value string) bool {
return paymentkit.ContainsFold(values, value)
}
// parseConfiguredAmount reads a provider amount field using an explicit unit
// conversion. Missing fields are reported separately so callers can avoid
// manufacturing a complete breakdown from incomplete provider responses.
func parseConfiguredAmount(object any, config map[string]any, fieldKey, scaleKey string, fallbackScale int64) (int64, bool, error) {
path := strings.TrimSpace(text(config, fieldKey))
if path == "" {
return 0, false, nil
}
raw := valueAtPath(object, path)
if raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || strings.EqualFold(strings.TrimSpace(fmt.Sprint(raw)), "<nil>") {
return 0, false, nil
}
value := strings.TrimSpace(fmt.Sprint(raw))
scale := configuredInt64(config, scaleKey, fallbackScale)
if scale <= 0 {
return 0, false, errors.New("支付金额换算比例无效")
}
amount, err := parseIntegerAmount(value)
if scale != 1 {
amount, err = parseDecimalAmount(value, scale)
}
if err != nil {
return 0, true, err
}
if amount < 0 {
return 0, true, errors.New("支付金额不能为负数")
}
return amount, true, nil
}
func amountFromDecimalField(values map[string]any, key string, fallback int64) (int64, bool, error) {
value := strings.TrimSpace(fmt.Sprint(values[key]))
if value == "" || value == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(value, fallback)
return amount, true, err
}
+96
View File
@@ -0,0 +1,96 @@
package payment
import (
"context"
"errors"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/saobei"
)
type saobeiAdapter struct{}
func (a *saobeiAdapter) client(c map[string]any) (*saobei.Client, error) {
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) {
client, err := a.client(c)
if err != nil {
return nil, err
}
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" {
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
}
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
}
func (a *saobeiAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
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}
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.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
}
if rsp.BuyerPayFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.BuyerPayFee)
result.CashPaidAmount = result.PayerPaidAmount
if result.Amount >= result.PayerPaidAmount {
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, err
}
func (a *saobeiAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, 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
}
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}
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 {
return nil, err
}
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
}
func (a *saobeiAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("扫呗支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+289
View File
@@ -0,0 +1,289 @@
package payment
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
type vendorProfile string
const (
vendorChinaums vendorProfile = "chinaums"
vendorSFT vendorProfile = "sft"
vendorSupperPay vendorProfile = "supper-pay"
vendorWechatGame vendorProfile = "wechat-game"
vendorDouyinGame vendorProfile = "douyin-game"
)
type vendorPaymentAdapter struct {
provider string
profile vendorProfile
}
func newVendorAdapter(provider string, profile vendorProfile) Adapter {
return &vendorPaymentAdapter{provider: provider, profile: profile}
}
func (a *vendorPaymentAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
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)
}
func (a *vendorPaymentAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
return a.call(ctx, "query_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": tradeNo, "timestamp": time.Now().Unix(), "nonce": nonce()}, tradeNo, c)
}
func (a *vendorPaymentAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
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) {
fields := callbackFields(callback)
if err := a.verify(fields, callback.Body, callback.Headers, c); err != nil {
return nil, err
}
object := jsonObject(callback.Body)
statusField := text(c, "callback_status_field")
if statusField == "" {
statusField = text(c, "query_status_field")
}
tradeNoField := text(c, "callback_trade_no_field")
if tradeNoField == "" {
tradeNoField = text(c, "query_trade_no_field")
}
providerTradeNoField := text(c, "callback_provider_trade_no_field")
if providerTradeNoField == "" {
providerTradeNoField = text(c, "query_provider_trade_no_field")
}
state := first(fields, statusField, "status", "trade_status", "order_status", "pay_status")
tradeNo := first(fields, tradeNoField, "trade_no", "out_trade_no", "merchant_order_no", "cp_order_id")
providerTradeNo := first(fields, providerTradeNoField, "transaction_id", "platform_trade_no", "order_no")
if object != nil {
if value := stringAtPath(object, statusField); value != "" {
state = value
}
if value := stringAtPath(object, tradeNoField); value != "" {
tradeNo = value
}
if value := stringAtPath(object, providerTradeNoField); value != "" {
providerTradeNo = value
}
}
status := "pending"
successValues := configuredValues(c, "callback_success_values")
if len(successValues) == 0 {
successValues = configuredValues(c, "query_success_values")
}
if containsFold(successValues, state) {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{Provider: a.provider, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Payload: payload}, nil
}
func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, payload map[string]any, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
endpoint := text(c, endpointKey)
if endpoint == "" {
return nil, fmt.Errorf("%s 未配置 %s", a.provider, endpointKey)
}
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return nil, fmt.Errorf("%s 未配置签名密钥", a.provider)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
a.signRequest(request, payload, raw, secret, c)
response, err := (&http.Client{Timeout: 20 * time.Second}).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
if err != nil {
return nil, err
}
if response.StatusCode >= 300 {
return nil, fmt.Errorf("%s HTTP %d", a.provider, response.StatusCode)
}
result := &biz.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
if endpointKey != "query_url" {
return result, nil
}
object := jsonObject(body)
if object == nil {
return nil, fmt.Errorf("%s 查询响应不是 JSON 对象", a.provider)
}
state := stringAtPath(object, text(c, "query_status_field"))
if !containsFold(configuredValues(c, "query_success_values"), state) {
result.Status = normalizePaymentStatus(state, "pending")
return result, nil
}
result.Status = "success"
result.TradeNo = stringAtPath(object, text(c, "query_trade_no_field"))
result.ProviderTradeNo = stringAtPath(object, text(c, "query_provider_trade_no_field"))
result.Currency = strings.ToUpper(stringAtPath(object, text(c, "query_currency_field")))
amountText := stringAtPath(object, text(c, "query_amount_field"))
amountScale := configuredInt64(c, "query_amount_scale", 0)
if amountScale == 1 {
result.Amount, err = parseIntegerAmount(amountText)
} else {
result.Amount, err = parseDecimalAmount(amountText, amountScale)
}
if err != nil {
return nil, fmt.Errorf("解析 %s 查询金额: %w", a.provider, err)
}
if result.TradeNo == "" || result.ProviderTradeNo == "" || result.Currency == "" {
return nil, fmt.Errorf("%s 查询响应缺少订单号、平台单号或币种", a.provider)
}
if err = populateVendorBreakdown(result, object, c); err != nil {
return nil, fmt.Errorf("解析 %s 查询金额拆分: %w", a.provider, err)
}
return result, nil
}
func populateVendorBreakdown(result *biz.PaymentResult, object map[string]any, c map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
defaultScale := configuredInt64(c, "query_amount_scale", 0)
payer, payerOK, err := parseConfiguredAmount(object, c, "query_payer_paid_amount_field", "query_payer_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("payer paid amount: %w", err)
}
if !payerOK {
return nil
}
cash, cashOK, err := parseConfiguredAmount(object, c, "query_cash_paid_amount_field", "query_cash_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("cash paid amount: %w", err)
}
point, pointOK, err := parseConfiguredAmount(object, c, "query_point_paid_amount_field", "query_point_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("point paid amount: %w", err)
}
discount, discountOK, err := parseConfiguredAmount(object, c, "query_discount_amount_field", "query_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("discount amount: %w", err)
}
providerDiscount, _, err := parseConfiguredAmount(object, c, "query_provider_discount_amount_field", "query_provider_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("provider discount amount: %w", err)
}
merchantDiscount, _, err := parseConfiguredAmount(object, c, "query_merchant_discount_amount_field", "query_merchant_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("merchant discount amount: %w", err)
}
settlement, _, err := parseConfiguredAmount(object, c, "query_settlement_amount_field", "query_settlement_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("settlement amount: %w", err)
}
if !cashOK && !pointOK {
cash = payer
cashOK = true
}
if !cashOK {
cash = payer - point
cashOK = cash >= 0
}
if !pointOK {
point = payer - cash
pointOK = point >= 0
}
if !discountOK {
discount = result.Amount - payer
discountOK = discount >= 0
}
if !cashOK || !pointOK || !discountOK || payer > result.Amount || cash+point != payer || payer+discount != result.Amount {
return errors.New("总额、用户实付、现金/积分和优惠金额不守恒")
}
result.PayerPaidAmount = payer
result.CashPaidAmount = cash
result.PointPaidAmount = point
result.DiscountAmount = discount
result.ProviderDiscountAmount = providerDiscount
result.MerchantDiscountAmount = merchantDiscount
result.SettlementAmount = settlement
result.PayerCurrency = strings.ToUpper(stringAtPath(object, text(c, "query_payer_currency_field")))
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
result.AmountBreakdownKnown = true
return nil
}
func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[string]any, raw []byte, secret string, c map[string]any) {
switch a.profile {
case vendorChinaums:
timestamp, nonceValue := fmt.Sprint(payload["timestamp"]), fmt.Sprint(payload["nonce"])
appID := text(c, "app_id")
digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret))
request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
case vendorSFT:
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret))
case vendorWechatGame:
request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
if token := text(c, "access_token"); token != "" {
query := request.URL.Query()
query.Set("access_token", token)
request.URL.RawQuery = query.Encode()
}
case vendorDouyinGame:
request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret))
default:
request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
}
}
func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error {
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return errors.New("支付回调未配置签名密钥")
}
expected := first(fields, "sign", "signature")
if expected == "" {
expected = first(headers, "X-Payment-Sign", "X-SFT-Sign", "X-Wechat-Game-Sign", "X-TT-Pay-Sign")
}
var actual string
switch a.profile {
case vendorSFT, vendorDouyinGame:
values := map[string]any{}
for key, value := range fields {
if key != "sign" && key != "signature" {
values[key] = value
}
}
actual = paymentkit.MD5Canonical(values, secret)
default:
actual = paymentkit.HMACSHA256Hex(raw, secret, false)
}
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
return errors.New("支付回调签名校验失败")
}
return nil
}
func ensureJSON(raw []byte) []byte {
if json.Valid(raw) {
return raw
}
encoded, _ := json.Marshal(string(raw))
return encoded
}
+435
View File
@@ -0,0 +1,435 @@
package payment
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/pkg/xhttp"
gopayWechat "github.com/go-pay/gopay/wechat"
)
type wechatV2Adapter struct{}
type wechatV2EndpointTransport struct {
endpoint *url.URL
next http.RoundTripper
}
func (t *wechatV2EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
clone := request.Clone(request.Context())
target := *t.endpoint
clone.URL = &target
clone.Host = target.Host
clone.RequestURI = ""
return t.next.RoundTrip(clone)
}
func wechatV2SignType(value string) string {
if strings.EqualFold(strings.TrimSpace(value), gopayWechat.SignType_HMAC_SHA256) {
return gopayWechat.SignType_HMAC_SHA256
}
return gopayWechat.SignType_MD5
}
func wechatV2Sign(values map[string]string, key, signType string) string {
body := toGoPayBodyMap(values)
body.Remove("sign")
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
}
func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url"))
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"body": req.Subject,
"out_trade_no": req.TradeNo,
"total_fee": fmt.Sprint(req.Amount),
"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")),
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = firstAny(c, "client_ip", "spbill_create_ip")
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = "127.0.0.1"
}
if values["trade_type"] == "" {
values["trade_type"] = "JSAPI"
}
mergeWechatV2Extras(values, req.Extra)
rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues, err := wechatV2ResponseValues(rsp)
if err != nil {
return nil, err
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
Payload: wechatV2Payload(resultValues),
}, nil
}
func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url"))
if err != nil {
return nil, err
}
body := gopay.BodyMap{
"nonce_str": nonce(),
"out_trade_no": tradeNo,
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
_, response, err := client.QueryOrder(ctx, body)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(response)
if err = validateWechatV2Response(values); err != nil {
return nil, err
}
if err = verifyWechatV2Response(values, client.ApiKey, body.GetString("sign_type")); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: normalizePaymentStatus(values["trade_state"], "pending"),
TradeNo: first(values, "out_trade_no"),
Payload: wechatV2Payload(values),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if result.Status != "success" {
return result, nil
}
result.ProviderTradeNo = values["transaction_id"]
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v2 查询响应缺少 transaction_id")
}
result.Amount, err = parseIntegerAmount(values["total_fee"])
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 订单金额: %w", err)
}
result.Currency = strings.ToUpper(values["fee_type"])
if result.Currency == "" {
result.Currency = "CNY"
}
if err = populateWechatV2Breakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v2 退款请求为空")
}
client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url"))
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"out_trade_no": req.TradeNo,
"out_refund_no": req.RefundNo,
"total_fee": fmt.Sprint(req.TotalAmount),
"refund_fee": fmt.Sprint(req.Amount),
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
if currency != "" {
values["refund_fee_type"] = currency
}
rsp, response, err := client.Refund(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues := fromGoPayBodyMap(response)
if rsp == nil {
return nil, errors.New("微信支付 v2 退款响应为空")
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
ProviderTradeNo: rsp.RefundId,
Currency: currency,
Payload: wechatV2Payload(resultValues),
}
if rsp.RefundFee != "" {
result.Amount, err = parseIntegerAmount(rsp.RefundFee)
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err)
}
}
return result, nil
}
func (a *wechatV2Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
body, err := gopayWechat.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(body)
if err = verifyWechatV2(values, firstAny(c, "mch_key", "api_key", "merchant_key")); err != nil {
return nil, err
}
if values["appid"] != firstAny(c, "app_id", "appid") || values["mch_id"] != firstAny(c, "merchant_id", "mch_id") {
return nil, errors.New("微信支付 v2 回调商户配置不匹配")
}
status := "pending"
if values["return_code"] == "SUCCESS" && values["result_code"] == "SUCCESS" && values["trade_state"] != "REFUND" {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentWechatV2, Status: status, TradeNo: first(values, "out_trade_no"), ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
func newWechatV2Client(c map[string]any, endpoint string) (*gopayWechat.Client, error) {
appID := firstAny(c, "app_id", "appid")
merchantID := firstAny(c, "merchant_id", "mch_id")
mchKey := firstAny(c, "mch_key", "api_key", "merchant_key")
if appID == "" || merchantID == "" || mchKey == "" {
return nil, errors.New("微信支付 v2 缺少 app_id、merchant_id 或 mch_key")
}
// The previous adapter sent the configured amount unchanged, including to
// sandbox or test endpoints. GoPay's sandbox mode rewrites total_fee, so use
// its production signing flow and redirect the high-level call when a custom
// endpoint is configured.
client := gopayWechat.NewClient(appID, merchantID, mchKey, true)
regular := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(4)
if err := setWechatV2Endpoint(regular, endpoint); err != nil {
return nil, err
}
client.SetHttpClient(regular)
tlsHTTPClient, err := paymentHTTPClient(c)
if err != nil {
return nil, err
}
tlsClient := xhttp.NewClient().SetBodySize(4)
tlsClient.HttpClient = tlsHTTPClient
if err = setWechatV2Endpoint(tlsClient, endpoint); err != nil {
return nil, err
}
client.SetTLSHttpClient(tlsClient)
return client, nil
}
func setWechatV2Endpoint(client *xhttp.Client, endpoint string) error {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil
}
target, err := url.Parse(endpoint)
if err != nil || target.Scheme == "" || target.Host == "" {
return fmt.Errorf("微信支付 v2 接口地址无效: %s", endpoint)
}
next := client.HttpClient.Transport
if next == nil {
next = http.DefaultTransport
}
client.SetTransport(&wechatV2EndpointTransport{endpoint: target, next: next})
return nil
}
func wechatV2ResponseValues(value any) (map[string]string, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var body gopay.BodyMap
if err = json.Unmarshal(raw, &body); err != nil {
return nil, err
}
return fromGoPayBodyMap(body), nil
}
func validateWechatV2Response(values map[string]string) error {
if values["return_code"] == "SUCCESS" && (values["result_code"] == "" || values["result_code"] == "SUCCESS") {
return nil
}
message := first(values, "err_code_des", "return_msg", "err_code")
if message == "" {
message = "未知错误"
}
return fmt.Errorf("微信支付 v2 错误: %s", message)
}
func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error {
if values["sign"] == "" {
return nil
}
signType := values["sign_type"]
if signType == "" {
signType = fallbackSignType
}
return verifyWechatV2WithSignType(values, key, signType)
}
func wechatV2Payload(values map[string]string) []byte {
return []byte(gopayWechat.GenerateXml(toGoPayBodyMap(values)))
}
func populateWechatV2Breakdown(result *biz.PaymentResult, values map[string]string) error {
if result == nil || result.Status != "success" {
return nil
}
// WeChat v2 reports total_fee/cash_fee/coupon_fee in the smallest currency
// unit. cash_fee is the payer cash amount; coupon_fee is the total coupon
// discount. v2 does not reliably identify coupon funding parties, so those
// fields remain zero rather than being misclassified.
cash, cashOK, err := parseWechatInteger(values, "cash_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 cash_fee: %w", err)
}
if !cashOK {
return nil
}
coupon, couponOK, err := parseWechatInteger(values, "coupon_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 coupon_fee: %w", err)
}
if !couponOK {
coupon = result.Amount - cash
}
if cash < 0 || coupon < 0 || cash > result.Amount || cash+coupon != result.Amount {
return errors.New("微信支付 v2 总额、现金实付和优惠金额不守恒")
}
result.PayerPaidAmount = cash
result.CashPaidAmount = cash
result.DiscountAmount = coupon
if settlement, ok, settlementErr := parseWechatInteger(values, "settlement_total_fee"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v2 settlement_total_fee: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// coupon_fee_n is a per-coupon detail in v2. Its aggregate still has no
// authoritative merchant/provider funding split, so preserve the raw list
// in Payload instead of assigning it to either party.
_ = couponOK
result.AmountBreakdownKnown = true
return nil
}
func parseWechatInteger(values map[string]string, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(value) == "" {
return 0, false, nil
}
amount, err := parseIntegerAmount(value)
return amount, true, err
}
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": {},
}
for key, value := range stringMap(extra) {
if _, exists := protected[key]; exists {
continue
}
dst[key] = value
}
}
func verifyWechatV2(values map[string]string, key string) error {
return verifyWechatV2WithSignType(values, key, values["sign_type"])
}
func verifyWechatV2WithSignType(values map[string]string, key, signType string) error {
if key == "" {
return errors.New("微信支付 v2 未配置 mch_key")
}
ok, err := gopayWechat.VerifySign(key, wechatV2SignType(signType), cloneGoPayBodyMap(toGoPayBodyMap(values)))
if err != nil {
return err
}
if !ok {
return errors.New("微信支付 v2 回调签名校验失败")
}
return nil
}
func paymentHTTPClient(c map[string]any) (*http.Client, error) {
certPEM := firstAny(c, "client_cert", "cert_pem", "apiclient_cert")
keyPEM := firstAny(c, "client_key", "key_pem", "apiclient_key")
if certPEM == "" && keyPEM == "" {
return &http.Client{Timeout: 20 * time.Second}, nil
}
if certPEM == "" || keyPEM == "" {
return nil, errors.New("微信支付 v2 client_cert 和 client_key 必须同时配置")
}
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, err
}
var pool *x509.CertPool
if ca := firstAny(c, "ca_cert", "root_cert"); ca != "" {
pool = x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca)) {
return nil, errors.New("微信支付 v2 ca_cert 格式错误")
}
}
return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil
}
func nonce() string { return fmt.Sprintf("%d", time.Now().UnixNano()) }
func stringOr(extra map[string]any, key, fallback string) string {
if value, ok := extra[key].(string); ok {
return value
}
return fallback
}
func stringMap(values map[string]any) map[string]string {
result := map[string]string{}
for key, value := range values {
if text, ok := value.(string); ok {
result[key] = text
}
}
return result
}
+461
View File
@@ -0,0 +1,461 @@
package payment
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
type wechatV3Adapter struct{}
func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "create_url")
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"appid": firstAny(c, "app_id", "appid"),
"mchid": firstAny(c, "merchant_id", "mch_id", "mchid"),
"description": req.Subject,
"out_trade_no": req.TradeNo,
"notify_url": req.NotifyURL,
}
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayExtras(bm, req.Extra,
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
"trade_type", "pay_type", "method", "openid", "open_id",
)
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "method"))
if tradeType == "" {
tradeType = strings.ToLower(firstAny(c, "trade_type", "pay_type", "method"))
}
switch tradeType {
case "", "jsapi", "mini", "miniprogram", "mini_program", "applet":
if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" {
bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("openid", openID) })
}
rsp, callErr := client.V3TransactionJsapi(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), tradeType, req.TradeNo, rsp)
case "app":
rsp, callErr := client.V3TransactionApp(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), "app", req.TradeNo, rsp)
case "native", "qr", "qrcode":
rsp, callErr := client.V3TransactionNative(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
case "h5", "mweb":
rsp, callErr := client.V3TransactionH5(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
default:
return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType)
}
}
func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "query_url")
if err != nil {
return nil, err
}
rsp, err := client.V3TransactionQueryOrder(ctx, gopayWechatV3.OutTradeNo, tradeNo)
if err != nil {
return nil, err
}
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
payload := wechatV3ResponsePayload(rsp.SignInfo, order)
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizePaymentStatus(order.TradeState, "pending"),
TradeNo: order.OutTradeNo,
ProviderTradeNo: order.TransactionId,
Payload: payload,
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 transaction_id")
}
if order.Amount == nil || order.Amount.Total <= 0 {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.total")
}
result.Amount = int64(order.Amount.Total)
result.Currency = strings.ToUpper(order.Amount.Currency)
if result.Currency == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.currency")
}
if err := populateWechatV3Breakdown(result, jsonObject(payload)); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
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.SetBodyMap("amount", func(values gopay.BodyMap) {
values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount")
rsp, err := client.V3Refund(ctx, bm)
if err != nil {
return nil, err
}
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 退款响应缺少退款单")
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizeRefundState(refund.Status),
TradeNo: refund.OutTradeNo,
ProviderTradeNo: refund.RefundId,
Payload: wechatV3ResponsePayload(rsp.SignInfo, refund),
}
if result.TradeNo == "" {
result.TradeNo = req.TradeNo
}
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
}
if result.Currency == "" {
result.Currency = strings.ToUpper(req.Currency)
}
return result, 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 回调为空")
}
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
if apiV3Key == "" {
return nil, errors.New("微信支付 v3 缺少 api_v3_key")
}
client, err := newWechatV3Client(c, "")
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
notify, err := gopayWechatV3.V3ParseNotify(request)
if err != nil {
return nil, err
}
if notify.SignInfo == nil {
return nil, errors.New("微信支付 v3 回调缺少签名信息")
}
platformKeys := client.WxPublicKeyMap()
if _, exists := platformKeys[notify.SignInfo.HeaderSerial]; !exists {
for serial, key := range platformKeys {
if strings.EqualFold(serial, notify.SignInfo.HeaderSerial) {
platformKeys[notify.SignInfo.HeaderSerial] = key
break
}
}
}
if err = notify.VerifySignByPKMap(platformKeys); err != nil {
return nil, err
}
pay, err := notify.DecryptPayCipherText(apiV3Key)
if err != nil {
return nil, err
}
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notify.Id
status := "pending"
if pay.TradeState == gopayWechatV3.TradeStateSuccess {
status = "success"
}
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
if pay.Mchid != "" && pay.Mchid != merchantID {
return nil, errors.New("微信支付 v3 回调商户号不匹配")
}
appID := firstAny(c, "app_id", "appid")
if pay.Appid != "" && pay.Appid != appID {
return nil, errors.New("微信支付 v3 回调 appid 不匹配")
}
plain, _ := json.Marshal(pay)
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: status,
TradeNo: pay.OutTradeNo,
ProviderTradeNo: pay.TransactionId,
Payload: plain,
}, nil
}
func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.ClientV3, error) {
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
serialNo := firstAny(c, "serial_no", "merchant_serial_no", "certificate_serial_no")
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
privateKey := firstAny(c, "private_key", "merchant_private_key")
if merchantID == "" || serialNo == "" || apiV3Key == "" || privateKey == "" {
return nil, errors.New("微信支付 v3 缺少 merchant_id、serial_no、api_v3_key 或 private_key")
}
client, err := gopayWechatV3.NewClientV3(merchantID, serialNo, apiV3Key, privateKey)
if err != nil {
return nil, err
}
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
if platformCert == "" {
return nil, errors.New("微信支付 v3 缺少 platform_cert")
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
if platformSerial == "" {
platformSerial, err = wechatV3PlatformSerial(platformCert)
if err != nil {
return nil, err
}
}
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
return nil, err
}
client.SetBodySize(8)
client.GetHttpClient().SetTimeout(20 * time.Second)
if endpointKey != "" {
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("微信支付 v3 %s 配置无效", endpointKey)
}
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("微信支付 v3 %s 只能配置 scheme 和 host", endpointKey)
}
httpClient := client.GetHttpClient()
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
return client, nil
}
}
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
parsed, parseErr := url.Parse(baseURL)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("微信支付 v3 base_url 配置无效")
}
client.SetProxyHost(baseURL)
}
return client, nil
}
type wechatV3EndpointTransport struct {
base http.RoundTripper
endpoint *url.URL
}
func (t *wechatV3EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
clone := request.Clone(request.Context())
target := *request.URL
target.Scheme = t.endpoint.Scheme
target.Host = t.endpoint.Host
target.User = t.endpoint.User
clone.URL = &target
clone.Host = ""
return base.RoundTrip(clone)
}
func wechatV3PlatformSerial(certificate string) (string, error) {
block, _ := pem.Decode([]byte(certificate))
if block == nil || block.Type != "CERTIFICATE" {
return "", errors.New("微信支付 v3 platform_cert 格式错误")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析微信支付 v3 platform_cert: %w", err)
}
return strings.ToUpper(cert.SerialNumber.Text(16)), nil
}
func wechatV3CreateResult(tradeNo string, code int, signInfo *gopayWechatV3.SignInfo, response any, errResponse gopayWechatV3.ErrResponse, rawError string) (*biz.PaymentResult, error) {
if code != gopayWechatV3.Success {
return nil, wechatV3APIError("下单", code, errResponse, rawError)
}
if response == nil {
return nil, errors.New("微信支付 v3 下单响应缺少预支付信息")
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: "created",
TradeNo: tradeNo,
Payload: wechatV3ResponsePayload(signInfo, response),
}, nil
}
func wechatV3PrepayCreateResult(client *gopayWechatV3.ClientV3, appID, tradeType, tradeNo string, rsp *gopayWechatV3.PrepayRsp) (*biz.PaymentResult, error) {
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
result, err := wechatV3CreateResult(tradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
if err != nil {
return nil, err
}
if rsp.Response == nil || strings.TrimSpace(rsp.Response.PrepayId) == "" {
return nil, errors.New("微信支付 v3 下单响应缺少 prepay_id")
}
var payParams any
switch strings.ToLower(strings.TrimSpace(tradeType)) {
case "app":
payParams, err = client.PaySignOfApp(appID, rsp.Response.PrepayId)
case "mini", "miniprogram", "mini_program", "applet":
payParams, err = client.PaySignOfApplet(appID, rsp.Response.PrepayId)
default:
payParams, err = client.PaySignOfJSAPI(appID, rsp.Response.PrepayId)
}
if err != nil {
return nil, fmt.Errorf("生成微信支付 v3 客户端调起参数: %w", err)
}
result.Payload = mustJSON(payParams)
return result, nil
}
func wechatV3APIError(operation string, code int, response gopayWechatV3.ErrResponse, raw string) error {
message := strings.TrimSpace(response.Message)
if message == "" {
message = strings.TrimSpace(raw)
}
if message == "" {
return fmt.Errorf("微信支付 v3 %s HTTP %d", operation, code)
}
return fmt.Errorf("微信支付 v3 %s HTTP %d: %s", operation, code, message)
}
func wechatV3ResponsePayload(signInfo *gopayWechatV3.SignInfo, response any) []byte {
if signInfo != nil && strings.TrimSpace(signInfo.SignBody) != "" {
return []byte(signInfo.SignBody)
}
payload, _ := json.Marshal(response)
return payload
}
func populateWechatV3Breakdown(result *biz.PaymentResult, object map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
amount, ok := valueAtPath(object, "amount").(map[string]any)
if !ok {
return nil
}
payerTotal, payerOK, err := parseJSONInteger(amount, "payer_total")
if err != nil {
return fmt.Errorf("解析微信支付 v3 amount.payer_total: %w", err)
}
if !payerOK {
return nil
}
if payerTotal < 0 || payerTotal > result.Amount {
return errors.New("微信支付 v3 用户实付金额无效")
}
result.PayerPaidAmount = payerTotal
result.CashPaidAmount = payerTotal
result.DiscountAmount = result.Amount - payerTotal
if settlement, ok, settlementErr := parseJSONInteger(amount, "settlement_amount"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v3 amount.settlement_amount: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// promotion_detail contains the authoritative promotion breakdown, but its
// funding fields vary by promotion type. We only expose the aggregate
// discount here; the original JSON remains in Payload for audit.
if result.DiscountAmount < 0 {
return errors.New("微信支付 v3 优惠金额无效")
}
result.AmountBreakdownKnown = true
return nil
}
func parseJSONInteger(object map[string]any, key string) (int64, bool, error) {
raw, exists := object[key]
if !exists || raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || fmt.Sprint(raw) == "<nil>" {
return 0, false, nil
}
amount, err := parseIntegerAmount(fmt.Sprint(raw))
return amount, true, err
}
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节")
}
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
}
+23
View File
@@ -0,0 +1,23 @@
package data
import "kra/pkg/paymentkit"
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// configuredInt64 keeps repository-side validation independent from the
// provider adapter package while sharing the canonical paymentkit parser.
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
+42
View File
@@ -0,0 +1,42 @@
package data
import (
"testing"
"kra/internal/biz"
)
func TestMigrateSeedsPaymentProviders(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(biz.SupportedPaymentProviders)) {
t.Fatalf("payment providers = %d", count)
}
if !db.Migrator().HasTable("pay_orders") {
t.Fatal("payment migration must create pay_orders")
}
if db.Migrator().HasTable("pay_callback_events") {
t.Fatal("payment migration must not create pay_callback_events")
}
}
func TestPaymentSecretMaskAndMerge(t *testing.T) {
values := map[string]any{"app_id": "a", "private_key": "private", "nested": map[string]any{"api_v3_key": "key"}}
maskPaymentSecrets(values)
if values["private_key"] != "******" || values["nested"].(map[string]any)["api_v3_key"] != "******" {
t.Fatalf("not masked: %#v", values)
}
mergePaymentSecrets(values, map[string]any{"private_key": "private", "nested": map[string]any{"api_v3_key": "key"}})
if values["private_key"] != "private" || values["nested"].(map[string]any)["api_v3_key"] != "key" {
t.Fatalf("not merged: %#v", values)
}
}
+488
View File
@@ -0,0 +1,488 @@
package data
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz"
)
type paymentOrderPO struct {
ID uint64 `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
TradeNo string `gorm:"size:128;not null;uniqueIndex:idx_pay_orders_provider_trade"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_pay_orders_provider_trade;uniqueIndex:idx_pay_orders_provider_provider_trade;index"`
ProviderTradeNo *string `gorm:"size:128;uniqueIndex:idx_pay_orders_provider_provider_trade"`
QueryID string `gorm:"size:128"`
BusinessType string `gorm:"size:64;not null;index:idx_pay_orders_business"`
BusinessID string `gorm:"size:128;not null;index:idx_pay_orders_business"`
Subject string `gorm:"size:256;not null"`
PaymentMode string `gorm:"size:32;not null;default:external;index"`
OriginalAmount int64 `gorm:"not null;default:0"`
Amount int64 `gorm:"not null"`
PaidAmount int64 `gorm:"not null;default:0"`
PayerPaidAmount int64 `gorm:"not null;default:0"`
CashPaidAmount int64 `gorm:"not null;default:0"`
PointPaidAmount int64 `gorm:"not null;default:0"`
DiscountAmount int64 `gorm:"not null;default:0"`
ProviderDiscountAmount int64 `gorm:"not null;default:0"`
MerchantDiscountAmount int64 `gorm:"not null;default:0"`
SettlementAmount int64 `gorm:"not null;default:0"`
Currency string `gorm:"size:16;not null"`
PayerCurrency string `gorm:"size:16"`
AmountBreakdownKnown bool `gorm:"not null;default:false"`
PaymentStatus string `gorm:"size:32;not null;index"`
ProviderStatus string `gorm:"size:64"`
FulfillmentStatus string `gorm:"size:32;not null;index"`
RefundStatus string `gorm:"size:32;not null;index"`
RefundedAmount int64 `gorm:"not null;default:0"`
RefundRequestedAmount int64 `gorm:"not null;default:0"`
RefundNo string `gorm:"size:128;index"`
ConfirmationID string `gorm:"size:36;not null;uniqueIndex"`
RequestFingerprint string `gorm:"size:64;not null;index"`
CreatePayload string `gorm:"type:text"`
Extra string `gorm:"type:text"`
LastEventID string `gorm:"size:128"`
LastPayloadHash string `gorm:"size:64"`
LastError string `gorm:"size:512"`
FulfillmentToken string `gorm:"size:36"`
RefundToken string `gorm:"size:36"`
Version uint64 `gorm:"not null;default:1"`
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
func (paymentOrderPO) TableName() string { return "pay_orders" }
type paymentOrderRepo struct{ data *Data }
func NewPaymentOrderRepo(data *Data) biz.PaymentOrderRepo {
return &paymentOrderRepo{data: data}
}
func newPaymentOrderPO(order *biz.PaymentOrder) (*paymentOrderPO, error) {
if order == nil {
return nil, errors.New("支付订单为空")
}
extra := string(order.Extra)
if extra == "" {
extra = "{}"
}
createPayload := string(order.CreatePayload)
return &paymentOrderPO{
ID: order.ID, TradeNo: order.TradeNo, Provider: order.Provider,
ProviderTradeNo: optionalString(order.ProviderTradeNo), QueryID: order.QueryID,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: defaultString(order.PaymentMode, biz.PaymentModeExternal), OriginalAmount: order.OriginalAmount,
Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount,
CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount, DiscountAmount: order.DiscountAmount,
ProviderDiscountAmount: order.ProviderDiscountAmount, MerchantDiscountAmount: order.MerchantDiscountAmount,
SettlementAmount: order.SettlementAmount, Currency: order.Currency, PayerCurrency: order.PayerCurrency,
AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: defaultString(order.PaymentStatus, biz.PaymentStatusInitialized),
ProviderStatus: order.ProviderStatus,
FulfillmentStatus: defaultString(order.FulfillmentStatus, biz.FulfillmentStatusPending),
RefundStatus: defaultString(order.RefundStatus, biz.RefundStatusNone),
RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount, RefundNo: order.RefundNo,
ConfirmationID: order.ConfirmationID, RequestFingerprint: order.RequestFingerprint,
CreatePayload: createPayload, Extra: extra, LastEventID: order.LastEventID,
LastPayloadHash: order.LastPayloadHash, LastError: order.LastError,
FulfillmentToken: order.FulfillmentToken, RefundToken: order.RefundToken, Version: defaultVersion(order.Version),
PaidAt: order.PaidAt, FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
FulfillmentLeaseUntil: order.FulfillmentLeaseUntil, RefundLeaseUntil: order.RefundLeaseUntil,
}, nil
}
func toBizPaymentOrder(po *paymentOrderPO) *biz.PaymentOrder {
if po == nil {
return nil
}
return &biz.PaymentOrder{
ID: po.ID, TradeNo: po.TradeNo, Provider: po.Provider,
ProviderTradeNo: dereferenceString(po.ProviderTradeNo), QueryID: po.QueryID,
BusinessType: po.BusinessType, BusinessID: po.BusinessID, Subject: po.Subject,
PaymentMode: defaultString(po.PaymentMode, biz.PaymentModeExternal), OriginalAmount: po.OriginalAmount,
Amount: po.Amount, PaidAmount: po.PaidAmount, PayerPaidAmount: po.PayerPaidAmount,
CashPaidAmount: po.CashPaidAmount, PointPaidAmount: po.PointPaidAmount, DiscountAmount: po.DiscountAmount,
ProviderDiscountAmount: po.ProviderDiscountAmount, MerchantDiscountAmount: po.MerchantDiscountAmount,
SettlementAmount: po.SettlementAmount, Currency: po.Currency, PayerCurrency: po.PayerCurrency,
AmountBreakdownKnown: po.AmountBreakdownKnown,
PaymentStatus: po.PaymentStatus, ProviderStatus: po.ProviderStatus,
FulfillmentStatus: po.FulfillmentStatus, RefundStatus: po.RefundStatus,
RefundedAmount: po.RefundedAmount, RefundRequestedAmount: po.RefundRequestedAmount, RefundNo: po.RefundNo,
ConfirmationID: po.ConfirmationID, RequestFingerprint: po.RequestFingerprint,
CreatePayload: json.RawMessage(po.CreatePayload), Extra: json.RawMessage(po.Extra),
LastEventID: po.LastEventID, LastPayloadHash: po.LastPayloadHash, LastError: po.LastError,
FulfillmentToken: po.FulfillmentToken, RefundToken: po.RefundToken, Version: po.Version, CreatedAt: po.CreatedAt,
UpdatedAt: po.UpdatedAt, PaidAt: po.PaidAt, FulfilledAt: po.FulfilledAt,
RefundedAt: po.RefundedAt, FulfillmentLeaseUntil: po.FulfillmentLeaseUntil, RefundLeaseUntil: po.RefundLeaseUntil,
}
}
func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.PaymentOrder) (*biz.PaymentOrder, bool, error) {
po, err := newPaymentOrderPO(order)
if err != nil {
return nil, false, err
}
db := r.data.gormDB.WithContext(ctx)
var current paymentOrderPO
err = db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error
if err == nil {
return toBizPaymentOrder(&current), false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
if err = db.Create(po).Error; err == nil {
return toBizPaymentOrder(po), true, nil
}
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == nil {
return toBizPaymentOrder(&current), false, nil
}
return nil, false, err
}
func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*biz.PaymentOrder, error) {
var po paymentOrderPO
if err := r.data.gormDB.WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrPaymentOrderNotFound
}
return nil, err
}
return toBizPaymentOrder(&po), nil
}
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付下单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if len(update.CreatePayload) > 0 {
po.CreatePayload = string(update.CreatePayload)
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
status := normalizeOrderPaymentStatus(update.Status)
if status == biz.PaymentStatusPaid {
// Provider create responses are never sufficient proof of payment.
status = biz.PaymentStatusPending
}
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded && status != "" {
po.PaymentStatus = status
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付查单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if update.Amount > 0 && update.Amount != po.Amount {
return biz.ErrPaymentOrderConflict
}
if update.PayerPaidAmount < 0 || update.CashPaidAmount < 0 || update.PointPaidAmount < 0 || update.DiscountAmount < 0 || update.ProviderDiscountAmount < 0 || update.MerchantDiscountAmount < 0 || update.SettlementAmount < 0 {
return biz.ErrPaymentOrderConflict
}
if update.Currency != "" && !strings.EqualFold(update.Currency, po.Currency) {
return biz.ErrPaymentOrderConflict
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
if update.AmountBreakdownKnown {
po.PayerPaidAmount = update.PayerPaidAmount
po.CashPaidAmount = update.CashPaidAmount
po.PointPaidAmount = update.PointPaidAmount
po.DiscountAmount = update.DiscountAmount
po.ProviderDiscountAmount = update.ProviderDiscountAmount
po.MerchantDiscountAmount = update.MerchantDiscountAmount
po.SettlementAmount = update.SettlementAmount
po.PayerCurrency = trimTo(update.PayerCurrency, 16)
po.AmountBreakdownKnown = true
}
switch normalizeOrderPaymentStatus(update.Status) {
case biz.PaymentStatusPaid:
if po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPaid
}
po.PaidAmount = po.Amount
if po.PaidAt == nil {
now := time.Now().UTC()
po.PaidAt = &now
}
case biz.PaymentStatusPending:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPending
}
case biz.PaymentStatusFailed:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusFailed
}
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider, tradeNo string, lease time.Duration) (*biz.PaymentOrder, string, bool, error) {
var token string
var duplicate bool
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus == biz.FulfillmentStatusSucceeded {
duplicate = true
return nil
}
if po.PaymentStatus != biz.PaymentStatusPaid {
return biz.ErrPaymentOrderState
}
now := time.Now().UTC()
if po.FulfillmentStatus == biz.FulfillmentStatusProcessing && po.FulfillmentLeaseUntil != nil && po.FulfillmentLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
until := now.Add(lease)
po.FulfillmentStatus = biz.FulfillmentStatusProcessing
po.FulfillmentToken = token
po.FulfillmentLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, duplicate, err
}
func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provider, tradeNo, token string, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus != biz.FulfillmentStatusProcessing || po.FulfillmentToken != token {
return biz.ErrPaymentOrderBusy
}
po.FulfillmentToken = ""
po.FulfillmentLeaseUntil = nil
po.LastError = trimTo(message, 512)
if success {
po.FulfillmentStatus = biz.FulfillmentStatusSucceeded
now := time.Now().UTC()
po.FulfilledAt = &now
} else {
po.FulfillmentStatus = biz.FulfillmentStatusFailed
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tradeNo string, amount int64, lease time.Duration) (*biz.PaymentOrder, string, error) {
var token string
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
return biz.ErrPaymentOrderState
}
if amount <= 0 {
return biz.ErrPaymentOrderConflict
}
now := time.Now().UTC()
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && po.RefundLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderConflict
}
if po.RefundStatus == biz.RefundStatusPending {
return biz.ErrPaymentOrderBusy
}
// An expired processing lease means the provider outcome is unknown.
// Retry the same refund amount with the same durable refund number. A
// different amount must never reuse that operation identity.
reserved := int64(0)
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && !po.RefundLeaseUntil.After(now) {
reserved = 0
} else {
reserved = po.RefundRequestedAmount
}
if amount > po.Amount-po.RefundedAmount-reserved {
return biz.ErrPaymentOrderConflict
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
if po.RefundNo == "" {
po.RefundNo = uuid.NewString()
}
until := now.Add(lease)
po.RefundStatus = biz.RefundStatusProcessing
po.RefundRequestedAmount = amount
po.RefundToken = token
po.RefundLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, err
}
func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, provider, tradeNo, token string, accepted bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusProcessing || po.RefundToken != token {
return biz.ErrPaymentOrderBusy
}
po.RefundToken = ""
po.RefundLeaseUntil = nil
po.LastError = trimTo(message, 512)
if accepted {
po.RefundStatus = biz.RefundStatusPending
} else {
po.RefundStatus = biz.RefundStatusFailed
po.RefundRequestedAmount = 0
po.RefundNo = ""
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusPending || po.RefundNo == "" || po.RefundNo != refundNo || po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderState
}
po.LastError = trimTo(message, 512)
po.RefundRequestedAmount = 0
if !success {
po.RefundStatus = biz.RefundStatusFailed
po.RefundNo = ""
po.Version++
return tx.Save(po).Error
}
po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount {
po.PaymentStatus = biz.PaymentStatusRefunded
po.RefundStatus = biz.RefundStatusSucceeded
} else {
po.PaymentStatus = biz.PaymentStatusPartiallyRefunded
po.RefundStatus = biz.RefundStatusPartial
}
po.RefundNo = ""
now := time.Now().UTC()
po.RefundedAt = &now
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*biz.PaymentOrder, error) {
var result *biz.PaymentOrder
err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var po paymentOrderPO
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return biz.ErrPaymentOrderNotFound
}
return err
}
if err := fn(tx, &po); err != nil {
return err
}
result = toBizPaymentOrder(&po)
return nil
})
return result, err
}
func applyProviderIdentity(tx *gorm.DB, po *paymentOrderPO, update *biz.PaymentProviderUpdate) error {
if update.ProviderTradeNo != "" {
if po.ProviderTradeNo != nil && *po.ProviderTradeNo != update.ProviderTradeNo {
return biz.ErrPaymentProviderConflict
}
var other paymentOrderPO
// This check is repeated under the order transaction so a platform
// transaction cannot be attached to a different merchant order.
if err := tx.Where("provider = ? AND provider_trade_no = ? AND id <> ?", po.Provider, update.ProviderTradeNo, po.ID).First(&other).Error; err == nil {
return biz.ErrPaymentProviderConflict
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
value := update.ProviderTradeNo
po.ProviderTradeNo = &value
}
if update.QueryID != "" {
if po.QueryID != "" && po.QueryID != update.QueryID {
return biz.ErrPaymentProviderConflict
}
po.QueryID = update.QueryID
}
return nil
}
func normalizeOrderPaymentStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "success", "paid", "fulfilled":
return biz.PaymentStatusPaid
case "pending", "created", "client_pending", "processing":
return biz.PaymentStatusPending
case "failed", "closed", "cancelled", "canceled":
return biz.PaymentStatusFailed
default:
return ""
}
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func dereferenceString(value *string) string {
if value == nil {
return ""
}
return *value
}
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func defaultVersion(value uint64) uint64 {
if value == 0 {
return 1
}
return value
}
func trimTo(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) > max {
return value[:max]
}
return value
}
+93
View File
@@ -0,0 +1,93 @@
package data
import (
"context"
"testing"
"time"
"kra/internal/biz"
)
func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
t.Helper()
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil {
t.Fatal(err)
}
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
}
func testPaymentOrder() *biz.PaymentOrder {
return &biz.PaymentOrder{
TradeNo: "order-1", Provider: biz.PaymentAlipay, BusinessType: "game_item", BusinessID: "item-1",
Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: biz.PaymentStatusInitialized,
FulfillmentStatus: biz.FulfillmentStatusPending, RefundStatus: biz.RefundStatusNone,
ConfirmationID: "11111111-1111-1111-1111-111111111111", RequestFingerprint: "fingerprint",
}
}
func TestPaymentOrderRepositoryPersistsPaymentFulfillmentAndRefundState(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
order, created, err := repo.CreatePaymentOrder(ctx, testPaymentOrder())
if err != nil || !created {
t.Fatalf("create order = %#v created=%v err=%v", order, created, err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY", EventID: "event-1"}
order, err = repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update)
if err != nil || order.PaymentStatus != biz.PaymentStatusPaid || order.PaidAmount != 100 {
t.Fatalf("apply payment = %#v err=%v", order, err)
}
order, token, duplicate, err := repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || duplicate || token == "" || order.FulfillmentStatus != biz.FulfillmentStatusProcessing {
t.Fatalf("begin fulfillment = %#v token=%q duplicate=%v err=%v", order, token, duplicate, err)
}
if _, _, _, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute); err == nil {
t.Fatal("concurrent fulfillment was accepted")
}
order, err = repo.CompletePaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.FulfillmentStatus != biz.FulfillmentStatusSucceeded {
t.Fatalf("complete fulfillment = %#v err=%v", order, err)
}
_, _, duplicate, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || !duplicate {
t.Fatalf("duplicate fulfillment = duplicate=%v err=%v", duplicate, err)
}
order, token, err = repo.BeginPaymentRefund(ctx, biz.PaymentAlipay, "order-1", 40, time.Minute)
if err != nil || token == "" || order.RefundStatus != biz.RefundStatusProcessing {
t.Fatalf("begin refund = %#v token=%q err=%v", order, token, err)
}
order, err = repo.CompletePaymentRefundRequest(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.RefundStatus != biz.RefundStatusPending || order.RefundRequestedAmount != 40 {
t.Fatalf("accept refund = %#v err=%v", order, err)
}
order, err = repo.ConfirmPaymentRefund(ctx, biz.PaymentAlipay, "order-1", order.RefundNo, 40, true, "")
if err != nil || order.RefundedAmount != 40 || order.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
t.Fatalf("confirm refund = %#v err=%v", order, err)
}
}
func TestPaymentOrderRepositoryRejectsProviderTradeReuse(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
first := testPaymentOrder()
if _, _, err := repo.CreatePaymentOrder(ctx, first); err != nil {
t.Fatal(err)
}
second := testPaymentOrder()
second.TradeNo = "order-2"
second.ConfirmationID = "22222222-2222-2222-2222-222222222222"
if _, _, err := repo.CreatePaymentOrder(ctx, second); err != nil {
t.Fatal(err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY"}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update); err != nil {
t.Fatal(err)
}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-2", update); err != biz.ErrPaymentProviderConflict {
t.Fatalf("provider trade reuse err = %v", err)
}
}
+8 -33
View File
@@ -2,8 +2,6 @@ package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime"
@@ -14,6 +12,7 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/osskit"
)
type fileStorage struct {
@@ -66,40 +65,16 @@ func New(config *conf.AdminBackend) (biz.FileStorage, error) {
}
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if ctx.Err() != nil {
errCh <- ctx.Err()
return
}
file, err := storage.Open(ctx, name)
if err != nil {
errCh <- err
return
}
_, err = io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if err != nil {
errCh <- err
return
}
}
errCh <- nil
}()
stored, err := storage.Put(ctx, destination, reader)
composeErr := <-errCh
var stored *biz.StoredFile
hash, err := osskit.ComposeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
var putErr error
stored, putErr = storage.Put(ctx, destination, reader)
return putErr
}, storage.Delete, destination)
if err != nil {
return nil, "", err
}
if composeErr != nil {
_ = storage.Delete(ctx, destination)
return nil, "", composeErr
}
return stored, hex.EncodeToString(hash.Sum(nil)), nil
return stored, hash, nil
}
func (s *fileStorage) resolve(name string) (string, error) {
+1
View File
@@ -63,6 +63,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
serverrouter.RegisterMedia(private, handlers.Media)
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
serverrouter.RegisterEmail(private, handlers.Email)
serverrouter.RegisterPayment(private, public, handlers.Payment)
registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime)
+9 -2
View File
@@ -21,7 +21,7 @@ func emptyHandlers() *handler.Set {
return &handler.Set{
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
Permission: &handler.Permission{}, Organization: &handler.Organization{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Task: &handler.Task{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{},
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
@@ -56,7 +56,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":178`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":185`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
@@ -256,6 +256,7 @@ GET /logViewer/content
GET /logViewer/dates
GET /logViewer/files
GET /menu/getMenuRoles
GET /payment/configs
GET /position/findPosition
GET /position/getPositionUsers
GET /securityConfig/getSecurityConfig
@@ -348,6 +349,12 @@ POST /menu/getMenuAuthority
POST /menu/getMenuList
POST /menu/setMenuRoles
POST /menu/updateBaseMenu
POST /payment/callback/:provider
POST /payment/config
POST /payment/create
POST /payment/order
POST /payment/query
POST /payment/refund
POST /position/createPosition
POST /position/getPositionList
POST /position/setPositionUsers
+137
View File
@@ -0,0 +1,137 @@
package handler
import (
"io"
"kra/internal/biz"
"kra/internal/server/httpx"
"kra/internal/service"
"kra/internal/service/dto"
"net/http"
"github.com/gin-gonic/gin"
)
type Payment struct{ service *service.PaymentService }
func NewPayment(service *service.PaymentService) *Payment { return &Payment{service: service} }
func (h *Payment) Configs(c *gin.Context) {
v, err := h.service.Configs(c.Request.Context())
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, v)
}
func (h *Payment) SaveConfig(c *gin.Context) {
var req dto.PaymentConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
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 {
httpx.Fail(c, err.Error())
return
}
httpx.OK(c)
}
func (h *Payment) Order(c *gin.Context) {
var req dto.PaymentQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
order, err := h.service.Order(c.Request.Context(), req.Provider, req.TradeNo)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, order)
}
func (h *Payment) Create(c *gin.Context) {
var req dto.PaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
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,
})
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
func (h *Payment) Query(c *gin.Context) {
var req dto.PaymentQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
result, err := h.service.Query(c.Request.Context(), req.Provider, req.TradeNo)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
func (h *Payment) Refund(c *gin.Context) {
var req dto.PaymentRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
result, err := h.service.Refund(c.Request.Context(), req.Provider, req.TradeNo, req.Amount)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
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))
return
}
headers := map[string]string{}
for k, v := range c.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
query := map[string]string{}
for k, v := range c.Request.URL.Query() {
if len(v) > 0 {
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
}
writePaymentCallbackAck(c, ack)
}
func writePaymentCallbackAck(c *gin.Context, ack biz.PaymentCallbackAck) {
if ack.StatusCode == 0 {
ack.StatusCode = http.StatusInternalServerError
}
if ack.ContentType != "" {
c.Header("Content-Type", ack.ContentType)
}
c.Status(ack.StatusCode)
if len(ack.Body) > 0 {
_, _ = c.Writer.Write(ack.Body)
}
}
+29
View File
@@ -0,0 +1,29 @@
package handler
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"kra/internal/biz"
)
func TestWritePaymentCallbackAckDoesNotExposeError(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writePaymentCallbackAck(context, biz.PaymentCallbackAck{
StatusCode: 500,
ContentType: "text/plain; charset=utf-8",
Body: []byte("failure"),
})
if recorder.Code != 500 {
t.Fatalf("status = %d, want 500", recorder.Code)
}
if recorder.Body.String() != "failure" {
t.Fatalf("body = %q, want fixed failure ack", recorder.Body.String())
}
if recorder.Body.String() == "internal order resolver details" {
t.Fatal("internal error was exposed in callback response")
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ type Set struct {
Organization *Organization
Announcement *Announcement
Email *Email
Payment *Payment
Task *Task
Media *Media
Audit *Audit
@@ -23,6 +24,6 @@ type Set struct {
Session *Session
}
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session) *Set {
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session}
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, payment *Payment, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session) *Set {
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Payment: payment, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session}
}
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
payment := group.Group("/payment")
payment.GET("/configs", h.Configs)
payment.POST("/config", h.SaveConfig)
payment.POST("/order", h.Order)
payment.POST("/create", h.Create)
payment.POST("/query", h.Query)
payment.POST("/refund", h.Refund)
public.POST("/payment/callback/:provider", h.Callback)
}
+1 -1
View File
@@ -7,4 +7,4 @@ import (
)
// ProviderSet is server providers.
var ProviderSet = wire.NewSet(NewGinEngine, NewGinServer, handler.NewAuthority, handler.NewMenu, handler.NewAPI, handler.NewPermission, handler.NewOrganization, handler.NewAnnouncement, handler.NewEmail, handler.NewTask, handler.NewMedia, handler.NewAudit, handler.NewExport, handler.NewVersion, handler.NewDictionary, handler.NewParameter, handler.NewAPIToken, handler.NewSystemConfig, handler.NewPublic, handler.NewUser, handler.NewNavigation, handler.NewSession, handler.NewSet)
var ProviderSet = wire.NewSet(NewGinEngine, NewGinServer, handler.NewAuthority, handler.NewMenu, handler.NewAPI, handler.NewPermission, handler.NewOrganization, handler.NewAnnouncement, handler.NewEmail, handler.NewPayment, handler.NewTask, handler.NewMedia, handler.NewAudit, handler.NewExport, handler.NewVersion, handler.NewDictionary, handler.NewParameter, handler.NewAPIToken, handler.NewSystemConfig, handler.NewPublic, handler.NewUser, handler.NewNavigation, handler.NewSession, handler.NewSet)
+69
View File
@@ -0,0 +1,69 @@
package dto
import (
"encoding/json"
"time"
)
type PaymentConfigRequest struct {
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
}
type PaymentRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Subject string `json:"subject"`
Amount int64 `json:"amount"`
OriginalAmount int64 `json:"originalAmount"`
Currency string `json:"currency"`
NotifyURL string `json:"notifyUrl"`
ReturnURL string `json:"returnUrl"`
Extra map[string]any `json:"extra"`
ClientIP string `json:"-"`
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"`
}
type PaymentQueryRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
}
type PaymentRefundRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Amount int64 `json:"amount"`
}
type PaymentOrderResponse struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
ProviderTradeNo string `json:"providerTradeNo"`
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
Subject string `json:"subject"`
PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"`
Amount int64 `json:"amount"`
PaidAmount int64 `json:"paidAmount"`
PayerPaidAmount int64 `json:"payerPaidAmount"`
CashPaidAmount int64 `json:"cashPaidAmount"`
PointPaidAmount int64 `json:"pointPaidAmount"`
DiscountAmount int64 `json:"discountAmount"`
ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
SettlementAmount int64 `json:"settlementAmount"`
Currency string `json:"currency"`
PayerCurrency string `json:"payerCurrency,omitempty"`
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
PaymentStatus string `json:"paymentStatus"`
FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"`
RefundNo string `json:"refundNo,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PaidAt *time.Time `json:"paidAt,omitempty"`
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
RefundedAt *time.Time `json:"refundedAt,omitempty"`
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"kra/internal/biz"
"kra/internal/service/dto"
)
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) SaveConfig(ctx context.Context, c *biz.PaymentConfig) error {
return s.uc.SaveConfig(ctx, c)
}
func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*dto.PaymentOrderResponse, error) {
order, err := s.uc.Order(ctx, provider, tradeNo)
if err != nil {
return nil, err
}
return &dto.PaymentOrderResponse{
Provider: order.Provider, TradeNo: order.TradeNo, ProviderTradeNo: order.ProviderTradeNo,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: order.PaymentMode, OriginalAmount: order.OriginalAmount,
Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount,
CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount,
DiscountAmount: order.DiscountAmount, ProviderDiscountAmount: order.ProviderDiscountAmount,
MerchantDiscountAmount: order.MerchantDiscountAmount, SettlementAmount: order.SettlementAmount,
Currency: order.Currency, PayerCurrency: order.PayerCurrency, AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: order.PaymentStatus, FulfillmentStatus: order.FulfillmentStatus,
RefundStatus: order.RefundStatus, RefundedAmount: order.RefundedAmount, RefundNo: order.RefundNo,
CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, PaidAt: order.PaidAt,
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) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
return s.uc.Query(ctx, provider, tradeNo)
}
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) Callback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
return s.uc.Callback(ctx, callback)
}
+1 -1
View File
@@ -3,4 +3,4 @@ package service
import "github.com/google/wire"
// ProviderSet is service providers.
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService)
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService, NewPaymentService)
+56
View File
@@ -0,0 +1,56 @@
package osskit
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
)
// ComposeStreams concatenates objects into destination while calculating the
// MD5 of the exact bytes written. Provider-specific storage clients stay out
// of this package; callbacks keep this helper independent from biz and SDKs.
func ComposeStreams(
ctx context.Context,
names []string,
open func(context.Context, string) (io.ReadCloser, error),
put func(context.Context, string, io.Reader) error,
remove func(context.Context, string) error,
destination string,
) (string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if err := ctx.Err(); err != nil {
errCh <- err
return
}
file, err := open(ctx, name)
if err != nil {
errCh <- err
return
}
_, copyErr := io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if copyErr != nil {
errCh <- copyErr
return
}
}
errCh <- nil
}()
putErr := put(ctx, destination, reader)
composeErr := <-errCh
if putErr != nil {
_ = remove(ctx, destination)
return "", putErr
}
if composeErr != nil {
_ = remove(ctx, destination)
return "", composeErr
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
+50
View File
@@ -0,0 +1,50 @@
package osskit
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
)
func TestComposeStreams(t *testing.T) {
objects := map[string]string{"a": "hello ", "b": "world"}
var stored string
hash, err := ComposeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) {
value, ok := objects[name]
if !ok {
return nil, errors.New("missing object")
}
return io.NopCloser(strings.NewReader(value)), nil
}, func(_ context.Context, _ string, reader io.Reader) error {
value, err := io.ReadAll(reader)
stored = string(value)
return err
}, func(context.Context, string) error { return nil }, "out")
if err != nil || stored != "hello world" {
t.Fatalf("compose = %q, %v", stored, err)
}
want := md5.Sum([]byte(stored))
if hash != hex.EncodeToString(want[:]) {
t.Fatalf("hash = %q", hash)
}
}
func TestComposeStreamsRemovesPartialDestination(t *testing.T) {
removed := false
_, err := ComposeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("open failed")
}, func(_ context.Context, _ string, reader io.Reader) error {
_, _ = io.ReadAll(reader)
return nil
}, func(context.Context, string) error {
removed = true
return nil
}, "out")
if err == nil || !removed {
t.Fatalf("compose error = %v, removed = %v", err, removed)
}
}
+97
View File
@@ -0,0 +1,97 @@
package paymentkit
import (
"errors"
"fmt"
"strconv"
"strings"
)
// ParseIntegerAmount parses an integer amount expressed in the smallest
// currency unit. Floating point is intentionally not involved.
func ParseIntegerAmount(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, errors.New("支付金额为空")
}
return strconv.ParseInt(value, 10, 64)
}
// ParseDecimalAmount converts a major-unit decimal string to an integer amount
// using a power-of-ten scale, without using floating point.
func ParseDecimalAmount(value string, scale int64) (int64, error) {
value = strings.TrimSpace(value)
if value == "" || scale <= 0 {
return 0, errors.New("支付金额或换算比例无效")
}
if strings.HasPrefix(value, "-") {
return 0, errors.New("支付金额不能为负数")
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, fmt.Errorf("支付金额格式错误: %s", value)
}
digits, err := scaleDigits(scale)
if err != nil {
return 0, err
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
}
if len(fraction) > digits {
return 0, fmt.Errorf("支付金额精度超过 %d 位", digits)
}
fraction += strings.Repeat("0", digits-len(fraction))
fractionValue := int64(0)
if fraction != "" {
fractionValue, err = strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
}
if whole > (int64(^uint64(0)>>1)-fractionValue)/scale {
return 0, errors.New("支付金额超出范围")
}
return whole*scale + fractionValue, nil
}
func FormatDecimalAmount(amount, scale int64) (string, error) {
if amount <= 0 || scale <= 0 {
return "", errors.New("支付金额或换算比例无效")
}
digits, err := scaleDigits(scale)
if err != nil {
return "", err
}
if digits == 0 {
return strconv.FormatInt(amount, 10), nil
}
return fmt.Sprintf("%d.%0*d", amount/scale, digits, amount%scale), nil
}
func scaleDigits(scale int64) (int, error) {
digits := 0
for n := scale; n > 1 && n%10 == 0; n /= 10 {
digits++
}
if scale <= 0 || scale != pow10(digits) {
return 0, errors.New("支付金额换算比例必须是 10 的幂")
}
return digits, nil
}
func pow10(digits int) int64 {
value := int64(1)
for i := 0; i < digits; i++ {
if value > (int64(^uint64(0)>>1))/10 {
return 0
}
value *= 10
}
return value
}
+25
View File
@@ -0,0 +1,25 @@
package paymentkit
import "testing"
func TestDecimalAmountUsesIntegerMath(t *testing.T) {
got, err := ParseDecimalAmount("12.34", 100)
if err != nil || got != 1234 {
t.Fatalf("ParseDecimalAmount = %d, %v", got, err)
}
formatted, err := FormatDecimalAmount(got, 100)
if err != nil || formatted != "12.34" {
t.Fatalf("FormatDecimalAmount = %q, %v", formatted, err)
}
}
func TestDecimalAmountRejectsUnsafeInput(t *testing.T) {
for _, value := range []string{"-1.00", "1.001", "1.2.3"} {
if _, err := ParseDecimalAmount(value, 100); err == nil {
t.Fatalf("ParseDecimalAmount(%q) accepted unsafe input", value)
}
}
if _, err := ParseDecimalAmount("1", 3); err == nil {
t.Fatal("non power-of-ten scale accepted")
}
}
+66
View File
@@ -0,0 +1,66 @@
package paymentkit
import (
"encoding/json"
"fmt"
"strings"
)
func JSONObject(raw []byte) map[string]any {
var value any
if json.Unmarshal(raw, &value) != nil {
return nil
}
object, _ := value.(map[string]any)
return object
}
func NestedString(value any, keys ...string) string {
found := nestedValue(value, keys...)
if found == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(found))
}
func StringAtPath(value any, path string) string {
found := ValueAtPath(value, path)
if found == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(found))
}
func ValueAtPath(value any, path string) any {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
current := value
for _, part := range strings.Split(path, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[part]
}
return current
}
func nestedValue(value any, keys ...string) any {
object, ok := value.(map[string]any)
if !ok {
return nil
}
for _, key := range keys {
if found, exists := object[key]; exists && found != nil && fmt.Sprint(found) != "" {
return found
}
}
for _, nestedKey := range []string{"data", "result", "order", "transaction", "response"} {
if found := nestedValue(object[nestedKey], keys...); found != nil {
return found
}
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package paymentkit
import "testing"
func TestNestedAndPathValues(t *testing.T) {
object := JSONObject([]byte(`{"data":{"status":"SUCCESS","amount":100}}`))
if got := NestedString(object, "status"); got != "SUCCESS" {
t.Fatalf("NestedString = %q", got)
}
if got := StringAtPath(object, "data.amount"); got != "100" {
t.Fatalf("StringAtPath = %q", got)
}
if got := StringAtPath(object, "data.missing"); got != "" {
t.Fatalf("missing path = %q", got)
}
}
+58
View File
@@ -0,0 +1,58 @@
package paymentkit
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"sort"
"strings"
)
func WechatV2Sign(values map[string]string, key, signType string) string {
keys := make([]string, 0, len(values))
for k, v := range values {
if k != "sign" && v != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+values[k])
}
raw := strings.Join(parts, "&") + "&key=" + key
if strings.EqualFold(signType, "HMAC-SHA256") {
return HMACSHA256Hex([]byte(raw), key, true)
}
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func MD5Canonical(values map[string]any, secret string) string {
keys := make([]string, 0, len(values))
for key, value := range values {
if key != "sign" && value != nil && fmt.Sprint(value) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
query := url.Values{}
for _, key := range keys {
query.Set(key, fmt.Sprint(values[key]))
}
sum := md5.Sum([]byte(query.Encode() + "&key=" + secret))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func HMACSHA256Hex(raw []byte, secret string, upper bool) string {
h := hmac.New(sha256.New, []byte(secret))
_, _ = h.Write(raw)
result := hex.EncodeToString(h.Sum(nil))
if upper {
return strings.ToUpper(result)
}
return result
}
+12
View File
@@ -0,0 +1,12 @@
package paymentkit
import "testing"
func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) {
values := map[string]string{"b": "2", "a": "1", "sign": "old"}
first := WechatV2Sign(values, "secret", "MD5")
values["sign"] = "different"
if second := WechatV2Sign(values, "secret", "MD5"); first != second {
t.Fatalf("signature changed when sign field changed: %q != %q", first, second)
}
}
+63
View File
@@ -0,0 +1,63 @@
package paymentkit
import (
"fmt"
"strings"
)
func NormalizeStatus(value, fallback string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS":
return "success"
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING":
return "pending"
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "PAYERROR":
return "failed"
default:
return fallback
}
}
func ConfiguredInt64(values map[string]any, key string, fallback int64) int64 {
value, ok := values[key]
if !ok {
return fallback
}
parsed, err := ParseIntegerAmount(strings.TrimSpace(toString(value)))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func ConfiguredValues(values map[string]any, key string) []string {
result := []string{}
for _, item := range strings.Split(toString(values[key]), ",") {
item = strings.ToUpper(strings.TrimSpace(item))
if item != "" {
result = append(result, item)
}
}
return result
}
func ContainsFold(values []string, value string) bool {
value = strings.ToUpper(strings.TrimSpace(value))
for _, item := range values {
if item == value {
return true
}
}
return false
}
func toString(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(stringify(value)), "<nil>", ""))
}
func stringify(value any) string {
return fmt.Sprint(value)
}
+60
View File
@@ -0,0 +1,60 @@
package paymentkit
import (
"bytes"
"encoding/xml"
"io"
"sort"
"strings"
)
type xmlValue struct {
XMLName xml.Name
Value string `xml:",chardata"`
}
func XMLValues(raw []byte) (map[string]string, error) {
decoder := xml.NewDecoder(bytes.NewReader(raw))
result := map[string]string{}
for {
token, err := decoder.Token()
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local == "xml" {
continue
}
var value xmlValue
if err = decoder.DecodeElement(&value, &start); err != nil {
return nil, err
}
if value.Value != "" {
result[start.Name.Local] = value.Value
}
}
}
func XMLEncode(values map[string]string) []byte {
var b strings.Builder
b.WriteString("<xml>")
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
b.WriteString("<")
b.WriteString(key)
b.WriteString("><![CDATA[")
b.WriteString(values[key])
b.WriteString("]]></")
b.WriteString(key)
b.WriteString(">")
}
b.WriteString("</xml>")
return []byte(b.String())
}