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