package service import ( "context" "crypto/hmac" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/sha512" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/model" ) type CheckoutResult struct { ProviderOrderID string CheckoutURL string } type PaymentWebhook struct { EventID string EventType string OrderNo string ProviderOrderID string TransactionID string Amount string Currency string Status string PayloadHash string } type StripeProvider struct { config config.StripeConfig client *http.Client } type BinancePayProvider struct { config config.BinancePayConfig client *http.Client } func NewStripeProvider(cfg config.StripeConfig) *StripeProvider { return &StripeProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}} } func NewBinancePayProvider(cfg config.BinancePayConfig) *BinancePayProvider { return &BinancePayProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}} } func (p *StripeProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL string) (*CheckoutResult, error) { if p.config.SecretKey == "" { return nil, errors.New("stripe is not configured") } values := url.Values{} values.Set("mode", "payment") values.Set("success_url", strings.TrimRight(webAppURL, "/")+"/?payment=success&order="+url.QueryEscape(order.OrderNo)) values.Set("cancel_url", strings.TrimRight(webAppURL, "/")+"/?payment=cancelled&order="+url.QueryEscape(order.OrderNo)) values.Set("customer_email", order.CustomerEmail) values.Set("line_items[0][quantity]", "1") values.Set("line_items[0][price_data][currency]", strings.ToLower(order.Currency)) values.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(order.Amount)) values.Set("line_items[0][price_data][product_data][name]", order.ProductName) values.Set("line_items[0][price_data][product_data][description]", "一次购买,永久解锁完整报告") values.Set("metadata[order_no]", order.OrderNo) values.Set("metadata[report_id]", order.ReportID) values.Set("metadata[user_id]", strconv.FormatUint(uint64(order.UserID), 10)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/v1/checkout/sessions", strings.NewReader(values.Encode())) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.config.SecretKey) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Idempotency-Key", "checkout-"+order.OrderNo) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("stripe create checkout failed: status=%d body=%s", resp.StatusCode, safeProviderError(body)) } var payload struct { ID string `json:"id"` URL string `json:"url"` } if err := json.Unmarshal(body, &payload); err != nil || payload.ID == "" || payload.URL == "" { return nil, errors.New("stripe returned an invalid checkout response") } return &CheckoutResult{ProviderOrderID: payload.ID, CheckoutURL: payload.URL}, nil } func (p *StripeProvider) ParseWebhook(raw []byte, signature string, now time.Time) (*PaymentWebhook, error) { if p.config.WebhookSecret == "" { return nil, errors.New("stripe webhook is not configured") } parts := strings.Split(signature, ",") var timestamp int64 var signatures []string for _, part := range parts { pair := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(pair) != 2 { continue } if pair[0] == "t" { timestamp, _ = strconv.ParseInt(pair[1], 10, 64) } if pair[0] == "v1" { signatures = append(signatures, pair[1]) } } if timestamp == 0 || len(signatures) == 0 || now.Sub(time.Unix(timestamp, 0)) > 5*time.Minute || time.Unix(timestamp, 0).Sub(now) > 5*time.Minute { return nil, errors.New("invalid stripe webhook timestamp") } mac := hmac.New(sha256.New, []byte(p.config.WebhookSecret)) mac.Write([]byte(strconv.FormatInt(timestamp, 10))) mac.Write([]byte(".")) mac.Write(raw) expected := hex.EncodeToString(mac.Sum(nil)) valid := false for _, candidate := range signatures { if hmac.Equal([]byte(expected), []byte(candidate)) { valid = true break } } if !valid { return nil, errors.New("invalid stripe webhook signature") } var event struct { ID string `json:"id"` Type string `json:"type"` Data struct { Object struct { ID string `json:"id"` PaymentIntent string `json:"payment_intent"` PaymentStatus string `json:"payment_status"` AmountTotal int64 `json:"amount_total"` Currency string `json:"currency"` Metadata map[string]string `json:"metadata"` } `json:"object"` } `json:"data"` } if err := json.Unmarshal(raw, &event); err != nil || event.ID == "" { return nil, errors.New("invalid stripe webhook body") } status := "ignored" switch event.Type { case "checkout.session.completed": if event.Data.Object.PaymentStatus == "paid" { status = "paid" } case "checkout.session.async_payment_succeeded": status = "paid" case "checkout.session.expired", "checkout.session.async_payment_failed": status = "cancelled" case "charge.refunded": status = "refunded" } return &PaymentWebhook{ EventID: event.ID, EventType: event.Type, OrderNo: event.Data.Object.Metadata["order_no"], ProviderOrderID: event.Data.Object.ID, TransactionID: event.Data.Object.PaymentIntent, Amount: strconv.FormatInt(event.Data.Object.AmountTotal, 10), Currency: strings.ToUpper(event.Data.Object.Currency), Status: status, PayloadHash: hashPayload(raw), }, nil } func (p *BinancePayProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL, publicBaseURL string) (*CheckoutResult, error) { if p.config.APIKey == "" || p.config.SecretKey == "" { return nil, errors.New("binance pay is not configured") } payload := map[string]interface{}{ "env": map[string]string{"terminalType": "WEB"}, "merchantTradeNo": order.OrderNo, "orderAmount": order.ProviderAmount, "currency": order.Currency, "goods": map[string]string{"goodsType": "02", "goodsCategory": "D000", "referenceGoodsId": order.ReportID, "goodsName": order.ProductName}, "returnUrl": strings.TrimRight(webAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo), "cancelUrl": strings.TrimRight(webAppURL, "/") + "/?payment=cancelled&order=" + url.QueryEscape(order.OrderNo), "webhookUrl": strings.TrimRight(publicBaseURL, "/") + "/api/commerce/webhooks/binance-pay", "orderExpireTime": time.Now().Add(30 * time.Minute).UnixMilli(), } body, err := json.Marshal(payload) if err != nil { return nil, err } timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { return nil, err } nonce := hex.EncodeToString(nonceBytes) mac := hmac.New(sha512.New, []byte(p.config.SecretKey)) mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n")) signature := strings.ToUpper(hex.EncodeToString(mac.Sum(nil))) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/binancepay/openapi/v2/order", strings.NewReader(string(body))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("BinancePay-Timestamp", timestamp) req.Header.Set("BinancePay-Nonce", nonce) req.Header.Set("BinancePay-Certificate-SN", p.config.APIKey) req.Header.Set("BinancePay-Signature", signature) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("binance pay create order failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody)) } var response struct { Status string `json:"status"` Code string `json:"code"` Data struct { PrepayID string `json:"prepayId"` UniversalURL string `json:"universalUrl"` CheckoutURL string `json:"checkoutUrl"` } `json:"data"` } if err := json.Unmarshal(responseBody, &response); err != nil || response.Status != "SUCCESS" || response.Data.PrepayID == "" { return nil, fmt.Errorf("binance pay rejected order: code=%s", response.Code) } checkoutURL := response.Data.CheckoutURL if checkoutURL == "" { checkoutURL = response.Data.UniversalURL } if checkoutURL == "" { return nil, errors.New("binance pay returned no checkout URL") } return &CheckoutResult{ProviderOrderID: response.Data.PrepayID, CheckoutURL: checkoutURL}, nil } func (p *BinancePayProvider) ParseWebhook(raw []byte, timestamp, nonce, signature string) (*PaymentWebhook, error) { if p.config.WebhookPublicKey == "" { return nil, errors.New("binance pay webhook public key is not configured") } block, _ := pem.Decode([]byte(strings.ReplaceAll(p.config.WebhookPublicKey, "\\n", "\n"))) if block == nil { return nil, errors.New("invalid binance pay webhook public key") } parsed, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { if certificate, certErr := x509.ParseCertificate(block.Bytes); certErr == nil { parsed = certificate.PublicKey } else if rsaKey, rsaErr := x509.ParsePKCS1PublicKey(block.Bytes); rsaErr == nil { parsed = rsaKey } else { return nil, err } } publicKey, ok := parsed.(*rsa.PublicKey) if !ok { return nil, errors.New("binance pay webhook key is not RSA") } decodedSignature, err := base64.StdEncoding.DecodeString(signature) if err != nil { return nil, errors.New("invalid binance pay webhook signature encoding") } payload := []byte(timestamp + "\n" + nonce + "\n" + string(raw) + "\n") digest := sha256.Sum256(payload) if err := rsa.VerifyPKCS1v15(publicKey, cryptoHashSHA256, digest[:], decodedSignature); err != nil { return nil, errors.New("invalid binance pay webhook signature") } var envelope struct { BizType string `json:"bizType"` BizIDStr string `json:"bizIdStr"` BizStatus string `json:"bizStatus"` Data string `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil || envelope.BizIDStr == "" { return nil, errors.New("invalid binance pay webhook body") } var data struct { MerchantTradeNo string `json:"merchantTradeNo"` TransactionID string `json:"transactionId"` TotalFee json.Number `json:"totalFee"` Currency string `json:"currency"` } decoder := json.NewDecoder(strings.NewReader(envelope.Data)) decoder.UseNumber() if err := decoder.Decode(&data); err != nil { return nil, errors.New("invalid binance pay webhook data") } status := "ignored" switch envelope.BizStatus { case "PAY_SUCCESS": status = "paid" case "PAY_CLOSED": status = "cancelled" case "REFUND_SUCCESS": status = "refunded" } return &PaymentWebhook{ EventID: envelope.BizIDStr + ":" + envelope.BizStatus, EventType: envelope.BizStatus, OrderNo: data.MerchantTradeNo, ProviderOrderID: envelope.BizIDStr, TransactionID: data.TransactionID, Amount: data.TotalFee.String(), Currency: strings.ToUpper(data.Currency), Status: status, PayloadHash: hashPayload(raw), }, nil } const cryptoHashSHA256 = 5 func hashPayload(raw []byte) string { digest := sha256.Sum256(raw) return hex.EncodeToString(digest[:]) } func safeProviderError(body []byte) string { text := strings.TrimSpace(string(body)) if len(text) > 500 { text = text[:500] } return text }