From 107af6dd605a3fce541b7b711ea1e769cf4295e8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 19 Jun 2026 09:54:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E4=BC=98=E5=8C=96-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 1464 ++++++++++++++++++++++++++++----------------- static/index.html | 26 +- 2 files changed, 947 insertions(+), 543 deletions(-) diff --git a/main.go b/main.go index 8e50c80..cb9638a 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,8 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" - "github.com/golang/freetype" - "github.com/golang/freetype/truetype" "image" "image/color" "image/png" @@ -15,30 +14,41 @@ import ( "mime/multipart" "net/http" "os" + "path/filepath" "strconv" "strings" "sync" - "sync/atomic" "time" + + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" ) const ( - MANUAL_ID = "G4KA4Qnz4LDp7" - ROOM_ID = "9263298" - EXCHANGE_URL = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange" - CSRF_URL = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf" - BIND_INFO = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo?actAlias=20260120QYOOB" - JIFEN_HOST = "https://www.douyu.com/japi/oms/web/peace/credit/balance" + manualID = "G4KA4Qnz4LDp7" + roomID = "9263298" + actAlias = "20260120QYOOB" - CONCURRENCY = 3 - GLOBAL_CONCURRENT = 300 + baseURL = "https://www.douyu.com" + shopReferer = "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=" + roomID + defaultTimeAPI = baseURL + "/wgapi/ordnc/activity/peace/storedetail?manualID=" + manualID + "&commodityID=20260120QYOOB_bag274&rid=" + roomID + storeHomeURL = baseURL + "/wgapi/ordnc/activity/peace/storehome?manualID=" + manualID + "&rid=" + roomID + csrfURL = baseURL + "/japi/carnival/nc/common/generateCsrf" + batchLimitURL = baseURL + "/wgapi/ordnc/activity/peace/batchExchangeLimit" + preExchangeURL = baseURL + "/wgapi/ordnc/activity/peace/preExchange" + exchangeURL = baseURL + "/wgapi/ordnc/activity/peace/exchange" + bindInfoURL = baseURL + "/japi/carnivalApi/v2/tencent/bindInfo?actAlias=" + actAlias + scoreURL = baseURL + "/japi/oms/web/peace/credit/balance" + userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + defaultAttempts = 1 + defaultConcurrency = 80 ) -type Config struct { - TimeApiUrl string `json:"timeApiUrl"` -} +var cst = time.FixedZone("CST", 8*3600) -var config Config +type Config struct { + TimeAPIURL string `json:"timeApiUrl"` +} type Account struct { Name string `json:"name"` @@ -52,6 +62,18 @@ type Product struct { Score int `json:"score"` Count int `json:"count"` Pic string `json:"pic"` + Status int `json:"status"` + OpenStatus int `json:"openStatus"` + StoreStatus int `json:"storeStatus"` + TimeLimited int `json:"timeLimited"` + CountLimited int `json:"countLimited"` + EliteLimited int `json:"eliteLimited"` +} + +type AccountView struct { + Index int `json:"index"` + Name string `json:"name"` + Cookie string `json:"cookie"` } type SeckillRequest struct { @@ -60,640 +82,1014 @@ type SeckillRequest struct { Advance int `json:"advance"` } -var ( - accounts []Account +type APIResponse struct { + Error int `json:"error"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +type appState struct { + config Config + client *DouyuClient + accountsMu sync.RWMutex + accounts []Account - products []Product productsMu sync.RWMutex + products []Product - results = make(map[string]string) resultsMu sync.RWMutex + results map[string]string - httpClient = &http.Client{ - Timeout: 10 * time.Second, // ← 加这一行,防止请求卡死 - Transport: &http.Transport{ - MaxConnsPerHost: 100, - MaxIdleConns: 100, - MaxIdleConnsPerHost: 50, - IdleConnTimeout: 30 * time.Second, - }, - } + scheduleMu sync.Mutex + schedule *time.Timer - seckillTimer *time.Timer - timerMu sync.Mutex + fontMu sync.Mutex + font *truetype.Font +} - cachedFont *truetype.Font - fontMu sync.Mutex -) +type DouyuClient struct { + httpClient *http.Client + timeAPIURL string +} + +type ExchangeAttempt struct { + Account Account + Product Product + Index int +} + +type ExchangeResult struct { + OK bool + Account string + Product string + Attempt int + RoleName string + Message string +} + +var state = &appState{ + config: Config{TimeAPIURL: defaultTimeAPI}, + results: make(map[string]string), +} -// main 是程序入口,负责初始化配置、账号、字体、HTTP 路由,并启动本地 Web 服务。 func main() { - // 设置日志格式,显示毫秒 - log.SetFlags(log.LstdFlags | log.Lmicroseconds) // ← 加这行 + log.SetFlags(log.LstdFlags | log.Lmicroseconds) - loadConfig() // ← 新增,放在最前面 - loadAccounts() - os.MkdirAll("success_images", 0755) + state.config = loadConfig() + state.client = NewDouyuClient(state.config.TimeAPIURL) + state.loadAccounts() + state.loadFont() + _ = os.MkdirAll("success_images", 0755) - initFont() // ← 加这一行,预加载字体 - - // 测试一下 currentTime 能不能通 - if t := fetchCurrentTime(); !t.IsZero() { - log.Printf("✅ currentTime 获取成功: %s", t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05")) + if serverTime, err := state.client.ServerTime(); err == nil { + log.Printf("服务端时间获取成功: %s", serverTime.In(cst).Format("2006-01-02 15:04:05.000")) } else { - log.Printf("⚠️ currentTime 获取失败,请检查网络") + log.Printf("服务端时间获取失败: %v", err) } - log.Printf("服务启动,账号数: %d", len(accounts)) + mux := http.NewServeMux() + state.registerRoutes(mux) - http.HandleFunc("/api/products", handleProducts) - http.HandleFunc("/api/load-products", handleLoadProducts) - http.HandleFunc("/api/accounts", handleAccounts) - http.HandleFunc("/api/reload-accounts", handleReloadAccounts) - http.HandleFunc("/api/seckill", handleSeckill) - http.HandleFunc("/api/schedule", handleSchedule) - http.HandleFunc("/api/results", handleResults) - http.HandleFunc("/api/time", handleTime) - http.HandleFunc("/api/clear-results", handleClearResults) - http.HandleFunc("/api/query-score", handleQueryScore) - http.Handle("/", http.FileServer(http.Dir("./static"))) - - log.Printf("✅ 服务启动: http://localhost:2169 | 账号:%d", len(accounts)) - log.Fatal(http.ListenAndServe(":2169", nil)) + log.Printf("服务启动: http://localhost:2169 | 账号: %d", state.accountCount()) + log.Fatal(http.ListenAndServe(":2169", mux)) } -// initFont 预加载微软雅黑字体,供成功兑换后生成结果图片时复用。 -func initFont() { - fontMu.Lock() - defer fontMu.Unlock() - if cachedFont != nil { - return - } - fontBytes, err := os.ReadFile("Microsoft Yahei.ttf") - if err != nil { - log.Printf("[ERROR] 字体文件加载失败: %v", err) - return - } - f, err := freetype.ParseFont(fontBytes) - if err != nil { - log.Printf("[ERROR] 字体解析失败: %v", err) - return - } - cachedFont = f - log.Printf("[OK] 字体加载成功") -} +// ==================== 初始化与状态 ==================== + +// loadConfig 读取 config.json;失败时使用默认商品详情接口做服务端时间来源。 +func loadConfig() Config { + cfg := Config{TimeAPIURL: defaultTimeAPI} -// loadConfig 读取 config.json 中的时间校准接口地址;读取或解析失败时使用默认商品详情接口。 -func loadConfig() { f, err := os.Open("config.json") if err != nil { - log.Printf("⚠️ 读取 config.json 失败,使用默认URL") - config.TimeApiUrl = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail?manualID=G4KA4Qnz4LDp7&commodityID=20260120QYOOB_bag274&rid=9263298" + log.Printf("读取 config.json 失败,使用默认配置: %v", err) + return cfg + } + defer f.Close() + + if err := json.NewDecoder(f).Decode(&cfg); err != nil { + log.Printf("解析 config.json 失败,使用默认配置: %v", err) + return Config{TimeAPIURL: defaultTimeAPI} + } + if strings.TrimSpace(cfg.TimeAPIURL) == "" { + cfg.TimeAPIURL = defaultTimeAPI + } + log.Printf("配置加载完成: timeApiUrl=%s", cfg.TimeAPIURL) + return cfg +} + +// loadAccounts 从 user.txt 加载账号,格式为“账号----密码----Cookie”。 +func (s *appState) loadAccounts() { + f, err := os.Open("user.txt") + if err != nil { + log.Printf("读取 user.txt 失败: %v", err) + s.accountsMu.Lock() + s.accounts = nil + s.accountsMu.Unlock() return } defer f.Close() - decoder := json.NewDecoder(f) - if err := decoder.Decode(&config); err != nil { - log.Printf("⚠️ 解析 config.json 失败,使用默认URL") - config.TimeApiUrl = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail?manualID=G4KA4Qnz4LDp7&commodityID=20260120QYOOB_bag274&rid=9263298" - return + + var list []Account + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "----", 3) + if len(parts) != 3 { + log.Printf("账号行格式错误,已跳过: %s", line) + continue + } + list = append(list, Account{ + Name: strings.TrimSpace(parts[0]), + Password: strings.TrimSpace(parts[1]), + Cookie: strings.TrimSpace(parts[2]), + }) } - log.Printf("✅ 加载配置成功: %s", config.TimeApiUrl) + if err := scanner.Err(); err != nil { + log.Printf("读取 user.txt 中断: %v", err) + } + + s.accountsMu.Lock() + s.accounts = list + s.accountsMu.Unlock() + log.Printf("账号加载完成: %d 个", len(list)) } -// ==================== 核心:从商品详情接口获取服务端当前时间 ==================== +// loadFont 预加载字体,成功兑换后用于生成结果图片。 +func (s *appState) loadFont() { + s.fontMu.Lock() + defer s.fontMu.Unlock() -// fetchCurrentTime 从商品详情接口获取服务端当前时间 -func fetchCurrentTime() time.Time { - url := config.TimeApiUrl + "&_=" + fmt.Sprintf("%d", time.Now().UnixNano()) - - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0") - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5") - req.Header.Set("Referer", "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId="+ROOM_ID) - req.Header.Set("Connection", "keep-alive") - - resp, err := httpClient.Do(req) + fontBytes, err := os.ReadFile("Microsoft Yahei.ttf") if err != nil { - log.Printf("fetchCurrentTime 请求失败: %v", err) - return time.Time{} + log.Printf("字体文件不存在,成功图片功能将跳过: %v", err) + return } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - log.Printf("fetchCurrentTime HTTP状态码: %d", resp.StatusCode) - return time.Time{} + font, err := freetype.ParseFont(fontBytes) + if err != nil { + log.Printf("字体解析失败,成功图片功能将跳过: %v", err) + return } + s.font = font + log.Printf("字体加载完成") +} - body, _ := io.ReadAll(resp.Body) +// accountCount 返回当前账号数。 +func (s *appState) accountCount() int { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + return len(s.accounts) +} +// accountSnapshot 复制一份账号列表,避免执行任务时长时间持锁。 +func (s *appState) accountSnapshot() []Account { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + + list := make([]Account, len(s.accounts)) + copy(list, s.accounts) + return list +} + +// productSnapshot 复制一份商品列表。 +func (s *appState) productSnapshot() []Product { + s.productsMu.RLock() + defer s.productsMu.RUnlock() + + list := make([]Product, len(s.products)) + copy(list, s.products) + return list +} + +// setProducts 替换商品缓存。 +func (s *appState) setProducts(products []Product) { + s.productsMu.Lock() + defer s.productsMu.Unlock() + s.products = products +} + +// clearResults 清空抢购结果。 +func (s *appState) clearResults() { + s.resultsMu.Lock() + defer s.resultsMu.Unlock() + s.results = make(map[string]string) +} + +// setResult 写入单次兑换结果。 +func (s *appState) setResult(key, value string) { + s.resultsMu.Lock() + defer s.resultsMu.Unlock() + s.results[key] = value +} + +// resultsSnapshot 复制当前结果。 +func (s *appState) resultsSnapshot() map[string]string { + s.resultsMu.RLock() + defer s.resultsMu.RUnlock() + + out := make(map[string]string, len(s.results)) + for k, v := range s.results { + out[k] = v + } + return out +} + +// productName 根据商品 ID 查找商品名,找不到时返回商品 ID。 +func (s *appState) productName(commodityID string) string { + for _, p := range s.productSnapshot() { + if p.CommodityID == commodityID { + return p.CommodityName + } + } + return commodityID +} + +// ==================== 斗鱼接口客户端 ==================== + +// NewDouyuClient 创建一个专门请求斗鱼接口的客户端。 +func NewDouyuClient(timeAPIURL string) *DouyuClient { + return &DouyuClient{ + timeAPIURL: timeAPIURL, + httpClient: &http.Client{ + Timeout: 8 * time.Second, + Transport: &http.Transport{ + MaxConnsPerHost: 120, + MaxIdleConns: 120, + MaxIdleConnsPerHost: 80, + IdleConnTimeout: 30 * time.Second, + }, + }, + } +} + +// ServerTime 从商品详情接口读取 data.preExchange.currentTime。 +func (c *DouyuClient) ServerTime() (time.Time, error) { + var lastErr error + for i := 0; i < 3; i++ { + t, err := c.fetchServerTimeOnce() + if err == nil { + return t, nil + } + lastErr = err + time.Sleep(100 * time.Millisecond) + } + return time.Now().UTC(), fmt.Errorf("服务端时间获取失败,已降级本地时间: %w", lastErr) +} + +// fetchServerTimeOnce 执行一次服务端时间请求。 +func (c *DouyuClient) fetchServerTimeOnce() (time.Time, error) { + url := appendCacheBuster(c.timeAPIURL) var result struct { + APIResponse Data struct { PreExchange struct { CurrentTime string `json:"currentTime"` } `json:"preExchange"` } `json:"data"` } - - if err := json.Unmarshal(body, &result); err != nil { - log.Printf("fetchCurrentTime 解析JSON失败: %v", err) - return time.Time{} + if err := c.getJSON(url, "", &result); err != nil { + return time.Time{}, err } - ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64) - if err != nil || ts == 0 { - log.Printf("fetchCurrentTime 解析时间戳失败: %s", result.Data.PreExchange.CurrentTime) - return time.Time{} + if err != nil || ts <= 0 { + return time.Time{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) } - - return time.Unix(ts, 0).UTC() + return time.Unix(ts, 0).UTC(), nil } -// getServerTime 获取服务端当前时间(带重试) -func getServerTime() time.Time { - for i := 0; i < 3; i++ { - t := fetchCurrentTime() - if !t.IsZero() { - return t - } - time.Sleep(100 * time.Millisecond) +// Products 拉取商城商品列表。 +func (c *DouyuClient) Products() ([]Product, error) { + var result struct { + APIResponse + Data struct { + List []Product `json:"list"` + } `json:"data"` } - // 全部失败,降级到本地时间 - log.Printf("⚠️ getServerTime 全部失败,降级到本地时间") - return time.Now().UTC() + if err := c.getJSON(storeHomeURL, "", &result); err != nil { + return nil, err + } + if result.Error != 0 { + return nil, fmt.Errorf("商品接口失败: %s", result.Msg) + } + return result.Data.List, nil } -// ==================== API ==================== - -// handleClearResults 清空内存中的抢购结果,通常在前端启动时调用以避免展示旧结果。 -func handleClearResults(w http.ResponseWriter, _ *http.Request) { - resultsMu.Lock() - results = make(map[string]string) - resultsMu.Unlock() - json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) -} - -// getScore 使用账号 Cookie 请求斗鱼积分余额接口,返回积分数字字符串或失败提示。 -func getScore(cookie string) string { - body := strings.NewReader(`{}`) - req, _ := http.NewRequest("POST", JIFEN_HOST, body) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "Mozilla/5.0") - req.Header.Set("Cookie", cookie) - req.Header.Set("Referer", "https://www.douyu.com/") - resp, err := httpClient.Do(req) +// Score 查询账号积分余额。 +func (c *DouyuClient) Score(cookie string) (int, error) { + req, err := http.NewRequest("POST", scoreURL, strings.NewReader(`{}`)) if err != nil { - return "查询失败" + return 0, err } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) + c.setJSONHeaders(req, cookie) + req.Header.Set("Referer", baseURL+"/") var result struct { + APIResponse Data struct { Balance int `json:"balance"` } `json:"data"` } - json.Unmarshal(b, &result) - return fmt.Sprintf("%d", result.Data.Balance) -} - -// handleQueryScore 接收前端传入的 Cookie,查询该账号积分并以 JSON 返回。 -func handleQueryScore(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - cookie := string(body) - if cookie == "" { - http.Error(w, "缺少cookie", 400) - return + if err := c.doJSON(req, &result); err != nil { + return 0, err } - score := getScore(cookie) - json.NewEncoder(w).Encode(map[string]interface{}{"score": score}) + if result.Error != 0 { + return 0, fmt.Errorf("积分接口失败: %s", result.Msg) + } + return result.Data.Balance, nil } -// loadAccounts 从 user.txt 读取账号列表,格式为“账号----密码----Cookie”,并刷新全局账号缓存。 -func loadAccounts() { - accountsMu.Lock() - defer accountsMu.Unlock() - accounts = nil - f, err := os.Open("user.txt") +// GenerateCSRF 调用 generateCsrf,并从 Set-Cookie 中取 cvl_csrf_token。 +func (c *DouyuClient) GenerateCSRF(cookie string) (string, error) { + req, err := http.NewRequest("POST", csrfURL, nil) if err != nil { - log.Printf("读取user.txt失败: %v", err) - return + return "", err } - defer f.Close() - sc := bufio.NewScanner(f) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" { - continue - } - parts := strings.SplitN(line, "----", 3) - if len(parts) == 3 { - accounts = append(accounts, Account{Name: parts[0], Password: parts[1], Cookie: parts[2]}) - } - } - log.Printf("加载 %d 个账号", len(accounts)) -} + c.setBrowserHeaders(req, cookie) -// handleTime 返回斗鱼服务端时间和本地时间,供前端展示与定时抢购校准使用。 -func handleTime(w http.ResponseWriter, _ *http.Request) { - loc := time.FixedZone("CST", 8*3600) - serverTime := getServerTime() - json.NewEncoder(w).Encode(map[string]interface{}{ - "serverTime": serverTime.In(loc).Format("2006-01-02 15:04:05.000"), - "localTime": time.Now().In(loc).Format("2006-01-02 15:04:05.000"), - "source": "currentTime", - }) -} - -// handleProducts 返回当前内存缓存的商品列表,不主动访问斗鱼接口。 -func handleProducts(w http.ResponseWriter, _ *http.Request) { - productsMu.RLock() - defer productsMu.RUnlock() - json.NewEncoder(w).Encode(products) -} - -// handleLoadProducts 请求斗鱼和平小店商品列表接口,并把商品数据写入全局缓存。 -func handleLoadProducts(w http.ResponseWriter, _ *http.Request) { - url := fmt.Sprintf("https://www.douyu.com/wgapi/ordnc/activity/peace/storehome?manualID=%s&rid=%s", MANUAL_ID, ROOM_ID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "Mozilla/5.0") - resp, err := httpClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { - http.Error(w, "请求失败", 500) - return + return "", err } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("CSRF HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 120)) + } + + var api APIResponse + if err := json.Unmarshal(body, &api); err != nil { + return "", fmt.Errorf("CSRF 响应解析失败: %w", err) + } + if api.Error != 0 { + return "", fmt.Errorf("CSRF 接口失败: %s", api.Msg) + } + for _, ck := range resp.Cookies() { + if ck.Name == "cvl_csrf_token" && ck.Value != "" { + return ck.Value, nil + } + } + return "", errors.New("CSRF 响应没有 cvl_csrf_token") +} + +// BatchExchangeLimit 请求兑换限制接口,返回本次允许的批量上限。 +func (c *DouyuClient) BatchExchangeLimit(cookie, commodityID, csrf string) (int, error) { var result struct { + APIResponse Data struct { - List []struct { - CommodityID string `json:"commodityId"` - CommodityName string `json:"commodityName"` - Score int `json:"score"` - Count int `json:"count"` - Pic string `json:"pic"` - } `json:"list"` + Limit string `json:"limit"` } `json:"data"` } - json.Unmarshal(body, &result) - - productsMu.Lock() - products = nil - for _, p := range result.Data.List { - products = append(products, Product{p.CommodityID, p.CommodityName, p.Score, p.Count, p.Pic}) + if err := c.postMultipart(batchLimitURL, cookie, csrf, map[string]string{ + "manualID": manualID, + "commodityID": commodityID, + "rid": roomID, + "csrfToken": csrf, + }, &result); err != nil { + return 0, err } - productsMu.Unlock() - log.Printf("加载商品: %d个", len(products)) - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "count": len(products)}) -} - -// handleAccounts 返回前端可展示的账号列表,仅暴露账号序号、名称和 Cookie。 -func handleAccounts(w http.ResponseWriter, _ *http.Request) { - accountsMu.RLock() - defer accountsMu.RUnlock() - type R struct { - Index int `json:"index"` - Name string `json:"name"` - Cookie string `json:"cookie"` + if result.Error != 0 { + return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg) } - list := []R{} - for i, a := range accounts { - list = append(list, R{i, a.Name, a.Cookie}) - } - json.NewEncoder(w).Encode(list) -} - -// handleReloadAccounts 重新读取 user.txt,并返回最新账号数量。 -func handleReloadAccounts(w http.ResponseWriter, _ *http.Request) { - loadAccounts() - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "count": len(accounts)}) -} - -// handleSeckill 接收账号到商品的映射,异步启动立即抢购任务。 -func handleSeckill(w http.ResponseWriter, r *http.Request) { - var req SeckillRequest - json.NewDecoder(r.Body).Decode(&req) - if len(req.AccountProducts) == 0 { - http.Error(w, "请选择账号和商品", 400) - return - } - go executeSeckill(req.AccountProducts) - json.NewEncoder(w).Encode(map[string]string{"success": "true", "msg": "抢购已启动"}) -} - -// handleSchedule 根据斗鱼服务端时间计算触发延迟,并注册一次性定时抢购任务。 -func handleSchedule(w http.ResponseWriter, r *http.Request) { - var req SeckillRequest - json.NewDecoder(r.Body).Decode(&req) - if req.TargetTime == "" { - http.Error(w, "请设置目标时间", 400) - return - } - targetTime, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, time.Local) + limit, err := strconv.Atoi(result.Data.Limit) if err != nil { - http.Error(w, "时间格式错误", 400) + return 0, fmt.Errorf("批量限制值无效: %q", result.Data.Limit) + } + return limit, nil +} + +// PreExchange 按抓包流程执行预兑换检查。 +func (c *DouyuClient) PreExchange(cookie, commodityID, csrf string) error { + var result APIResponse + if err := c.postMultipart(preExchangeURL, cookie, csrf, map[string]string{ + "manualID": manualID, + "rid": roomID, + "commodityID": commodityID, + "num": "1", + "csrfToken": csrf, + }, &result); err != nil { + return err + } + if result.Error != 0 { + return fmt.Errorf("预兑换失败: %s", result.Msg) + } + return nil +} + +// Exchange 正式兑换一个商品,num 固定为 1,和浏览器抓包一致。 +func (c *DouyuClient) Exchange(cookie, commodityID, csrf string) (string, error) { + var result struct { + APIResponse + Data struct { + ExchangeID int `json:"exchangeId"` + ExchangeNum string `json:"exchangeNum"` + CommodityTyp int `json:"commodityType"` + } `json:"data"` + } + if err := c.postMultipart(exchangeURL, cookie, csrf, map[string]string{ + "manualID": manualID, + "commodityID": commodityID, + "num": "1", + "rid": roomID, + "csrfToken": csrf, + }, &result); err != nil { + return "", err + } + if result.Error != 0 { + if strings.TrimSpace(result.Msg) == "" { + return "", errors.New("兑换失败,接口未返回原因") + } + return "", fmt.Errorf("兑换失败: %s", result.Msg) + } + return fmt.Sprintf("exchangeId=%d,num=%s", result.Data.ExchangeID, result.Data.ExchangeNum), nil +} + +// RoleName 查询绑定角色名。 +func (c *DouyuClient) RoleName(cookie string) (string, error) { + var result struct { + APIResponse + Data struct { + GameRole struct { + RoleName string `json:"roleName"` + } `json:"gameRole"` + } `json:"data"` + } + if err := c.getJSON(bindInfoURL, cookie, &result); err != nil { + return "", err + } + if result.Error != 0 { + return "", fmt.Errorf("角色接口失败: %s", result.Msg) + } + return result.Data.GameRole.RoleName, nil +} + +// getJSON 发送 GET 请求并解析 JSON。 +func (c *DouyuClient) getJSON(url, cookie string, out any) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + c.setBrowserHeaders(req, cookie) + return c.doJSON(req, out) +} + +// postMultipart 发送 multipart/form-data 请求并解析 JSON。 +func (c *DouyuClient) postMultipart(url, cookie, csrf string, fields map[string]string, out any) error { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return err + } + } + if err := writer.Close(); err != nil { + return err + } + + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return err + } + c.setBrowserHeaders(req, mergeCookie(cookie, "cvl_csrf_token", csrf)) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return c.doJSON(req, out) +} + +// doJSON 执行请求、检查 HTTP 状态码并解析 JSON。 +func (c *DouyuClient) doJSON(req *http.Request, out any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trimForLog(string(body), 160)) + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("JSON 解析失败: %w, body=%s", err, trimForLog(string(body), 160)) + } + return nil +} + +// setBrowserHeaders 设置接近浏览器抓包的通用请求头。 +func (c *DouyuClient) setBrowserHeaders(req *http.Request, cookie string) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.6,en;q=0.5") + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Origin", baseURL) + req.Header.Set("Referer", shopReferer) + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + if strings.TrimSpace(cookie) != "" { + req.Header.Set("Cookie", cookie) + } +} + +// setJSONHeaders 设置 JSON POST 请求头。 +func (c *DouyuClient) setJSONHeaders(req *http.Request, cookie string) { + c.setBrowserHeaders(req, cookie) + req.Header.Set("Content-Type", "application/json") +} + +// ==================== 抢购任务 ==================== + +// runNow 立即执行一批抢购任务。 +func (s *appState) runNow(accountProducts map[string]string) { + s.clearResults() + attempts := s.buildAttempts(accountProducts) + if len(attempts) == 0 { + log.Printf("没有可执行的抢购任务") return } - targetTime = targetTime.UTC() - // ✅ 用 currentTime 获取服务端当前时间 - serverTime := getServerTime() - if serverTime.IsZero() { - http.Error(w, "获取服务端时间失败", 500) - return + log.Printf("开始抢购: %d 个请求", len(attempts)) + limit := make(chan struct{}, defaultConcurrency) + var wg sync.WaitGroup + for _, attempt := range attempts { + wg.Add(1) + go func(attempt ExchangeAttempt) { + defer wg.Done() + limit <- struct{}{} + defer func() { <-limit }() + + result := s.runAttempt(attempt) + key := fmt.Sprintf("%s_第%d包", result.Account, result.Attempt) + s.setResult(key, result.Message) + if result.OK { + log.Printf("成功: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) + go s.saveSuccessImage(attempt.Account, attempt.Product, result.RoleName) + } else { + log.Printf("失败: %s %s 第%d包 %s", result.Account, result.Product, result.Attempt, result.Message) + } + }(attempt) + } + wg.Wait() + log.Printf("抢购任务结束") +} + +// buildAttempts 根据前端传入的账号到商品映射,生成具体兑换尝试。 +func (s *appState) buildAttempts(accountProducts map[string]string) []ExchangeAttempt { + accounts := s.accountSnapshot() + products := s.productSnapshot() + productByID := make(map[string]Product, len(products)) + for _, p := range products { + productByID[p.CommodityID] = p + } + + var attempts []ExchangeAttempt + for _, account := range accounts { + commodityID := accountProducts[account.Name] + if commodityID == "" { + continue + } + product := productByID[commodityID] + if product.CommodityID == "" { + product = Product{CommodityID: commodityID, CommodityName: commodityID} + } + for i := 1; i <= defaultAttempts; i++ { + attempts = append(attempts, ExchangeAttempt{ + Account: account, + Product: product, + Index: i, + }) + } + } + return attempts +} + +// runAttempt 按抓包链路执行一次兑换:CSRF -> 限制 -> 预兑换 -> 正式兑换。 +func (s *appState) runAttempt(attempt ExchangeAttempt) ExchangeResult { + result := ExchangeResult{ + Account: attempt.Account.Name, + Product: attempt.Product.CommodityName, + Attempt: attempt.Index, + } + + limitCSRF, err := s.client.GenerateCSRF(attempt.Account.Cookie) + if err != nil { + result.Message = "失败|生成CSRF失败: " + err.Error() + return result + } + + limit, err := s.client.BatchExchangeLimit(attempt.Account.Cookie, attempt.Product.CommodityID, limitCSRF) + if err != nil { + result.Message = "失败|批量限制检查失败: " + err.Error() + return result + } + if limit <= 0 { + result.Message = "失败|当前商品限制兑换" + return result + } + + preCSRF, err := s.client.GenerateCSRF(attempt.Account.Cookie) + if err != nil { + result.Message = "失败|生成预兑换CSRF失败: " + err.Error() + return result + } + if err := s.client.PreExchange(attempt.Account.Cookie, attempt.Product.CommodityID, preCSRF); err != nil { + result.Message = "失败|预兑换失败: " + err.Error() + return result + } + + exchangeCSRF, err := s.client.GenerateCSRF(attempt.Account.Cookie) + if err != nil { + result.Message = "失败|生成兑换CSRF失败: " + err.Error() + return result + } + exchangeInfo, err := s.client.Exchange(attempt.Account.Cookie, attempt.Product.CommodityID, exchangeCSRF) + if err != nil { + result.Message = "失败|" + err.Error() + return result + } + + roleName, err := s.client.RoleName(attempt.Account.Cookie) + if err != nil { + log.Printf("%s 角色名查询失败: %v", attempt.Account.Name, err) + } + + result.OK = true + result.RoleName = roleName + if roleName != "" { + result.Message = "成功|" + roleName + "|" + exchangeInfo + } else { + result.Message = "成功|" + exchangeInfo + } + return result +} + +// scheduleRun 注册一次定时抢购。 +func (s *appState) scheduleRun(req SeckillRequest) (map[string]any, error) { + if len(req.AccountProducts) == 0 { + return nil, errors.New("请选择账号和商品") + } + if strings.TrimSpace(req.TargetTime) == "" { + return nil, errors.New("请设置目标时间") + } + + targetLocal, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, cst) + if err != nil { + return nil, fmt.Errorf("时间格式错误: %w", err) + } + targetTime := targetLocal.UTC() + + serverTime, err := s.client.ServerTime() + if err != nil { + log.Printf("定时使用降级时间: %v", err) } advance := time.Duration(req.Advance) * time.Millisecond triggerTime := targetTime.Add(-advance) - remain := triggerTime.Sub(serverTime) - log.Printf("📍 目标时间: %s, 服务端时间: %s, 剩余: %v, 提前: %dms", - targetTime.In(time.FixedZone("CST", 8*3600)).Format("15:04:05.000"), - serverTime.In(time.FixedZone("CST", 8*3600)).Format("15:04:05.000"), - remain, + if remain <= 0 { + return nil, errors.New("目标时间已过") + } + + ap := cloneStringMap(req.AccountProducts) + s.scheduleMu.Lock() + if s.schedule != nil { + s.schedule.Stop() + } + s.schedule = time.AfterFunc(remain, func() { + log.Printf("定时触发: 本地=%s", time.Now().In(cst).Format("15:04:05.000")) + s.runNow(ap) + }) + s.scheduleMu.Unlock() + + log.Printf("定时已设置: 目标=%s 触发=%s 提前=%dms", + targetTime.In(cst).Format("2006-01-02 15:04:05.000"), + triggerTime.In(cst).Format("2006-01-02 15:04:05.000"), req.Advance, ) - if remain < 0 { - http.Error(w, "目标时间已过", 400) - return - } - - timerMu.Lock() - if seckillTimer != nil { - seckillTimer.Stop() - } - timerMu.Unlock() - - ap := req.AccountProducts - loc := time.FixedZone("CST", 8*3600) - - timerMu.Lock() - // 定时器回调会在到达触发时间后执行实际抢购逻辑。 - seckillTimer = time.AfterFunc(remain, func() { - //// ✅ 到点后再次获取最新的服务端时间,确保准时 - //now := getServerTime() - // - //log.Printf("🚀 触发! 目标:%s 实际:%s 偏差:%v", - // targetTime.In(loc).Format("15:04:05.000"), - // now.In(loc).Format("15:04:05.000"), - // now.Sub(targetTime), - //) - log.Printf("🚀 触发! 本地时间: %s", time.Now().In(loc).Format("15:04:05.000")) - executeSeckill(ap) - }) - timerMu.Unlock() - - log.Printf("⏰ 定时: %s(北京时间) 提前%dms", targetTime.In(loc).Format("15:04:05"), req.Advance) - json.NewEncoder(w).Encode(map[string]interface{}{ + return map[string]any{ "success": true, - "targetTime": targetTime.In(loc).Format("2006-01-02 15:04:05"), - "triggerTime": triggerTime.In(loc).Format("2006-01-02 15:04:05.000"), + "targetTime": targetTime.In(cst).Format("2006-01-02 15:04:05"), + "triggerTime": triggerTime.In(cst).Format("2006-01-02 15:04:05.000"), "advance": req.Advance, "remainSecond": int(remain.Seconds()), - "serverTime": serverTime.In(loc).Format("2006-01-02 15:04:05.000"), + "serverTime": serverTime.In(cst).Format("2006-01-02 15:04:05.000"), + }, nil +} + +// cancelSchedule 停止尚未触发的定时任务。 +func (s *appState) cancelSchedule() bool { + s.scheduleMu.Lock() + defer s.scheduleMu.Unlock() + if s.schedule == nil { + return false + } + stopped := s.schedule.Stop() + s.schedule = nil + return stopped +} + +// ==================== HTTP API ==================== + +// registerRoutes 注册前端需要的所有接口。 +func (s *appState) registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/products", s.handleProducts) + mux.HandleFunc("/api/load-products", s.handleLoadProducts) + mux.HandleFunc("/api/accounts", s.handleAccounts) + mux.HandleFunc("/api/reload-accounts", s.handleReloadAccounts) + mux.HandleFunc("/api/seckill", s.handleSeckill) + mux.HandleFunc("/api/schedule", s.handleSchedule) + mux.HandleFunc("/api/cancel-schedule", s.handleCancelSchedule) + mux.HandleFunc("/api/results", s.handleResults) + mux.HandleFunc("/api/time", s.handleTime) + mux.HandleFunc("/api/clear-results", s.handleClearResults) + mux.HandleFunc("/api/query-score", s.handleQueryScore) + mux.Handle("/", http.FileServer(http.Dir("./static"))) +} + +// handleProducts 返回商品缓存。 +func (s *appState) handleProducts(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, s.productSnapshot()) +} + +// handleLoadProducts 从斗鱼重新拉取商品列表。 +func (s *appState) handleLoadProducts(w http.ResponseWriter, _ *http.Request) { + products, err := s.client.Products() + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + s.setProducts(products) + log.Printf("商品加载完成: %d 个", len(products)) + writeJSON(w, map[string]any{"success": true, "count": len(products)}) +} + +// handleAccounts 返回账号列表。 +func (s *appState) handleAccounts(w http.ResponseWriter, _ *http.Request) { + accounts := s.accountSnapshot() + list := make([]AccountView, 0, len(accounts)) + for i, account := range accounts { + list = append(list, AccountView{ + Index: i, + Name: account.Name, + Cookie: account.Cookie, + }) + } + writeJSON(w, list) +} + +// handleReloadAccounts 重新加载 user.txt。 +func (s *appState) handleReloadAccounts(w http.ResponseWriter, _ *http.Request) { + s.loadAccounts() + writeJSON(w, map[string]any{"success": true, "count": s.accountCount()}) +} + +// handleSeckill 立即启动抢购。 +func (s *appState) handleSeckill(w http.ResponseWriter, r *http.Request) { + var req SeckillRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if len(req.AccountProducts) == 0 { + writeError(w, http.StatusBadRequest, errors.New("请选择账号和商品")) + return + } + go s.runNow(cloneStringMap(req.AccountProducts)) + writeJSON(w, map[string]any{"success": true, "msg": "抢购已启动"}) +} + +// handleSchedule 设置定时抢购。 +func (s *appState) handleSchedule(w http.ResponseWriter, r *http.Request) { + var req SeckillRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + resp, err := s.scheduleRun(req) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, resp) +} + +// handleCancelSchedule 取消未触发的定时抢购。 +func (s *appState) handleCancelSchedule(w http.ResponseWriter, _ *http.Request) { + stopped := s.cancelSchedule() + writeJSON(w, map[string]any{"success": true, "stopped": stopped}) +} + +// handleResults 返回当前抢购结果。 +func (s *appState) handleResults(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, s.resultsSnapshot()) +} + +// handleClearResults 清空抢购结果。 +func (s *appState) handleClearResults(w http.ResponseWriter, _ *http.Request) { + s.clearResults() + writeJSON(w, map[string]any{"success": true}) +} + +// handleTime 返回服务端时间和本地时间。 +func (s *appState) handleTime(w http.ResponseWriter, _ *http.Request) { + serverTime, err := s.client.ServerTime() + source := "currentTime" + if err != nil { + source = "localFallback" + log.Printf("时间接口降级: %v", err) + } + writeJSON(w, map[string]any{ + "serverTime": serverTime.In(cst).Format("2006-01-02 15:04:05.000"), + "localTime": time.Now().In(cst).Format("2006-01-02 15:04:05.000"), + "source": source, }) } -// handleResults 返回最新抢购结果映射,供前端定时轮询并更新状态。 -func handleResults(w http.ResponseWriter, _ *http.Request) { - resultsMu.RLock() - defer resultsMu.RUnlock() - json.NewEncoder(w).Encode(results) -} - -// ==================== 抢购执行 ==================== -// executeSeckill 按账号和商品映射批量发起兑换请求,并统计成功、失败数量。 -func executeSeckill(ap map[string]string) { - var wg sync.WaitGroup - var ok, fail int32 - limit := make(chan struct{}, GLOBAL_CONCURRENT) - - accountsMu.RLock() - accs := make([]Account, len(accounts)) - copy(accs, accounts) - accountsMu.RUnlock() - - for _, a := range accs { - cid, ex := ap[a.Name] - if !ex || cid == "" { - continue - } - - // ✅ 每个账号只获取一次 CSRF(移到这里,在循环内部) - csrf := getCsrf(a.Cookie) - if csrf == "" { - log.Printf("❌ %s 获取 CSRF 失败,跳过", a.Name) - continue - } - - for i := 0; i < CONCURRENCY; i++ { - wg.Add(1) - // 每个 goroutine 负责一次兑换尝试,并通过 limit 控制全局并发量。 - go func(a Account, cid string, csrf string, idx int) { - defer wg.Done() - limit <- struct{}{} - // 退出时释放一个并发名额,避免后续兑换任务被永久阻塞。 - defer func() { <-limit }() - res := doExchange(a, cid, csrf) - resultsMu.Lock() - results[fmt.Sprintf("%s_第%d包", a.Name, idx+1)] = res - resultsMu.Unlock() - if strings.Contains(res, "成功") { - atomic.AddInt32(&ok, 1) - log.Printf("✅ %s 第%d包 成功: %s", a.Name, idx+1, res) - rn := "" - if strings.Contains(res, "|") { - rn = strings.SplitN(res, "|", 2)[1] - } - go saveImg(a, cid, rn) // ← 异步保存,不阻塞 - } else { - atomic.AddInt32(&fail, 1) - log.Printf("❌ %s 第%d包 失败: %s", a.Name, idx+1, res) - } - }(a, cid, csrf, i) // ← 传入 csrf - } - } - wg.Wait() - log.Printf("抢购完成: 成功%d 失败%d", ok, fail) -} - -// doExchange 使用单个账号、商品 ID 和 CSRF Token 调用斗鱼兑换接口,返回本次兑换结果。 -func doExchange(a Account, cid string, csrf string) string { - //csrf := getCsrf(a.Cookie) - if csrf == "" { - return "CSRF失败" - } - - ccn := extractMid(a.Cookie, "acf_ccn=", ";") - ck := a.Cookie - if ccn != "" { - ck += "; acf_ccn=" + ccn - } - ck += "; cvl_csrf_token=" + csrf - ck = strings.ReplaceAll(ck, ";;", ";") - - var buf bytes.Buffer - w := multipart.NewWriter(&buf) - w.WriteField("manualID", MANUAL_ID) - w.WriteField("commodityID", cid) - w.WriteField("rid", ROOM_ID) - w.WriteField("csrfToken", csrf) - w.Close() - - req, _ := http.NewRequest("POST", EXCHANGE_URL, &buf) - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("User-Agent", "Mozilla/5.0") - req.Header.Set("Origin", "https://www.douyu.com") - req.Header.Set("Referer", "https://www.douyu.com/pages/live-peace-handbook/web/shop?roomId=9263298") - req.Header.Set("Cookie", ck) - - resp, err := httpClient.Do(req) +// handleQueryScore 查询单个 Cookie 的积分。 +func (s *appState) handleQueryScore(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) if err != nil { - return "请求失败" - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - s := string(b) - if strings.Contains(s, `"error":0`) { - rn := getRoleName(a.Cookie) - if rn != "" { - return "成功|" + rn - } - return "成功" - } - return "失败|" + trunc(s, 80) -} - -// ==================== 辅助函数 ==================== - -// getCsrf 访问斗鱼 CSRF 生成接口,并从响应 Cookie 中提取 cvl_csrf_token。 -func getCsrf(cookie string) string { - req, _ := http.NewRequest("POST", CSRF_URL, nil) - req.Header.Set("User-Agent", "Mozilla/5.0") - req.Header.Set("Cookie", cookie) - req.Header.Set("Referer", "https://www.douyu.com/") - resp, err := httpClient.Do(req) - if err != nil { - return "" - } - defer resp.Body.Close() - for _, c := range resp.Cookies() { - if c.Name == "cvl_csrf_token" { - return c.Value - } - } - return "" -} - -// getRoleName 查询腾讯游戏绑定信息,并从响应中提取游戏角色名。 -func getRoleName(cookie string) string { - req, _ := http.NewRequest("GET", BIND_INFO, nil) - req.Header.Set("User-Agent", "Mozilla/5.0") - req.Header.Set("Cookie", cookie) - req.Header.Set("X-Requested-With", "XMLHttpRequest") - req.Header.Set("Referer", "https://www.douyu.com/") - resp, err := httpClient.Do(req) - if err != nil { - return "" - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - return extractMid(string(b), `"roleName":"`, `"`) -} - -// saveImg 在抢购成功后生成一张包含商品、时间、角色名和账号的结果图片。 -func saveImg(a Account, cid, roleName string) { - productsMu.RLock() - var pname string - for _, p := range products { - if p.CommodityID == cid { - pname = p.CommodityName - break - } - } - productsMu.RUnlock() - - if cachedFont == nil { - log.Printf("字体未加载,无法生成图片") + writeError(w, http.StatusBadRequest, err) return } + cookie := strings.TrimSpace(string(body)) + if cookie == "" { + writeError(w, http.StatusBadRequest, errors.New("缺少 Cookie")) + return + } + score, err := s.client.Score(cookie) + if err != nil { + writeJSON(w, map[string]any{"score": "查询失败: " + err.Error()}) + return + } + writeJSON(w, map[string]any{"score": score}) +} + +// ==================== 成功图片 ==================== + +// saveSuccessImage 保存成功兑换结果图片;字体缺失时直接跳过。 +func (s *appState) saveSuccessImage(account Account, product Product, roleName string) { + s.fontMu.Lock() + font := s.font + s.fontMu.Unlock() + if font == nil { + return + } + + productName := product.CommodityName + if productName == "" { + productName = product.CommodityID + } width, height := 600, 280 img := image.NewRGBA(image.Rect(0, 0, width, height)) - bgColor := color.RGBA{213, 239, 253, 255} - greenColor := color.RGBA{76, 175, 80, 255} - whiteColor := color.RGBA{255, 255, 255, 255} - blackColor := color.RGBA{0, 0, 0, 255} + topColor := color.RGBA{76, 175, 80, 255} + white := color.RGBA{255, 255, 255, 255} + black := color.RGBA{0, 0, 0, 255} - // 画背景 for x := 0; x < width; x++ { for y := 0; y < height; y++ { if y < 60 { - img.Set(x, y, greenColor) + img.Set(x, y, topColor) } else { img.Set(x, y, bgColor) } } } - c := freetype.NewContext() - c.SetDPI(72) - c.SetFont(cachedFont) // ← 用缓存的字体,不再读取文件 - c.SetClip(img.Bounds()) - c.SetDst(img) + ctx := freetype.NewContext() + ctx.SetDPI(72) + ctx.SetFont(font) + ctx.SetClip(img.Bounds()) + ctx.SetDst(img) - loc := time.FixedZone("CST", 8*3600) - now := time.Now().In(loc) - - texts := []struct { - text string - size float64 - x, y int - color color.Color + lines := []struct { + Text string + Size float64 + X, Y int + Color color.Color }{ - {pname, 20, 25, 42, whiteColor}, - {"兑换时间:" + now.Format("2006年01月02日 15:04:05"), 16, 25, 110, blackColor}, - {"游戏昵称:" + roleName, 16, 25, 150, blackColor}, - {"兑换账号:" + a.Name, 16, 25, 190, blackColor}, + {productName, 20, 25, 42, white}, + {"兑换时间:" + time.Now().In(cst).Format("2006年01月02日 15:04:05"), 16, 25, 110, black}, + {"游戏昵称:" + emptyDefault(roleName, "未知"), 16, 25, 150, black}, + {"兑换账号:" + account.Name, 16, 25, 190, black}, + } + for _, line := range lines { + ctx.SetFontSize(line.Size) + ctx.SetSrc(image.NewUniform(line.Color)) + _, _ = ctx.DrawString(line.Text, freetype.Pt(line.X, line.Y)) } - for _, t := range texts { - c.SetFontSize(t.size) - c.SetSrc(image.NewUniform(t.color)) - pt := freetype.Pt(t.x, t.y) - c.DrawString(t.text, pt) + name := safeFilename(fmt.Sprintf("%s_%s_%d.png", account.Name, productName, time.Now().Unix())) + path := filepath.Join("success_images", name) + file, err := os.Create(path) + if err != nil { + log.Printf("成功图片创建失败: %v", err) + return } - - fn := fmt.Sprintf("success_images/%s_%s_%d.png", a.Name, pname, time.Now().Unix()) - f2, _ := os.Create(fn) - defer f2.Close() - png.Encode(f2, img) - log.Printf("✅ 图片已保存: %s", fn) + defer file.Close() + if err := png.Encode(file, img); err != nil { + log.Printf("成功图片保存失败: %v", err) + return + } + log.Printf("成功图片已保存: %s", path) } -// extractMid 从字符串 s 中截取 start 和 end 之间的内容;找不到起点时返回空字符串。 -func extractMid(s, start, end string) string { - i := strings.Index(s, start) - if i == -1 { - return "" - } - i += len(start) - j := strings.Index(s[i:], end) - if j == -1 { - return s[i:] - } - return s[i : i+j] +// ==================== 通用工具 ==================== + +// readJSON 读取请求 JSON。 +func readJSON(r *http.Request, out any) error { + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(out) } -// trunc 将过长字符串截断到指定长度,并追加省略号,便于日志展示。 -func trunc(s string, n int) string { - if len(s) > n { - return s[:n] + "..." +// writeJSON 写出 JSON 响应。 +func writeJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(value) +} + +// writeError 写出统一错误 JSON,同时设置 HTTP 状态码。 +func writeError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "error": err.Error(), + }) +} + +// appendCacheBuster 给 URL 追加时间戳,避免时间接口被缓存。 +func appendCacheBuster(rawURL string) string { + sep := "?" + if strings.Contains(rawURL, "?") { + sep = "&" } - return s + return rawURL + sep + "_=" + strconv.FormatInt(time.Now().UnixNano(), 10) +} + +// cloneStringMap 复制 map,避免异步任务读到调用方后续修改。 +func cloneStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +// mergeCookie 把指定 Cookie 写入原 Cookie 字符串,已有同名 Cookie 会被覆盖。 +func mergeCookie(cookie, name, value string) string { + parts := strings.Split(cookie, ";") + pairs := make([]string, 0, len(parts)+1) + replaced := false + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, _, ok := strings.Cut(part, "=") + if ok && strings.TrimSpace(key) == name { + pairs = append(pairs, name+"="+value) + replaced = true + continue + } + pairs = append(pairs, part) + } + if !replaced && value != "" { + pairs = append(pairs, name+"="+value) + } + return strings.Join(pairs, "; ") +} + +// trimForLog 截断接口响应,避免日志刷屏。 +func trimForLog(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +// emptyDefault 在字符串为空时返回默认值。 +func emptyDefault(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +// safeFilename 替换文件名中的危险字符。 +func safeFilename(name string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + return replacer.Replace(name) } diff --git a/static/index.html b/static/index.html index 4a24114..dad532e 100644 --- a/static/index.html +++ b/static/index.html @@ -386,13 +386,18 @@ // ==================== 立即抢购(不弹窗) ==================== // startNow 构建账号商品映射后,调用后端立即启动抢购。 async function startNow(){ - let ap=buildAP(); - if(!ap) return; - addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i'); - toast('⚡ 抢购中...',`${Object.keys(ap).length} 个账号正在抢购 ${selProdName}`,'info',3000); - let r=await fetch('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})}); - let d=await r.json(); - addLog(d.msg||'已提交','i'); + let ap=buildAP(); + if(!ap) return; + addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i'); + toast('⚡ 抢购中...',`${Object.keys(ap).length} 个账号正在抢购 ${selProdName}`,'info',3000); + let r=await fetch('/api/seckill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accountProducts:ap})}); + let d=await r.json(); + if(d.success){ + addLog(d.msg||'已提交','i'); + }else{ + addLog('❌ 提交失败: '+(d.error||'未知错误'),'e'); + toast('❌ 提交失败',d.error||'未知错误','error'); + } } // ==================== 定时抢购 ==================== @@ -429,9 +434,12 @@ toast('❌ 定时失败',d.error||'未知错误','error'); } } - // cancelSchedule 只恢复前端定时状态和按钮显示;当前后端没有对应取消接口。 + // cancelSchedule 调用后端取消尚未触发的定时任务,并恢复前端按钮状态。 async function cancelSchedule(){ - scheduleActive=false; + try{ + await fetch('/api/cancel-schedule',{method:'POST'}); + }catch(e){} + scheduleActive=false; // 清除缓存的定时 localStorage.removeItem('targetTime'); localStorage.removeItem('advance');