From 79b9111a99904adceb42bcb3c109687a1fe547f6 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 15:07:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2):=20stripe=20adapter=20=E2=80=94=20Chec?= =?UTF-8?q?kout=20Session=20=E8=B7=B3=E8=BD=AC=20+=20webhook=20=E9=AA=8C?= =?UTF-8?q?=E7=AD=BE(stripe-go/v79=20pin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 9 ++ internal/provider/stripe/stripe.go | 138 ++++++++++++++++++++++++ internal/provider/stripe/stripe_test.go | 109 +++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 internal/provider/stripe/stripe.go create mode 100644 internal/provider/stripe/stripe_test.go diff --git a/go.mod b/go.mod index cb9a790..3dc94b0 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartwalle/alipay/v3 v3.2.29 github.com/spf13/viper v1.21.0 + github.com/stripe/stripe-go/v79 v79.12.0 github.com/wechatpay-apiv3/wechatpay-go v0.2.21 gorm.io/gorm v1.31.1 ) diff --git a/go.sum b/go.sum index 3ce3e8f..fbb913e 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -116,6 +117,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v79 v79.12.0 h1:HQs/kxNEB3gYA7FnkSFkp0kSOeez0fsmCWev6SxftYs= +github.com/stripe/stripe-go/v79 v79.12.0/go.mod h1:cuH6X0zC8peY6f1AubHwgJ/fJSn2dh5pfiCr6CjyKVU= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -134,13 +137,19 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go new file mode 100644 index 0000000..bc80c30 --- /dev/null +++ b/internal/provider/stripe/stripe.go @@ -0,0 +1,138 @@ +// Package stripe adapts Stripe Checkout to provider.Provider: Create → hosted +// Checkout Session (redirect), VerifyCallback → webhook signature verify, Query → +// session lookup. The *client.API + webhook secret are injected at assembly (default +// backend in prod; httptest backend in tests) so nothing hits the real network in CI. +package stripe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + "github.com/stripe/stripe-go/v79/webhook" + + "github.com/wangjia/pay/internal/provider" +) + +// USD 是 Stripe 最小单位(cent)= money 包的 USD minor(均为 1e-2),两边天然对齐, +// Create/Query/VerifyCallback 全程直传 int64 分,不经 money.Parse/Format。 +// +// 零小数币种注意(如 JPY/KRW):Stripe 对这类币种的"最小单位"就是整数主单位本身(无 cent +// 概念),若未来扩展这类币种,不能再假设 AmountMinor 与 Stripe 金额 1:1——当前仅支持 +// USD,不涉及该分支,留此注释供后续扩展参考。 +const supportedCurrency = "USD" + +type Provider struct { + sc *client.API + webhookSecret string +} + +// New 装配期注入已配置好 backend(生产走默认;测试注入指向 httptest 的 backend)的 +// *client.API,以及来自 env(CredentialEnvPrefix)的 webhook 签名密钥。 +func New(sc *client.API, webhookSecret string) *Provider { + return &Provider{sc: sc, webhookSecret: webhookSecret} +} + +func (p *Provider) Method() string { return "stripe" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRefund: false, // P4 + SettleCurrencies: []string{supportedCurrency}, + Regions: []string{"global"}, + } +} + +func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != supportedCurrency { + return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency) + } + params := &gostripe.CheckoutSessionParams{ + Mode: gostripe.String(string(gostripe.CheckoutSessionModePayment)), + SuccessURL: gostripe.String(req.ReturnURL), + ClientReferenceID: gostripe.String(req.OutTradeNo), + LineItems: []*gostripe.CheckoutSessionLineItemParams{{ + Quantity: gostripe.Int64(1), + PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{ + Currency: gostripe.String(strings.ToLower(supportedCurrency)), + UnitAmount: gostripe.Int64(req.AmountMinor), // cent = USD minor,直传 + ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{ + Name: gostripe.String(req.Subject), + }, + }, + }}, + } + sess, err := p.sc.CheckoutSessions.New(params) + if err != nil { + return nil, fmt.Errorf("stripe: 创建 Checkout Session 失败: %w", err) + } + return &provider.Session{ + ProviderRef: sess.ID, + RenderType: provider.RenderRedirect, + Payload: map[string]any{"url": sess.URL}, + }, nil +} + +func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + sig := in.Headers["Stripe-Signature"] + // stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量 + // stripe.APIVersion,但 webhook 端点的 API 版本是在 Stripe Dashboard 独立配置的, + // 与所拉取的 SDK 版本不必一致——我们又不依赖 SDK 按版本反序列化(下面对 + // event.Data.Raw 自己 json.Unmarshal 成 CheckoutSession,不吃 SDK 的类型化解码), + // 所以显式 IgnoreAPIVersionMismatch:true,避免把"版本不同"误判成"验签失败"。 + // 时间容差不受影响:Tolerance 留零值,constructEvent 内部仍回退到 DefaultTolerance + // (5 分钟),签名 HMAC 校验本身完全不受此 flag 影响。 + event, err := webhook.ConstructEventWithOptions(in.Raw, sig, p.webhookSecret, + webhook.ConstructEventOptions{IgnoreAPIVersionMismatch: true}) + if err != nil { + return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err) + } + if event.Type != "checkout.session.completed" { + // 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。 + return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil + } + var sess gostripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { + return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err) + } + ev := sessionToEvent(&sess, in.Raw) + if event.Created > 0 { + paidAt := unixToTime(event.Created) + ev.PaidAt = &paidAt + } + return ev, nil +} + +func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + sess, err := p.sc.CheckoutSessions.Get(req.ProviderRef, nil) + if err != nil { + return nil, fmt.Errorf("stripe: 查询 session 失败: %w", err) + } + ev := sessionToEvent(sess, nil) + ev.ProviderRef = req.ProviderRef + return ev, nil +} + +func unixToTime(sec int64) time.Time { return time.Unix(sec, 0).UTC() } + +func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEvent { + status := provider.PaidPending + switch sess.PaymentStatus { + case gostripe.CheckoutSessionPaymentStatusPaid, gostripe.CheckoutSessionPaymentStatusNoPaymentRequired: + status = provider.PaidSucceeded + case gostripe.CheckoutSessionPaymentStatusUnpaid: + status = provider.PaidPending + } + return &provider.PaidEvent{ + ProviderRef: sess.ID, + Status: status, + PaidAmountMinor: sess.AmountTotal, // cent + PaidCurrency: strings.ToUpper(string(sess.Currency)), + Raw: string(raw), + } +} diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go new file mode 100644 index 0000000..1ec09e5 --- /dev/null +++ b/internal/provider/stripe/stripe_test.go @@ -0,0 +1,109 @@ +package stripe_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + gostripe "github.com/stripe/stripe-go/v79" + "github.com/stripe/stripe-go/v79/client" + + "github.com/wangjia/pay/internal/provider" + st "github.com/wangjia/pay/internal/provider/stripe" +) + +const whSecret = "whsec_test_secret" + +func fakeStripeAPI(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"): + // 创建 session + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"): + // 查询 session — 已付 + fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`) + default: + http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) + } + })) +} + +func newStripe(t *testing.T, ts *httptest.Server) *st.Provider { + backends := &gostripe.Backends{ + API: gostripe.GetBackendWithConfig(gostripe.APIBackend, &gostripe.BackendConfig{ + URL: gostripe.String(ts.URL), + }), + } + sc := client.New("sk_test_x", backends) + return st.New(sc, whSecret) +} + +func TestCreateCheckoutRedirect(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD", + ReturnURL: "https://x/return", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_test_123" { + t.Fatalf("session = %+v", sess) + } + if !strings.Contains(sess.Payload["url"].(string), "cs_test_123") { + t.Fatalf("url = %v", sess.Payload["url"]) + } +} + +func TestQueryPaid(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + ev, err := p.Query(context.Background(), provider.QueryRequest{ProviderRef: "cs_test_123", Currency: "USD"}) + if err != nil { + t.Fatalf("query: %v", err) + } + if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" { + t.Fatalf("event = %+v", ev) + } +} + +func TestVerifyWebhook(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}}}` + sig := signStripe(payload, whSecret, time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.ProviderRef != "cs_test_123" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 2999 { + t.Fatalf("event = %+v", ev) + } +} + +// signStripe 复刻 Stripe webhook 签名头: t=,v1=hex(HMAC-SHA256(secret, ".")) +func signStripe(payload, secret string, ts int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + fmt.Fprintf(mac, "%d.%s", ts, payload) + return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))) +}