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 }