Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

299
pkg/stripe/checkout_flow.go Normal file
View File

@@ -0,0 +1,299 @@
package stripe
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"gpt-plus/config"
"gpt-plus/pkg/captcha"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/provider/card"
)
// ErrNoCaptchaSolver indicates hCaptcha was triggered but no solver is configured.
var ErrNoCaptchaSolver = errors.New("payment requires hCaptcha challenge but no solver configured")
// PaymentFlowParams holds all parameters for the common payment retry loop.
type PaymentFlowParams struct {
Client *httpclient.Client
CheckoutResult *CheckoutResult
FirstCard *card.CardInfo // first card (already used for checkout creation)
CardProv card.CardProvider
Solver *captcha.Solver
StripeCfg config.StripeConfig
Fingerprint *Fingerprint
EmailAddr string
StripeJsID string
UserAgent string
StatusFn func(string, ...interface{})
MaxRetries int // default 20
CheckAmountFn func(amount int) error // optional: return error to reject amount (e.g. Plus $20 check)
}
// PaymentFlowResult holds the outcome of a successful payment flow.
type PaymentFlowResult struct {
ConfirmResult *ConfirmResult
CardInfo *card.CardInfo // the card that succeeded
}
// RunPaymentFlow executes the card retry + payment + captcha loop shared by Plus and Team flows.
//
// Internal flow (per card attempt):
// 1. GetCard (first attempt uses FirstCard, retries get new card)
// 2. InitCheckoutSession → init_checksum
// 3. UpdateCheckoutSession (billing address, tax recalculation)
// 4. CheckAmountFn (optional, e.g. $20 = no free trial for Plus)
// 5. First attempt only: send telemetry events + solve passive captcha
// 6. ConfirmPaymentDirect (with js_checksum, rv_timestamp, passive_captcha_token)
// 7. No captcha → success
// 8. hCaptcha triggered → solve → verify → success
// 9. Card error / captcha failure → continue with new card
func RunPaymentFlow(ctx context.Context, params *PaymentFlowParams) (*PaymentFlowResult, error) {
sf := func(format string, args ...interface{}) {
if params.StatusFn != nil {
params.StatusFn(format, args...)
}
}
maxRetries := params.MaxRetries
if maxRetries <= 0 {
maxRetries = 20
}
// Set solver status function for progress printing
if params.Solver != nil {
params.Solver.SetStatusFn(params.StatusFn)
}
cardInfo := params.FirstCard
var paymentSuccess bool
var lastErr error
var confirmResult *ConfirmResult
for attempt := 1; attempt <= maxRetries; attempt++ {
// Get new card for retries (first attempt uses FirstCard)
if attempt > 1 {
sf(" → 换卡重试 (%d/%d): 获取新卡片...", attempt, maxRetries)
var err error
cardInfo, err = params.CardProv.GetCard(ctx)
if err != nil {
return nil, fmt.Errorf("get card (attempt %d): %w", attempt, err)
}
}
sf(" → 当前卡片 [%d/%d]: %s | %s/%s | %s | %s %s %s %s",
attempt, maxRetries, cardInfo.Number, cardInfo.ExpMonth, cardInfo.ExpYear,
cardInfo.Country, cardInfo.Address, cardInfo.City, cardInfo.State, cardInfo.PostalCode)
expectedAmount := params.CheckoutResult.ExpectedAmount
// Step 1: Init checkout session (get init_checksum), with network retry
sf(" → 初始化 Checkout 会话...")
var initResult *InitResult
var initErr error
for initTry := 1; initTry <= 3; initTry++ {
initResult, initErr = InitCheckoutSession(
params.Client,
params.CheckoutResult.CheckoutSessionID,
params.CheckoutResult.PublishableKey,
params.StripeCfg.StripeVersion,
params.StripeJsID,
params.UserAgent,
cardInfo.Country,
)
if initErr == nil {
break
}
if initTry < 3 {
sf(" → 初始化失败 (%d/3)%d秒后重试: %v", initTry, initTry*2, initErr)
log.Printf("[payment-flow] init attempt %d failed: %v, retrying...", initTry, initErr)
time.Sleep(time.Duration(initTry*2) * time.Second)
}
}
if initErr != nil {
return nil, fmt.Errorf("init checkout session (3 attempts): %w", initErr)
}
log.Printf("[payment-flow] init done, checksum=%s", initResult.InitChecksum)
// Step 2: Update checkout session with billing address (tax recalculation), with retry
sf(" → 更新账单地址 (税费计算)...")
var updateResult *UpdateResult
var updateErr error
for updTry := 1; updTry <= 3; updTry++ {
updateResult, updateErr = UpdateCheckoutSession(params.Client, &UpdateCheckoutParams{
CheckoutSessionID: params.CheckoutResult.CheckoutSessionID,
PublishableKey: params.CheckoutResult.PublishableKey,
StripeVersion: params.StripeCfg.StripeVersion,
StripeJsID: params.StripeJsID,
BillingCountry: cardInfo.Country,
BillingAddress: cardInfo.Address,
BillingCity: cardInfo.City,
BillingState: cardInfo.State,
BillingZip: cardInfo.PostalCode,
UserAgent: params.UserAgent,
})
if updateErr == nil {
break
}
if updTry < 3 {
sf(" → 更新地址失败 (%d/3),重试: %v", updTry, updateErr)
time.Sleep(time.Duration(updTry*2) * time.Second)
}
}
if updateErr != nil {
return nil, fmt.Errorf("update checkout session (3 attempts): %w", updateErr)
}
if updateResult.UpdatedAmount > 0 {
log.Printf("[payment-flow] amount updated: %d -> %d (tax recalculated)", expectedAmount, updateResult.UpdatedAmount)
expectedAmount = updateResult.UpdatedAmount
}
// Step 3: Check amount (optional callback, e.g. Plus $20 = no free trial)
if params.CheckAmountFn != nil {
if err := params.CheckAmountFn(expectedAmount); err != nil {
return nil, err
}
}
// Step 4: Confirm payment with inline card data
sf(" → 确认支付 (金额=$%.2f)...", float64(expectedAmount)/100)
var confirmErr error
confirmResult, confirmErr = ConfirmPaymentDirect(params.Client, &DirectConfirmParams{
CheckoutSessionID: params.CheckoutResult.CheckoutSessionID,
CardNumber: cardInfo.Number,
CardCVC: cardInfo.CVC,
CardExpMonth: cardInfo.ExpMonth,
CardExpYear: cardInfo.ExpYear,
BillingName: cardInfo.Name,
BillingEmail: params.EmailAddr,
BillingCountry: cardInfo.Country,
BillingAddress: cardInfo.Address,
BillingCity: cardInfo.City,
BillingState: cardInfo.State,
BillingZip: cardInfo.PostalCode,
GUID: params.Fingerprint.GUID,
MUID: params.Fingerprint.MUID,
SID: params.Fingerprint.SID,
ExpectedAmount: expectedAmount,
InitChecksum: initResult.InitChecksum,
PublishableKey: params.CheckoutResult.PublishableKey,
BuildHash: params.StripeCfg.BuildHash,
StripeVersion: params.StripeCfg.StripeVersion,
StripeJsID: params.StripeJsID,
UserAgent: params.UserAgent,
})
if confirmErr != nil {
errMsg := confirmErr.Error()
if flowIsCardError(errMsg) {
sf(" ⚠ 卡号被拒 (%d/%d): %s", attempt, maxRetries, errMsg)
params.CardProv.ReportResult(ctx, cardInfo, false)
log.Printf("[payment-flow] card rejected (attempt %d/%d): %v", attempt, maxRetries, confirmErr)
lastErr = confirmErr
time.Sleep(1 * time.Second)
continue // retry with new card
}
return nil, fmt.Errorf("confirm payment: %w", confirmErr)
}
// Step 6: Payment confirmed — check if captcha challenge required
if !confirmResult.RequiresAction {
sf(" → 卡号 ...%s 支付成功 (第 %d 次尝试, 无验证码)", flowLast4(cardInfo.Number), attempt)
paymentSuccess = true
break
}
// Step 7: hCaptcha challenge triggered
if params.Solver == nil {
return nil, ErrNoCaptchaSolver
}
sf(" → 触发 hCaptcha 验证码,正在解决...")
captchaToken, captchaEKey, solveErr := params.Solver.SolveHCaptcha(ctx,
confirmResult.SiteKey,
"https://b.stripecdn.com",
confirmResult.RqData,
)
if solveErr != nil {
// Solver failure = skip this activation, not a card issue
sf(" ⚠ 验证码解决失败,跳过: %v", solveErr)
return nil, fmt.Errorf("captcha solve failed: %w", solveErr)
}
sf(" → hCaptcha 已解决,验证中...")
verifyErr := VerifyChallenge(params.Client, &VerifyChallengeParams{
SetupIntentID: confirmResult.SetupIntentID,
ClientSecret: confirmResult.ClientSecret,
CaptchaToken: captchaToken,
CaptchaEKey: captchaEKey,
PublishableKey: params.CheckoutResult.PublishableKey,
StripeVersion: params.StripeCfg.StripeVersion,
UserAgent: params.UserAgent,
MUID: params.Fingerprint.MUID,
SID: params.Fingerprint.SID,
})
if verifyErr != nil {
verifyMsg := verifyErr.Error()
log.Printf("[payment-flow] captcha verify failed (attempt %d/%d): %v", attempt, maxRetries, verifyErr)
// Card decline after captcha → reject card, switch card, retry
if flowIsCardError(verifyMsg) {
sf(" ⚠ 验证码后卡被拒 (%d/%d): %v", attempt, maxRetries, verifyErr)
params.CardProv.ReportResult(ctx, cardInfo, false)
lastErr = verifyErr
time.Sleep(1 * time.Second)
continue // retry with new card
}
// Non-card failure (e.g. "Captcha challenge failed") → skip activation
sf(" ⚠ 验证码验证失败,跳过: %v", verifyErr)
return nil, fmt.Errorf("captcha verify failed: %w", verifyErr)
}
sf(" → 卡号 ...%s 支付+验证码通过 (第 %d 次尝试)", flowLast4(cardInfo.Number), attempt)
paymentSuccess = true
break
}
if !paymentSuccess {
return nil, fmt.Errorf("all %d payment attempts failed: %w", maxRetries, lastErr)
}
params.CardProv.ReportResult(ctx, cardInfo, true)
return &PaymentFlowResult{
ConfirmResult: confirmResult,
CardInfo: cardInfo,
}, nil
}
// flowIsCardError checks if the error message indicates a Stripe card decline that warrants switching cards.
func flowIsCardError(errMsg string) bool {
cardErrors := []string{
"card_declined",
"incorrect_number",
"invalid_number",
"invalid_expiry",
"invalid_cvc",
"expired_card",
"processing_error",
"type=card_error",
"requires new payment method",
"Your card number is incorrect",
"Your card was declined",
}
for _, e := range cardErrors {
if strings.Contains(errMsg, e) {
return true
}
}
return false
}
// flowLast4 returns the last 4 characters of a string.
func flowLast4(s string) string {
if len(s) <= 4 {
return s
}
return s[len(s)-4:]
}