656 lines
18 KiB
Go
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:]
|
|
}
|