diff --git a/.gitignore b/.gitignore index 66be280..c554f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,11 @@ *.iml logs/ success_images/ + +# 本地账号、Cookie 与抓包资料 +user.txt +ck 对比.md +软软马卡龙.txt + +# 本地字体文件 +*.ttf diff --git a/config.go b/config.go new file mode 100644 index 0000000..8e040ea --- /dev/null +++ b/config.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" +) + +// defaultAppPaths 收集运行时路径,支持环境变量覆盖,方便不同平台和部署方式调整。 +func defaultAppPaths() appPaths { + return appPaths{ + ConfigFile: envOrDefault("DOUYU_CONFIG", defaultConfigFile), + AccountFile: envOrDefault("DOUYU_USERS", defaultAccountFile), + LogDir: envOrDefault("DOUYU_LOG_DIR", defaultLogDir), + SuccessDir: envOrDefault("DOUYU_SUCCESS_DIR", defaultSuccessDir), + FontFiles: collectFontCandidates(), + } +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return filepath.Clean(value) + } + return fallback +} + +// loadConfig 读取配置文件;缺失时安静使用内置默认配置。 +func loadConfig(path string) Config { + cfg := Config{TimeAPIURL: defaultTimeAPI} + if path == "" { + path = defaultConfigFile + } + + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Printf("配置加载完成: 内置默认配置") + } else { + log.Printf("读取 %s 出错,使用内置默认配置: %v", path, err) + } + return cfg + } + defer f.Close() + + if err := json.NewDecoder(f).Decode(&cfg); err != nil { + log.Printf("解析 %s 失败,使用内置默认配置: %v", path, err) + return Config{TimeAPIURL: defaultTimeAPI} + } + if cfg.TimeAPIURL == "" { + cfg.TimeAPIURL = defaultTimeAPI + } + log.Printf("配置加载完成: %s", path) + return cfg +} diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..c3c1a8d --- /dev/null +++ b/constants.go @@ -0,0 +1,33 @@ +package main + +import "time" + +const ( + manualID = "G4KA4Qnz4LDp7" + roomID = "9263298" + actAlias = "20260120QYOOB" + + 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 + productCacheTTL = 30 * time.Second + + serverAddr = "127.0.0.1:2169" + staticDir = "./static" + defaultConfigFile = "config.json" + defaultAccountFile = "user.txt" + defaultLogDir = "logs" + defaultSuccessDir = "success_images" +) + +var cst = time.FixedZone("CST", 8*3600) diff --git a/douyu_client.go b/douyu_client.go new file mode 100644 index 0000000..74c0665 --- /dev/null +++ b/douyu_client.go @@ -0,0 +1,308 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" +) + +// 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 := 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 { + return time.Time{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) + } + return time.Unix(ts, 0).UTC(), nil +} + +// Products 拉取商城商品列表。 +func (c *DouyuClient) Products() ([]Product, error) { + var result struct { + APIResponse + Data struct { + List []Product `json:"list"` + } `json:"data"` + } + 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 +} + +// Score 查询账号积分余额。 +func (c *DouyuClient) Score(cookie string) (int, error) { + req, err := http.NewRequest("POST", scoreURL, strings.NewReader(`{}`)) + if err != nil { + return 0, err + } + c.setJSONHeaders(req, cookie) + req.Header.Set("Referer", baseURL+"/") + + var result struct { + APIResponse + Data struct { + Balance int `json:"balance"` + } `json:"data"` + } + if err := c.doJSON(req, &result); err != nil { + return 0, err + } + if result.Error != 0 { + return 0, fmt.Errorf("积分接口失败: %s", result.Msg) + } + return result.Data.Balance, nil +} + +// 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 { + return "", err + } + c.setBrowserHeaders(req, cookie) + + 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("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 { + Limit string `json:"limit"` + } `json:"data"` + } + if err := c.postMultipart(batchLimitURL, cookie, csrf, map[string]string{ + "manualID": manualID, + "commodityID": commodityID, + "rid": roomID, + "csrfToken": csrf, + }, &result); err != nil { + return 0, err + } + if result.Error != 0 { + return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg) + } + limit, err := strconv.Atoi(result.Data.Limit) + if err != nil { + 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") +} diff --git a/font.go b/font.go new file mode 100644 index 0000000..0466ab1 --- /dev/null +++ b/font.go @@ -0,0 +1,94 @@ +package main + +import ( + "errors" + "log" + "os" + "runtime" + + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font/gofont/goregular" +) + +// collectFontCandidates 收集常见中文字体路径,允许用 DOUYU_FONT 指定自定义字体。 +func collectFontCandidates() []string { + candidates := []string{} + if custom := os.Getenv("DOUYU_FONT"); custom != "" { + candidates = append(candidates, custom) + } + candidates = append(candidates, + "Microsoft Yahei.ttf", + "Microsoft YaHei.ttf", + "NotoSansCJKsc-Regular.otf", + "NotoSansCJK-Regular.ttc", + ) + + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Songti.ttc", + ) + case "windows": + candidates = append(candidates, + `C:\Windows\Fonts\msyh.ttc`, + `C:\Windows\Fonts\simhei.ttf`, + `C:\Windows\Fonts\simsun.ttc`, + ) + default: + candidates = append(candidates, + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ) + } + return candidates +} + +// loadFont 预加载字体,优先使用本地或系统中文字体,缺失时使用内置字体兜底。 +func (s *appState) loadFont() { + s.fontMu.Lock() + defer s.fontMu.Unlock() + + source, font, err := parseFirstFont(s.paths.FontFiles) + if err != nil { + source = "内置 Go Regular 字体" + font, err = freetype.ParseFont(goregular.TTF) + if err != nil { + log.Printf("内置字体解析失败,成功图片功能将跳过: %v", err) + return + } + log.Printf("未找到可用系统字体,使用内置字体兜底,中文可能显示不完整") + } + s.font = font + log.Printf("字体加载完成: %s", source) +} + +func parseFirstFont(candidates []string) (string, *truetype.Font, error) { + for _, path := range candidates { + if path == "" { + continue + } + fontBytes, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Printf("字体读取失败,已跳过: %s: %v", path, err) + } + continue + } + if len(fontBytes) == 0 { + continue + } + font, err := freetype.ParseFont(fontBytes) + if err != nil { + log.Printf("字体解析失败,已跳过: %s: %v", path, err) + continue + } + return path, font, nil + } + return "", nil, errors.New("没有可用字体") +} diff --git a/go.mod b/go.mod index 3c2f681..6ef6525 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,4 @@ go 1.26.3 require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 -require golang.org/x/image v0.42.0 // indirect +require golang.org/x/image v0.42.0 diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..3578238 --- /dev/null +++ b/handlers.go @@ -0,0 +1,194 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// 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/logs", s.handleLogs) + 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(staticDir))) +} + +// handleProducts 返回商品列表;带 refresh=1 时会从斗鱼实时刷新,失败不使用旧库存冒充成功。 +func (s *appState) handleProducts(w http.ResponseWriter, r *http.Request) { + refresh := r.URL.Query().Get("refresh") == "1" + if refresh { + products, err := s.client.Products() + if err != nil { + log.Printf("商品刷新失败: %v", err) + writeError(w, http.StatusBadGateway, err) + return + } + fetchedAt := time.Now() + s.setProducts(products, fetchedAt) + log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) + writeJSON(w, s.productPayload("live")) + return + } + writeJSON(w, s.productPayload("cache")) +} + +// handleLoadProducts 兼容旧前端入口;新前端直接调用 /api/products?refresh=1。 +func (s *appState) handleLoadProducts(w http.ResponseWriter, _ *http.Request) { + products, err := s.client.Products() + if err != nil { + log.Printf("商品刷新失败: %v", err) + writeError(w, http.StatusBadGateway, err) + return + } + fetchedAt := time.Now() + s.setProducts(products, fetchedAt) + log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) + writeJSON(w, s.productPayload("live")) +} + +// 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: maskCookie(account.Cookie), + }) + } + writeJSON(w, list) +} + +// handleReloadAccounts 重新加载账号文件。 +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()) +} + +// handleLogs 返回后端内存日志,支持用 since 参数增量获取。 +func (s *appState) handleLogs(w http.ResponseWriter, r *http.Request) { + var since int64 + if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("since 参数错误: %w", err)) + return + } + since = parsed + } + + entries := s.logs.Since(since) + nextSince := since + if len(entries) > 0 { + nextSince = entries[len(entries)-1].ID + } + writeJSON(w, map[string]any{ + "success": true, + "logs": entries, + "since": nextSince, + "file": s.logs.FilePath(), + }) +} + +// 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, + }) +} + +// handleQueryScore 查询单个账号的积分。 +func (s *appState) handleQueryScore(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + accountName := strings.TrimSpace(string(body)) + if accountName == "" { + writeError(w, http.StatusBadRequest, errors.New("缺少账号名")) + return + } + cookie, ok := s.accountCookieByName(accountName) + if !ok || strings.TrimSpace(cookie) == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("账号不存在或 Cookie 为空: %s", accountName)) + 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}) +} diff --git a/image.go b/image.go new file mode 100644 index 0000000..dc1185e --- /dev/null +++ b/image.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "log" + "os" + "path/filepath" + "time" + + "github.com/golang/freetype" +) + +// 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} + 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, topColor) + } else { + img.Set(x, y, bgColor) + } + } + } + + ctx := freetype.NewContext() + ctx.SetDPI(72) + ctx.SetFont(font) + ctx.SetClip(img.Bounds()) + ctx.SetDst(img) + + lines := []struct { + Text string + Size float64 + X, Y int + Color color.Color + }{ + {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)) + } + + name := safeFilename(fmt.Sprintf("%s_%s_%d.png", account.Name, productName, time.Now().Unix())) + path := filepath.Join(s.paths.SuccessDir, name) + file, err := os.Create(path) + if err != nil { + log.Printf("成功图片创建失败: %v", err) + return + } + defer file.Close() + if err := png.Encode(file, img); err != nil { + log.Printf("成功图片保存失败: %v", err) + return + } + log.Printf("成功图片已保存: %s", path) +} diff --git a/log_store.go b/log_store.go new file mode 100644 index 0000000..3ead305 --- /dev/null +++ b/log_store.go @@ -0,0 +1,120 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +// newMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。 +func newMemoryLogStore(max int) *memoryLogStore { + if max <= 0 { + max = 500 + } + return &memoryLogStore{ + max: max, + entries: make([]LogEntry, 0, max), + } +} + +// initLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。 +func initLogging(store *memoryLogStore, logDir string) func() { + if logDir == "" { + logDir = defaultLogDir + } + if err := os.MkdirAll(logDir, 0755); err != nil { + log.SetOutput(io.MultiWriter(os.Stdout, store)) + log.Printf("日志目录创建失败,仅写入终端和内存: %v", err) + return func() {} + } + + filePath := filepath.Join(logDir, "douyu_"+time.Now().In(cst).Format("20060102_150405")+".log") + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + log.SetOutput(io.MultiWriter(os.Stdout, store)) + log.Printf("日志文件创建失败,仅写入终端和内存: %v", err) + return func() {} + } + + store.setFilePath(filePath) + log.SetOutput(io.MultiWriter(os.Stdout, file, store)) + log.Printf("日志文件已启用: %s", filePath) + return func() { + _ = file.Close() + } +} + +// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 +func (s *memoryLogStore) Write(p []byte) (int, error) { + text := strings.TrimRight(string(p), "\r\n") + if text == "" { + return len(p), nil + } + + s.mu.Lock() + defer s.mu.Unlock() + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + s.nextID++ + s.entries = append(s.entries, LogEntry{ + ID: s.nextID, + Time: time.Now().In(cst).Format("2006-01-02 15:04:05.000"), + Level: detectLogLevel(line), + Message: line, + }) + if len(s.entries) > s.max { + copy(s.entries, s.entries[len(s.entries)-s.max:]) + s.entries = s.entries[:s.max] + } + } + return len(p), nil +} + +// setFilePath 保存当前日志文件路径,供前端展示或排查使用。 +func (s *memoryLogStore) setFilePath(path string) { + s.mu.Lock() + defer s.mu.Unlock() + s.filePath = path +} + +// Since 返回 ID 大于 since 的日志。 +func (s *memoryLogStore) Since(since int64) []LogEntry { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]LogEntry, 0, len(s.entries)) + for _, entry := range s.entries { + if entry.ID > since { + out = append(out, entry) + } + } + return out +} + +// FilePath 返回当前日志文件路径。 +func (s *memoryLogStore) FilePath() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.filePath +} + +// detectLogLevel 根据日志文本粗略分类,方便前端染色。 +func detectLogLevel(line string) string { + lower := strings.ToLower(line) + switch { + case strings.Contains(line, "成功"): + return "success" + case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"): + return "error" + case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"): + return "warn" + default: + return "info" + } +} diff --git a/main.go b/main.go index 6167976..1a7a323 100644 --- a/main.go +++ b/main.go @@ -1,177 +1,26 @@ package main import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "image" - "image/color" - "image/png" - "io" "log" - "mime/multipart" "net/http" "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" - - "github.com/golang/freetype" - "github.com/golang/freetype/truetype" ) -const ( - manualID = "G4KA4Qnz4LDp7" - roomID = "9263298" - actAlias = "20260120QYOOB" - - 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 - productCacheTTL = 30 * time.Second -) - -var cst = time.FixedZone("CST", 8*3600) - -type Config struct { - TimeAPIURL string `json:"timeApiUrl"` -} - -type Account struct { - Name string `json:"name"` - Password string `json:"password"` - Cookie string `json:"cookie"` -} - -type Product struct { - CommodityID string `json:"commodityId"` - CommodityName string `json:"commodityName"` - 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 ProductPayload struct { - Success bool `json:"success"` - Source string `json:"source"` - Products []Product `json:"products"` - Count int `json:"count"` - FetchedAt string `json:"fetchedAt"` - AgeSecond int `json:"ageSecond"` - Stale bool `json:"stale"` -} - -type AccountView struct { - Index int `json:"index"` - Name string `json:"name"` - Cookie string `json:"cookie"` -} - -type SeckillRequest struct { - AccountProducts map[string]string `json:"accountProducts"` - TargetTime string `json:"targetTime"` - Advance int `json:"advance"` -} - -type APIResponse struct { - Error int `json:"error"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` -} - -type LogEntry struct { - ID int64 `json:"id"` - Time string `json:"time"` - Level string `json:"level"` - Message string `json:"message"` -} - -type appState struct { - config Config - client *DouyuClient - logs *memoryLogStore - - accountsMu sync.RWMutex - accounts []Account - - productsMu sync.RWMutex - products []Product - productsAt time.Time - - resultsMu sync.RWMutex - results map[string]string - - scheduleMu sync.Mutex - schedule *time.Timer - - fontMu sync.Mutex - font *truetype.Font -} - -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 -} - -type memoryLogStore struct { - mu sync.RWMutex - nextID int64 - max int - filePath string - entries []LogEntry -} - -var state = &appState{ - config: Config{TimeAPIURL: defaultTimeAPI}, - results: make(map[string]string), -} - func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + state.paths = defaultAppPaths() state.logs = newMemoryLogStore(800) - closeLogFile := initLogging(state.logs) + closeLogFile := initLogging(state.logs, state.paths.LogDir) defer closeLogFile() - state.config = loadConfig() + state.config = loadConfig(state.paths.ConfigFile) state.client = NewDouyuClient(state.config.TimeAPIURL) state.loadAccounts() state.loadFont() - _ = os.MkdirAll("success_images", 0755) + if err := os.MkdirAll(state.paths.SuccessDir, 0755); err != nil { + log.Printf("成功图片目录创建失败,图片保存功能可能不可用: %v", err) + } if serverTime, err := state.client.ServerTime(); err == nil { log.Printf("服务端时间获取成功: %s", serverTime.In(cst).Format("2006-01-02 15:04:05.000")) @@ -182,1121 +31,6 @@ func main() { mux := http.NewServeMux() state.registerRoutes(mux) - log.Printf("服务启动: http://localhost:2169 | 账号: %d", state.accountCount()) - log.Fatal(http.ListenAndServe(":2169", mux)) -} - -// ==================== 日志系统 ==================== - -// newMemoryLogStore 创建内存日志缓冲,最多保留 max 条最新日志。 -func newMemoryLogStore(max int) *memoryLogStore { - if max <= 0 { - max = 500 - } - return &memoryLogStore{ - max: max, - entries: make([]LogEntry, 0, max), - } -} - -// initLogging 初始化日志输出:终端、日志文件、内存缓冲三处同时写入。 -func initLogging(store *memoryLogStore) func() { - if err := os.MkdirAll("logs", 0755); err != nil { - log.SetOutput(io.MultiWriter(os.Stdout, store)) - log.Printf("日志目录创建失败,仅写入终端和内存: %v", err) - return func() {} - } - - filePath := filepath.Join("logs", "douyu_"+time.Now().In(cst).Format("20060102_150405")+".log") - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - log.SetOutput(io.MultiWriter(os.Stdout, store)) - log.Printf("日志文件创建失败,仅写入终端和内存: %v", err) - return func() {} - } - - store.setFilePath(filePath) - log.SetOutput(io.MultiWriter(os.Stdout, file, store)) - log.Printf("日志文件已启用: %s", filePath) - return func() { - _ = file.Close() - } -} - -// Write 实现 io.Writer,把标准 log 输出转存为内存日志。 -func (s *memoryLogStore) Write(p []byte) (int, error) { - text := strings.TrimRight(string(p), "\r\n") - if text == "" { - return len(p), nil - } - - s.mu.Lock() - defer s.mu.Unlock() - for _, line := range strings.Split(text, "\n") { - line = strings.TrimRight(line, "\r") - if strings.TrimSpace(line) == "" { - continue - } - s.nextID++ - s.entries = append(s.entries, LogEntry{ - ID: s.nextID, - Time: time.Now().In(cst).Format("2006-01-02 15:04:05.000"), - Level: detectLogLevel(line), - Message: line, - }) - if len(s.entries) > s.max { - copy(s.entries, s.entries[len(s.entries)-s.max:]) - s.entries = s.entries[:s.max] - } - } - return len(p), nil -} - -// setFilePath 保存当前日志文件路径,供前端展示或排查使用。 -func (s *memoryLogStore) setFilePath(path string) { - s.mu.Lock() - defer s.mu.Unlock() - s.filePath = path -} - -// Since 返回 ID 大于 since 的日志。 -func (s *memoryLogStore) Since(since int64) []LogEntry { - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]LogEntry, 0, len(s.entries)) - for _, entry := range s.entries { - if entry.ID > since { - out = append(out, entry) - } - } - return out -} - -// FilePath 返回当前日志文件路径。 -func (s *memoryLogStore) FilePath() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.filePath -} - -// detectLogLevel 根据日志文本粗略分类,方便前端染色。 -func detectLogLevel(line string) string { - lower := strings.ToLower(line) - switch { - case strings.Contains(line, "成功"): - return "success" - case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"): - return "error" - case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"): - return "warn" - default: - return "info" - } -} - -// ==================== 初始化与状态 ==================== - -// loadConfig 读取 config.json;失败时使用默认商品详情接口做服务端时间来源。 -func loadConfig() Config { - cfg := Config{TimeAPIURL: defaultTimeAPI} - - f, err := os.Open("config.json") - if err != nil { - 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() - - 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]), - }) - } - 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() - - fontBytes, err := os.ReadFile("Microsoft Yahei.ttf") - if err != nil { - log.Printf("字体文件不存在,成功图片功能将跳过: %v", err) - return - } - font, err := freetype.ParseFont(fontBytes) - if err != nil { - log.Printf("字体解析失败,成功图片功能将跳过: %v", err) - return - } - s.font = font - log.Printf("字体加载完成") -} - -// 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, time.Time) { - s.productsMu.RLock() - defer s.productsMu.RUnlock() - - list := make([]Product, len(s.products)) - copy(list, s.products) - return list, s.productsAt -} - -// setProducts 替换商品缓存,并记录这批库存数据的刷新时间。 -func (s *appState) setProducts(products []Product, fetchedAt time.Time) { - s.productsMu.Lock() - defer s.productsMu.Unlock() - s.products = products - s.productsAt = fetchedAt -} - -// productPayload 根据当前缓存生成前端商品响应。 -func (s *appState) productPayload(source string) ProductPayload { - products, fetchedAt := s.productSnapshot() - payload := ProductPayload{ - Success: true, - Source: source, - Products: products, - Count: len(products), - } - if !fetchedAt.IsZero() { - age := time.Since(fetchedAt) - if age < 0 { - age = 0 - } - payload.FetchedAt = fetchedAt.In(cst).Format("2006-01-02 15:04:05.000") - payload.AgeSecond = int(age.Seconds()) - payload.Stale = age > productCacheTTL - } else { - payload.Stale = true - } - return payload -} - -// 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 { - products, _ := s.productSnapshot() - for _, p := range products { - 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 := 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 { - return time.Time{}, fmt.Errorf("currentTime 无效: %q", result.Data.PreExchange.CurrentTime) - } - return time.Unix(ts, 0).UTC(), nil -} - -// Products 拉取商城商品列表。 -func (c *DouyuClient) Products() ([]Product, error) { - var result struct { - APIResponse - Data struct { - List []Product `json:"list"` - } `json:"data"` - } - 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 -} - -// Score 查询账号积分余额。 -func (c *DouyuClient) Score(cookie string) (int, error) { - req, err := http.NewRequest("POST", scoreURL, strings.NewReader(`{}`)) - if err != nil { - return 0, err - } - c.setJSONHeaders(req, cookie) - req.Header.Set("Referer", baseURL+"/") - - var result struct { - APIResponse - Data struct { - Balance int `json:"balance"` - } `json:"data"` - } - if err := c.doJSON(req, &result); err != nil { - return 0, err - } - if result.Error != 0 { - return 0, fmt.Errorf("积分接口失败: %s", result.Msg) - } - return result.Data.Balance, nil -} - -// 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 { - return "", err - } - c.setBrowserHeaders(req, cookie) - - 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("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 { - Limit string `json:"limit"` - } `json:"data"` - } - if err := c.postMultipart(batchLimitURL, cookie, csrf, map[string]string{ - "manualID": manualID, - "commodityID": commodityID, - "rid": roomID, - "csrfToken": csrf, - }, &result); err != nil { - return 0, err - } - if result.Error != 0 { - return 0, fmt.Errorf("批量限制接口失败: %s", result.Msg) - } - limit, err := strconv.Atoi(result.Data.Limit) - if err != nil { - 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 - } - - 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) - 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, - ) - - return map[string]any{ - "success": true, - "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(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/logs", s.handleLogs) - 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 返回商品列表;带 refresh=1 时会从斗鱼实时刷新,失败不使用旧库存冒充成功。 -func (s *appState) handleProducts(w http.ResponseWriter, r *http.Request) { - refresh := r.URL.Query().Get("refresh") == "1" - if refresh { - products, err := s.client.Products() - if err != nil { - log.Printf("商品刷新失败: %v", err) - writeError(w, http.StatusBadGateway, err) - return - } - fetchedAt := time.Now() - s.setProducts(products, fetchedAt) - log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) - writeJSON(w, s.productPayload("live")) - return - } - writeJSON(w, s.productPayload("cache")) -} - -// handleLoadProducts 兼容旧前端入口;新前端直接调用 /api/products?refresh=1。 -func (s *appState) handleLoadProducts(w http.ResponseWriter, _ *http.Request) { - products, err := s.client.Products() - if err != nil { - log.Printf("商品刷新失败: %v", err) - writeError(w, http.StatusBadGateway, err) - return - } - fetchedAt := time.Now() - s.setProducts(products, fetchedAt) - log.Printf("商品刷新完成: %d 个,刷新时间=%s", len(products), fetchedAt.In(cst).Format("15:04:05.000")) - writeJSON(w, s.productPayload("live")) -} - -// 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()) -} - -// handleLogs 返回后端内存日志,支持用 since 参数增量获取。 -func (s *appState) handleLogs(w http.ResponseWriter, r *http.Request) { - var since int64 - if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { - parsed, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, fmt.Errorf("since 参数错误: %w", err)) - return - } - since = parsed - } - - entries := s.logs.Since(since) - nextSince := since - if len(entries) > 0 { - nextSince = entries[len(entries)-1].ID - } - writeJSON(w, map[string]any{ - "success": true, - "logs": entries, - "since": nextSince, - "file": s.logs.FilePath(), - }) -} - -// 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, - }) -} - -// handleQueryScore 查询单个 Cookie 的积分。 -func (s *appState) handleQueryScore(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - 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} - 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, topColor) - } else { - img.Set(x, y, bgColor) - } - } - } - - ctx := freetype.NewContext() - ctx.SetDPI(72) - ctx.SetFont(font) - ctx.SetClip(img.Bounds()) - ctx.SetDst(img) - - lines := []struct { - Text string - Size float64 - X, Y int - Color color.Color - }{ - {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)) - } - - 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 - } - defer file.Close() - if err := png.Encode(file, img); err != nil { - log.Printf("成功图片保存失败: %v", err) - return - } - log.Printf("成功图片已保存: %s", path) -} - -// ==================== 通用工具 ==================== - -// readJSON 读取请求 JSON。 -func readJSON(r *http.Request, out any) error { - defer r.Body.Close() - return json.NewDecoder(r.Body).Decode(out) -} - -// 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 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) + log.Printf("服务启动: http://%s | 账号: %d", serverAddr, state.accountCount()) + log.Fatal(http.ListenAndServe(serverAddr, mux)) } diff --git a/seckill.go b/seckill.go new file mode 100644 index 0000000..1e0d474 --- /dev/null +++ b/seckill.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "fmt" + "log" + "strings" + "sync" + "time" +) + +// runNow 立即执行一批抢购任务。 +func (s *appState) runNow(accountProducts map[string]string) { + s.clearResults() + attempts := s.buildAttempts(accountProducts) + if len(attempts) == 0 { + log.Printf("没有可执行的抢购任务") + 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) + 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, + ) + + return map[string]any{ + "success": true, + "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(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 +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..5eff048 --- /dev/null +++ b/state.go @@ -0,0 +1,151 @@ +package main + +import ( + "bufio" + "log" + "os" + "strings" + "time" +) + +// loadAccounts 从账号文件加载账号,格式为“账号----密码----Cookie”。 +func (s *appState) loadAccounts() { + path := s.paths.AccountFile + if path == "" { + path = defaultAccountFile + } + f, err := os.Open(path) + if err != nil { + log.Printf("读取账号文件失败: %s: %v", path, err) + s.accountsMu.Lock() + s.accounts = nil + s.accountsMu.Unlock() + return + } + defer f.Close() + + 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]), + }) + } + if err := scanner.Err(); err != nil { + log.Printf("读取账号文件中断: %v", err) + } + + s.accountsMu.Lock() + s.accounts = list + s.accountsMu.Unlock() + log.Printf("账号加载完成: %d 个,来源=%s", len(list), path) +} + +// 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 +} + +// accountCookieByName 按账号名查找真实 Cookie,仅在后端内部使用。 +func (s *appState) accountCookieByName(name string) (string, bool) { + s.accountsMu.RLock() + defer s.accountsMu.RUnlock() + + for _, account := range s.accounts { + if account.Name == name { + return account.Cookie, true + } + } + return "", false +} + +// productSnapshot 复制一份商品列表和对应的刷新时间。 +func (s *appState) productSnapshot() ([]Product, time.Time) { + s.productsMu.RLock() + defer s.productsMu.RUnlock() + + list := make([]Product, len(s.products)) + copy(list, s.products) + return list, s.productsAt +} + +// setProducts 替换商品缓存,并记录这批库存数据的刷新时间。 +func (s *appState) setProducts(products []Product, fetchedAt time.Time) { + s.productsMu.Lock() + defer s.productsMu.Unlock() + s.products = products + s.productsAt = fetchedAt +} + +// productPayload 根据当前缓存生成前端商品响应。 +func (s *appState) productPayload(source string) ProductPayload { + products, fetchedAt := s.productSnapshot() + payload := ProductPayload{ + Success: true, + Source: source, + Products: products, + Count: len(products), + } + if !fetchedAt.IsZero() { + age := time.Since(fetchedAt) + if age < 0 { + age = 0 + } + payload.FetchedAt = fetchedAt.In(cst).Format("2006-01-02 15:04:05.000") + payload.AgeSecond = int(age.Seconds()) + payload.Stale = age > productCacheTTL + } else { + payload.Stale = true + } + return payload +} + +// 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 +} diff --git a/static/index.html b/static/index.html index a2c2b4b..1b86bae 100644 --- a/static/index.html +++ b/static/index.html @@ -232,13 +232,10 @@ tasks.push((async (idx) => { let a = accounts[idx]; try { - console.log('账号对象:', JSON.stringify(a)); - console.log('Cookie长度:', a.cookie?.length, '前50字符:', a.cookie?.substring(0, 50)); - let d = await apiJSON('/api/query-score', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, - body: a.cookie + body: a.name }); let score = (d.score !== undefined && d.score !== null && d.score !== '') ? d.score : 'N/A'; // ✅ 用账号名存积分 @@ -377,10 +374,9 @@ let name = key.substring(key.indexOf('_') + 1); if (!validNames.has(name)) { localStorage.removeItem(key); - console.log('清理无效缓存:', key); - } - } - } + } + } + } renderAccounts(); } @@ -398,19 +394,19 @@ let statusStr = '-'; if (st === true) statusStr = '✅'; else if (st === false) statusStr = '❌'; - return `