Files
huya-go/internal/sniper/exchange.go
T
yml2213 6e5beac151 refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热
- 抢购调度重构:参考 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 构建正常,单测覆盖双时间与预热配置
2026-10-17 18:29:17 +08:00

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