- GUI 接线 Exchange 绑定:新增「直接兑换」按钮,结果带 category/message 并进入结果面板 - 补 -max-price/-min-left(CLI 与 GUI 设置),Target 的价格与库存过滤不再悬空 - 删除死代码 sendUpdateUserInfo 及其请求结构体(浏览器流程不含该帧) - ClassifyExchange 改为多路判定:合并 msg/actPreCondition/exchangeInfo 文案,status=200 且有 orderId 视为成功,新增 condition 分类(precondition 门禁) - WSS 建连改为锁外 dial、锁内注册,同 uid 并发调用共享一次握手 - 默认传输层统一为 http(CLI/GUI/README),.gitignore 忽略 .DS_Store - 同步 Wails 依赖升级(2.12.0 -> 2.15.0)并重新生成前端绑定
756 lines
20 KiB
Go
756 lines
20 KiB
Go
package sniper
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"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"
|
||
)
|
||
|
||
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
|
||
Loops int
|
||
Delay time.Duration
|
||
Concurrency int
|
||
StopAfterSuccess bool
|
||
StopAccountAfterSuccess bool
|
||
Prewarm bool
|
||
DirectExchange bool
|
||
ListBeforeExchange bool
|
||
SharePrizeList bool
|
||
RefreshAfterSuccess bool
|
||
StopOnNotEnough bool
|
||
ContinueOnNotEnough bool
|
||
StopOnSoldOut bool
|
||
ContinueOnSoldOut 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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))
|
||
for loop := 1; loop <= cfg.Loops; loop++ {
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
if state.allDone() {
|
||
return nil
|
||
}
|
||
|
||
sharedList := r.sharedList(ctx, client, accounts, targets, cfg, state)
|
||
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
|
||
}
|
||
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) {
|
||
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, nil)
|
||
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
r.refreshAfterSuccess(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 {
|
||
resp, err := client.GetActPrizeList(ctx, account.UID, account.Cookie, cfg.SID)
|
||
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(cfg.ErrorBackoff)
|
||
state.setRetryAfter(account.UID, index, retryAt)
|
||
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
|
||
}
|
||
|
||
for _, index := range listTargets {
|
||
target := targets[index]
|
||
prize := matchPrize(list.resp.Prizes, target)
|
||
if prize == nil {
|
||
result := Result{
|
||
Loop: loop,
|
||
UID: account.UID,
|
||
TargetIndex: index,
|
||
Target: target,
|
||
Action: "not_found",
|
||
Category: CategoryNotFound,
|
||
Message: "未查询到所需物品,继续监控",
|
||
}
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
continue
|
||
}
|
||
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, prize)
|
||
result = r.applyExchangeDecision(state, account, index, cfg, result, cancel)
|
||
if emit != nil {
|
||
emit(result)
|
||
}
|
||
r.refreshAfterSuccess(client, account, target, index, loop, cfg, result, emit)
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient, 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,
|
||
}
|
||
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
|
||
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()
|
||
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, CategorySoldOut:
|
||
if cfg.StopOnSoldOut {
|
||
state.markTargetDone(index)
|
||
result.Done = true
|
||
} else {
|
||
retryAt := now.Add(cfg.ErrorBackoff)
|
||
state.setRetryAfter(account.UID, index, retryAt)
|
||
result.RetryAfter = retryAt.UnixMilli()
|
||
}
|
||
case CategoryTooFrequent:
|
||
retryAt := now.Add(cfg.TooFrequentBackoff)
|
||
state.setRetryAfter(account.UID, index, retryAt)
|
||
result.RetryAfter = retryAt.UnixMilli()
|
||
case CategoryError, CategoryFailed, CategoryCondition:
|
||
retryAt := now.Add(cfg.ErrorBackoff)
|
||
state.setRetryAfter(account.UID, index, retryAt)
|
||
result.RetryAfter = retryAt.UnixMilli()
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (r *Runner) refreshAfterSuccess(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
|
||
}
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
score, err := 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 := 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
|
||
}
|
||
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
|
||
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 int) *runState {
|
||
state := &runState{
|
||
accounts: make(map[int64][]targetState, len(accounts)),
|
||
targetDone: make([]bool, targetCount),
|
||
}
|
||
for _, account := range accounts {
|
||
state.accounts[account.UID] = make([]targetState, targetCount)
|
||
}
|
||
return state
|
||
}
|
||
|
||
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 {
|
||
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
|
||
}
|
||
if !cfg.ContinueOnSoldOut {
|
||
cfg.StopOnSoldOut = true
|
||
}
|
||
if !cfg.ListBeforeExchange {
|
||
cfg.DirectExchange = true
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func normalizeTargets(targets []Target) []Target {
|
||
out := make([]Target, 0, len(targets))
|
||
seen := map[string]bool{}
|
||
for _, target := range targets {
|
||
target.Name = strings.TrimSpace(target.Name)
|
||
if target.PID <= 0 && target.Name == "" && !target.Auto {
|
||
continue
|
||
}
|
||
key := targetKey(target)
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
out = append(out, target)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func shouldDirectExchange(target Target, cfg Config) bool {
|
||
return cfg.DirectExchange && !cfg.ListBeforeExchange && target.PID > 0 && !target.Auto
|
||
}
|
||
|
||
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
|
||
}
|