- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰 - 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms - 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐 - 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移 - 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
package sniper
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"huya-go/internal/huya"
|
|
)
|
|
|
|
// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。
|
|
//
|
|
// 每个「账号 + 奖品」只提示一次:窗口模式下每轮都会跳过同一个奖品,
|
|
// 不记录会刷出一整个窗口的重复提示(但请求数不会增加)。
|
|
func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, prizeID int64, target Target) {
|
|
if s.markDuplicateNotified(uid, prizeID) {
|
|
s.recordOutcome(CategoryAlreadyDone)
|
|
}
|
|
// 同一奖品已由本账号兑换成功,这个 target 不可能再成功,直接标记完成,
|
|
// 避免多名称目标反复空转。
|
|
s.markDone(uid, index)
|
|
if emit == nil {
|
|
return
|
|
}
|
|
emit(Result{
|
|
Loop: loop,
|
|
UID: uid,
|
|
TargetIndex: index,
|
|
Target: target,
|
|
Action: "skip_duplicate",
|
|
Category: CategoryAlreadyDone,
|
|
Message: "该奖品本账号已兑换成功,跳过重复兑换",
|
|
})
|
|
}
|
|
|
|
// markDuplicateNotified 记录并返回是否是首次提示。
|
|
func (s *runState) markDuplicateNotified(uid, prizeID int64) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.duplicateNotified[uid] == nil {
|
|
s.duplicateNotified[uid] = map[int64]bool{}
|
|
}
|
|
if s.duplicateNotified[uid][prizeID] {
|
|
return false
|
|
}
|
|
s.duplicateNotified[uid][prizeID] = true
|
|
return true
|
|
}
|
|
|
|
func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, state *runState, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result {
|
|
pid := target.PID
|
|
if prize != nil {
|
|
pid = int32(prize.ID)
|
|
}
|
|
result := Result{
|
|
Loop: loop,
|
|
UID: account.UID,
|
|
TargetIndex: index,
|
|
Target: target,
|
|
Action: "exchange",
|
|
Prize: prize,
|
|
}
|
|
if !state.acquire(ctx) {
|
|
result.Action = actionExchangeCanceled
|
|
result.Category = CategoryError
|
|
result.Message = "任务已取消,未发起兑换"
|
|
result.Error = ctx.Err().Error()
|
|
return result
|
|
}
|
|
// 排队等额度期间窗口可能已经结束,不能把请求补发到窗口外。
|
|
if state.windowExpired() {
|
|
state.release()
|
|
result.Action = actionExchangeCanceled
|
|
result.Category = CategoryError
|
|
result.Message = "窗口已结束,未发起兑换"
|
|
return result
|
|
}
|
|
started := time.Now()
|
|
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
|
|
state.release()
|
|
state.recordRequest(reqExchange, time.Since(started), err)
|
|
result.Exchange = exchange
|
|
result.Category = ClassifyExchange(exchange, err)
|
|
result.Message = ExchangeMessage(exchange)
|
|
if err != nil {
|
|
result.Action = "exchange_error"
|
|
result.Error = err.Error()
|
|
if result.Message == "" {
|
|
result.Message = "兑换请求失败"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (r *Runner) applyExchangeDecision(state *runState, account Account, index int, cfg Config, result Result, cancel context.CancelFunc) Result {
|
|
state.recordOutcome(result.Category)
|
|
if result.Category == CategorySuccess {
|
|
state.markSuccessUID(account.UID)
|
|
}
|
|
switch result.Category {
|
|
case CategorySuccess:
|
|
if cfg.StopAccountAfterSuccess {
|
|
state.markAccountDone(account.UID)
|
|
result.Done = true
|
|
} else {
|
|
state.markDone(account.UID, index)
|
|
}
|
|
if cfg.StopAfterSuccess {
|
|
cancel()
|
|
}
|
|
case CategoryNotEnough, CategoryAlreadyDone:
|
|
if cfg.StopOnNotEnough {
|
|
state.markDone(account.UID, index)
|
|
result.Done = true
|
|
}
|
|
case CategoryOffShelf:
|
|
// 已下架 / 奖品不存在:本窗口内不会再出现,直接终止该 target。
|
|
state.markTargetDone(index)
|
|
result.Done = true
|
|
case CategorySoldOut:
|
|
if cfg.StopOnSoldOut {
|
|
state.markTargetDone(index)
|
|
result.Done = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (r *Runner) refreshAfterSuccess(state *runState, client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(Result)) {
|
|
if !cfg.RefreshAfterSuccess || emit == nil || result.Category != CategorySuccess {
|
|
return
|
|
}
|
|
state.refresh.Add(1)
|
|
go func() {
|
|
defer state.refresh.Done()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// 刷新请求同样走并发闸门,但用拥有 5 秒预算的独立 ctx。
|
|
score, err := func() (*huya.GetUserScoreResp, error) {
|
|
if !state.acquire(ctx) {
|
|
return nil, ctx.Err()
|
|
}
|
|
defer state.release()
|
|
started := time.Now()
|
|
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
|
|
state.recordRequest(reqScore, time.Since(started), err)
|
|
return resp, err
|
|
}()
|
|
scoreResult := Result{
|
|
Loop: loop,
|
|
UID: account.UID,
|
|
TargetIndex: index,
|
|
Target: target,
|
|
Action: "refresh_score",
|
|
Category: CategoryReady,
|
|
Message: "刷新积分完成",
|
|
Score: score,
|
|
}
|
|
if err != nil {
|
|
scoreResult.Category = CategoryError
|
|
scoreResult.Message = "刷新积分失败"
|
|
scoreResult.Error = err.Error()
|
|
} else if score != nil {
|
|
scoreResult.Message = fmt.Sprintf("剩余积分: %d", score.AvailableScore())
|
|
}
|
|
emit(scoreResult)
|
|
|
|
records, err := func() (*huya.GetUserPrizeRecordsResp, error) {
|
|
if !state.acquire(ctx) {
|
|
return nil, ctx.Err()
|
|
}
|
|
defer state.release()
|
|
started := time.Now()
|
|
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
|
|
state.recordRequest(reqRecord, time.Since(started), err)
|
|
return resp, err
|
|
}()
|
|
recordResult := Result{
|
|
Loop: loop,
|
|
UID: account.UID,
|
|
TargetIndex: index,
|
|
Target: target,
|
|
Action: "refresh_records",
|
|
Category: CategoryReady,
|
|
Message: "刷新兑换记录完成",
|
|
Records: records,
|
|
}
|
|
if err != nil {
|
|
recordResult.Category = CategoryError
|
|
recordResult.Message = "刷新兑换记录失败"
|
|
recordResult.Error = err.Error()
|
|
}
|
|
emit(recordResult)
|
|
}()
|
|
}
|
|
|
|
// listForAccount 返回本账号本轮要用的奖品列表快照。
|
|
//
|
|
// SharePrizeList 打开时多账号复用同一份快照:TTL 内第一个账号查完,其余账号直接复用
|
|
// (单飞:同一时刻只有一个账号在查,避免并发重复查询)。关闭时各查各的。
|
|
func (r *Runner) listForAccount(ctx context.Context, client huya.ActivityClient, account Account, cfg Config, state *runState) *listSnapshot {
|
|
fetch := func() *listSnapshot {
|
|
if !state.acquire(ctx) {
|
|
return &listSnapshot{err: ctx.Err()}
|
|
}
|
|
if state.windowExpired() {
|
|
state.release()
|
|
return &listSnapshot{err: errors.New("窗口已结束")}
|
|
}
|
|
started := time.Now()
|
|
resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID)
|
|
state.release()
|
|
state.recordRequest(reqList, time.Since(started), err)
|
|
return &listSnapshot{resp: resp, err: err}
|
|
}
|
|
if !cfg.SharePrizeList {
|
|
return fetch()
|
|
}
|
|
return state.sharedListOnce(ctx, fetch)
|
|
}
|