From 817f05886a90e48ab53e48696d23bb94c6080da1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 19 Jun 2026 09:13:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=AD=E6=96=87=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 27 ++++++- static/index.html | 178 +++++++++++++++++++++++++++------------------- 2 files changed, 130 insertions(+), 75 deletions(-) diff --git a/main.go b/main.go index 2604c68..8e50c80 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,7 @@ var ( fontMu sync.Mutex ) +// main 是程序入口,负责初始化配置、账号、字体、HTTP 路由,并启动本地 Web 服务。 func main() { // 设置日志格式,显示毫秒 log.SetFlags(log.LstdFlags | log.Lmicroseconds) // ← 加这行 @@ -122,6 +123,7 @@ func main() { log.Fatal(http.ListenAndServe(":2169", nil)) } +// initFont 预加载微软雅黑字体,供成功兑换后生成结果图片时复用。 func initFont() { fontMu.Lock() defer fontMu.Unlock() @@ -142,7 +144,7 @@ func initFont() { log.Printf("[OK] 字体加载成功") } -// 新增加载配置函数 +// loadConfig 读取 config.json 中的时间校准接口地址;读取或解析失败时使用默认商品详情接口。 func loadConfig() { f, err := os.Open("config.json") if err != nil { @@ -225,6 +227,7 @@ func getServerTime() time.Time { // ==================== API ==================== +// handleClearResults 清空内存中的抢购结果,通常在前端启动时调用以避免展示旧结果。 func handleClearResults(w http.ResponseWriter, _ *http.Request) { resultsMu.Lock() results = make(map[string]string) @@ -232,7 +235,7 @@ func handleClearResults(w http.ResponseWriter, _ *http.Request) { 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) @@ -256,6 +259,7 @@ func getScore(cookie string) string { 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) @@ -267,6 +271,7 @@ func handleQueryScore(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{"score": score}) } +// loadAccounts 从 user.txt 读取账号列表,格式为“账号----密码----Cookie”,并刷新全局账号缓存。 func loadAccounts() { accountsMu.Lock() defer accountsMu.Unlock() @@ -291,6 +296,7 @@ func loadAccounts() { log.Printf("加载 %d 个账号", len(accounts)) } +// handleTime 返回斗鱼服务端时间和本地时间,供前端展示与定时抢购校准使用。 func handleTime(w http.ResponseWriter, _ *http.Request) { loc := time.FixedZone("CST", 8*3600) serverTime := getServerTime() @@ -301,12 +307,14 @@ func handleTime(w http.ResponseWriter, _ *http.Request) { }) } +// 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) @@ -342,6 +350,7 @@ func handleLoadProducts(w http.ResponseWriter, _ *http.Request) { 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() @@ -357,11 +366,13 @@ func handleAccounts(w http.ResponseWriter, _ *http.Request) { 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) @@ -373,6 +384,7 @@ func handleSeckill(w http.ResponseWriter, r *http.Request) { 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) @@ -420,6 +432,7 @@ func handleSchedule(w http.ResponseWriter, r *http.Request) { loc := time.FixedZone("CST", 8*3600) timerMu.Lock() + // 定时器回调会在到达触发时间后执行实际抢购逻辑。 seckillTimer = time.AfterFunc(remain, func() { //// ✅ 到点后再次获取最新的服务端时间,确保准时 //now := getServerTime() @@ -445,6 +458,7 @@ func handleSchedule(w http.ResponseWriter, r *http.Request) { }) } +// handleResults 返回最新抢购结果映射,供前端定时轮询并更新状态。 func handleResults(w http.ResponseWriter, _ *http.Request) { resultsMu.RLock() defer resultsMu.RUnlock() @@ -452,6 +466,7 @@ func handleResults(w http.ResponseWriter, _ *http.Request) { } // ==================== 抢购执行 ==================== +// executeSeckill 按账号和商品映射批量发起兑换请求,并统计成功、失败数量。 func executeSeckill(ap map[string]string) { var wg sync.WaitGroup var ok, fail int32 @@ -477,9 +492,11 @@ func executeSeckill(ap map[string]string) { 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() @@ -504,6 +521,7 @@ func executeSeckill(ap map[string]string) { 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 == "" { @@ -552,6 +570,7 @@ func doExchange(a Account, cid string, csrf string) string { // ==================== 辅助函数 ==================== +// 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") @@ -570,6 +589,7 @@ func getCsrf(cookie string) string { return "" } +// getRoleName 查询腾讯游戏绑定信息,并从响应中提取游戏角色名。 func getRoleName(cookie string) string { req, _ := http.NewRequest("GET", BIND_INFO, nil) req.Header.Set("User-Agent", "Mozilla/5.0") @@ -585,6 +605,7 @@ func getRoleName(cookie string) string { return extractMid(string(b), `"roleName":"`, `"`) } +// saveImg 在抢购成功后生成一张包含商品、时间、角色名和账号的结果图片。 func saveImg(a Account, cid, roleName string) { productsMu.RLock() var pname string @@ -655,6 +676,7 @@ func saveImg(a Account, cid, roleName string) { log.Printf("✅ 图片已保存: %s", fn) } +// extractMid 从字符串 s 中截取 start 和 end 之间的内容;找不到起点时返回空字符串。 func extractMid(s, start, end string) string { i := strings.Index(s, start) if i == -1 { @@ -668,6 +690,7 @@ func extractMid(s, start, end string) string { return s[i : i+j] } +// trunc 将过长字符串截断到指定长度,并追加省略号,便于日志展示。 func trunc(s string, n int) string { if len(s) > n { return s[:n] + "..." diff --git a/static/index.html b/static/index.html index 8fad9a0..4a24114 100644 --- a/static/index.html +++ b/static/index.html @@ -153,7 +153,8 @@ let scheduleActive=false, countdownTimer=null, lastResults={}; let statusMap={}; // ← 加这行 - async function init(){ + // init 初始化页面状态:恢复本地缓存、加载账号和商品、启动时间与结果轮询。 + async function init(){ // 恢复缓存的选择 let savedAccs=localStorage.getItem('selAccs'); let savedProd=localStorage.getItem('selProd'); @@ -188,26 +189,29 @@ setInterval(checkResults,2000); } - function updateTime(){ - fetch('/api/time').then(r=>r.json()).then(d=>{ - document.getElementById('svrTime').textContent=d.serverTime||'--'; - }).catch(()=>{}); + // updateTime 从后端获取斗鱼服务端时间,并刷新顶部时间展示。 + function updateTime(){ + // then 回调依次负责解析 JSON 和更新 DOM;catch 回调忽略短暂网络失败。 + fetch('/api/time').then(r=>r.json()).then(d=>{ + document.getElementById('svrTime').textContent=d.serverTime||'--'; + }).catch(()=>{}); } - //加载账号 - async function reloadAccounts(){ + // reloadAccounts 通知后端重新读取 user.txt,然后刷新前端账号表格。 + async function reloadAccounts(){ let r=await fetch('/api/reload-accounts'); let d=await r.json(); await loadAccounts(); toast('✅ 账号已刷新',`共 ${d.count} 个账号`,'success'); } - //并发查询函数 - async function queryAllScore() { - addLog('正在并发查询积分...', 'i'); - let tasks = []; - for (let i = 0; i < accounts.length; i++) { - tasks.push((async (idx) => { + // queryAllScore 并发查询所有账号积分,并把积分按账号名缓存到 localStorage。 + async function queryAllScore() { + addLog('正在并发查询积分...', 'i'); + let tasks = []; + for (let i = 0; i < accounts.length; i++) { + // 每个账号单独启动一个异步查询任务,避免账号多时串行等待过久。 + tasks.push((async (idx) => { let a = accounts[idx]; try { console.log('账号对象:', JSON.stringify(a)); @@ -234,25 +238,27 @@ // ==================== Toast提示 ==================== - function toast(title, body, type, duration){ + // toast 创建右侧滑入提示,按类型控制颜色,并在指定时间后自动移除。 + function toast(title, body, type, duration){ type=type||'info'; duration=duration||4000; let container=document.getElementById('toastContainer'); - let div=document.createElement('div'); - div.className='toast toast-'+type; - div.innerHTML=`
${title}
${body}
`; - container.appendChild(div); - // 触发滑入 - setTimeout(()=>container.classList.add('show'),10); - // 自动移除 - setTimeout(()=>{ - div.style.opacity='0'; - div.style.transition='opacity .3s'; + let div=document.createElement('div'); + div.className='toast toast-'+type; + div.innerHTML=`
${title}
${body}
`; + container.appendChild(div); + // 短延迟回调用于触发 CSS 过渡,让提示从右侧滑入。 + setTimeout(()=>container.classList.add('show'),10); + // 外层回调控制停留时长,内层回调等待淡出动画结束后移除节点。 + setTimeout(()=>{ + div.style.opacity='0'; + div.style.transition='opacity .3s'; setTimeout(()=>{div.remove();if(container.children.length===0)container.classList.remove('show');},300); },duration); } // ==================== 商品 ==================== - async function loadProducts(){ + // loadProducts 触发后端加载斗鱼商品接口,然后拉取缓存商品并重新渲染列表。 + async function loadProducts(){ addLog('正在从斗鱼加载商品...','i'); try{ await fetch('/api/load-products'); @@ -265,9 +271,11 @@ toast('❌ 加载失败','请检查网络连接','error'); } } - function renderProducts(){ - let h=''; - products.forEach(p=>{ + // renderProducts 根据当前商品数组绘制左侧商品列表,并标记已选商品。 + function renderProducts(){ + let h=''; + // 遍历商品缓存,生成每个商品的点击项和库存、积分信息。 + products.forEach(p=>{ h+=`
${p.commodityName}
@@ -277,14 +285,16 @@ document.getElementById('prodList').innerHTML=h||'
暂无商品
'; } - function selProduct(id, name) { + // selProduct 记录当前选择的商品,并把它绑定到所有已勾选账号。 + function selProduct(id, name) { selProdId = id; - selProdName = name; - document.getElementById('selProd').textContent = name; - // ✅ 用账号名存商品绑定 - selAccs.forEach(i => { - let accountName = accounts[i].name; // 用账号名作为 key - localStorage.setItem('accProd_' + accountName, id); + selProdName = name; + document.getElementById('selProd').textContent = name; + // ✅ 用账号名存商品绑定 + // 该回调把当前商品写入每个已选账号的本地绑定记录。 + selAccs.forEach(i => { + let accountName = accounts[i].name; // 用账号名作为 key + localStorage.setItem('accProd_' + accountName, id); localStorage.setItem('accProdName_' + accountName, name); }); @@ -300,11 +310,13 @@ // renderAccounts(); // } - async function loadAccounts() { - let r = await fetch('/api/accounts'); - accounts = await r.json(); - - let validNames = new Set(accounts.map(a => a.name)); + // loadAccounts 从后端获取账号列表,清理无效本地缓存后重新渲染账号表格。 + async function loadAccounts() { + let r = await fetch('/api/accounts'); + accounts = await r.json(); + + // map 回调抽取账号名,用于判断 localStorage 中的旧缓存是否仍然有效。 + let validNames = new Set(accounts.map(a => a.name)); for (let key in localStorage) { // 只清理商品和积分缓存,保留 selAccs、statusMap、targetTime、advance @@ -320,9 +332,11 @@ } - function renderAccounts() { - document.getElementById('accCnt').textContent = accounts.length; - let h = accounts.map((a, i) => { + // renderAccounts 根据账号、选择状态、商品绑定、积分和抢购结果绘制账号表格。 + function renderAccounts() { + document.getElementById('accCnt').textContent = accounts.length; + // 将账号数组映射为表格行 HTML,行内状态从 localStorage 和 statusMap 读取。 + let h = accounts.map((a, i) => { let isSel = selAccs.has(i); // ✅ 用账号名读取商品、积分、状态 let prodName = localStorage.getItem('accProdName_' + a.name) || '-'; @@ -351,23 +365,27 @@ } - function toggleAll(){ - let ck=document.getElementById('chkAll').checked; - if(ck) accounts.forEach((_,i)=>selAccs.add(i)); - else selAccs.clear(); + // toggleAll 根据表头复选框批量选择或取消所有账号,并持久化选择结果。 + function toggleAll(){ + let ck=document.getElementById('chkAll').checked; + // 批量选择时的回调把每个账号下标加入选择集合。 + if(ck) accounts.forEach((_,i)=>selAccs.add(i)); + else selAccs.clear(); localStorage.setItem('selAccs',JSON.stringify([...selAccs])); renderAccounts(); } - function togAcc(i){ + // togAcc 切换单个账号的选择状态,并同步更新 localStorage。 + function togAcc(i){ if(selAccs.has(i)) selAccs.delete(i); else selAccs.add(i); localStorage.setItem('selAccs',JSON.stringify([...selAccs])); renderAccounts(); } // ==================== 立即抢购(不弹窗) ==================== - async function startNow(){ + // startNow 构建账号商品映射后,调用后端立即启动抢购。 + async function startNow(){ let ap=buildAP(); if(!ap) return; addLog(`⚡ 立即抢购: ${Object.keys(ap).length}个账号 → ${selProdName}`,'i'); @@ -378,7 +396,8 @@ } // ==================== 定时抢购 ==================== - async function startSchedule(){ + // startSchedule 构建定时参数并提交给后端,由后端按服务端时间安排抢购。 + async function startSchedule(){ let ap=buildAP(); if(!ap) return; let timeStr=document.getElementById('targetTime').value.trim(); @@ -410,7 +429,8 @@ toast('❌ 定时失败',d.error||'未知错误','error'); } } - async function cancelSchedule(){ + // cancelSchedule 只恢复前端定时状态和按钮显示;当前后端没有对应取消接口。 + async function cancelSchedule(){ scheduleActive=false; // 清除缓存的定时 localStorage.removeItem('targetTime'); @@ -424,41 +444,47 @@ toast('⏹ 定时已停止','抢购任务已取消','warn'); } - function startCountdown(sec) { + // startCountdown 启动前端倒计时徽标,用后端返回的剩余秒数做展示。 + function startCountdown(sec) { stopCountdown(); let badge = document.getElementById('cdBadge'); badge.style.display = 'inline-block'; let startTime = Date.now(); let endTime = startTime + sec * 1000; - countdownTimer = setInterval(() => { + // 每 500ms 刷新一次显示,确保用户能看到接近触发时的状态变化。 + countdownTimer = setInterval(() => { let now = Date.now(); let remain = Math.max(0, Math.ceil((endTime - now) / 1000)); if (remain <= 0) { badge.textContent = '🚀 触发中...'; stopCountdown(); scheduleActive = false; - document.getElementById('btnSchedule').style.display = 'inline-block'; - document.getElementById('btnCancel').style.display = 'none'; - setTimeout(() => badge.style.display = 'none', 5000); - } else { - badge.textContent = `⏱ ${formatRemain(remain)}`; + document.getElementById('btnSchedule').style.display = 'inline-block'; + document.getElementById('btnCancel').style.display = 'none'; + // 延迟回调让“触发中”状态短暂停留,避免用户看不到最终状态。 + setTimeout(() => badge.style.display = 'none', 5000); + } else { + badge.textContent = `⏱ ${formatRemain(remain)}`; } }, 500); } - function stopCountdown(){ + // stopCountdown 停止倒计时定时器,并隐藏倒计时徽标。 + function stopCountdown(){ clearInterval(countdownTimer); document.getElementById('cdBadge').style.display='none'; } - function formatRemain(sec){ + // formatRemain 把秒数格式化为 h:mm:ss,供定时提示和倒计时徽标使用。 + function formatRemain(sec){ let h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=sec%60; return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; } // ==================== 结果刷新 ==================== - async function checkResults() { + // checkResults 轮询后端抢购结果,更新日志区、账号状态和本地状态缓存。 + async function checkResults() { try { let r = await fetch('/api/results'); let d = await r.json(); @@ -486,15 +512,17 @@ - function buildAP() { + // buildAP 根据已勾选账号和账号绑定商品,生成后端需要的账号到商品 ID 映射。 + function buildAP() { if (selAccs.size === 0) { toast('⚠️ 提示', '请勾选要抢购的账号', 'warn'); - return null; - } - let ap = {}; - selAccs.forEach(i => { - let accountName = accounts[i].name; // ← 先取账号名 - let cid = localStorage.getItem('accProd_' + accountName); // ← 用账号名读 + return null; + } + let ap = {}; + // 遍历已选账号,把存在商品绑定的账号转换成后端需要的映射。 + selAccs.forEach(i => { + let accountName = accounts[i].name; // ← 先取账号名 + let cid = localStorage.getItem('accProd_' + accountName); // ← 用账号名读 if (cid) { ap[accountName] = cid; } @@ -507,14 +535,16 @@ } - function hasAnyAccProd(){ + // hasAnyAccProd 判断当前账号列表中是否存在任意商品绑定。 + function hasAnyAccProd(){ for(let i=0; i/g,'>').replace(/"/g,'"'); } + // escHtml 转义商品名中的 HTML 特殊字符,避免拼接到 onclick 时破坏页面结构。 + function escHtml(s){ return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } init(); - \ No newline at end of file +