Files
dygou_go/main.go
T
2026-06-19 09:13:39 +08:00

700 lines
20 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"image"
"image/color"
"image/png"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
MANUAL_ID = "G4KA4Qnz4LDp7"
ROOM_ID = "9263298"
EXCHANGE_URL = "https://www.douyu.com/wgapi/ordnc/activity/peace/exchange"
CSRF_URL = "https://www.douyu.com/japi/carnival/nc/common/generateCsrf"
BIND_INFO = "https://www.douyu.com/japi/carnivalApi/v2/tencent/bindInfo?actAlias=20260120QYOOB"
JIFEN_HOST = "https://www.douyu.com/japi/oms/web/peace/credit/balance"
CONCURRENCY = 3
GLOBAL_CONCURRENT = 300
)
type Config struct {
TimeApiUrl string `json:"timeApiUrl"`
}
var config Config
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"`
}
type SeckillRequest struct {
AccountProducts map[string]string `json:"accountProducts"`
TargetTime string `json:"targetTime"`
Advance int `json:"advance"`
}
var (
accounts []Account
accountsMu sync.RWMutex
products []Product
productsMu sync.RWMutex
results = make(map[string]string)
resultsMu sync.RWMutex
httpClient = &http.Client{
Timeout: 10 * time.Second, // ← 加这一行,防止请求卡死
Transport: &http.Transport{
MaxConnsPerHost: 100,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 30 * time.Second,
},
}
seckillTimer *time.Timer
timerMu sync.Mutex
cachedFont *truetype.Font
fontMu sync.Mutex
)
// main 是程序入口,负责初始化配置、账号、字体、HTTP 路由,并启动本地 Web 服务。
func main() {
// 设置日志格式,显示毫秒
log.SetFlags(log.LstdFlags | log.Lmicroseconds) // ← 加这行
loadConfig() // ← 新增,放在最前面
loadAccounts()
os.MkdirAll("success_images", 0755)
initFont() // ← 加这一行,预加载字体
// 测试一下 currentTime 能不能通
if t := fetchCurrentTime(); !t.IsZero() {
log.Printf("✅ currentTime 获取成功: %s", t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05"))
} else {
log.Printf("⚠️ currentTime 获取失败,请检查网络")
}
log.Printf("服务启动,账号数: %d", len(accounts))
http.HandleFunc("/api/products", handleProducts)
http.HandleFunc("/api/load-products", handleLoadProducts)
http.HandleFunc("/api/accounts", handleAccounts)
http.HandleFunc("/api/reload-accounts", handleReloadAccounts)
http.HandleFunc("/api/seckill", handleSeckill)
http.HandleFunc("/api/schedule", handleSchedule)
http.HandleFunc("/api/results", handleResults)
http.HandleFunc("/api/time", handleTime)
http.HandleFunc("/api/clear-results", handleClearResults)
http.HandleFunc("/api/query-score", handleQueryScore)
http.Handle("/", http.FileServer(http.Dir("./static")))
log.Printf("✅ 服务启动: http://localhost:2169 | 账号:%d", len(accounts))
log.Fatal(http.ListenAndServe(":2169", nil))
}
// initFont 预加载微软雅黑字体,供成功兑换后生成结果图片时复用。
func initFont() {
fontMu.Lock()
defer fontMu.Unlock()
if cachedFont != nil {
return
}
fontBytes, err := os.ReadFile("Microsoft Yahei.ttf")
if err != nil {
log.Printf("[ERROR] 字体文件加载失败: %v", err)
return
}
f, err := freetype.ParseFont(fontBytes)
if err != nil {
log.Printf("[ERROR] 字体解析失败: %v", err)
return
}
cachedFont = f
log.Printf("[OK] 字体加载成功")
}
// loadConfig 读取 config.json 中的时间校准接口地址;读取或解析失败时使用默认商品详情接口。
func loadConfig() {
f, err := os.Open("config.json")
if err != nil {
log.Printf("⚠️ 读取 config.json 失败,使用默认URL")
config.TimeApiUrl = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail?manualID=G4KA4Qnz4LDp7&commodityID=20260120QYOOB_bag274&rid=9263298"
return
}
defer f.Close()
decoder := json.NewDecoder(f)
if err := decoder.Decode(&config); err != nil {
log.Printf("⚠️ 解析 config.json 失败,使用默认URL")
config.TimeApiUrl = "https://www.douyu.com/wgapi/ordnc/activity/peace/storedetail?manualID=G4KA4Qnz4LDp7&commodityID=20260120QYOOB_bag274&rid=9263298"
return
}
log.Printf("✅ 加载配置成功: %s", config.TimeApiUrl)
}
// ==================== 核心:从商品详情接口获取服务端当前时间 ====================
// fetchCurrentTime 从商品详情接口获取服务端当前时间
func fetchCurrentTime() time.Time {
url := config.TimeApiUrl + "&_=" + fmt.Sprintf("%d", time.Now().UnixNano())
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5")
req.Header.Set("Referer", "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId="+ROOM_ID)
req.Header.Set("Connection", "keep-alive")
resp, err := httpClient.Do(req)
if err != nil {
log.Printf("fetchCurrentTime 请求失败: %v", err)
return time.Time{}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("fetchCurrentTime HTTP状态码: %d", resp.StatusCode)
return time.Time{}
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
PreExchange struct {
CurrentTime string `json:"currentTime"`
} `json:"preExchange"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
log.Printf("fetchCurrentTime 解析JSON失败: %v", err)
return time.Time{}
}
ts, err := strconv.ParseInt(result.Data.PreExchange.CurrentTime, 10, 64)
if err != nil || ts == 0 {
log.Printf("fetchCurrentTime 解析时间戳失败: %s", result.Data.PreExchange.CurrentTime)
return time.Time{}
}
return time.Unix(ts, 0).UTC()
}
// getServerTime 获取服务端当前时间(带重试)
func getServerTime() time.Time {
for i := 0; i < 3; i++ {
t := fetchCurrentTime()
if !t.IsZero() {
return t
}
time.Sleep(100 * time.Millisecond)
}
// 全部失败,降级到本地时间
log.Printf("⚠️ getServerTime 全部失败,降级到本地时间")
return time.Now().UTC()
}
// ==================== API ====================
// handleClearResults 清空内存中的抢购结果,通常在前端启动时调用以避免展示旧结果。
func handleClearResults(w http.ResponseWriter, _ *http.Request) {
resultsMu.Lock()
results = make(map[string]string)
resultsMu.Unlock()
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// getScore 使用账号 Cookie 请求斗鱼积分余额接口,返回积分数字字符串或失败提示。
func getScore(cookie string) string {
body := strings.NewReader(`{}`)
req, _ := http.NewRequest("POST", JIFEN_HOST, body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", "https://www.douyu.com/")
resp, err := httpClient.Do(req)
if err != nil {
return "查询失败"
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
Balance int `json:"balance"`
} `json:"data"`
}
json.Unmarshal(b, &result)
return fmt.Sprintf("%d", result.Data.Balance)
}
// handleQueryScore 接收前端传入的 Cookie,查询该账号积分并以 JSON 返回。
func handleQueryScore(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
cookie := string(body)
if cookie == "" {
http.Error(w, "缺少cookie", 400)
return
}
score := getScore(cookie)
json.NewEncoder(w).Encode(map[string]interface{}{"score": score})
}
// loadAccounts 从 user.txt 读取账号列表,格式为“账号----密码----Cookie”,并刷新全局账号缓存。
func loadAccounts() {
accountsMu.Lock()
defer accountsMu.Unlock()
accounts = nil
f, err := os.Open("user.txt")
if err != nil {
log.Printf("读取user.txt失败: %v", err)
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, "----", 3)
if len(parts) == 3 {
accounts = append(accounts, Account{Name: parts[0], Password: parts[1], Cookie: parts[2]})
}
}
log.Printf("加载 %d 个账号", len(accounts))
}
// handleTime 返回斗鱼服务端时间和本地时间,供前端展示与定时抢购校准使用。
func handleTime(w http.ResponseWriter, _ *http.Request) {
loc := time.FixedZone("CST", 8*3600)
serverTime := getServerTime()
json.NewEncoder(w).Encode(map[string]interface{}{
"serverTime": serverTime.In(loc).Format("2006-01-02 15:04:05.000"),
"localTime": time.Now().In(loc).Format("2006-01-02 15:04:05.000"),
"source": "currentTime",
})
}
// handleProducts 返回当前内存缓存的商品列表,不主动访问斗鱼接口。
func handleProducts(w http.ResponseWriter, _ *http.Request) {
productsMu.RLock()
defer productsMu.RUnlock()
json.NewEncoder(w).Encode(products)
}
// handleLoadProducts 请求斗鱼和平小店商品列表接口,并把商品数据写入全局缓存。
func handleLoadProducts(w http.ResponseWriter, _ *http.Request) {
url := fmt.Sprintf("https://www.douyu.com/wgapi/ordnc/activity/peace/storehome?manualID=%s&rid=%s", MANUAL_ID, ROOM_ID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := httpClient.Do(req)
if err != nil {
http.Error(w, "请求失败", 500)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
List []struct {
CommodityID string `json:"commodityId"`
CommodityName string `json:"commodityName"`
Score int `json:"score"`
Count int `json:"count"`
Pic string `json:"pic"`
} `json:"list"`
} `json:"data"`
}
json.Unmarshal(body, &result)
productsMu.Lock()
products = nil
for _, p := range result.Data.List {
products = append(products, Product{p.CommodityID, p.CommodityName, p.Score, p.Count, p.Pic})
}
productsMu.Unlock()
log.Printf("加载商品: %d个", len(products))
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "count": len(products)})
}
// handleAccounts 返回前端可展示的账号列表,仅暴露账号序号、名称和 Cookie。
func handleAccounts(w http.ResponseWriter, _ *http.Request) {
accountsMu.RLock()
defer accountsMu.RUnlock()
type R struct {
Index int `json:"index"`
Name string `json:"name"`
Cookie string `json:"cookie"`
}
list := []R{}
for i, a := range accounts {
list = append(list, R{i, a.Name, a.Cookie})
}
json.NewEncoder(w).Encode(list)
}
// handleReloadAccounts 重新读取 user.txt,并返回最新账号数量。
func handleReloadAccounts(w http.ResponseWriter, _ *http.Request) {
loadAccounts()
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "count": len(accounts)})
}
// handleSeckill 接收账号到商品的映射,异步启动立即抢购任务。
func handleSeckill(w http.ResponseWriter, r *http.Request) {
var req SeckillRequest
json.NewDecoder(r.Body).Decode(&req)
if len(req.AccountProducts) == 0 {
http.Error(w, "请选择账号和商品", 400)
return
}
go executeSeckill(req.AccountProducts)
json.NewEncoder(w).Encode(map[string]string{"success": "true", "msg": "抢购已启动"})
}
// handleSchedule 根据斗鱼服务端时间计算触发延迟,并注册一次性定时抢购任务。
func handleSchedule(w http.ResponseWriter, r *http.Request) {
var req SeckillRequest
json.NewDecoder(r.Body).Decode(&req)
if req.TargetTime == "" {
http.Error(w, "请设置目标时间", 400)
return
}
targetTime, err := time.ParseInLocation("2006-01-02 15:04:05", req.TargetTime, time.Local)
if err != nil {
http.Error(w, "时间格式错误", 400)
return
}
targetTime = targetTime.UTC()
// ✅ 用 currentTime 获取服务端当前时间
serverTime := getServerTime()
if serverTime.IsZero() {
http.Error(w, "获取服务端时间失败", 500)
return
}
advance := time.Duration(req.Advance) * time.Millisecond
triggerTime := targetTime.Add(-advance)
remain := triggerTime.Sub(serverTime)
log.Printf("📍 目标时间: %s, 服务端时间: %s, 剩余: %v, 提前: %dms",
targetTime.In(time.FixedZone("CST", 8*3600)).Format("15:04:05.000"),
serverTime.In(time.FixedZone("CST", 8*3600)).Format("15:04:05.000"),
remain,
req.Advance,
)
if remain < 0 {
http.Error(w, "目标时间已过", 400)
return
}
timerMu.Lock()
if seckillTimer != nil {
seckillTimer.Stop()
}
timerMu.Unlock()
ap := req.AccountProducts
loc := time.FixedZone("CST", 8*3600)
timerMu.Lock()
// 定时器回调会在到达触发时间后执行实际抢购逻辑。
seckillTimer = time.AfterFunc(remain, func() {
//// ✅ 到点后再次获取最新的服务端时间,确保准时
//now := getServerTime()
//
//log.Printf("🚀 触发! 目标:%s 实际:%s 偏差:%v",
// targetTime.In(loc).Format("15:04:05.000"),
// now.In(loc).Format("15:04:05.000"),
// now.Sub(targetTime),
//)
log.Printf("🚀 触发! 本地时间: %s", time.Now().In(loc).Format("15:04:05.000"))
executeSeckill(ap)
})
timerMu.Unlock()
log.Printf("⏰ 定时: %s(北京时间) 提前%dms", targetTime.In(loc).Format("15:04:05"), req.Advance)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"targetTime": targetTime.In(loc).Format("2006-01-02 15:04:05"),
"triggerTime": triggerTime.In(loc).Format("2006-01-02 15:04:05.000"),
"advance": req.Advance,
"remainSecond": int(remain.Seconds()),
"serverTime": serverTime.In(loc).Format("2006-01-02 15:04:05.000"),
})
}
// handleResults 返回最新抢购结果映射,供前端定时轮询并更新状态。
func handleResults(w http.ResponseWriter, _ *http.Request) {
resultsMu.RLock()
defer resultsMu.RUnlock()
json.NewEncoder(w).Encode(results)
}
// ==================== 抢购执行 ====================
// executeSeckill 按账号和商品映射批量发起兑换请求,并统计成功、失败数量。
func executeSeckill(ap map[string]string) {
var wg sync.WaitGroup
var ok, fail int32
limit := make(chan struct{}, GLOBAL_CONCURRENT)
accountsMu.RLock()
accs := make([]Account, len(accounts))
copy(accs, accounts)
accountsMu.RUnlock()
for _, a := range accs {
cid, ex := ap[a.Name]
if !ex || cid == "" {
continue
}
// ✅ 每个账号只获取一次 CSRF(移到这里,在循环内部)
csrf := getCsrf(a.Cookie)
if csrf == "" {
log.Printf("❌ %s 获取 CSRF 失败,跳过", a.Name)
continue
}
for i := 0; i < CONCURRENCY; i++ {
wg.Add(1)
// 每个 goroutine 负责一次兑换尝试,并通过 limit 控制全局并发量。
go func(a Account, cid string, csrf string, idx int) {
defer wg.Done()
limit <- struct{}{}
// 退出时释放一个并发名额,避免后续兑换任务被永久阻塞。
defer func() { <-limit }()
res := doExchange(a, cid, csrf)
resultsMu.Lock()
results[fmt.Sprintf("%s_第%d包", a.Name, idx+1)] = res
resultsMu.Unlock()
if strings.Contains(res, "成功") {
atomic.AddInt32(&ok, 1)
log.Printf("✅ %s 第%d包 成功: %s", a.Name, idx+1, res)
rn := ""
if strings.Contains(res, "|") {
rn = strings.SplitN(res, "|", 2)[1]
}
go saveImg(a, cid, rn) // ← 异步保存,不阻塞
} else {
atomic.AddInt32(&fail, 1)
log.Printf("❌ %s 第%d包 失败: %s", a.Name, idx+1, res)
}
}(a, cid, csrf, i) // ← 传入 csrf
}
}
wg.Wait()
log.Printf("抢购完成: 成功%d 失败%d", ok, fail)
}
// doExchange 使用单个账号、商品 ID 和 CSRF Token 调用斗鱼兑换接口,返回本次兑换结果。
func doExchange(a Account, cid string, csrf string) string {
//csrf := getCsrf(a.Cookie)
if csrf == "" {
return "CSRF失败"
}
ccn := extractMid(a.Cookie, "acf_ccn=", ";")
ck := a.Cookie
if ccn != "" {
ck += "; acf_ccn=" + ccn
}
ck += "; cvl_csrf_token=" + csrf
ck = strings.ReplaceAll(ck, ";;", ";")
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("manualID", MANUAL_ID)
w.WriteField("commodityID", cid)
w.WriteField("rid", ROOM_ID)
w.WriteField("csrfToken", csrf)
w.Close()
req, _ := http.NewRequest("POST", EXCHANGE_URL, &buf)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Origin", "https://www.douyu.com")
req.Header.Set("Referer", "https://www.douyu.com/pages/live-peace-handbook/web/shop?roomId=9263298")
req.Header.Set("Cookie", ck)
resp, err := httpClient.Do(req)
if err != nil {
return "请求失败"
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
s := string(b)
if strings.Contains(s, `"error":0`) {
rn := getRoleName(a.Cookie)
if rn != "" {
return "成功|" + rn
}
return "成功"
}
return "失败|" + trunc(s, 80)
}
// ==================== 辅助函数 ====================
// getCsrf 访问斗鱼 CSRF 生成接口,并从响应 Cookie 中提取 cvl_csrf_token。
func getCsrf(cookie string) string {
req, _ := http.NewRequest("POST", CSRF_URL, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", "https://www.douyu.com/")
resp, err := httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
for _, c := range resp.Cookies() {
if c.Name == "cvl_csrf_token" {
return c.Value
}
}
return ""
}
// getRoleName 查询腾讯游戏绑定信息,并从响应中提取游戏角色名。
func getRoleName(cookie string) string {
req, _ := http.NewRequest("GET", BIND_INFO, nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Cookie", cookie)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Referer", "https://www.douyu.com/")
resp, err := httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return extractMid(string(b), `"roleName":"`, `"`)
}
// saveImg 在抢购成功后生成一张包含商品、时间、角色名和账号的结果图片。
func saveImg(a Account, cid, roleName string) {
productsMu.RLock()
var pname string
for _, p := range products {
if p.CommodityID == cid {
pname = p.CommodityName
break
}
}
productsMu.RUnlock()
if cachedFont == nil {
log.Printf("字体未加载,无法生成图片")
return
}
width, height := 600, 280
img := image.NewRGBA(image.Rect(0, 0, width, height))
bgColor := color.RGBA{213, 239, 253, 255}
greenColor := color.RGBA{76, 175, 80, 255}
whiteColor := color.RGBA{255, 255, 255, 255}
blackColor := color.RGBA{0, 0, 0, 255}
// 画背景
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
if y < 60 {
img.Set(x, y, greenColor)
} else {
img.Set(x, y, bgColor)
}
}
}
c := freetype.NewContext()
c.SetDPI(72)
c.SetFont(cachedFont) // ← 用缓存的字体,不再读取文件
c.SetClip(img.Bounds())
c.SetDst(img)
loc := time.FixedZone("CST", 8*3600)
now := time.Now().In(loc)
texts := []struct {
text string
size float64
x, y int
color color.Color
}{
{pname, 20, 25, 42, whiteColor},
{"兑换时间:" + now.Format("2006年01月02日 15:04:05"), 16, 25, 110, blackColor},
{"游戏昵称:" + roleName, 16, 25, 150, blackColor},
{"兑换账号:" + a.Name, 16, 25, 190, blackColor},
}
for _, t := range texts {
c.SetFontSize(t.size)
c.SetSrc(image.NewUniform(t.color))
pt := freetype.Pt(t.x, t.y)
c.DrawString(t.text, pt)
}
fn := fmt.Sprintf("success_images/%s_%s_%d.png", a.Name, pname, time.Now().Unix())
f2, _ := os.Create(fn)
defer f2.Close()
png.Encode(f2, img)
log.Printf("✅ 图片已保存: %s", fn)
}
// extractMid 从字符串 s 中截取 start 和 end 之间的内容;找不到起点时返回空字符串。
func extractMid(s, start, end string) string {
i := strings.Index(s, start)
if i == -1 {
return ""
}
i += len(start)
j := strings.Index(s[i:], end)
if j == -1 {
return s[i:]
}
return s[i : i+j]
}
// trunc 将过长字符串截断到指定长度,并追加省略号,便于日志展示。
func trunc(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}