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.logs = newMemoryLogStore(800) closeLogFile := initLogging(state.logs) defer closeLogFile() state.config = loadConfig() state.client = NewDouyuClient(state.config.TimeAPIURL) state.loadAccounts() state.loadFont() _ = os.MkdirAll("success_images", 0755) 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("服务端时间获取失败: %v", err) } 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) }