diff --git a/cmd/kratos-admin/wire_gen.go b/cmd/kratos-admin/wire_gen.go index 79aacfb..dfbbd23 100644 --- a/cmd/kratos-admin/wire_gen.go +++ b/cmd/kratos-admin/wire_gen.go @@ -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) diff --git a/docs/PAYMENT.md b/docs/PAYMENT.md new file mode 100644 index 0000000..ac3266f --- /dev/null +++ b/docs/PAYMENT.md @@ -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 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。 diff --git a/go.mod b/go.mod index f3923b3..7687b50 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4d40196..a52f274 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/biz/biz.go b/internal/biz/biz.go index a1a6d58..e4a4b57 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/payment.go b/internal/biz/payment.go new file mode 100644 index 0000000..11d1deb --- /dev/null +++ b/internal/biz/payment.go @@ -0,0 +1,1115 @@ +package biz + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + PaymentAlipay = "alipay" + PaymentWechatV2 = "wechat-v2" + PaymentWechatV3 = "wechat-v3" + PaymentApple = "apple-iap" + PaymentChinaums = "chinaums" + PaymentSFT = "sft" + PaymentSuperPay = "supper-pay" + PaymentWechatGame = "wechat-game-pay" + PaymentDouyinGame = "douyin-game-pay" + // GoPay-backed providers. These are kept separate from the existing + // configuration-driven game/aggregator protocols above. + PaymentDouyin = "douyin" + PaymentQQ = "qq" + PaymentAllinPay = "allinpay" + PaymentLakala = "lakala" + PaymentPayPal = "paypal" + PaymentSaobei = "saobei" + PaymentInternal = "internal" + + PaymentModeExternal = "external" + PaymentModeInternal = "internal" +) + +var SupportedPaymentProviders = []string{ + PaymentAlipay, PaymentWechatV2, PaymentWechatV3, PaymentApple, + PaymentChinaums, PaymentSFT, PaymentSuperPay, PaymentWechatGame, PaymentDouyinGame, + PaymentDouyin, PaymentQQ, PaymentAllinPay, PaymentLakala, PaymentPayPal, PaymentSaobei, +} + +var ( + ErrPaymentProviderNotFound = errors.New("支付渠道未配置") + ErrPaymentOperationRejected = errors.New("支付操作被平台拒绝") +) + +func PaymentRejected(err error) error { + if err == nil { + return ErrPaymentOperationRejected + } + return fmt.Errorf("%w: %v", ErrPaymentOperationRejected, err) +} + +type PaymentConfig struct { + Provider string + Enabled bool + Values json.RawMessage +} + +type PaymentRequest struct { + Provider string `json:"provider"` + TradeNo string `json:"tradeNo"` + Subject string `json:"subject"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + NotifyURL string `json:"notifyUrl"` + ReturnURL string `json:"returnUrl"` + ClientIP string `json:"clientIp"` + Extra map[string]any `json:"extra"` + BusinessType string `json:"businessType"` + BusinessID string `json:"businessId"` + PaymentMode string `json:"paymentMode"` + OriginalAmount int64 `json:"originalAmount"` +} + +// PaymentRefundRequest carries the durable provider identities needed to +// retry a refund without treating runtime configuration as per-order state. +type PaymentRefundRequest struct { + Provider string + TradeNo string + ProviderTradeNo string + QueryID string + RefundNo string + Amount int64 + TotalAmount int64 + Currency string +} + +// PaymentIntent is the canonical business-facing payment snapshot. +type PaymentIntent struct { + Provider string + TradeNo string + BusinessType string + BusinessID string + Subject string + Amount int64 + Currency string + Extra json.RawMessage + PaymentMode string + OriginalAmount int64 +} + +// InternalPaymentAuthorization is returned by a business module after it has +// atomically and idempotently deducted points, balance, or another platform +// owned asset. TradeNo must be the idempotency key for the deduction. +type InternalPaymentAuthorization struct { + AuthorizationID string + Amount int64 + Currency string + PayerPaidAmount int64 + CashPaidAmount int64 + PointPaidAmount int64 + DiscountAmount int64 + SettlementAmount int64 + PayerCurrency string + AmountBreakdownKnown bool + Payload json.RawMessage +} + +type InternalPaymentRefund struct { + RefundID string + Amount int64 + Currency string + Payload json.RawMessage +} + +type PaymentInternalPayer interface { + PayInternal(context.Context, *PaymentIntent) (*InternalPaymentAuthorization, error) +} + +type PaymentInternalRefundProcessor interface { + RefundInternal(context.Context, *PaymentOrder, int64) (*InternalPaymentRefund, error) +} + +type PaymentConfirmation struct { + ID string + Provider string + TradeNo string + ProviderTradeNo string + BusinessType string + BusinessID string + Subject string + Amount int64 + OriginalAmount int64 + PayerPaidAmount int64 + CashPaidAmount int64 + PointPaidAmount int64 + DiscountAmount int64 + ProviderDiscountAmount int64 + MerchantDiscountAmount int64 + SettlementAmount int64 + Currency string + PayerCurrency string + AmountBreakdownKnown bool + Payload json.RawMessage + CreatedAt time.Time +} + +type PaymentResult struct { + Provider string `json:"provider"` + Status string `json:"status"` + TradeNo string `json:"tradeNo"` + ProviderTradeNo string `json:"providerTradeNo"` + Amount int64 `json:"amount"` + PayerPaidAmount int64 `json:"payerPaidAmount,omitempty"` + CashPaidAmount int64 `json:"cashPaidAmount,omitempty"` + PointPaidAmount int64 `json:"pointPaidAmount,omitempty"` + DiscountAmount int64 `json:"discountAmount,omitempty"` + ProviderDiscountAmount int64 `json:"providerDiscountAmount,omitempty"` + MerchantDiscountAmount int64 `json:"merchantDiscountAmount,omitempty"` + SettlementAmount int64 `json:"settlementAmount,omitempty"` + Currency string `json:"currency"` + PayerCurrency string `json:"payerCurrency,omitempty"` + AmountBreakdownKnown bool `json:"amountBreakdownKnown"` + Duplicate bool `json:"duplicate"` + Payload json.RawMessage `json:"payload"` + OrderStatus string `json:"orderStatus,omitempty"` + FulfillmentStatus string `json:"fulfillmentStatus,omitempty"` + RefundStatus string `json:"refundStatus,omitempty"` + EventID string `json:"-"` + QueryID string `json:"-"` + SuccessAck PaymentCallbackAck `json:"-"` + FailureAck PaymentCallbackAck `json:"-"` +} + +type PaymentCallbackAck struct { + StatusCode int + ContentType string + Body []byte +} + +type PaymentCallbackError struct { + Cause error + Ack PaymentCallbackAck +} + +func (e *PaymentCallbackError) Error() string { + if e == nil || e.Cause == nil { + return "支付回调处理失败" + } + return e.Cause.Error() +} + +func (e *PaymentCallbackError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func CallbackFailure(err error, fallback PaymentCallbackAck) PaymentCallbackAck { + var callbackErr *PaymentCallbackError + if errors.As(err, &callbackErr) && callbackErr.Ack.StatusCode != 0 { + return callbackErr.Ack + } + return fallback +} + +func DefaultPaymentCallbackAck(provider string, success bool) PaymentCallbackAck { + status := 200 + if !success { + status = 500 + } + switch provider { + case PaymentAlipay: + body := "success" + if !success { + body = "failure" + } + return PaymentCallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)} + case PaymentWechatV2: + code, message := "SUCCESS", "OK" + if !success { + code, message = "FAIL", "FAIL" + } + body := fmt.Sprintf("", code, message) + return PaymentCallbackAck{StatusCode: status, ContentType: "application/xml; charset=utf-8", Body: []byte(body)} + case PaymentWechatV3: + code, message := "SUCCESS", "成功" + if !success { + code, message = "FAIL", "失败" + } + body, _ := json.Marshal(map[string]string{"code": code, "message": message}) + return PaymentCallbackAck{StatusCode: status, ContentType: "application/json; charset=utf-8", Body: body} + case PaymentApple: + return PaymentCallbackAck{StatusCode: status} + default: + body := "success" + if !success { + body = "failure" + } + return PaymentCallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)} + } +} + +type PaymentCallback struct { + Provider string + Headers map[string]string + Body []byte + Query map[string]string +} + +type PaymentRepo interface { + ListConfigs(context.Context) ([]*PaymentConfig, error) + SaveConfig(context.Context, *PaymentConfig) error + Create(context.Context, *PaymentRequest) (*PaymentResult, error) + Query(context.Context, string, string) (*PaymentResult, error) + Refund(context.Context, *PaymentRefundRequest) (*PaymentResult, error) + HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) +} + +type PaymentHooks interface { + BeforeCreate(context.Context, *PaymentRequest) error +} + +type NoopPaymentHooks struct{} + +func (NoopPaymentHooks) BeforeCreate(context.Context, *PaymentRequest) error { return nil } + +type PaymentFulfillmentHandler interface { + Type() string + Fulfill(context.Context, *PaymentConfirmation) error +} + +type PaymentFulfillmentRegistry struct { + mu sync.RWMutex + handlers map[string]PaymentFulfillmentHandler +} + +func NewPaymentFulfillmentRegistry() *PaymentFulfillmentRegistry { + return &PaymentFulfillmentRegistry{handlers: map[string]PaymentFulfillmentHandler{}} +} + +func (r *PaymentFulfillmentRegistry) Register(handler PaymentFulfillmentHandler) error { + if handler == nil || strings.TrimSpace(handler.Type()) == "" { + return errors.New("支付发货 handler 无效") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.handlers[handler.Type()]; exists { + return errors.New("支付发货 handler 已注册: " + handler.Type()) + } + r.handlers[handler.Type()] = handler + return nil +} + +func (r *PaymentFulfillmentRegistry) Handler(kind string) PaymentFulfillmentHandler { + r.mu.RLock() + defer r.mu.RUnlock() + return r.handlers[kind] +} + +type PaymentUsecase struct { + repo PaymentRepo + orders PaymentOrderRepo + hooks PaymentHooks + sources *PaymentOrderSourceRegistry + fulfillments *PaymentFulfillmentRegistry + logger PaymentLogger +} + +func NewPaymentUsecase(repo PaymentRepo, orders PaymentOrderRepo, appLogger *slog.Logger) *PaymentUsecase { + return &PaymentUsecase{ + repo: repo, + orders: orders, + hooks: NoopPaymentHooks{}, + sources: NewPaymentOrderSourceRegistry(), + fulfillments: NewPaymentFulfillmentRegistry(), + logger: NewPaymentLogger(appLogger), + } +} + +func (uc *PaymentUsecase) SetHooks(hooks PaymentHooks) { + if hooks != nil { + uc.hooks = hooks + } +} + +func (uc *PaymentUsecase) SetOrderSourceRegistry(registry *PaymentOrderSourceRegistry) { + if registry != nil { + uc.sources = registry + } +} + +func (uc *PaymentUsecase) SetFulfillmentRegistry(registry *PaymentFulfillmentRegistry) { + if registry != nil { + uc.fulfillments = registry + } +} + +func (uc *PaymentUsecase) RegisterBusinessModule(module PaymentBusinessModule) error { + if module == nil { + return errors.New("支付业务模块为空") + } + if err := uc.sources.Register(module); err != nil { + return err + } + if err := uc.fulfillments.Register(module); err != nil { + uc.sources.Unregister(module.Type()) + return err + } + return nil +} + +func (uc *PaymentUsecase) Configs(ctx context.Context) ([]*PaymentConfig, error) { + return uc.repo.ListConfigs(ctx) +} + +func (uc *PaymentUsecase) SaveConfig(ctx context.Context, config *PaymentConfig) error { + return uc.repo.SaveConfig(ctx, config) +} + +func (uc *PaymentUsecase) Order(ctx context.Context, provider, tradeNo string) (*PaymentOrder, error) { + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { + return nil, errors.New("支付订单查询参数不完整") + } + return uc.orders.FindPaymentOrder(ctx, provider, tradeNo) +} + +func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) { + if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) { + return nil, errors.New("支付参数不完整") + } + if !supportedPaymentProvider(req.Provider) { + return nil, errors.New("不支持的支付渠道") + } + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + prepared, err := uc.preparePaymentRequest(ctx, req) + if err != nil { + return nil, err + } + req = prepared + if !validPaymentText(req.Currency, 16) || !validPaymentText(req.Subject, 256) || req.Amount <= 0 { + return nil, errors.New("支付订单金额、币种或标题无效") + } + req.PaymentMode = defaultPaymentMode(req.Provider, req.PaymentMode) + if req.PaymentMode != PaymentModeExternal && req.PaymentMode != PaymentModeInternal { + return nil, ErrPaymentOrderConflict + } + if req.Provider == PaymentInternal && req.PaymentMode != PaymentModeInternal { + return nil, ErrPaymentOrderConflict + } + if req.Provider != PaymentInternal && req.PaymentMode == PaymentModeInternal { + return nil, ErrPaymentOrderConflict + } + if req.OriginalAmount <= 0 { + req.OriginalAmount = req.Amount + } + if req.OriginalAmount < req.Amount { + return nil, errors.New("原始金额不能小于支付订单金额") + } + canonicalProvider, canonicalTradeNo := req.Provider, req.TradeNo + canonicalBusinessType, canonicalBusinessID := req.BusinessType, req.BusinessID + canonicalSubject, canonicalCurrency, canonicalAmount := req.Subject, req.Currency, req.Amount + canonicalOriginalAmount, canonicalPaymentMode := req.OriginalAmount, req.PaymentMode + canonicalExtra, err := json.Marshal(req.Extra) + if err != nil { + return nil, fmt.Errorf("支付扩展参数无效: %w", err) + } + if err := uc.hooks.BeforeCreate(ctx, req); err != nil { + return nil, err + } + currentExtra, err := json.Marshal(req.Extra) + if err != nil { + return nil, fmt.Errorf("支付扩展参数无效: %w", err) + } + if req.Provider != canonicalProvider || req.TradeNo != canonicalTradeNo || req.BusinessType != canonicalBusinessType || req.BusinessID != canonicalBusinessID || req.Subject != canonicalSubject || req.Currency != canonicalCurrency || req.Amount != canonicalAmount || req.OriginalAmount != canonicalOriginalAmount || req.PaymentMode != canonicalPaymentMode || string(currentExtra) != string(canonicalExtra) { + return nil, ErrPaymentOrderConflict + } + return uc.createWithOrder(ctx, req) +} + +func (uc *PaymentUsecase) preparePaymentRequest(ctx context.Context, req *PaymentRequest) (*PaymentRequest, error) { + source := uc.sources.Source(req.BusinessType) + if source == nil { + return nil, fmt.Errorf("支付业务订单来源未注册: %s", req.BusinessType) + } + intent, err := source.PreparePayment(ctx, req.Provider, req.TradeNo, req.BusinessID) + if err != nil { + return nil, err + } + if intent == nil || intent.Provider != req.Provider || intent.TradeNo != req.TradeNo || intent.BusinessType != req.BusinessType || intent.BusinessID != req.BusinessID { + return nil, ErrPaymentOrderConflict + } + prepared := *req + prepared.Subject = intent.Subject + prepared.Amount = intent.Amount + prepared.Currency = strings.ToUpper(intent.Currency) + prepared.PaymentMode = defaultPaymentMode(intent.Provider, intent.PaymentMode) + prepared.OriginalAmount = intent.OriginalAmount + if prepared.OriginalAmount <= 0 { + prepared.OriginalAmount = prepared.Amount + } + prepared.Extra = mergeCanonicalPaymentExtra(req.Extra, intent.Extra) + return &prepared, nil +} + +func mergeCanonicalPaymentExtra(client map[string]any, canonical json.RawMessage) map[string]any { + merged := make(map[string]any, len(client)+4) + for key, value := range client { + merged[key] = value + } + var values map[string]any + if json.Unmarshal(canonical, &values) == nil { + for key, value := range values { + merged[key] = value + } + } + return merged +} + +func supportedPaymentProvider(provider string) bool { + if provider == PaymentInternal { + return true + } + for _, supported := range SupportedPaymentProviders { + if provider == supported { + return true + } + } + return false +} + +func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) { + if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { + return nil, errors.New("查询支付参数不完整") + } + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + order, err := uc.orders.FindPaymentOrder(ctx, provider, tradeNo) + if err != nil { + return nil, err + } + if provider == PaymentInternal || order.PaymentMode == PaymentModeInternal { + return paymentResultFromOrder(order), nil + } + queryID := tradeNo + if order.QueryID != "" { + queryID = order.QueryID + } else if provider == PaymentApple { + if order.ProviderTradeNo == "" { + return nil, ErrPaymentOrderState + } + queryID = order.ProviderTradeNo + } + result, err := uc.repo.Query(ctx, provider, queryID) + if err != nil { + uc.logger.Error(ctx, "payment.query.failed", "provider", provider, "trade_no", tradeNo, "query_id", queryID, "error", err) + return nil, err + } + if err = validatePaymentQueryResult(provider, queryID, result); err != nil { + uc.logger.Error(ctx, "payment.query.invalid", "provider", provider, "trade_no", tradeNo, "query_id", queryID, "error", err) + return nil, err + } + if result.TradeNo != tradeNo { + return nil, errors.New("支付平台查单商户订单号不匹配") + } + return uc.reconcilePaymentResult(ctx, result, nil) +} + +func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) { + if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) || amount <= 0 { + return nil, errors.New("退款参数不完整") + } + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + return uc.refundWithOrder(ctx, provider, tradeNo, amount) +} + +// Callback verifies the notification in the adapter, then always performs a +// provider-side query before it can dispatch fulfillment. Callback payloads +// are never trusted for amount, currency, or final payment state. +func (uc *PaymentUsecase) Callback(ctx context.Context, callback *PaymentCallback) (*PaymentResult, error) { + if callback == nil || strings.TrimSpace(callback.Provider) == "" || len(callback.Body) == 0 { + err := errors.New("支付回调参数不完整") + uc.logger.Error(ctx, "payment.callback.invalid", "error", err) + return nil, err + } + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + return uc.callbackWithOrder(ctx, callback) +} + +func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) { + extra, err := json.Marshal(req.Extra) + if err != nil { + return nil, fmt.Errorf("支付扩展参数无效: %w", err) + } + if len(extra) == 0 || string(extra) == "null" { + extra = []byte("{}") + } + order := &PaymentOrder{ + TradeNo: req.TradeNo, + Provider: req.Provider, + BusinessType: req.BusinessType, + BusinessID: req.BusinessID, + Subject: req.Subject, + PaymentMode: defaultPaymentMode(req.Provider, req.PaymentMode), + OriginalAmount: req.OriginalAmount, + Amount: req.Amount, + Currency: strings.ToUpper(req.Currency), + PaymentStatus: PaymentStatusInitialized, + FulfillmentStatus: FulfillmentStatusPending, + RefundStatus: RefundStatusNone, + ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo), + RequestFingerprint: paymentOrderFingerprint(req, extra), + Extra: extra, + } + order, created, err := uc.orders.CreatePaymentOrder(ctx, order) + if err != nil { + return nil, err + } + if order.RequestFingerprint != paymentOrderFingerprint(req, extra) { + return nil, ErrPaymentOrderConflict + } + if !created && len(order.CreatePayload) > 0 && string(order.CreatePayload) != "{}" { + return paymentResultFromOrder(order), nil + } + if req.Provider == PaymentInternal || order.PaymentMode == PaymentModeInternal { + source := uc.sources.Source(req.BusinessType) + payer, ok := source.(PaymentInternalPayer) + if !ok { + return nil, fmt.Errorf("支付业务未实现内部支付: %s", req.BusinessType) + } + authorization, err := payer.PayInternal(ctx, &PaymentIntent{ + Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType, + BusinessID: req.BusinessID, Subject: req.Subject, Amount: req.Amount, + Currency: req.Currency, Extra: extra, PaymentMode: PaymentModeInternal, + OriginalAmount: req.OriginalAmount, + }) + if err != nil { + return nil, err + } + if authorization == nil || authorization.AuthorizationID == "" || authorization.Amount != req.Amount || !strings.EqualFold(authorization.Currency, req.Currency) { + return nil, errors.New("内部支付授权结果无效") + } + result := &PaymentResult{ + Provider: PaymentInternal, Status: "success", TradeNo: req.TradeNo, + ProviderTradeNo: authorization.AuthorizationID, QueryID: authorization.AuthorizationID, + Amount: req.Amount, PayerPaidAmount: authorization.PayerPaidAmount, + CashPaidAmount: authorization.CashPaidAmount, PointPaidAmount: authorization.PointPaidAmount, + DiscountAmount: authorization.DiscountAmount, SettlementAmount: authorization.SettlementAmount, + Currency: strings.ToUpper(req.Currency), PayerCurrency: strings.ToUpper(authorization.PayerCurrency), + AmountBreakdownKnown: authorization.AmountBreakdownKnown, Payload: authorization.Payload, + } + if result.PayerCurrency == "" { + result.PayerCurrency = result.Currency + } + if !authorization.AmountBreakdownKnown { + return nil, errors.New("内部支付必须返回完整金额拆分") + } + if err = validatePaymentBreakdown(result); err != nil { + return nil, fmt.Errorf("内部支付金额拆分无效: %w", err) + } + if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)); err != nil { + return nil, err + } + return uc.reconcilePaymentResult(ctx, result, nil) + } + + uc.logger.Info(ctx, "payment.order.create", "provider", req.Provider, "trade_no", req.TradeNo, "business_type", req.BusinessType, "business_id", req.BusinessID) + result, err := uc.repo.Create(ctx, req) + if err != nil { + uc.logger.Error(ctx, "payment.order.provider_create_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) + return nil, err + } + if err = validatePaymentCreateResult(req, result); err != nil { + uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) + return nil, err + } + order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)) + if err != nil { + uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) + return nil, err + } + if strings.EqualFold(result.Status, "success") { + result.Status = "pending" + } + return attachOrderResult(result, order), nil +} + +func (uc *PaymentUsecase) callbackWithOrder(ctx context.Context, callback *PaymentCallback) (*PaymentResult, error) { + callbackResult, err := uc.repo.HandleCallback(ctx, callback) + if err != nil { + uc.logger.Error(ctx, "payment.callback.verify_failed", "provider", callback.Provider, "error", err) + return nil, err + } + if callbackResult == nil || callbackResult.Provider != callback.Provider || !validPaymentText(callbackResult.TradeNo, 128) || (callbackResult.QueryID != "" && !validPaymentText(callbackResult.QueryID, 128)) { + err = errors.New("支付回调渠道或商户订单号校验失败") + return nil, callbackResultError(callbackResult, err) + } + queryID := callbackResult.QueryID + if queryID == "" { + queryID = callbackResult.TradeNo + } + queryResult, err := uc.repo.Query(ctx, callbackResult.Provider, queryID) + if err != nil { + uc.logger.Error(ctx, "payment.callback.query_failed", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err) + return nil, callbackResultError(callbackResult, err) + } + if err = validatePaymentQueryResult(callbackResult.Provider, queryID, queryResult); err != nil { + uc.logger.Error(ctx, "payment.callback.query_invalid", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err) + return nil, callbackResultError(callbackResult, err) + } + if queryResult.TradeNo != callbackResult.TradeNo { + err = errors.New("支付回调商户订单号与主动查单结果不匹配") + return nil, callbackResultError(callbackResult, err) + } + queryResult = clonePaymentResult(queryResult) + queryResult.EventID = callbackResult.EventID + queryResult.SuccessAck = callbackResult.SuccessAck + queryResult.FailureAck = callbackResult.FailureAck + return uc.reconcilePaymentResult(ctx, queryResult, callbackResult) +} + +func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, callbackSource *PaymentResult) (*PaymentResult, error) { + if result == nil { + return nil, errors.New("支付查单结果为空") + } + fail := func(err error) (*PaymentResult, error) { + if callbackSource != nil { + return nil, callbackResultError(callbackSource, err) + } + return nil, err + } + order, err := uc.orders.FindPaymentOrder(ctx, result.Provider, result.TradeNo) + if err != nil { + uc.logger.Error(ctx, "payment.order.not_found", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) + return fail(err) + } + if strings.EqualFold(result.Status, "success") { + if err = validatePaymentConfirmation(order.Intent(), result.Provider, result.TradeNo, result); err != nil { + uc.logger.Error(ctx, "payment.order.validation_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) + return fail(err) + } + } + order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, paymentProviderUpdate(result)) + if err != nil { + uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) + return fail(err) + } + result = attachOrderResult(result, order) + if !strings.EqualFold(result.Status, "success") { + return result, nil + } + + order, token, duplicate, err := uc.orders.BeginPaymentFulfillment(ctx, result.Provider, result.TradeNo, 10*time.Minute) + if err != nil { + uc.logger.Error(ctx, "payment.order.fulfillment_begin_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) + return fail(err) + } + if duplicate { + result.Duplicate = true + result.Status = "fulfilled" + return attachOrderResult(result, order), nil + } + handler := uc.fulfillments.Handler(order.BusinessType) + if handler == nil { + err = fmt.Errorf("支付发货 handler 未注册: %s", order.BusinessType) + _, _ = uc.orders.CompletePaymentFulfillment(ctx, result.Provider, result.TradeNo, token, false, err.Error()) + uc.logger.Error(ctx, "payment.order.fulfillment_handler_missing", "provider", result.Provider, "trade_no", result.TradeNo, "business_type", order.BusinessType, "error", err) + return fail(err) + } + confirmation := &PaymentConfirmation{ + ID: order.ConfirmationID, Provider: order.Provider, TradeNo: order.TradeNo, + ProviderTradeNo: result.ProviderTradeNo, BusinessType: order.BusinessType, + BusinessID: order.BusinessID, Subject: order.Subject, Amount: order.Amount, + OriginalAmount: order.OriginalAmount, 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, + Payload: result.Payload, CreatedAt: time.Now().UTC(), + } + if err = handler.Fulfill(ctx, confirmation); err != nil { + _, _ = uc.orders.CompletePaymentFulfillment(ctx, result.Provider, result.TradeNo, token, false, err.Error()) + uc.logger.Error(ctx, "payment.order.fulfillment_failed", "provider", result.Provider, "trade_no", result.TradeNo, "confirmation_id", confirmation.ID, "error", err) + return fail(err) + } + order, err = uc.orders.CompletePaymentFulfillment(ctx, result.Provider, result.TradeNo, token, true, "") + if err != nil { + uc.logger.Error(ctx, "payment.order.fulfillment_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "confirmation_id", confirmation.ID, "error", err) + return fail(err) + } + result.Status = "fulfilled" + return attachOrderResult(result, order), nil +} + +func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) { + current, err := uc.orders.FindPaymentOrder(ctx, provider, tradeNo) + if err != nil { + return nil, err + } + handler := uc.fulfillments.Handler(current.BusinessType) + authorizer, ok := handler.(PaymentRefundAuthorizer) + if !ok { + return nil, fmt.Errorf("支付业务未实现退款授权: %s", current.BusinessType) + } + if err = authorizer.AuthorizeRefund(ctx, current, amount); err != nil { + return nil, err + } + order, token, err := uc.orders.BeginPaymentRefund(ctx, provider, tradeNo, amount, 10*time.Minute) + if err != nil { + return nil, err + } + if provider == PaymentInternal || current.PaymentMode == PaymentModeInternal { + processor, ok := handler.(PaymentInternalRefundProcessor) + if !ok { + _, _ = uc.orders.CompletePaymentRefundRequest(ctx, provider, tradeNo, token, false, "支付业务未实现内部退款") + return nil, fmt.Errorf("支付业务未实现内部退款: %s", current.BusinessType) + } + refund, refundErr := processor.RefundInternal(ctx, order, amount) + accepted := refundErr == nil && refund != nil && refund.Amount == amount && strings.EqualFold(refund.Currency, current.Currency) + message := "" + if refundErr != nil { + message = refundErr.Error() + } else if !accepted { + message = "内部退款结果无效" + } + if refundErr != nil && !errors.Is(refundErr, ErrPaymentOperationRejected) { + return nil, refundErr + } + if refundErr == nil && refund == nil { + return nil, errors.New("内部退款结果为空") + } + if _, err = uc.orders.CompletePaymentRefundRequest(ctx, provider, tradeNo, token, accepted, message); err != nil { + return nil, err + } + if !accepted { + return nil, errors.New(message) + } + order, err = uc.orders.ConfirmPaymentRefund(ctx, provider, tradeNo, order.RefundNo, amount, true, "") + if err != nil { + return nil, err + } + return attachOrderResult(&PaymentResult{Provider: provider, TradeNo: tradeNo, ProviderTradeNo: refund.RefundID, Amount: amount, Currency: current.Currency, Payload: refund.Payload, Status: "refunded"}, order), nil + } + result, providerErr := uc.repo.Refund(ctx, &PaymentRefundRequest{ + Provider: order.Provider, + TradeNo: order.TradeNo, + ProviderTradeNo: order.ProviderTradeNo, + QueryID: order.QueryID, + RefundNo: order.RefundNo, + Amount: amount, + TotalAmount: order.Amount, + Currency: order.Currency, + }) + if providerErr == nil { + providerErr = validatePaymentRefundResult(provider, tradeNo, result) + } + if providerErr != nil && !errors.Is(providerErr, ErrPaymentOperationRejected) { + // A timeout or malformed response cannot prove that the provider did not + // accept the refund. Keep the durable refund operation and its idempotency + // number; the lease expiry path will retry the same operation. + return nil, providerErr + } + accepted := providerErr == nil + message := "" + if providerErr != nil { + message = providerErr.Error() + } else if !accepted { + message = "支付平台未接受退款请求" + } + order, err = uc.orders.CompletePaymentRefundRequest(ctx, provider, tradeNo, token, accepted, message) + if err != nil { + return nil, err + } + if !accepted { + if providerErr != nil { + return nil, providerErr + } + return nil, errors.New(message) + } + result.Status = "refund_pending" + return attachOrderResult(result, order), nil +} + +// ConfirmRefund is called by a provider-specific refund notification or a +// reconciliation worker after the platform reports the final refund state. +func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*PaymentResult, error) { + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) || !validPaymentText(refundNo, 128) || amount <= 0 { + return nil, errors.New("退款确认参数无效") + } + order, err := uc.orders.ConfirmPaymentRefund(ctx, provider, tradeNo, refundNo, amount, success, message) + if err != nil { + return nil, err + } + result := &PaymentResult{Provider: provider, TradeNo: tradeNo, Status: "refund_failed"} + if success { + result.Status = "refunded" + } + return attachOrderResult(result, order), nil +} + +func paymentProviderUpdate(result *PaymentResult) *PaymentProviderUpdate { + if result == nil { + return &PaymentProviderUpdate{} + } + var payloadHash string + if len(result.Payload) > 0 { + sum := sha256.Sum256(result.Payload) + payloadHash = hex.EncodeToString(sum[:]) + } + return &PaymentProviderUpdate{ + Status: result.Status, ProviderStatus: result.Status, ProviderTradeNo: result.ProviderTradeNo, + QueryID: result.QueryID, Amount: result.Amount, PayerPaidAmount: result.PayerPaidAmount, + CashPaidAmount: result.CashPaidAmount, PointPaidAmount: result.PointPaidAmount, DiscountAmount: result.DiscountAmount, + ProviderDiscountAmount: result.ProviderDiscountAmount, MerchantDiscountAmount: result.MerchantDiscountAmount, + SettlementAmount: result.SettlementAmount, Currency: result.Currency, PayerCurrency: result.PayerCurrency, + AmountBreakdownKnown: result.AmountBreakdownKnown, + EventID: result.EventID, PayloadHash: payloadHash, CreatePayload: result.Payload, + } +} + +func paymentOrderFingerprint(req *PaymentRequest, extra json.RawMessage) string { + value, _ := json.Marshal(map[string]any{ + "provider": req.Provider, "trade_no": req.TradeNo, "business_type": req.BusinessType, + "business_id": req.BusinessID, "subject": req.Subject, "amount": req.Amount, + "original_amount": req.OriginalAmount, "payment_mode": defaultPaymentMode(req.Provider, req.PaymentMode), + "currency": strings.ToUpper(req.Currency), "extra": json.RawMessage(extra), + }) + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func paymentResultFromOrder(order *PaymentOrder) *PaymentResult { + if order == nil { + return nil + } + status := "pending" + switch order.PaymentStatus { + case PaymentStatusPaid: + status = "success" + case PaymentStatusPartiallyRefunded: + status = "partially_refunded" + case PaymentStatusRefunded: + status = "refunded" + case PaymentStatusFailed, PaymentStatusClosed: + status = "failed" + } + if order.FulfillmentStatus == FulfillmentStatusSucceeded && status == "success" { + status = "fulfilled" + } + amount := order.PaidAmount + if amount == 0 { + amount = order.Amount + } + return &PaymentResult{ + Provider: order.Provider, Status: status, TradeNo: order.TradeNo, + ProviderTradeNo: order.ProviderTradeNo, Amount: amount, 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, + Payload: append(json.RawMessage(nil), order.CreatePayload...), OrderStatus: order.PaymentStatus, + FulfillmentStatus: order.FulfillmentStatus, RefundStatus: order.RefundStatus, + } +} + +func attachOrderResult(result *PaymentResult, order *PaymentOrder) *PaymentResult { + if result == nil { + result = paymentResultFromOrder(order) + return result + } + if order == nil { + return result + } + result.OrderStatus = order.PaymentStatus + result.FulfillmentStatus = order.FulfillmentStatus + result.RefundStatus = order.RefundStatus + return result +} + +func validatePaymentQueryResult(provider, queryID string, result *PaymentResult) error { + if result == nil { + return errors.New("支付平台查单结果为空") + } + if result.Provider != provider || !validPaymentText(result.TradeNo, 128) { + return errors.New("支付平台查单渠道或商户订单号无效") + } + if result.QueryID != "" { + if result.QueryID != queryID { + return errors.New("支付平台查单键不匹配") + } + } else if result.TradeNo != queryID { + return errors.New("支付平台查单商户订单号不匹配") + } + switch strings.ToLower(strings.TrimSpace(result.Status)) { + case "success": + if !validPaymentText(result.ProviderTradeNo, 128) { + return errors.New("支付平台查单成功但缺少平台交易号") + } + if result.Amount <= 0 { + return errors.New("支付平台查单成功但金额无效") + } + if !validPaymentText(result.Currency, 16) { + return errors.New("支付平台查单成功但币种为空") + } + case "pending", "failed": + default: + return fmt.Errorf("支付平台查单返回未知状态: %s", result.Status) + } + return nil +} + +func validatePaymentCreateResult(req *PaymentRequest, result *PaymentResult) error { + if req == nil || result == nil { + return errors.New("支付平台下单结果为空") + } + if result.Provider != req.Provider || result.TradeNo != req.TradeNo { + return errors.New("支付平台下单结果渠道或商户订单号不匹配") + } + if result.ProviderTradeNo != "" && !validPaymentText(result.ProviderTradeNo, 128) { + return errors.New("支付平台交易号无效") + } + if result.QueryID != "" && !validPaymentText(result.QueryID, 128) { + return errors.New("支付平台查单键无效") + } + switch strings.ToLower(strings.TrimSpace(result.Status)) { + case "created", "client_pending", "pending", "processing", "success": + return nil + default: + return fmt.Errorf("支付平台下单返回未知状态: %s", result.Status) + } +} + +func validatePaymentRefundResult(provider, tradeNo string, result *PaymentResult) error { + if result == nil { + return errors.New("支付平台退款结果为空") + } + if result.Provider != provider || result.TradeNo != tradeNo { + return errors.New("支付平台退款结果渠道或商户订单号不匹配") + } + if result.ProviderTradeNo != "" && !validPaymentText(result.ProviderTradeNo, 128) { + return errors.New("支付平台退款交易号无效") + } + switch strings.ToLower(strings.TrimSpace(result.Status)) { + case "created", "pending", "processing", "success", "refunded": + return nil + case "failed": + return PaymentRejected(errors.New("支付平台拒绝退款请求")) + default: + return fmt.Errorf("支付平台退款返回未知状态: %s", result.Status) + } +} + +func validPaymentText(value string, maxBytes int) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed != value || len(value) > maxBytes { + return false + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func callbackResultError(result *PaymentResult, err error) error { + if err == nil { + return nil + } + if result == nil || result.FailureAck.StatusCode == 0 { + return err + } + return &PaymentCallbackError{Cause: err, Ack: result.FailureAck} +} + +func validatePaymentConfirmation(intent *PaymentIntent, provider, tradeNo string, result *PaymentResult) error { + if intent == nil || result == nil { + return errors.New("支付确认数据为空") + } + if !strings.EqualFold(intent.Provider, provider) || intent.TradeNo != tradeNo || intent.TradeNo == "" { + return errors.New("支付商户订单号校验失败") + } + if strings.ToLower(result.Status) != "success" || result.Provider != provider || result.TradeNo != tradeNo { + return errors.New("支付平台订单状态校验失败") + } + if result.ProviderTradeNo == "" || result.Amount <= 0 || intent.Amount <= 0 || result.Amount != intent.Amount { + return errors.New("支付金额校验失败") + } + if result.PayerPaidAmount < 0 || result.CashPaidAmount < 0 || result.PointPaidAmount < 0 || result.DiscountAmount < 0 || result.ProviderDiscountAmount < 0 || result.MerchantDiscountAmount < 0 || result.SettlementAmount < 0 { + return errors.New("支付优惠或实付金额校验失败") + } + if result.AmountBreakdownKnown { + if err := validatePaymentBreakdown(result); err != nil { + return err + } + } + if strings.TrimSpace(intent.Currency) == "" || strings.TrimSpace(result.Currency) == "" || !strings.EqualFold(intent.Currency, result.Currency) { + return errors.New("支付币种校验失败") + } + if strings.TrimSpace(intent.BusinessType) == "" || strings.TrimSpace(intent.BusinessID) == "" { + return errors.New("支付业务标识不完整") + } + return nil +} + +func validatePaymentBreakdown(result *PaymentResult) error { + if result == nil { + return errors.New("支付金额拆分为空") + } + payerCurrency := result.PayerCurrency + if payerCurrency == "" { + payerCurrency = result.Currency + } + if strings.EqualFold(payerCurrency, result.Currency) { + if result.PayerPaidAmount > result.Amount || result.DiscountAmount > result.Amount-result.PayerPaidAmount || result.PayerPaidAmount+result.DiscountAmount != result.Amount { + return errors.New("支付总额、实付和优惠金额不守恒") + } + if result.CashPaidAmount > result.PayerPaidAmount || result.PointPaidAmount > result.PayerPaidAmount-result.CashPaidAmount || result.CashPaidAmount+result.PointPaidAmount != result.PayerPaidAmount { + return errors.New("支付现金和积分金额不守恒") + } + } + if result.ProviderDiscountAmount > result.DiscountAmount || result.MerchantDiscountAmount > result.DiscountAmount-result.ProviderDiscountAmount { + return errors.New("支付优惠出资金额超过总优惠") + } + if result.SettlementAmount > result.Amount { + return errors.New("支付结算金额超过订单总额") + } + return nil +} + +func paymentConfirmationID(provider, tradeNo string) string { + return uuid.NewSHA1(uuid.Nil, []byte(provider+"\x00"+tradeNo)).String() +} + +func clonePaymentResult(result *PaymentResult) *PaymentResult { + if result == nil { + return nil + } + cloned := *result + cloned.Payload = append(json.RawMessage(nil), result.Payload...) + cloned.SuccessAck.Body = append([]byte(nil), result.SuccessAck.Body...) + cloned.FailureAck.Body = append([]byte(nil), result.FailureAck.Body...) + return &cloned +} diff --git a/internal/biz/payment_log.go b/internal/biz/payment_log.go new file mode 100644 index 0000000..59f281f --- /dev/null +++ b/internal/biz/payment_log.go @@ -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...) +} diff --git a/internal/biz/payment_order.go b/internal/biz/payment_order.go new file mode 100644 index 0000000..bdb5939 --- /dev/null +++ b/internal/biz/payment_order.go @@ -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 +} diff --git a/internal/biz/payment_test.go b/internal/biz/payment_test.go new file mode 100644 index 0000000..95698bf --- /dev/null +++ b/internal/biz/payment_test.go @@ -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 = ©Req + 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 = ©Order + 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 = ©Req + } + 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") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index 7bb6d5c..7afe6f2 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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 diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 8626b98..80f4ae8 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -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 } diff --git a/internal/data/payment.go b/internal/data/payment.go new file mode 100644 index 0000000..b4b65d5 --- /dev/null +++ b/internal/data/payment.go @@ -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 +} diff --git a/internal/data/payment/adapter.go b/internal/data/payment/adapter.go new file mode 100644 index 0000000..5218184 --- /dev/null +++ b/internal/data/payment/adapter.go @@ -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) + } +} diff --git a/internal/data/payment/adapter_test.go b/internal/data/payment/adapter_test.go new file mode 100644 index 0000000..29d6129 --- /dev/null +++ b/internal/data/payment/adapter_test.go @@ -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) + } + }) + } +} diff --git a/internal/data/payment/alipay.go b/internal/data/payment/alipay.go new file mode 100644 index 0000000..d776acd --- /dev/null +++ b/internal/data/payment/alipay.go @@ -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) == "" { + 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 + } +} diff --git a/internal/data/payment/allinpay.go b/internal/data/payment/allinpay.go new file mode 100644 index 0000000..784c10d --- /dev/null +++ b/internal/data/payment/allinpay.go @@ -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 验签器,请改用主动查单") +} diff --git a/internal/data/payment/apple.go b/internal/data/payment/apple.go new file mode 100644 index 0000000..7c64437 --- /dev/null +++ b/internal/data/payment/apple.go @@ -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 == "" || transactionID == "" || transactionID == "" { + 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"]) != "" { + status = "failed" + } + providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"])) + if providerTradeNo == "" || providerTradeNo == "" || providerTradeNo != transactionID { + return nil, errors.New("Apple 查询返回的 transactionId 不匹配") + } + tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"])) + if tradeNo == "" || tradeNo == "" { + 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 == "" { + 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 == "" { + 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 +} diff --git a/internal/data/payment/douyin.go b/internal/data/payment/douyin.go new file mode 100644 index 0000000..ae1387e --- /dev/null +++ b/internal/data/payment/douyin.go @@ -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) +} diff --git a/internal/data/payment/gopay.go b/internal/data/payment/gopay.go new file mode 100644 index 0000000..23fd7cf --- /dev/null +++ b/internal/data/payment/gopay.go @@ -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), "") +} diff --git a/internal/data/payment/gopay_helpers.go b/internal/data/payment/gopay_helpers.go new file mode 100644 index 0000000..3b71825 --- /dev/null +++ b/internal/data/payment/gopay_helpers.go @@ -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" + } +} diff --git a/internal/data/payment/gopay_test.go b/internal/data/payment/gopay_test.go new file mode 100644 index 0000000..5bbc635 --- /dev/null +++ b/internal/data/payment/gopay_test.go @@ -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"]) + } +} diff --git a/internal/data/payment/lakala.go b/internal/data/payment/lakala.go new file mode 100644 index 0000000..442dc41 --- /dev/null +++ b/internal/data/payment/lakala.go @@ -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 +} diff --git a/internal/data/payment/native_test.go b/internal/data/payment/native_test.go new file mode 100644 index 0000000..272573c --- /dev/null +++ b/internal/data/payment/native_test.go @@ -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) + } +} diff --git a/internal/data/payment/paypal.go b/internal/data/payment/paypal.go new file mode 100644 index 0000000..abe489c --- /dev/null +++ b/internal/data/payment/paypal.go @@ -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 != "" { + 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 +} diff --git a/internal/data/payment/paypal_test.go b/internal/data/payment/paypal_test.go new file mode 100644 index 0000000..d127c36 --- /dev/null +++ b/internal/data/payment/paypal_test.go @@ -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", + } +} diff --git a/internal/data/payment/qq.go b/internal/data/payment/qq.go new file mode 100644 index 0000000..1c3e79c --- /dev/null +++ b/internal/data/payment/qq.go @@ -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 +} diff --git a/internal/data/payment/result.go b/internal/data/payment/result.go new file mode 100644 index 0000000..9e284c3 --- /dev/null +++ b/internal/data/payment/result.go @@ -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)), "") { + 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 == "" { + return 0, false, nil + } + amount, err := parseDecimalAmount(value, fallback) + return amount, true, err +} diff --git a/internal/data/payment/saobei.go b/internal/data/payment/saobei.go new file mode 100644 index 0000000..c33e0f4 --- /dev/null +++ b/internal/data/payment/saobei.go @@ -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 验签器,请改用主动查单") +} diff --git a/internal/data/payment/vendor.go b/internal/data/payment/vendor.go new file mode 100644 index 0000000..93d6fd8 --- /dev/null +++ b/internal/data/payment/vendor.go @@ -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 +} diff --git a/internal/data/payment/wechat_v2.go b/internal/data/payment/wechat_v2.go new file mode 100644 index 0000000..e5d60e6 --- /dev/null +++ b/internal/data/payment/wechat_v2.go @@ -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 +} diff --git a/internal/data/payment/wechat_v3.go b/internal/data/payment/wechat_v3.go new file mode 100644 index 0000000..26bb1c2 --- /dev/null +++ b/internal/data/payment/wechat_v3.go @@ -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) == "" { + 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) +} diff --git a/internal/data/payment_helpers.go b/internal/data/payment_helpers.go new file mode 100644 index 0000000..5a9ca11 --- /dev/null +++ b/internal/data/payment_helpers.go @@ -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) +} diff --git a/internal/data/payment_native_test.go b/internal/data/payment_native_test.go new file mode 100644 index 0000000..65c7d7c --- /dev/null +++ b/internal/data/payment_native_test.go @@ -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) + } +} diff --git a/internal/data/payment_order.go b/internal/data/payment_order.go new file mode 100644 index 0000000..8fd5766 --- /dev/null +++ b/internal/data/payment_order.go @@ -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(¤t).Error + if err == nil { + return toBizPaymentOrder(¤t), 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(¤t).Error; lookupErr == nil { + return toBizPaymentOrder(¤t), 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 +} diff --git a/internal/data/payment_order_test.go b/internal/data/payment_order_test.go new file mode 100644 index 0000000..6e92eac --- /dev/null +++ b/internal/data/payment_order_test.go @@ -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) + } +} diff --git a/internal/data/storage/local.go b/internal/data/storage/local.go index 0cf09bc..dc04edf 100644 --- a/internal/data/storage/local.go +++ b/internal/data/storage/local.go @@ -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) { diff --git a/internal/server/gin.go b/internal/server/gin.go index e923f89..4c93457 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -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) diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index cf1bd2f..120eb53 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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 diff --git a/internal/server/handler/payment.go b/internal/server/handler/payment.go new file mode 100644 index 0000000..fa3a47b --- /dev/null +++ b/internal/server/handler/payment.go @@ -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) + } +} diff --git a/internal/server/handler/payment_test.go b/internal/server/handler/payment_test.go new file mode 100644 index 0000000..314c80b --- /dev/null +++ b/internal/server/handler/payment_test.go @@ -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") + } +} diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 9e65113..12a3427 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -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} } diff --git a/internal/server/router/payment.go b/internal/server/router/payment.go new file mode 100644 index 0000000..50de76d --- /dev/null +++ b/internal/server/router/payment.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go index ace3287..ce84f5a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/service/dto/payment.go b/internal/service/dto/payment.go new file mode 100644 index 0000000..7d33100 --- /dev/null +++ b/internal/service/dto/payment.go @@ -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"` +} diff --git a/internal/service/payment.go b/internal/service/payment.go new file mode 100644 index 0000000..3722391 --- /dev/null +++ b/internal/service/payment.go @@ -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) +} diff --git a/internal/service/service.go b/internal/service/service.go index d0fb462..832e825 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) diff --git a/pkg/osskit/compose.go b/pkg/osskit/compose.go new file mode 100644 index 0000000..42a9ad2 --- /dev/null +++ b/pkg/osskit/compose.go @@ -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 +} diff --git a/pkg/osskit/compose_test.go b/pkg/osskit/compose_test.go new file mode 100644 index 0000000..19a943c --- /dev/null +++ b/pkg/osskit/compose_test.go @@ -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) + } +} diff --git a/pkg/paymentkit/amount.go b/pkg/paymentkit/amount.go new file mode 100644 index 0000000..0f71ac5 --- /dev/null +++ b/pkg/paymentkit/amount.go @@ -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 +} diff --git a/pkg/paymentkit/amount_test.go b/pkg/paymentkit/amount_test.go new file mode 100644 index 0000000..5697861 --- /dev/null +++ b/pkg/paymentkit/amount_test.go @@ -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") + } +} diff --git a/pkg/paymentkit/json.go b/pkg/paymentkit/json.go new file mode 100644 index 0000000..98bdf0e --- /dev/null +++ b/pkg/paymentkit/json.go @@ -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 +} diff --git a/pkg/paymentkit/json_test.go b/pkg/paymentkit/json_test.go new file mode 100644 index 0000000..fcb0bc8 --- /dev/null +++ b/pkg/paymentkit/json_test.go @@ -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) + } +} diff --git a/pkg/paymentkit/signing.go b/pkg/paymentkit/signing.go new file mode 100644 index 0000000..0abb32e --- /dev/null +++ b/pkg/paymentkit/signing.go @@ -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 +} diff --git a/pkg/paymentkit/signing_test.go b/pkg/paymentkit/signing_test.go new file mode 100644 index 0000000..18793a2 --- /dev/null +++ b/pkg/paymentkit/signing_test.go @@ -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) + } +} diff --git a/pkg/paymentkit/status.go b/pkg/paymentkit/status.go new file mode 100644 index 0000000..8108b30 --- /dev/null +++ b/pkg/paymentkit/status.go @@ -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)), "", "")) +} + +func stringify(value any) string { + return fmt.Sprint(value) +} diff --git a/pkg/paymentkit/xml.go b/pkg/paymentkit/xml.go new file mode 100644 index 0000000..60dd4fd --- /dev/null +++ b/pkg/paymentkit/xml.go @@ -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("") + 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(">") + } + b.WriteString("") + return []byte(b.String()) +}