Initial sanitized code sync
This commit is contained in:
655
pkg/provider/card/api.go
Normal file
655
pkg/provider/card/api.go
Normal 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
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
|
||||
}
|
||||
220
pkg/provider/card/db_test.go
Normal file
220
pkg/provider/card/db_test.go
Normal 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
134
pkg/provider/card/file.go
Normal 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
146
pkg/provider/card/pool.go
Normal 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
|
||||
}
|
||||
26
pkg/provider/card/provider.go
Normal file
26
pkg/provider/card/provider.go
Normal 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
|
||||
}
|
||||
53
pkg/provider/card/static.go
Normal file
53
pkg/provider/card/static.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user