Initial sanitized code sync
This commit is contained in:
350
pkg/provider/card/db.go
Normal file
350
pkg/provider/card/db.go
Normal file
@@ -0,0 +1,350 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user