package sniper import ( "errors" "fmt" "strconv" "strings" "huya-go/internal/huya" ) // 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 只依据文案判定失败原因,命中时返回分类。 // // 判定顺序固定:未开放 → 售罄 → 货币/次数 → 会话失效 → 下架 → 限流。 // 顺序很关键:限流关键词里有「稍后重试」「稍后」,如果排在前面会把更具体的原因吃掉。 // 2026-10-16 的日志里 244 条「领取频率限制,请稍后重试!!」和 917 条「活动太火爆了, // 请稍后重试」过去全落到 failed:既看不出是被限流,也享受不到限流该有的退避语义。 func classifyExchangeText(text string) (string, bool) { switch { 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, "账号未绑定", "未绑定账号", "未绑定手机", "登录态", "登录失效", "请先登录", "凭证失效", "token 失效"): return CategorySessionLost, true case containsAny(text, "奖品已下架", "已下架", "奖品不存在", "活动不存在"): return CategoryOffShelf, true case containsAny(text, "操作频繁", "太频繁", "稍后再试", "稍后重试", "火爆", "频繁", "频率", "限流", "繁忙", "人多", "排队", "手慢", "点击太快", "请求过多", "行为异常"): return CategoryTooFrequent, true } return "", false } func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string { if err != nil { // 协议层错误(缺少 tRsp/tResp、解码失败)单独归类:它不是业务失败, // 通常是服务端在高并发下回了错误信封,需要单独统计与排查。 if errors.Is(err, huya.ErrProtocolResponse) { return CategoryProtocol } 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") }