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

655
pkg/provider/card/api.go Normal file
View File

@@ -0,0 +1,655 @@
package card
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
)
// APIProvider fetches cards from a redeem code platform (yyl.ncet.top).
// Flow: validate code → POST redeem → poll task-status until cards ready.
type APIProvider struct {
baseURL string
codes []string
defaultName string
defaultCountry string
defaultCurrency string
defaultAddress string
defaultCity string
defaultState string
defaultPostalCode string
cachePath string // local card cache file
httpClient *http.Client
pool *CardPool
mu sync.Mutex
usedIdx int // next code index to try
allFetched bool
}
// cachedCard is the JSON-serializable form of a cached card entry.
type cachedCard struct {
Card *CardInfo `json:"card"`
AddedAt time.Time `json:"added_at"`
BindCount int `json:"bind_count"`
Rejected bool `json:"rejected"`
}
// APIProviderConfig holds config for the API card provider.
type APIProviderConfig struct {
BaseURL string
Codes []string // redeem codes
CodesFile string // path to file with one code per line (alternative to Codes)
DefaultName string
DefaultCountry string
DefaultCurrency string
DefaultAddress string
DefaultCity string
DefaultState string
DefaultPostalCode string
PoolCfg PoolConfig
CachePath string // path to card cache file (default: card_cache.json)
}
// --- API response types ---
type validateResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Valid bool `json:"valid"`
RemainingQuantity int `json:"remainingQuantity"`
Quantity int `json:"quantity"`
ProductName string `json:"productName"`
IsUsed bool `json:"isUsed"`
} `json:"data"`
}
type redeemRequest struct {
Code string `json:"code"`
ContactEmail string `json:"contactEmail"`
VisitorID string `json:"visitorId"`
Quantity int `json:"quantity"`
}
type redeemResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
OrderNo string `json:"orderNo"`
IsAsync bool `json:"isAsync"`
TaskID string `json:"taskId"`
Cards []redeemCard `json:"cards"`
CardTemplate string `json:"cardTemplate"`
DeliveryStatus int `json:"deliveryStatus"`
} `json:"data"`
}
type taskStatusResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
OrderNo string `json:"orderNo"`
Status int `json:"status"` // 1=processing, 2=done
Cards []redeemCard `json:"cards"`
CardTemplate string `json:"cardTemplate"`
Progress string `json:"progress"`
TaskID string `json:"taskId"`
} `json:"data"`
}
type redeemCard struct {
ID int `json:"id"`
CardNumber string `json:"cardNumber"`
CardPassword string `json:"cardPassword"`
CardData string `json:"cardData"` // JSON string
Remark string `json:"remark"`
}
type cardDataJSON struct {
CVV string `json:"cvv"`
ExpireTime string `json:"expireTime"` // ISO timestamp for card validity
Expiry string `json:"expiry"` // MMYY format
Nickname string `json:"nickname"`
Limit int `json:"limit"`
}
// NewAPIProvider creates an API-based card provider.
func NewAPIProvider(cfg APIProviderConfig) (*APIProvider, error) {
codes := cfg.Codes
// Load codes from file if specified
if cfg.CodesFile != "" && len(codes) == 0 {
fileCodes, err := loadCodesFromFile(cfg.CodesFile)
if err != nil {
return nil, fmt.Errorf("load codes file: %w", err)
}
codes = fileCodes
}
if len(codes) == 0 {
return nil, fmt.Errorf("api card provider: no redeem codes configured")
}
if cfg.DefaultCountry == "" {
cfg.DefaultCountry = "US"
}
if cfg.DefaultCurrency == "" {
cfg.DefaultCurrency = "USD"
}
pool := NewCardPool(cfg.PoolCfg)
cachePath := cfg.CachePath
if cachePath == "" {
cachePath = "card_cache.json"
}
p := &APIProvider{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
codes: codes,
defaultName: cfg.DefaultName,
defaultCountry: cfg.DefaultCountry,
defaultCurrency: cfg.DefaultCurrency,
defaultAddress: cfg.DefaultAddress,
defaultCity: cfg.DefaultCity,
defaultState: cfg.DefaultState,
defaultPostalCode: cfg.DefaultPostalCode,
cachePath: cachePath,
httpClient: &http.Client{Timeout: 20 * time.Second},
pool: pool,
}
// Try to load cached cards from disk
p.loadCachedCards()
return p, nil
}
// GetCard returns a card, fetching from API if the pool is empty.
func (p *APIProvider) GetCard(ctx context.Context) (*CardInfo, error) {
// Try pool first
card, err := p.pool.GetCard()
if err == nil {
return card, nil
}
// Pool empty or exhausted, try fetching more cards
if fetchErr := p.fetchNextCode(ctx); fetchErr != nil {
return nil, fmt.Errorf("no cards available and fetch failed: %w (pool: %v)", fetchErr, err)
}
return p.pool.GetCard()
}
// ReportResult reports the usage outcome.
func (p *APIProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error {
p.pool.ReportResult(card, success)
p.saveCachedCards() // persist bindCount/rejected to disk
return nil
}
// fetchNextCode tries the next unused redeem code through the full 3-step flow.
func (p *APIProvider) fetchNextCode(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.allFetched {
return fmt.Errorf("all redeem codes exhausted")
}
for p.usedIdx < len(p.codes) {
code := p.codes[p.usedIdx]
p.usedIdx++
cards, tmpl, err := p.redeemFullFlow(ctx, code)
if err != nil {
log.Printf("[card-api] code %s failed: %v", maskCode(code), err)
continue
}
if len(cards) > 0 {
parsed := p.parseCards(cards, tmpl)
if len(parsed) > 0 {
p.pool.AddCards(parsed)
log.Printf("[card-api] redeemed code %s: got %d card(s)", maskCode(code), len(parsed))
p.saveCachedCards() // persist to disk
return nil
}
}
log.Printf("[card-api] code %s: no valid cards returned", maskCode(code))
}
p.allFetched = true
return fmt.Errorf("all %d redeem codes exhausted", len(p.codes))
}
// redeemFullFlow performs the complete 3-step redeem flow:
// 1. Validate code
// 2. POST redeem
// 3. Poll task-status until done
func (p *APIProvider) redeemFullFlow(ctx context.Context, code string) ([]redeemCard, string, error) {
// Step 1: Validate
log.Printf("[card-api] step 1: validating code %s", maskCode(code))
valid, err := p.validateCode(ctx, code)
if err != nil {
return nil, "", fmt.Errorf("validate: %w", err)
}
if !valid.Data.Valid {
return nil, "", fmt.Errorf("code invalid: %s (isUsed=%v, remaining=%d)",
valid.Message, valid.Data.IsUsed, valid.Data.RemainingQuantity)
}
log.Printf("[card-api] code valid: product=%s, quantity=%d", valid.Data.ProductName, valid.Data.Quantity)
// Step 2: Redeem
log.Printf("[card-api] step 2: submitting redeem for code %s", maskCode(code))
redeemResp, err := p.submitRedeem(ctx, code, valid.Data.Quantity)
if err != nil {
return nil, "", fmt.Errorf("redeem: %w", err)
}
// If not async, cards may be returned directly
if !redeemResp.Data.IsAsync && len(redeemResp.Data.Cards) > 0 {
log.Printf("[card-api] sync redeem: got %d card(s) directly", len(redeemResp.Data.Cards))
return redeemResp.Data.Cards, redeemResp.Data.CardTemplate, nil
}
if redeemResp.Data.TaskID == "" {
return nil, "", fmt.Errorf("redeem returned no taskId and no cards")
}
// Step 3: Poll task status
log.Printf("[card-api] step 3: polling task %s", redeemResp.Data.TaskID)
cards, tmpl, err := p.pollTaskStatus(ctx, redeemResp.Data.TaskID)
if err != nil {
return nil, "", fmt.Errorf("poll task: %w", err)
}
return cards, tmpl, nil
}
// validateCode calls GET /shop/shop/redeem/validate?code={code}
func (p *APIProvider) validateCode(ctx context.Context, code string) (*validateResponse, error) {
url := fmt.Sprintf("%s/shop/shop/redeem/validate?code=%s", p.baseURL, code)
body, err := p.doGet(ctx, url)
if err != nil {
return nil, err
}
var result validateResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse validate response: %w", err)
}
if result.Code != 200 {
return nil, fmt.Errorf("validate API error %d: %s", result.Code, result.Message)
}
return &result, nil
}
// submitRedeem calls POST /shop/shop/redeem
func (p *APIProvider) submitRedeem(ctx context.Context, code string, quantity int) (*redeemResponse, error) {
if quantity <= 0 {
quantity = 1
}
reqBody := redeemRequest{
Code: code,
ContactEmail: "",
VisitorID: generateVisitorID(),
Quantity: quantity,
}
bodyBytes, _ := json.Marshal(reqBody)
url := p.baseURL + "/shop/shop/redeem"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("build redeem request: %w", err)
}
p.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("redeem request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read redeem response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("redeem returned %d: %s", resp.StatusCode, string(body))
}
var result redeemResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse redeem response: %w", err)
}
if result.Code != 200 {
return nil, fmt.Errorf("redeem API error %d: %s", result.Code, result.Message)
}
return &result, nil
}
// pollTaskStatus polls GET /shop/shop/redeem/task-status/{taskId} every 2s until status=2.
func (p *APIProvider) pollTaskStatus(ctx context.Context, taskID string) ([]redeemCard, string, error) {
url := fmt.Sprintf("%s/shop/shop/redeem/task-status/%s", p.baseURL, taskID)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
timeout := time.After(2 * time.Minute)
for attempt := 1; ; attempt++ {
body, err := p.doGet(ctx, url)
if err != nil {
log.Printf("[card-api] poll attempt %d error: %v", attempt, err)
} else {
var result taskStatusResponse
if err := json.Unmarshal(body, &result); err != nil {
log.Printf("[card-api] poll attempt %d parse error: %v", attempt, err)
} else if result.Code == 200 {
if result.Data.Status == 2 {
log.Printf("[card-api] task complete: %s, got %d card(s)",
result.Data.Progress, len(result.Data.Cards))
return result.Data.Cards, result.Data.CardTemplate, nil
}
log.Printf("[card-api] task in progress (attempt %d, status=%d)", attempt, result.Data.Status)
}
}
select {
case <-ctx.Done():
return nil, "", ctx.Err()
case <-timeout:
return nil, "", fmt.Errorf("task %s timed out after 2 minutes", taskID)
case <-ticker.C:
}
}
}
// --- card cache persistence ---
// loadCachedCards loads cards from the local cache file, skipping expired ones.
func (p *APIProvider) loadCachedCards() {
data, err := os.ReadFile(p.cachePath)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[card-cache] failed to read cache %s: %v", p.cachePath, err)
}
return
}
var cached []cachedCard
if err := json.Unmarshal(data, &cached); err != nil {
log.Printf("[card-cache] failed to parse cache: %v", err)
return
}
now := time.Now()
ttl := p.pool.ttl
var valid []*CardInfo
var validEntries []cachedCard
for _, entry := range cached {
if ttl > 0 && now.Sub(entry.AddedAt) > ttl {
log.Printf("[card-cache] skipping expired card %s...%s (age=%v)",
entry.Card.Number[:4], entry.Card.Number[len(entry.Card.Number)-4:], now.Sub(entry.AddedAt).Round(time.Second))
continue
}
// Apply default address fields to cached cards (same as parseCard)
c := entry.Card
if c.Address == "" && p.defaultAddress != "" {
c.Address = p.defaultAddress
}
if c.City == "" && p.defaultCity != "" {
c.City = p.defaultCity
}
if c.State == "" && p.defaultState != "" {
c.State = p.defaultState
}
if c.PostalCode == "" && p.defaultPostalCode != "" {
c.PostalCode = p.defaultPostalCode
}
valid = append(valid, c)
validEntries = append(validEntries, entry)
log.Printf("[card-cache] loaded card %s...%s (binds=%d, rejected=%v)",
c.Number[:4], c.Number[len(c.Number)-4:], entry.BindCount, entry.Rejected)
}
if len(valid) > 0 {
// Add cards to pool with their original addedAt timestamps
p.pool.mu.Lock()
for i, c := range valid {
p.pool.cards = append(p.pool.cards, &poolEntry{
card: c,
addedAt: validEntries[i].AddedAt,
bindCount: validEntries[i].BindCount,
rejected: validEntries[i].Rejected,
})
}
p.pool.mu.Unlock()
log.Printf("[card-cache] loaded %d valid card(s) from cache (skipped %d expired)", len(valid), len(cached)-len(valid))
} else {
log.Printf("[card-cache] no valid cards in cache (%d expired)", len(cached))
}
}
// saveCachedCards writes current pool cards to the cache file.
func (p *APIProvider) saveCachedCards() {
p.pool.mu.Lock()
var entries []cachedCard
for _, e := range p.pool.cards {
entries = append(entries, cachedCard{
Card: e.card,
AddedAt: e.addedAt,
BindCount: e.bindCount,
Rejected: e.rejected,
})
}
p.pool.mu.Unlock()
data, err := json.MarshalIndent(entries, "", " ")
if err != nil {
log.Printf("[card-cache] failed to marshal cache: %v", err)
return
}
if err := os.WriteFile(p.cachePath, data, 0644); err != nil {
log.Printf("[card-cache] failed to write cache %s: %v", p.cachePath, err)
return
}
log.Printf("[card-cache] saved %d card(s) to %s", len(entries), p.cachePath)
}
// --- helpers ---
func (p *APIProvider) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
p.setHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func (p *APIProvider) setHeaders(req *http.Request) {
req.Header.Set("accept", "application/json, text/plain, */*")
req.Header.Set("accept-language", "en,zh-CN;q=0.9,zh;q=0.8")
req.Header.Set("cache-control", "no-cache")
req.Header.Set("pragma", "no-cache")
req.Header.Set("referer", p.baseURL+"/")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
}
// parseCards converts raw API cards to CardInfo structs.
func (p *APIProvider) parseCards(cards []redeemCard, tmpl string) []*CardInfo {
tmplName, tmplCountry := parseCardTemplate(tmpl)
var result []*CardInfo
for _, rc := range cards {
card := p.parseRedeemCard(rc, tmplName, tmplCountry)
if card != nil {
result = append(result, card)
}
}
return result
}
// parseRedeemCard extracts CardInfo from a redeemed card entry.
func (p *APIProvider) parseRedeemCard(rc redeemCard, tmplName, tmplCountry string) *CardInfo {
card := &CardInfo{
Number: rc.CardNumber,
CVC: rc.CardPassword,
Country: p.defaultCountry,
Currency: p.defaultCurrency,
}
// Parse cardData JSON for expiry and CVV
if rc.CardData != "" {
var cd cardDataJSON
if err := json.Unmarshal([]byte(rc.CardData), &cd); err == nil {
if cd.CVV != "" {
card.CVC = cd.CVV
}
// Parse MMYY expiry → ExpMonth + ExpYear
if len(cd.Expiry) == 4 {
card.ExpMonth = cd.Expiry[:2]
card.ExpYear = "20" + cd.Expiry[2:]
}
}
}
// Name: prefer template → default config
if tmplName != "" {
card.Name = tmplName
} else if p.defaultName != "" {
card.Name = p.defaultName
}
// Country: prefer template → default config
if tmplCountry != "" {
card.Country = tmplCountry
}
// Apply default address fields
if card.Address == "" && p.defaultAddress != "" {
card.Address = p.defaultAddress
}
if card.City == "" && p.defaultCity != "" {
card.City = p.defaultCity
}
if card.State == "" && p.defaultState != "" {
card.State = p.defaultState
}
if card.PostalCode == "" && p.defaultPostalCode != "" {
card.PostalCode = p.defaultPostalCode
}
if card.Number == "" || card.ExpMonth == "" || card.ExpYear == "" || card.CVC == "" {
log.Printf("[card-api] skipping incomplete card: number=%s exp=%s/%s cvc=%s",
card.Number, card.ExpMonth, card.ExpYear, card.CVC)
return nil
}
return card
}
// parseCardTemplate extracts name and country from the card template string.
func parseCardTemplate(tmpl string) (name, country string) {
if tmpl == "" {
return "", ""
}
lines := strings.Split(tmpl, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "姓名") {
name = strings.TrimSpace(strings.TrimPrefix(line, "姓名"))
}
if strings.HasPrefix(line, "国家") {
c := strings.TrimSpace(strings.TrimPrefix(line, "国家"))
country = countryNameToCode(c)
}
}
return
}
// countryNameToCode maps common country names to ISO codes.
func countryNameToCode(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
switch {
case strings.Contains(name, "united states"), strings.Contains(name, "美国"):
return "US"
case strings.Contains(name, "japan"), strings.Contains(name, "日本"):
return "JP"
case strings.Contains(name, "united kingdom"), strings.Contains(name, "英国"):
return "GB"
case strings.Contains(name, "canada"), strings.Contains(name, "加拿大"):
return "CA"
case strings.Contains(name, "australia"), strings.Contains(name, "澳大利亚"):
return "AU"
default:
return "US"
}
}
// generateVisitorID generates a visitor ID matching the frontend format.
func generateVisitorID() string {
ts := time.Now().UnixMilli()
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
suffix := make([]byte, 9)
for i := range suffix {
suffix[i] = chars[rand.Intn(len(chars))]
}
return fmt.Sprintf("visitor_%d_%s", ts, string(suffix))
}
func loadCodesFromFile(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var codes []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
codes = append(codes, line)
}
}
return codes, scanner.Err()
}
func maskCode(code string) string {
if len(code) <= 8 {
return code[:2] + "****"
}
return code[:4] + "****" + code[len(code)-4:]
}

350
pkg/provider/card/db.go Normal file
View 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
}

View File

@@ -0,0 +1,220 @@
package card
import (
"context"
"testing"
"gpt-plus/internal/db"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupCardTestDB(t *testing.T) *gorm.DB {
t.Helper()
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open db: %v", err)
}
d.AutoMigrate(&db.SystemConfig{}, &db.Card{}, &db.CardCode{})
db.DB = d
return d
}
func insertCard(t *testing.T, d *gorm.DB, number, status string, maxBinds int) *db.Card {
t.Helper()
card := &db.Card{
NumberHash: db.HashSHA256(number),
NumberEnc: number, // no encryption in test (key not set)
CVCEnc: "123",
ExpMonth: "12", ExpYear: "2030",
Name: "Test", Country: "US",
Status: status, MaxBinds: maxBinds,
}
d.Create(card)
return card
}
func TestGetCardReturnsActiveCard(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "4111111111111111", "active", 3)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, err := prov.GetCard(context.Background())
if err != nil {
t.Fatalf("GetCard: %v", err)
}
if card.Number != "4111111111111111" {
t.Fatalf("number = %q", card.Number)
}
}
func TestGetCardActivatesAvailable(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "4242424242424242", "available", 1)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, err := prov.GetCard(context.Background())
if err != nil {
t.Fatalf("GetCard: %v", err)
}
if card.Number != "4242424242424242" {
t.Fatalf("number = %q", card.Number)
}
var dbCard db.Card
d.First(&dbCard, "number_hash = ?", db.HashSHA256("4242424242424242"))
if dbCard.Status != "active" {
t.Fatalf("card should now be active, got %q", dbCard.Status)
}
}
func TestGetCardNoCardsReturnsError(t *testing.T) {
d := setupCardTestDB(t)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
_, err := prov.GetCard(context.Background())
if err == nil {
t.Fatal("expected error when no cards")
}
}
func TestReportResultSuccessIncrementsBind(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "5555555555554444", "active", 3)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, _ := prov.GetCard(context.Background())
err := prov.ReportResult(context.Background(), card, true)
if err != nil {
t.Fatalf("ReportResult: %v", err)
}
var dbCard db.Card
d.First(&dbCard, "number_hash = ?", db.HashSHA256("5555555555554444"))
if dbCard.BindCount != 1 {
t.Fatalf("bind_count = %d, want 1", dbCard.BindCount)
}
if dbCard.Status != "active" {
t.Fatalf("still active when under max_binds, got %q", dbCard.Status)
}
}
func TestReportResultExhaustsCard(t *testing.T) {
d := setupCardTestDB(t)
c := insertCard(t, d, "6011111111111117", "active", 1)
// Pre-set to 0, so one success = 1 = maxBinds → exhausted
_ = c
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, _ := prov.GetCard(context.Background())
prov.ReportResult(context.Background(), card, true)
var dbCard db.Card
d.First(&dbCard, "number_hash = ?", db.HashSHA256("6011111111111117"))
if dbCard.Status != "exhausted" {
t.Fatalf("status = %q, want exhausted", dbCard.Status)
}
}
func TestReportResultExhaustedActivatesNext(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "1111000011110000", "active", 1)
insertCard(t, d, "2222000022220000", "available", 5)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, _ := prov.GetCard(context.Background())
prov.ReportResult(context.Background(), card, true) // exhaust first
// Now GetCard should return the second card
next, err := prov.GetCard(context.Background())
if err != nil {
t.Fatalf("GetCard after exhaust: %v", err)
}
if next.Number != "2222000022220000" {
t.Fatalf("next card = %q, want 2222000022220000", next.Number)
}
}
func TestReportResultRejectedCard(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "3333000033330000", "active", 5)
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, _ := prov.GetCard(context.Background())
prov.ReportResult(context.Background(), card, false)
var dbCard db.Card
d.First(&dbCard, "number_hash = ?", db.HashSHA256("3333000033330000"))
if dbCard.Status != "rejected" {
t.Fatalf("status = %q, want rejected", dbCard.Status)
}
if dbCard.LastError == "" {
t.Fatal("last_error should be set")
}
}
func TestDefaultCountryAndCurrency(t *testing.T) {
prov := NewDBCardProvider(DBCardProviderConfig{})
if prov.defaultCountry != "US" {
t.Fatalf("defaultCountry = %q, want US", prov.defaultCountry)
}
if prov.defaultCurrency != "USD" {
t.Fatalf("defaultCurrency = %q, want USD", prov.defaultCurrency)
}
}
func TestToCardInfoAppliesDefaults(t *testing.T) {
d := setupCardTestDB(t)
prov := NewDBCardProvider(DBCardProviderConfig{
DB: d,
DefaultAddress: "123 Main St",
DefaultCity: "NYC",
DefaultState: "NY",
DefaultPostalCode: "10001",
})
card := &db.Card{
NumberEnc: "4111111111111111",
CVCEnc: "123",
ExpMonth: "12", ExpYear: "2030",
Name: "Test", Country: "US",
}
info, err := prov.toCardInfo(card)
if err != nil {
t.Fatalf("toCardInfo: %v", err)
}
if info.Address != "123 Main St" {
t.Fatalf("address = %q, want default", info.Address)
}
if info.City != "NYC" || info.State != "NY" || info.PostalCode != "10001" {
t.Fatalf("defaults not applied: %+v", info)
}
}
func TestSingleActiveCardPolicy(t *testing.T) {
d := setupCardTestDB(t)
insertCard(t, d, "AAAA000000001111", "active", 5)
insertCard(t, d, "BBBB000000002222", "available", 5)
// Verify only one active
var count int64
d.Model(&db.Card{}).Where("status = ?", "active").Count(&count)
if count != 1 {
t.Fatalf("active count = %d, want 1", count)
}
prov := NewDBCardProvider(DBCardProviderConfig{DB: d})
card, _ := prov.GetCard(context.Background())
if card.Number != "AAAA000000001111" {
t.Fatalf("should return the active card, got %q", card.Number)
}
}

134
pkg/provider/card/file.go Normal file
View File

@@ -0,0 +1,134 @@
package card
import (
"bufio"
"context"
"fmt"
"os"
"strings"
)
// FileProvider loads cards from a local TXT file (one card per line).
// Supported formats:
// 卡号|月|年|CVC
// 卡号|月|年|CVC|姓名|国家|货币
// 卡号|月|年|CVC|姓名|国家|货币|地址|城市|州|邮编
// 卡号,月,年,CVC
// 卡号,月,年,CVC,姓名,国家,货币,地址,城市,州,邮编
type FileProvider struct {
pool *CardPool
}
// NewFileProvider creates a FileProvider by reading cards from a text file.
func NewFileProvider(filePath string, defaultCountry, defaultCurrency string, poolCfg PoolConfig) (*FileProvider, error) {
cards, err := parseCardFile(filePath, defaultCountry, defaultCurrency)
if err != nil {
return nil, err
}
if len(cards) == 0 {
return nil, fmt.Errorf("no valid cards found in %s", filePath)
}
pool := NewCardPool(poolCfg)
pool.AddCards(cards)
return &FileProvider{pool: pool}, nil
}
// GetCard returns the next available card from the pool.
func (p *FileProvider) GetCard(ctx context.Context) (*CardInfo, error) {
return p.pool.GetCard()
}
// ReportResult reports the usage outcome.
func (p *FileProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error {
p.pool.ReportResult(card, success)
return nil
}
// parseCardFile reads and parses a card file.
func parseCardFile(filePath, defaultCountry, defaultCurrency string) ([]*CardInfo, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open card file: %w", err)
}
defer f.Close()
var cards []*CardInfo
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
card, err := parseLine(line, defaultCountry, defaultCurrency)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
cards = append(cards, card)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read card file: %w", err)
}
return cards, nil
}
// parseLine parses a single card line. Supports | and , delimiters.
func parseLine(line, defaultCountry, defaultCurrency string) (*CardInfo, error) {
var parts []string
if strings.Contains(line, "|") {
parts = strings.Split(line, "|")
} else {
parts = strings.Split(line, ",")
}
// Trim all parts
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 4 {
return nil, fmt.Errorf("expected at least 4 fields (number|month|year|cvc), got %d", len(parts))
}
card := &CardInfo{
Number: parts[0],
ExpMonth: parts[1],
ExpYear: parts[2],
CVC: parts[3],
Country: defaultCountry,
Currency: defaultCurrency,
}
if len(parts) >= 5 && parts[4] != "" {
card.Name = parts[4]
}
if len(parts) >= 6 && parts[5] != "" {
card.Country = parts[5]
}
if len(parts) >= 7 && parts[6] != "" {
card.Currency = parts[6]
}
if len(parts) >= 8 && parts[7] != "" {
card.Address = parts[7]
}
if len(parts) >= 9 && parts[8] != "" {
card.City = parts[8]
}
if len(parts) >= 10 && parts[9] != "" {
card.State = parts[9]
}
if len(parts) >= 11 && parts[10] != "" {
card.PostalCode = parts[10]
}
return card, nil
}

146
pkg/provider/card/pool.go Normal file
View File

@@ -0,0 +1,146 @@
package card
import (
"fmt"
"log"
"sync"
"time"
)
// CardPool manages a pool of cards with TTL expiration and multi-bind support.
type CardPool struct {
mu sync.Mutex
cards []*poolEntry
index int
ttl time.Duration // card validity duration (0 = no expiry)
multiBind bool // allow reusing cards multiple times
maxBinds int // max binds per card (0 = unlimited when multiBind=true)
}
type poolEntry struct {
card *CardInfo
addedAt time.Time
bindCount int
rejected bool // upstream rejected this card
}
// PoolConfig configures the card pool behavior.
type PoolConfig struct {
TTL time.Duration // card validity period (default: 1h)
MultiBind bool // allow one card to be used multiple times
MaxBinds int // max uses per card (0 = unlimited)
}
// NewCardPool creates a card pool with the given config.
func NewCardPool(cfg PoolConfig) *CardPool {
if cfg.TTL == 0 {
cfg.TTL = time.Hour
}
return &CardPool{
ttl: cfg.TTL,
multiBind: cfg.MultiBind,
maxBinds: cfg.MaxBinds,
}
}
// AddCards adds cards to the pool.
func (p *CardPool) AddCards(cards []*CardInfo) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for _, c := range cards {
p.cards = append(p.cards, &poolEntry{
card: c,
addedAt: now,
})
}
}
// GetCard returns the next available card from the pool.
func (p *CardPool) GetCard() (*CardInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
checked := 0
for checked < len(p.cards) {
entry := p.cards[p.index]
p.index = (p.index + 1) % len(p.cards)
checked++
// Skip expired cards
if p.ttl > 0 && now.Sub(entry.addedAt) > p.ttl {
continue
}
// Skip rejected cards
if entry.rejected {
continue
}
// Check bind limits
if !p.multiBind && entry.bindCount > 0 {
continue
}
if p.multiBind && p.maxBinds > 0 && entry.bindCount >= p.maxBinds {
continue
}
// Don't increment bindCount here — caller must call IncrementBind() after
// a real payment attempt reaches a terminal state (success or upstream decline).
log.Printf("[card-pool] dispensing card %s...%s (current binds=%d)",
entry.card.Number[:4], entry.card.Number[len(entry.card.Number)-4:], entry.bindCount)
return entry.card, nil
}
return nil, fmt.Errorf("no available cards in pool (total=%d)", len(p.cards))
}
// ReportResult handles payment terminal states:
// - success=true: bindCount++ (card used successfully)
// - success=false: bindCount++ AND rejected=true (upstream declined, card is dead)
func (p *CardPool) ReportResult(card *CardInfo, success bool) {
p.mu.Lock()
defer p.mu.Unlock()
for _, entry := range p.cards {
if entry.card.Number == card.Number {
entry.bindCount++
if !success {
entry.rejected = true
log.Printf("[card-pool] card %s...%s marked as rejected (bind #%d)",
card.Number[:4], card.Number[len(card.Number)-4:], entry.bindCount)
} else {
log.Printf("[card-pool] card %s...%s bind #%d recorded (success)",
card.Number[:4], card.Number[len(card.Number)-4:], entry.bindCount)
}
return
}
}
}
// Stats returns pool statistics.
func (p *CardPool) Stats() (total, available, expired, rejected int) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
total = len(p.cards)
for _, e := range p.cards {
switch {
case e.rejected:
rejected++
case p.ttl > 0 && now.Sub(e.addedAt) > p.ttl:
expired++
case !p.multiBind && e.bindCount > 0:
// used up in single-bind mode
case p.multiBind && p.maxBinds > 0 && e.bindCount >= p.maxBinds:
// used up max binds
default:
available++
}
}
return
}

View File

@@ -0,0 +1,26 @@
package card
import "context"
// CardInfo holds bank card details for Stripe payments.
type CardInfo struct {
Number string // Card number
ExpMonth string // Expiration month MM
ExpYear string // Expiration year YYYY
CVC string // CVC code
Name string // Cardholder name (optional)
Country string // Country code e.g. JP, US
Currency string // Currency e.g. JPY, USD
Address string // Street address (optional)
City string // City (optional)
State string // State/province (optional)
PostalCode string // Postal/ZIP code (optional)
}
// CardProvider is the pluggable interface for bank card sources.
type CardProvider interface {
// GetCard returns an available card for payment.
GetCard(ctx context.Context) (*CardInfo, error)
// ReportResult reports the usage outcome so the provider can manage its card pool.
ReportResult(ctx context.Context, card *CardInfo, success bool) error
}

View File

@@ -0,0 +1,53 @@
package card
import (
"context"
"fmt"
"gpt-plus/config"
)
// StaticProvider serves cards from a static YAML config list via a card pool.
type StaticProvider struct {
pool *CardPool
}
// NewStaticProvider creates a StaticProvider from config card entries.
func NewStaticProvider(cards []config.CardEntry, poolCfg PoolConfig) (*StaticProvider, error) {
if len(cards) == 0 {
return nil, fmt.Errorf("static card provider: no cards configured")
}
infos := make([]*CardInfo, len(cards))
for i, c := range cards {
infos[i] = &CardInfo{
Number: c.Number,
ExpMonth: c.ExpMonth,
ExpYear: c.ExpYear,
CVC: c.CVC,
Name: c.Name,
Country: c.Country,
Currency: c.Currency,
Address: c.Address,
City: c.City,
State: c.State,
PostalCode: c.PostalCode,
}
}
pool := NewCardPool(poolCfg)
pool.AddCards(infos)
return &StaticProvider{pool: pool}, nil
}
// GetCard returns the next available card from the pool.
func (p *StaticProvider) GetCard(ctx context.Context) (*CardInfo, error) {
return p.pool.GetCard()
}
// ReportResult reports the usage outcome.
func (p *StaticProvider) ReportResult(ctx context.Context, card *CardInfo, success bool) error {
p.pool.ReportResult(card, success)
return nil
}