351 lines
9.4 KiB
Go
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
|
|
}
|