Files
huya-go/internal/sniper/sniper.go
T
yml2213 0294606807 参考 dygou_go 优化抢购调度:请求级并发闸门、售罄继续探测与任务汇总
- 并发额度下沉到单次请求:runState.sem 只覆盖一次在途请求(列表/兑换/刷新),账号在退避等待期间释放额度;旧实现把额度给整个账号循环,退避中的账号一直霸占额度,账号多时互相饿死
- 售罄不再默认终止:normalizeConfig 去掉 StopOnSoldOut 默认 true,售罄按退避继续探测到轮次结束(别人取消或锁单过期会让库存回流);CategoryOffShelf 拆成独立分支,下架/不存在仍终止该奖品;删除已无意义的 ContinueOnSoldOut
- 退避统一加 0-25% 抖动(jitterDuration,只往上抖,配置值仍是下限),避免多账号在同一时刻整批重试形成周期尖峰
- 新增 Runner.OnSummary/RunSummary:轮次、账号、目标、耗时、列表/兑换/积分/记录请求次数、成功账号数与结局分布(按次数排序),在 refresh.Wait() 之后回调;CLI 与 GUI 各输出一行汇总加逐类结局
- 取消清理:等待额度的账号返回 action=exchange_canceled 且调用方直接返回,停止监控时不再为每个排队账号刷错误结果
- http 传输补连接池参数(MaxConnsPerHost=64、MaxIdleConns=128、MaxIdleConnsPerHost=64、IdleConnTimeout=30s),多账号不再反复重建连接
- CLI/GUI 的 waitUntil 从 200ms 轮询改为一次性定时器,消除 0-200ms 随机延迟(正好错过开抢窗口)
- GUI 抽出 runnerConfig(),用 ListBeforeExchange=!DirectExchange 表达界面语义,修复「直接兑换」开关失效(旧实现被引擎强制回落直兑,价格/库存过滤不生效)
- 测试:sniper 新增售罄继续探测、StopOnSoldOut 即停、下架终止、在途请求数等于并发数、汇总含刷新请求、抖动区间等用例;GUI 新增 runnerConfig 用例;README 补「抢购调度与退避(2026-10-16 调整)」
2026-10-16 16:06:49 +08:00

1117 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sniper
import (
"context"
"errors"
"fmt"
"math/rand"
"sort"
"strconv"
"strings"
"sync"
"time"
"huya-go/internal/huya"
)
const (
CategoryReady = "ready"
CategorySuccess = "success"
CategoryNotFound = "not_found"
CategoryNotOpen = "not_open"
CategoryTooFrequent = "too_frequent"
CategoryNotEnough = "not_enough"
CategoryAlreadyDone = "already_done"
CategoryOffShelf = "off_shelf"
CategorySoldOut = "sold_out"
CategoryFailed = "failed"
CategoryError = "error"
// CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成),
// 没有可识别文案,按可重试失败处理但单独分类便于观察。
CategoryCondition = "condition"
// actionExchangeCanceled 表示申请并发额度时任务已被取消:这次请求根本没发出去,
// 不应产出结果,否则停止监控时每个排队账号都会刷一条错误。
actionExchangeCanceled = "exchange_canceled"
)
// CategoryText 把类目限制渲染成日志文案(空串表示不限类目)。
func CategoryText(category string) string {
key := strings.TrimSpace(category)
if key == "" {
return "不限"
}
return key
}
// LogLevelForCategory 把结果分类映射成日志等级,供界面按等级高亮与筛选。
func LogLevelForCategory(category string) string {
switch category {
case CategorySuccess:
return "success"
case CategoryError:
return "error"
case CategoryFailed, CategoryTooFrequent, CategoryCondition:
return "warn"
default:
return "info"
}
}
type Account struct {
UID int64 `json:"uid"`
Cookie string `json:"-"`
}
type Target struct {
PID int32 `json:"pid"`
Name string `json:"name"`
Auto bool `json:"auto,omitempty"`
MaxPrice int64 `json:"max_price,omitempty"`
MinLeft int64 `json:"min_left,omitempty"`
}
type Config struct {
SID int32
// Category 限定只在某个类目(限量返场/精英专享,或类目 id)里挑选可兑奖品,
// 留空表示不限制。见 huya.GetActPrizeListResp.FilterByCategory。
Category string
Loops int
Delay time.Duration
Concurrency int
StopAfterSuccess bool
StopAccountAfterSuccess bool
Prewarm bool
DirectExchange bool
ListBeforeExchange bool
SharePrizeList bool
RefreshAfterSuccess bool
// StopOnNotEnough 表示「积分不足 / 已兑换」后终止该账号该奖品(默认 true:
// 积分不会自己变多,继续打只是浪费请求)。
StopOnNotEnough bool
ContinueOnNotEnough bool
// StopOnSoldOut 表示「已抢完」后终止该奖品。默认 false:别人锁单未支付或取消
// 会让库存回流,窗口内应当继续低频探测(dygou_go 2026-10-03 实测结论)。
StopOnSoldOut bool
TooFrequentBackoff time.Duration
ErrorBackoff time.Duration
}
type Result struct {
Loop int `json:"loop"`
UID int64 `json:"uid"`
TargetIndex int `json:"target_index,omitempty"`
Target Target `json:"target"`
Action string `json:"action"`
Category string `json:"category,omitempty"`
Message string `json:"message,omitempty"`
Prize *huya.PrizeInfo `json:"prize,omitempty"`
Exchange *huya.ScoreExchangePrizeResp `json:"exchange,omitempty"`
Score *huya.GetUserScoreResp `json:"score,omitempty"`
Records *huya.GetUserPrizeRecordsResp `json:"records,omitempty"`
Done bool `json:"done,omitempty"`
RetryAfter int64 `json:"retry_after,omitempty"`
Error string `json:"error,omitempty"`
}
type Runner struct {
Client huya.ActivityClient
// OnSummary 在任务结束时回调一次汇总(可为 nil)。
// 汇总在成功后的异步刷新全部结束后才输出,因此能带上刷新产生的请求次数。
OnSummary func(RunSummary)
}
// RunSummary 汇总一次抢购任务的请求次数与结局分布,供日志复盘。
// 形状参考 dygou_go 的 logRunSummary:抢购结束后只打一行,替代逐条刷屏。
type RunSummary struct {
Loops int
Accounts int
Targets int
Duration time.Duration
ListReqs int
ExchangeReqs int
ScoreReqs int
RecordReqs int
SuccessUIDs int
Outcomes map[string]int
}
// Categories 按次数从多到少返回结局分类,次数相同时按名称排序,保证日志稳定。
func (s RunSummary) Categories() []string {
names := make([]string, 0, len(s.Outcomes))
for name := range s.Outcomes {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
if s.Outcomes[names[i]] != s.Outcomes[names[j]] {
return s.Outcomes[names[i]] > s.Outcomes[names[j]]
}
return names[i] < names[j]
})
return names
}
type ActivityPrewarmer interface {
PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error
}
type runState struct {
mu sync.Mutex
accounts map[int64][]targetState
targetDone []bool
// exchanged 记录每个账号已成功兑换的奖品,避免多个 target 命中同一奖品时重复兑换。
exchanged map[int64]map[int64]bool
// refresh 用于等待成功后的异步刷新,确保 RunTargets 返回前不会提前释放连接。
refresh sync.WaitGroup
// sem 是请求级并发闸门:一个额度对应一次在途请求,账号在退避等待期间
// 不再占用额度(参考 dygou_go runControl.enter)。旧实现把额度给整个账号
// 循环,退避中的账号会一直霸占额度,账号多时互相饿死。
sem chan struct{}
// stats 记录请求次数与结局分布,任务结束时汇总成一行日志。
stats runStats
}
// runStats 是一次任务的运行统计。
type runStats struct {
listReqs int
exchangeReqs int
scoreReqs int
recordReqs int
outcomes map[string]int
successUIDs map[int64]bool
}
type targetState struct {
Done bool
RetryAfter time.Time
}
type listSnapshot struct {
resp *huya.GetActPrizeListResp
err error
}
func (r *Runner) Run(ctx context.Context, accounts []Account, target Target, cfg Config, emit func(Result)) error {
return r.RunTargets(ctx, accounts, []Target{target}, cfg, emit)
}
func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []Target, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
targets = normalizeTargets(targets)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
if len(targets) == 0 {
return errors.New("目标奖品不能为空")
}
client := r.client()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if cfg.Prewarm {
if err := r.Prewarm(ctx, accounts, cfg, emit); err != nil {
return err
}
}
state := newRunState(accounts, len(targets), cfg.Concurrency)
startedAt := time.Now()
loopsRun := 0
// 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接;
// 汇总放在最后,才能把刷新产生的请求次数一起带上。
defer func() {
state.refresh.Wait()
r.emitSummary(state, len(accounts), len(targets), loopsRun, time.Since(startedAt))
}()
for loop := 1; loop <= cfg.Loops; loop++ {
loopsRun = loop
if ctx.Err() != nil {
return ctx.Err()
}
if state.allDone() {
return nil
}
sharedList := r.sharedList(ctx, client, accounts, targets, cfg, state)
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
// 并发额度由每次请求自己申请(见 runState.acquire),
// 账号在退避等待期间不占额度。
r.runAccountLoop(ctx, client, state, account, targets, cfg, loop, sharedList, emit, cancel)
}()
}
wg.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
if state.allDone() {
return nil
}
if loop < cfg.Loops {
if err := sleepContext(ctx, cfg.Delay); err != nil {
return err
}
}
}
return nil
}
func (r *Runner) Prewarm(ctx context.Context, accounts []Account, cfg Config, emit func(Result)) error {
cfg = normalizeConfig(cfg)
if len(accounts) == 0 {
return errors.New("账号不能为空")
}
client := r.client()
prewarmer, ok := client.(ActivityPrewarmer)
if !ok {
return nil
}
sem := make(chan struct{}, cfg.Concurrency)
var wg sync.WaitGroup
for _, account := range accounts {
account := account
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
err := prewarmer.PrewarmActivity(ctx, account.UID, account.Cookie, cfg.SID)
result := Result{
UID: account.UID,
Action: "prewarm",
Category: CategoryReady,
Message: "连接已预热",
}
if err != nil {
result.Category = CategoryError
result.Error = err.Error()
result.Message = "连接预热失败"
}
if emit != nil {
emit(result)
}
}()
}
wg.Wait()
return ctx.Err()
}
func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient, state *runState, account Account, targets []Target, cfg Config, loop int, sharedList *listSnapshot, emit func(Result), cancel context.CancelFunc) {
active := state.activeTargets(account.UID, len(targets), time.Now())
if len(active) == 0 {
return
}
listTargets := make([]int, 0, len(active))
for _, index := range active {
target := targets[index]
if shouldDirectExchange(target, cfg) {
// 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。
if state.prizeExchanged(account.UID, int64(target.PID)) {
state.emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, nil)
if result.Action == actionExchangeCanceled {
return
}
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, int64(target.PID))
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
continue
}
listTargets = append(listTargets, index)
}
if len(listTargets) == 0 || ctx.Err() != nil {
return
}
list := sharedList
if list == nil {
if !state.acquire(ctx) {
return
}
state.countRequest(reqList)
resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID)
state.release()
list = &listSnapshot{resp: resp, err: err}
}
if list.err != nil || list.resp == nil {
errText := "奖品列表响应为空"
if list.err != nil {
errText = list.err.Error()
}
for _, index := range listTargets {
retryAt := time.Now().Add(jitterDuration(cfg.ErrorBackoff))
state.setRetryAfter(account.UID, index, retryAt)
state.recordOutcome(CategoryError)
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: targets[index],
Action: "list_error",
Category: CategoryError,
Message: "查询奖品列表失败",
RetryAfter: retryAt.UnixMilli(),
Error: errText,
}
if emit != nil {
emit(result)
}
}
return
}
// 只按目标类目匹配:类目为空表示不限制(限量返场/精英专享都参与)。
prizes := list.resp.FilterByCategory(cfg.Category)
for _, index := range listTargets {
target := targets[index]
prize := matchPrize(prizes, target)
if prize == nil {
state.recordOutcome(CategoryNotFound)
result := Result{
Loop: loop,
UID: account.UID,
TargetIndex: index,
Target: target,
Action: "not_found",
Category: CategoryNotFound,
Message: notFoundMessage(cfg.Category),
}
if emit != nil {
emit(result)
}
continue
}
// 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。
if state.prizeExchanged(account.UID, prize.ID) {
state.emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, state, account, target, index, loop, cfg.SID, prize)
if result.Action == actionExchangeCanceled {
return
}
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
if result.Category == CategorySuccess {
state.markPrizeExchanged(account.UID, prize.ID)
}
if emit != nil {
emit(result)
}
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
}
}
// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。
func (s *runState) emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, target Target) {
s.recordOutcome(CategoryAlreadyDone)
if emit == nil {
return
}
emit(Result{
Loop: loop,
UID: uid,
TargetIndex: index,
Target: target,
Action: "skip_duplicate",
Category: CategoryAlreadyDone,
Message: "该奖品本账号已兑换成功,跳过重复兑换",
})
}
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
}
state.countRequest(reqExchange)
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
state.release()
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 {
now := time.Now()
state.recordOutcome(result.Category)
if result.Category == CategorySuccess {
state.markSuccessUID(account.UID)
}
switch result.Category {
case CategorySuccess:
if cfg.StopAccountAfterSuccess {
state.markDone(account.UID, index)
result.Done = true
}
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
} else {
// 别人锁单未支付或取消会让库存回流,窗口内继续探测(只是放慢节奏)。
retryAt := now.Add(jitterDuration(cfg.ErrorBackoff))
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
}
case CategoryTooFrequent:
retryAt := now.Add(jitterDuration(cfg.TooFrequentBackoff))
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
case CategoryError, CategoryFailed, CategoryCondition:
retryAt := now.Add(jitterDuration(cfg.ErrorBackoff))
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
}
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()
state.countRequest(reqScore)
return client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
}()
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()
state.countRequest(reqRecord)
return client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
}()
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)
}()
}
func (r *Runner) sharedList(ctx context.Context, client huya.ActivityClient, accounts []Account, targets []Target, cfg Config, state *runState) *listSnapshot {
if !cfg.SharePrizeList || len(accounts) == 0 {
return nil
}
needsList := false
for _, account := range accounts {
for _, index := range state.activeTargets(account.UID, len(targets), time.Now()) {
if !shouldDirectExchange(targets[index], cfg) {
needsList = true
break
}
}
if needsList {
break
}
}
if !needsList {
return nil
}
if !state.acquire(ctx) {
return &listSnapshot{err: ctx.Err()}
}
state.countRequest(reqList)
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
state.release()
return &listSnapshot{resp: resp, err: err}
}
func (r *Runner) client() huya.ActivityClient {
if r.Client != nil {
return r.Client
}
return huya.NewHTTPClient(nil)
}
func newRunState(accounts []Account, targetCount, concurrency int) *runState {
if concurrency <= 0 {
concurrency = 1
}
state := &runState{
accounts: make(map[int64][]targetState, len(accounts)),
targetDone: make([]bool, targetCount),
exchanged: make(map[int64]map[int64]bool, len(accounts)),
sem: make(chan struct{}, concurrency),
stats: runStats{
outcomes: map[string]int{},
successUIDs: map[int64]bool{},
},
}
for _, account := range accounts {
state.accounts[account.UID] = make([]targetState, targetCount)
}
return state
}
// requestKind 标识一次在途请求的类型,用于汇总日志里的请求次数。
type requestKind int
const (
reqList requestKind = iota
reqExchange
reqScore
reqRecord
)
// acquire 申请一个请求额度;ctx 结束时会返回 false,调用方应直接放弃这次请求。
func (s *runState) acquire(ctx context.Context) bool {
if s.sem == nil {
return ctx.Err() == nil
}
select {
case s.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (s *runState) release() {
if s.sem == nil {
return
}
<-s.sem
}
func (s *runState) countRequest(kind requestKind) {
s.mu.Lock()
defer s.mu.Unlock()
switch kind {
case reqList:
s.stats.listReqs++
case reqExchange:
s.stats.exchangeReqs++
case reqScore:
s.stats.scoreReqs++
case reqRecord:
s.stats.recordReqs++
}
}
// recordOutcome 累计一条结局分类,供任务结束时的汇总日志使用。
func (s *runState) recordOutcome(category string) {
if category == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.stats.outcomes == nil {
s.stats.outcomes = map[string]int{}
}
s.stats.outcomes[category]++
}
func (s *runState) markSuccessUID(uid int64) {
s.mu.Lock()
defer s.mu.Unlock()
if s.stats.successUIDs == nil {
s.stats.successUIDs = map[int64]bool{}
}
s.stats.successUIDs[uid] = true
}
func (s *runState) snapshotStats() runStats {
s.mu.Lock()
defer s.mu.Unlock()
out := runStats{
listReqs: s.stats.listReqs,
exchangeReqs: s.stats.exchangeReqs,
scoreReqs: s.stats.scoreReqs,
recordReqs: s.stats.recordReqs,
outcomes: make(map[string]int, len(s.stats.outcomes)),
successUIDs: make(map[int64]bool, len(s.stats.successUIDs)),
}
for key, value := range s.stats.outcomes {
out.outcomes[key] = value
}
for uid := range s.stats.successUIDs {
out.successUIDs[uid] = true
}
return out
}
// emitSummary 在任务结束时回调一次汇总;OnSummary 为 nil 时不做任何事。
func (r *Runner) emitSummary(state *runState, accounts, targets, loops int, cost time.Duration) {
if r.OnSummary == nil {
return
}
stats := state.snapshotStats()
r.OnSummary(RunSummary{
Loops: loops,
Accounts: accounts,
Targets: targets,
Duration: cost,
ListReqs: stats.listReqs,
ExchangeReqs: stats.exchangeReqs,
ScoreReqs: stats.scoreReqs,
RecordReqs: stats.recordReqs,
SuccessUIDs: len(stats.successUIDs),
Outcomes: stats.outcomes,
})
}
// prizeExchanged 判断该账号是否已经成功兑换过这个奖品。
func (s *runState) prizeExchanged(uid, prizeID int64) bool {
if prizeID <= 0 {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.exchanged[uid][prizeID]
}
func (s *runState) markPrizeExchanged(uid, prizeID int64) {
if prizeID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.exchanged[uid] == nil {
s.exchanged[uid] = map[int64]bool{}
}
s.exchanged[uid][prizeID] = true
}
func (s *runState) activeTargets(uid int64, targetCount int, now time.Time) []int {
s.mu.Lock()
defer s.mu.Unlock()
items := s.accounts[uid]
active := make([]int, 0, targetCount)
for i := 0; i < targetCount; i++ {
if i < len(s.targetDone) && s.targetDone[i] {
continue
}
if i >= len(items) || items[i].Done {
continue
}
if !items[i].RetryAfter.IsZero() && items[i].RetryAfter.After(now) {
continue
}
active = append(active, i)
}
return active
}
func (s *runState) markDone(uid int64, index int) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].Done = true
items[index].RetryAfter = time.Time{}
s.accounts[uid] = items
}
}
func (s *runState) markTargetDone(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index >= 0 && index < len(s.targetDone) {
s.targetDone[index] = true
}
}
func (s *runState) setRetryAfter(uid int64, index int, when time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if items := s.accounts[uid]; index >= 0 && index < len(items) {
items[index].RetryAfter = when
s.accounts[uid] = items
}
}
func (s *runState) allDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
for index, globallyDone := range s.targetDone {
if globallyDone {
continue
}
for _, items := range s.accounts {
if index < len(items) && !items[index].Done {
return false
}
}
}
return true
}
func normalizeConfig(cfg Config) Config {
cfg.Category = strings.TrimSpace(cfg.Category)
if cfg.Loops <= 0 {
cfg.Loops = 1
}
if cfg.Delay <= 0 {
cfg.Delay = 150 * time.Millisecond
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
if cfg.TooFrequentBackoff <= 0 {
cfg.TooFrequentBackoff = maxDuration(cfg.Delay*2, 300*time.Millisecond)
}
if cfg.ErrorBackoff <= 0 {
cfg.ErrorBackoff = maxDuration(cfg.Delay, 300*time.Millisecond)
}
// 积分不足是终态:积分不会自己变多,继续打只是浪费请求,默认停手。
if !cfg.ContinueOnNotEnough {
cfg.StopOnNotEnough = true
}
// 售罄刻意不给默认值:库存可能因别人取消 / 锁单过期而回流,默认继续探测到
// 循环结束;需要「售罄即停」时显式设置 StopOnSoldOut。
if !cfg.ListBeforeExchange {
cfg.DirectExchange = true
}
return cfg
}
// normalizeTargets 去掉空目标与重复目标。
//
// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称,
// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。
func normalizeTargets(targets []Target) []Target {
out := make([]Target, 0, len(targets))
seenKey := map[string]bool{}
seenPID := map[int32]bool{}
seenName := map[string]bool{}
for _, target := range targets {
target.Name = strings.TrimSpace(target.Name)
if target.PID <= 0 && target.Name == "" && !target.Auto {
continue
}
if target.PID > 0 && seenPID[target.PID] {
continue
}
if target.Name != "" && seenName[target.Name] {
continue
}
key := targetKey(target)
if seenKey[key] {
continue
}
seenKey[key] = true
if target.PID > 0 {
seenPID[target.PID] = true
}
if target.Name != "" {
seenName[target.Name] = true
}
out = append(out, target)
}
return out
}
func shouldDirectExchange(target Target, cfg Config) bool {
return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto
}
// notFoundMessage 在未命中时把类目限制写进提示,便于区分「被类目过滤掉」与「列表里确实没有」。
func notFoundMessage(category string) string {
if strings.TrimSpace(category) == "" {
return "未查询到所需物品,继续监控"
}
return fmt.Sprintf("类目[%s]内未查询到所需物品,继续监控", strings.TrimSpace(category))
}
func matchPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
if target.Auto {
return autoPrize(prizes, target)
}
for i := range prizes {
if target.PID > 0 && int32(prizes[i].ID) != target.PID {
continue
}
if target.PID == 0 && target.Name != "" && !strings.Contains(prizes[i].Name, target.Name) {
continue
}
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
continue
}
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
continue
}
return &prizes[i]
}
return nil
}
func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
var fallback *huya.PrizeInfo
for i := range prizes {
if target.MaxPrice > 0 && prizes[i].Price() > target.MaxPrice {
continue
}
if target.MinLeft > 0 && prizes[i].EstimatedLeft() < target.MinLeft {
continue
}
if fallback == nil {
fallback = &prizes[i]
}
if prizes[i].EstimatedLeft() > 0 {
return &prizes[i]
}
}
return fallback
}
// exchangeSignals 汇总一次兑换响应里可用于判定的信号。
//
// 虎牙把失败原因分散在 msg / actPreCondition(Tip/ButtonDesc/ConditionParams)
// 和 exchangeInfo.desc 里,status 除 200 外没有可依据的公开语义,
// 所以判定顺序是:文案 -> 成功结构(status=200 且有 orderId)-> 结构门禁 -> 兜底失败。
type exchangeSignals struct {
text string
status int32
hasOrderID bool
conditionType int32
}
func buildExchangeSignals(resp *huya.ScoreExchangePrizeResp) exchangeSignals {
if resp == nil {
return exchangeSignals{}
}
parts := []string{
resp.Msg,
resp.ActPreCondition.Tip,
resp.ActPreCondition.ButtonDesc,
resp.ExchangeInfo.Desc,
}
for _, value := range resp.ActPreCondition.ConditionParams {
parts = append(parts, value)
}
texts := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
texts = append(texts, item)
}
}
return exchangeSignals{
text: strings.Join(texts, " "),
status: resp.Status,
hasOrderID: strings.TrimSpace(resp.OrderID) != "",
conditionType: resp.ActPreCondition.ConditionType,
}
}
// classifyExchangeText 只依据文案判定失败原因,命中时返回分类。
func classifyExchangeText(text string) (string, bool) {
switch {
case containsAny(text, "操作频繁", "太频繁", "稍后再试"):
return CategoryTooFrequent, true
case containsAny(text, "暂未开放", "未开放", "未开始", "活动未开始"):
return CategoryNotOpen, true
case containsAny(text, "已抢完", "已兑完", "库存不足", "没有库存", "领完了"):
return CategorySoldOut, true
case containsAny(text, "货币不足", "积分不足", "余额不足", "点券不足", "不足"):
return CategoryNotEnough, true
case containsAny(text, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"):
return CategoryAlreadyDone, true
case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
return CategoryOffShelf, true
}
return "", false
}
func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
if err != nil {
return CategoryError
}
if resp == nil {
return CategoryFailed
}
if resp.Success() {
return CategorySuccess
}
signals := buildExchangeSignals(resp)
if category, ok := classifyExchangeText(signals.text); ok {
return category
}
// 成功回包也可能不带中文文案:status=200 且有 orderId 视为成功。
if signals.status == 200 && signals.hasOrderID {
return CategorySuccess
}
// 没有文案但服务端回了 precondition 门禁,归为条件未达成,按可重试失败处理。
if signals.conditionType != 0 {
return CategoryCondition
}
return CategoryFailed
}
// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。
func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
if resp == nil {
return ""
}
for _, text := range []string{
resp.Msg,
resp.ActPreCondition.Tip,
resp.ActPreCondition.ButtonDesc,
resp.ExchangeInfo.Desc,
} {
if strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func containsAny(text string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(text, needle) {
return true
}
}
return false
}
func targetKey(target Target) string {
return strings.Join([]string{
strings.TrimSpace(target.Name),
int32String(target.PID),
strconv.FormatBool(target.Auto),
int64String(target.MaxPrice),
int64String(target.MinLeft),
}, "\x00")
}
func int32String(value int32) string {
return strconv.FormatInt(int64(value), 10)
}
func int64String(value int64) string {
return strconv.FormatInt(value, 10)
}
func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
// jitterDuration 在基准退避上叠加 0-25% 的随机抖动。
//
// 多个账号同时失败时,固定退避会让它们在同一个时刻整批重试,形成周期性尖峰,
// 更容易撞上限频(参考 dygou_go 的 jitterFraction)。这里只往长了抖,不缩短,
// 保证配置值始终是退避下限。
func jitterDuration(base time.Duration) time.Duration {
if base <= 0 {
return base
}
span := int64(base) / 4
if span <= 0 {
return base
}
return base + time.Duration(rand.Int63n(span+1))
}