Files
dygou_go/main.go
T
2026-06-19 10:57:42 +08:00

1303 lines
36 KiB
Go

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)
}