Files
2026-03-15 20:48:19 +08:00

656 lines
18 KiB
Go

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:]
}