- 并发额度下沉到单次请求: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 调整)」
1117 lines
31 KiB
Go
1117 lines
31 KiB
Go
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))
|
||
}
|