Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理 - 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode - 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/* - 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件 - 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例 - 新增 AGENTS.md 项目代理指令文件 - 新增 REMINDER.md 商业化功能开发规范 - 修复 JWT 解析:添加 WithValidMethods 限制 HS256 - 修复 GetPayStatus:增加用户权限校验 - 更新 .gitignore 和 README.md
337 lines
12 KiB
Go
337 lines
12 KiB
Go
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
|
|
}
|