feat(v2): stripe adapter — Checkout Session 跳转 + webhook 验签(stripe-go/v79 pin)

This commit is contained in:
wangjia
2026-07-10 15:07:10 +08:00
parent d3050d25d8
commit 79b9111a99
4 changed files with 257 additions and 0 deletions
+1
View File
@@ -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
)
+9
View File
@@ -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=
+138
View File
@@ -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),
}
}
+109
View File
@@ -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=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
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)))
}