Files
gpt-plus-gpt/pkg/provider/card/db.go
2026-03-15 20:48:19 +08:00

351 lines
9.4 KiB
Go

package card
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"time"
"gpt-plus/internal/db"
"gorm.io/gorm"
)
// DBCardProvider implements CardProvider backed by the SQLite database.
// It enforces a single-active-card policy: only one card has status=active at any time.
type DBCardProvider struct {
mu sync.Mutex
gormDB *gorm.DB
defaultName string
defaultCountry string
defaultCurrency string
defaultAddress string
defaultCity string
defaultState string
defaultPostalCode string
apiBaseURL string
}
type DBCardProviderConfig struct {
DB *gorm.DB
DefaultName string
DefaultCountry string
DefaultCurrency string
DefaultAddress string
DefaultCity string
DefaultState string
DefaultPostalCode string
APIBaseURL string
}
func NewDBCardProvider(cfg DBCardProviderConfig) *DBCardProvider {
if cfg.DefaultCountry == "" {
cfg.DefaultCountry = "US"
}
if cfg.DefaultCurrency == "" {
cfg.DefaultCurrency = "USD"
}
return &DBCardProvider{
gormDB: cfg.DB,
defaultName: cfg.DefaultName,
defaultCountry: cfg.DefaultCountry,
defaultCurrency: cfg.DefaultCurrency,
defaultAddress: cfg.DefaultAddress,
defaultCity: cfg.DefaultCity,
defaultState: cfg.DefaultState,
defaultPostalCode: cfg.DefaultPostalCode,
apiBaseURL: cfg.APIBaseURL,
}
}
const cardTTL = 1 * time.Hour
// GetCard returns the currently active card. If none is active, triggers auto-switch.
// If the active card has expired (>1h since activation), it is marked expired and switched.
func (p *DBCardProvider) GetCard(ctx context.Context) (*CardInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
var card db.Card
err := p.gormDB.Where("status = ?", "active").First(&card).Error
if err == nil {
// Check if the card has expired (API-sourced cards have 1h TTL)
if card.Source == "api" && card.ActivatedAt != nil && time.Since(*card.ActivatedAt) > cardTTL {
card.Status = "expired"
card.LastError = fmt.Sprintf("卡片已过期 (激活于 %s)", card.ActivatedAt.Format("15:04:05"))
p.gormDB.Save(&card)
log.Printf("[db-card] card ID=%d expired (activated %v ago)", card.ID, time.Since(*card.ActivatedAt).Round(time.Second))
if err := p.activateNext(); err != nil {
return nil, fmt.Errorf("卡片已过期且无可用替代: %w", err)
}
if err := p.gormDB.Where("status = ?", "active").First(&card).Error; err != nil {
return nil, fmt.Errorf("切换后仍找不到卡片: %w", err)
}
}
return p.toCardInfo(&card)
}
// No active card — try to activate next available
if err := p.activateNext(); err != nil {
return nil, fmt.Errorf("无可用卡片: %w", err)
}
if err := p.gormDB.Where("status = ?", "active").First(&card).Error; err != nil {
return nil, fmt.Errorf("激活后仍找不到卡片: %w", err)
}
return p.toCardInfo(&card)
}
// ReportResult updates the card status based on payment outcome.
func (p *DBCardProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error {
p.mu.Lock()
defer p.mu.Unlock()
hash := db.HashSHA256(card.Number)
var dbCard db.Card
if err := p.gormDB.Where("number_hash = ?", hash).First(&dbCard).Error; err != nil {
return fmt.Errorf("card not found in DB: %w", err)
}
now := time.Now()
dbCard.LastUsedAt = &now
if success {
dbCard.BindCount++
// Update bound accounts list
var bound []string
if dbCard.BoundAccounts != "" {
json.Unmarshal([]byte(dbCard.BoundAccounts), &bound)
}
boundJSON, _ := json.Marshal(bound)
dbCard.BoundAccounts = string(boundJSON)
// MaxBinds=0 means unlimited
if dbCard.MaxBinds > 0 && dbCard.BindCount >= dbCard.MaxBinds {
dbCard.Status = "exhausted"
log.Printf("[db-card] card *%s exhausted (%d/%d binds)", card.Number[len(card.Number)-4:], dbCard.BindCount, dbCard.MaxBinds)
p.gormDB.Save(&dbCard)
return p.activateNext()
}
p.gormDB.Save(&dbCard)
return nil
}
// Card rejected
dbCard.Status = "rejected"
dbCard.LastError = "Stripe declined"
p.gormDB.Save(&dbCard)
log.Printf("[db-card] card *%s rejected", card.Number[len(card.Number)-4:])
return p.activateNext()
}
// activateNext picks the next available card and sets it to active.
// If no available cards, tries to redeem a card code.
func (p *DBCardProvider) activateNext() error {
activate := func(c *db.Card) {
now := time.Now()
c.Status = "active"
c.ActivatedAt = &now
p.gormDB.Save(c)
log.Printf("[db-card] activated card ID=%d (source=%s)", c.ID, c.Source)
}
var next db.Card
err := p.gormDB.Where("status = ?", "available").Order("created_at ASC").First(&next).Error
if err == nil {
activate(&next)
return nil
}
// No available cards — try to redeem a card code
if p.apiBaseURL != "" {
if err := p.redeemNextCode(); err != nil {
log.Printf("[db-card] redeem failed: %v", err)
} else {
err = p.gormDB.Where("status = ?", "available").Order("created_at ASC").First(&next).Error
if err == nil {
activate(&next)
return nil
}
}
}
return fmt.Errorf("no available cards and no unused card codes")
}
// redeemNextCode finds the next unused card code and redeems it via the API.
func (p *DBCardProvider) redeemNextCode() error {
var code db.CardCode
if err := p.gormDB.Where("status = ?", "unused").Order("created_at ASC").First(&code).Error; err != nil {
return fmt.Errorf("no unused card codes")
}
code.Status = "redeeming"
p.gormDB.Save(&code)
// Use a unique cache path per code to avoid loading stale cards from shared cache
cachePath := fmt.Sprintf("card_cache_redeem_%d.json", code.ID)
apiProv, err := NewAPIProvider(APIProviderConfig{
BaseURL: p.apiBaseURL,
Codes: []string{code.Code},
DefaultName: p.defaultName,
DefaultCountry: p.defaultCountry,
DefaultCurrency: p.defaultCurrency,
DefaultAddress: p.defaultAddress,
DefaultCity: p.defaultCity,
DefaultState: p.defaultState,
DefaultPostalCode: p.defaultPostalCode,
PoolCfg: PoolConfig{MultiBind: true, MaxBinds: 999},
CachePath: cachePath,
})
if err != nil {
code.Status = "failed"
code.Error = err.Error()
p.gormDB.Save(&code)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cardInfo, err := apiProv.GetCard(ctx)
if err != nil {
code.Status = "failed"
code.Error = err.Error()
p.gormDB.Save(&code)
return err
}
// Clean up temp cache
os.Remove(cachePath)
// Check if card already exists in DB (dedup by number_hash)
hash := db.HashSHA256(cardInfo.Number)
var existing db.Card
if p.gormDB.Where("number_hash = ?", hash).First(&existing).Error == nil {
// Card already in DB — if it's exhausted/rejected/expired, reset to available
if existing.Status == "exhausted" || existing.Status == "rejected" || existing.Status == "expired" {
oldStatus := existing.Status
existing.Status = "available"
existing.BindCount = 0
existing.LastError = ""
existing.ActivatedAt = nil
p.gormDB.Save(&existing)
log.Printf("[db-card] reused existing card ID=%d (reset from %s to available)", existing.ID, oldStatus)
}
now := time.Now()
code.Status = "redeemed"
code.CardID = &existing.ID
code.RedeemedAt = &now
p.gormDB.Save(&code)
return nil
}
// Save as new card
newCard, err := p.saveCard(cardInfo, "api", &code.ID)
if err != nil {
code.Status = "failed"
code.Error = err.Error()
p.gormDB.Save(&code)
return err
}
now := time.Now()
code.Status = "redeemed"
code.CardID = &newCard.ID
code.RedeemedAt = &now
p.gormDB.Save(&code)
return nil
}
func (p *DBCardProvider) saveCard(info *CardInfo, source string, codeID *uint) (*db.Card, error) {
numberEnc, err := db.Encrypt(info.Number)
if err != nil {
return nil, fmt.Errorf("encrypt card number: %w", err)
}
cvcEnc, err := db.Encrypt(info.CVC)
if err != nil {
return nil, fmt.Errorf("encrypt cvc: %w", err)
}
maxBinds := -1
var cfg db.SystemConfig
if p.gormDB.Where("key = ?", "card.max_binds").First(&cfg).Error == nil {
fmt.Sscanf(cfg.Value, "%d", &maxBinds)
}
if maxBinds < 0 {
maxBinds = 0 // default: 0 = unlimited
}
card := &db.Card{
NumberHash: db.HashSHA256(info.Number),
NumberEnc: numberEnc,
CVCEnc: cvcEnc,
ExpMonth: info.ExpMonth,
ExpYear: info.ExpYear,
Name: info.Name,
Country: info.Country,
Address: info.Address,
City: info.City,
State: info.State,
PostalCode: info.PostalCode,
Source: source,
CardCodeID: codeID,
Status: "available",
MaxBinds: maxBinds,
}
if err := p.gormDB.Create(card).Error; err != nil {
return nil, err
}
return card, nil
}
func (p *DBCardProvider) toCardInfo(card *db.Card) (*CardInfo, error) {
number, err := db.Decrypt(card.NumberEnc)
if err != nil {
return nil, fmt.Errorf("decrypt card number: %w", err)
}
cvc, err := db.Decrypt(card.CVCEnc)
if err != nil {
return nil, fmt.Errorf("decrypt cvc: %w", err)
}
info := &CardInfo{
Number: number,
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
CVC: cvc,
Name: card.Name,
Country: card.Country,
Currency: p.defaultCurrency,
Address: card.Address,
City: card.City,
State: card.State,
PostalCode: card.PostalCode,
}
// Apply defaults
if info.Address == "" {
info.Address = p.defaultAddress
}
if info.City == "" {
info.City = p.defaultCity
}
if info.State == "" {
info.State = p.defaultState
}
if info.PostalCode == "" {
info.PostalCode = p.defaultPostalCode
}
return info, nil
}