package payment import ( "context" "encoding/json" "errors" "fmt" bizpayment "kra/internal/biz/payment" "kra/internal/paymentkit" "strings" "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 *bizpayment.PaymentRequest, c map[string]any) (*bizpayment.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("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, err := douyinCreateMethod(req.Extra, c) if err != nil { return nil, err } switch tradeType { case "app": 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) } return douyinPrepayResult(client, text(c, "app_id"), req.TradeNo, rsp.Response.PrepayId, true) case "h5": rsp, callErr := client.H5Order(ctx, bm) if callErr != nil { return nil, callErr } if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success { return nil, douyinError(rsp) } if strings.TrimSpace(rsp.Response.H5Url) == "" { return nil, errors.New("抖音支付 H5 下单响应缺少 h5_url") } payload, _ := json.Marshal(rsp.Response) return &bizpayment.PaymentResult{Provider: bizpayment.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil case "native": 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) } if strings.TrimSpace(rsp.Response.CodeUrl) == "" { return nil, errors.New("抖音支付 Native 下单响应缺少 code_url") } payload, _ := json.Marshal(rsp.Response) return &bizpayment.PaymentResult{Provider: bizpayment.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil case "jsapi": 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) } return douyinPrepayResult(client, text(c, "app_id"), req.TradeNo, rsp.Response.PrepayId, false) default: return nil, fmt.Errorf("抖音支付不支持的下单方式: %s", tradeType) } } func douyinCreateMethod(extra, config map[string]any) (string, error) { value := firstAny(extra, "method", "pay_method", "trade_type", "pay_type", "channel") if value == "" { value = firstAny(config, "method", "pay_method", "trade_type", "pay_type", "channel") } normalized := paymentkit.NormalizePaymentMethod(value) switch normalized { case "", "jsapi", "js_api", "mini", "mini_program", "miniprogram", "applet": return "jsapi", nil case "app", "app_pay", "apporder": return "app", nil case "h5", "h5_pay": return "h5", nil case "native", "qr", "qrcode": return "native", nil default: return "", fmt.Errorf("抖音支付不支持的下单方式: %s", value) } } func douyinPrepayResult(client *douyin.Client, appID, tradeNo, prepayID string, app bool) (*bizpayment.PaymentResult, error) { if client == nil || strings.TrimSpace(prepayID) == "" { return nil, errors.New("抖音支付下单响应缺少 prepay_id") } var ( params any err error ) if app { params, err = client.PaySignOfApp(appID, prepayID) } else { params, err = client.PaySignOfJSAPI(appID, prepayID) } if err != nil { return nil, fmt.Errorf("生成抖音支付客户端调起参数: %w", err) } return &bizpayment.PaymentResult{ Provider: bizpayment.PaymentDouyin, Status: "client_pending", TradeNo: tradeNo, Payload: mustJSON(params), }, nil } func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*bizpayment.PaymentResult, error) { tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return nil, errors.New("抖音支付查单缺少 out_trade_no") } client, err := a.client(c) if err != nil { return nil, err } 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 if err = validateDouyinMerchantIdentity(order.Mchid, order.Appid, c, false); err != nil { return nil, err } payload := mustJSON(order) var signedBody []byte if rsp.SignInfo != nil { signedBody = []byte(rsp.SignInfo.SignBody) if json.Valid(signedBody) { payload = append([]byte(nil), signedBody...) } } result := &bizpayment.PaymentResult{Provider: bizpayment.PaymentDouyin, Status: normalizeDouyinState(order.TradeState), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: payload} if result.TradeNo == "" { result.TradeNo = tradeNo } else if result.TradeNo != tradeNo { return nil, errors.New("抖音支付查单响应的 out_trade_no 不匹配") } if err = populateDouyinPaymentAmount(result, order.Amount, signedBody); err != nil { return nil, err } return result, nil } func (a *douyinAdapter) Refund(ctx context.Context, req *bizpayment.PaymentRefundRequest, c map[string]any) (*bizpayment.PaymentResult, error) { if req == nil { return nil, errors.New("抖音支付退款请求为空") } tradeNo := strings.TrimSpace(req.TradeNo) if tradeNo == "" { return nil, errors.New("抖音支付退款请求缺少 out_trade_no") } refundNo := strings.TrimSpace(req.RefundNo) if refundNo == "" { return nil, errors.New("抖音支付退款请求缺少 out_refund_no") } if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { return nil, errors.New("抖音支付退款金额无效") } client, err := a.client(c) if err != nil { return nil, err } bm := gopay.BodyMap{} bm.Set("out_trade_no", tradeNo).Set("out_refund_no", refundNo) bm.SetBodyMap("amount", func(m gopay.BodyMap) { m.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency)) }) 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 refundID, err := validateDouyinRefundIdentity(r, tradeNo, refundNo) if err != nil { return nil, err } result := &bizpayment.PaymentResult{Provider: bizpayment.PaymentDouyin, Status: normalizeRefundState(r.Status), TradeNo: tradeNo, ProviderTradeNo: refundID, Payload: mustJSON(r)} if r.Amount == nil { return nil, errors.New("抖音支付退款响应缺少金额") } result.Amount = int64(r.Amount.Refund) result.Currency = strings.ToUpper(strings.TrimSpace(r.Amount.Currency)) if result.Amount != req.Amount { return nil, errors.New("抖音支付退款响应金额不匹配") } if result.Currency == "" { return nil, errors.New("抖音支付退款响应缺少币种") } if req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { return nil, errors.New("抖音支付退款响应币种不匹配") } return result, nil } func validateDouyinRefundIdentity(refund *douyin.Refund, tradeNo, refundNo string) (string, error) { if refund == nil { return "", errors.New("抖音支付退款响应缺少退款单") } tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return "", errors.New("抖音支付退款请求缺少 out_trade_no") } refundNo = strings.TrimSpace(refundNo) if refundNo == "" { return "", errors.New("抖音支付退款请求缺少 out_refund_no") } returnedTradeNo := strings.TrimSpace(refund.OutTradeNo) if returnedTradeNo == "" { return "", errors.New("抖音支付退款响应缺少 out_trade_no") } if returnedTradeNo != tradeNo { return "", errors.New("抖音支付退款响应的 out_trade_no 不匹配") } returnedRefundNo := strings.TrimSpace(refund.OutRefundNo) if returnedRefundNo == "" { return "", errors.New("抖音支付退款响应缺少 out_refund_no") } if returnedRefundNo != refundNo { return "", errors.New("抖音支付退款响应的 out_refund_no 不匹配") } refundID := strings.TrimSpace(refund.RefundId) if refundID == "" { return "", errors.New("抖音支付退款响应缺少 refund_id") } return refundID, nil } func (a *douyinAdapter) Callback(ctx context.Context, callback *bizpayment.PaymentCallback, c map[string]any) (*bizpayment.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 notify.Resource != nil && strings.TrimSpace(notify.Resource.Mchid) != "" { if err = validateDouyinMerchantIdentity(notify.Resource.Mchid, "", c, false); err != nil { return nil, err } } if strings.EqualFold(notify.EventType, douyin.EventTransactionSuccess) { pay, err := notify.DecryptPayCipherText(text(c, "api_key")) if err != nil { return nil, err } if err = validateDouyinMerchantIdentity(pay.Mchid, pay.Appid, c, true); err != nil { return nil, err } plain, err := douyinNotifyPlaintext(notify, text(c, "api_key")) if err != nil { return nil, err } result := &bizpayment.PaymentResult{Provider: bizpayment.PaymentDouyin, Status: normalizeDouyinState(pay.TradeState), TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: append([]byte(nil), plain...), EventID: notify.Id} if err = populateDouyinPaymentAmount(result, pay.Amount, plain); err != nil { return nil, err } return result, nil } if strings.HasPrefix(strings.ToUpper(notify.EventType), "REFUND.") { refund, err := notify.DecryptRefundCipherText(text(c, "api_key")) if err != nil { return nil, err } if strings.TrimSpace(refund.Mchid) != "" { if err = validateDouyinMerchantIdentity(refund.Mchid, "", c, false); err != nil { return nil, err } } status := "pending" if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusSuccess) { status = "success" } else if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusClosed) || strings.EqualFold(refund.RefundStatus, douyin.RefundStatusAbnormal) { status = "failed" } result := &bizpayment.PaymentResult{Provider: bizpayment.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) } func validateDouyinMerchantIdentity(merchantID, appID string, c map[string]any, requireAppID bool) error { merchantID = strings.TrimSpace(merchantID) expectedMerchantID := strings.TrimSpace(text(c, "merchant_id")) if merchantID == "" || expectedMerchantID == "" || merchantID != expectedMerchantID { return errors.New("抖音支付响应商户号不匹配") } appID = strings.TrimSpace(appID) expectedAppID := strings.TrimSpace(text(c, "app_id")) if requireAppID && appID == "" { return errors.New("抖音支付响应缺少 appid") } if appID != "" && (expectedAppID == "" || appID != expectedAppID) { return errors.New("抖音支付响应 appid 不匹配") } return nil } func populateDouyinPaymentAmount(result *bizpayment.PaymentResult, amount *douyin.AmountInfo, raw []byte) error { if result == nil || amount == nil { return nil } result.Amount = int64(amount.Total) result.Currency = strings.ToUpper(strings.TrimSpace(amount.Currency)) result.PayerCurrency = strings.ToUpper(strings.TrimSpace(amount.PayerCurrency)) if result.PayerCurrency == "" { result.PayerCurrency = result.Currency } var amountObject map[string]any if object := jsonObject(raw); object != nil { amountObject, _ = valueAtPath(object, "amount").(map[string]any) } payerTotal, present, err := parseJSONInteger(amountObject, "payer_total") if err != nil { return fmt.Errorf("解析抖音支付 amount.payer_total: %w", err) } if !present && len(raw) == 0 && amount.PayerTotal > 0 { payerTotal = int64(amount.PayerTotal) present = true } if !present { return nil } if payerTotal < 0 || payerTotal > result.Amount { return errors.New("抖音支付用户实付金额无效") } result.PayerPaidAmount = payerTotal result.CashPaidAmount = payerTotal result.DiscountAmount = result.Amount - payerTotal result.AmountBreakdownKnown = true return nil } func douyinNotifyPlaintext(notify *douyin.NotifyReq, apiKey string) ([]byte, error) { if notify == nil || notify.Resource == nil { return nil, errors.New("抖音支付回调缺少 resource") } return douyin.DecryptNotifyCipherTextToBytes( notify.Resource.Ciphertext, notify.Resource.Nonce, notify.Resource.AssociatedData, apiKey, ) }