补全过滤入口与直接兑换,优化兑换分类和 WSS 建连

- 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)并重新生成前端绑定
This commit is contained in:
yml2213
2026-10-13 19:57:08 +08:00
parent 72d59670d4
commit e8bb4eb571
13 changed files with 411 additions and 120 deletions
+21 -6
View File
@@ -48,6 +48,8 @@ type Config struct {
PID int32 `json:"pid"`
Name string `json:"name"`
TargetText string `json:"targetText"`
MaxPrice int64 `json:"maxPrice"`
MinLeft int64 `json:"minLeft"`
CookieText string `json:"cookieText"`
SelectedAccountLines []int `json:"selectedAccountLines"`
Loops int `json:"loops"`
@@ -103,9 +105,11 @@ type RecordResult struct {
}
type ExchangeResult struct {
UID int64 `json:"uid"`
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
UID int64 `json:"uid"`
Resp *huya.ScoreExchangePrizeResp `json:"resp,omitempty"`
Category string `json:"category,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type MonitorState struct {
@@ -138,7 +142,7 @@ func (a *App) Shutdown(ctx context.Context) {
func (a *App) DefaultConfig() Config {
return Config{
Transport: "wss",
Transport: "http",
WSSHost: huya.DefaultWSSHost,
SID: 2203,
Loops: 20,
@@ -296,7 +300,13 @@ func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, cfg.SID, cfg.PID)
results[i] = ExchangeResult{UID: account.UID, Resp: resp, Error: errorString(err)}
results[i] = ExchangeResult{
UID: account.UID,
Resp: resp,
Category: sniper.ClassifyExchange(resp, err),
Message: sniper.ExchangeMessage(resp),
Error: errorString(err),
}
})
a.addLog("info", "直接兑换完成: sid=%d pid=%d accounts=%d", cfg.SID, cfg.PID, len(accounts))
return results, nil
@@ -530,7 +540,7 @@ func (a *App) emit(event string, data any) {
func normalizeConfig(cfg Config) Config {
if strings.TrimSpace(cfg.Transport) == "" {
cfg.Transport = "wss"
cfg.Transport = "http"
}
if strings.TrimSpace(cfg.WSSHost) == "" {
cfg.WSSHost = huya.DefaultWSSHost
@@ -582,6 +592,11 @@ func parseTargets(cfg Config) []sniper.Target {
}
targets = append(targets, sniper.Target{Name: item})
}
// 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。
for i := range targets {
targets[i].MaxPrice = cfg.MaxPrice
targets[i].MinLeft = cfg.MinLeft
}
return targets
}
+32
View File
@@ -98,3 +98,35 @@ func TestAddLogDoesNotBlockWhenQueueIsFull(t *testing.T) {
t.Fatalf("droppedLogs = %d, want 1", dropped)
}
}
func TestParseTargetsAppliesPriceAndInventoryFilters(t *testing.T) {
targets := parseTargets(Config{
PID: 12889,
Name: "小黄鸭",
TargetText: "13001\nauto",
AutoSelect: true,
MaxPrice: 1000,
MinLeft: 2,
})
if len(targets) != 4 {
t.Fatalf("targets=%d, want 4", len(targets))
}
for _, target := range targets {
if target.MaxPrice != 1000 {
t.Fatalf("target %+v maxPrice=%d, want 1000", target, target.MaxPrice)
}
if target.MinLeft != 2 {
t.Fatalf("target %+v minLeft=%d, want 2", target, target.MinLeft)
}
}
}
func TestParseTargetsWithoutFiltersKeepsZeroValues(t *testing.T) {
targets := parseTargets(Config{PID: 12889})
if len(targets) != 1 {
t.Fatalf("targets=%d, want 1", len(targets))
}
if targets[0].MaxPrice != 0 || targets[0].MinLeft != 0 {
t.Fatalf("unexpected filters: %+v", targets[0])
}
}
+49 -63
View File
@@ -35,8 +35,13 @@ type WSSClient struct {
Logger func(string)
CallTimeout time.Duration
// dialFunc 仅用于测试注入,为 nil 时走 dialSession。
dialFunc func(ctx context.Context, uid int64, cookie string) (*WSSSession, error)
mu sync.Mutex
sessions map[string]*WSSSession
// dialing 记录正在建连的 uid,避免并发调用重复握手。
dialing map[string]chan struct{}
}
type WSSSession struct {
@@ -73,6 +78,7 @@ func NewWSSClient(logger func(string)) *WSSClient {
Logger: logger,
CallTimeout: 10 * time.Second,
sessions: map[string]*WSSSession{},
dialing: map[string]chan struct{}{},
}
}
@@ -105,26 +111,54 @@ func (c *WSSClient) PrewarmActivity(ctx context.Context, uid int64, cookie strin
return err
}
// session 返回 uid 对应的连接,必要时新建。
//
// 握手与初始化在锁外完成,同一 uid 的并发调用共享同一次建连(single-flight),
// 因此多账号预热/抢购时不会因为持有全局锁而串行化。
func (c *WSSClient) session(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
key := strconv.FormatInt(uid, 10)
c.mu.Lock()
if session := c.sessions[key]; session != nil && !session.isClosed() {
c.mu.Unlock()
return session, nil
}
c.mu.Unlock()
for {
c.mu.Lock()
if session := c.sessions[key]; session != nil && !session.isClosed() {
c.mu.Unlock()
return session, nil
}
dialing := c.dialing[key]
if dialing == nil {
dialing = make(chan struct{})
c.dialing[key] = dialing
c.mu.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
if session := c.sessions[key]; session != nil && !session.isClosed() {
return session, nil
session, err := c.dial(ctx, uid, cookie)
c.mu.Lock()
delete(c.dialing, key)
if err == nil {
c.sessions[key] = session
}
c.mu.Unlock()
close(dialing)
if err != nil {
return nil, err
}
return session, nil
}
c.mu.Unlock()
// 已有同 uid 的建连在途,等它结束后复用结果或自行重试。
select {
case <-dialing:
case <-ctx.Done():
return nil, ctx.Err()
}
}
session, err := c.dialSession(ctx, uid, cookie)
if err != nil {
return nil, err
}
func (c *WSSClient) dial(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
if c.dialFunc != nil {
return c.dialFunc(ctx, uid, cookie)
}
c.sessions[key] = session
return session, nil
return c.dialSession(ctx, uid, cookie)
}
func (c *WSSClient) dialSession(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
@@ -383,15 +417,6 @@ func (s *WSSSession) callGetConfig(ctx context.Context) error {
return err
}
func (s *WSSSession) sendUpdateUserInfo(ctx context.Context, launchGUID string) error {
req := &wsUpdateUserInfoReq{AppSrc: "HUYA&ZH&2052", GUID: launchGUID}
payload, err := encodeWSSStructCommand(WSSCmdUpdateUserInfoReq, req)
if err != nil {
return err
}
return s.writePayload(ctx, payload)
}
func (s *WSSSession) sendUpdateUserExps(ctx context.Context) error {
payload, err := encodeWSSStructCommand(WSSCmdUpdateUserExpsReq, &wsUpdateUserExpsReq{})
if err != nil {
@@ -631,45 +656,6 @@ func (r *wsLaunchReq) WriteTo(w *taf.Writer) error {
return w.WriteStructEnd()
}
type wsUpdateUserInfoReq struct {
AppSrc string
GUID string
}
func (r *wsUpdateUserInfoReq) WriteTo(w *taf.Writer) error {
if err := w.WriteString(0, r.AppSrc); err != nil {
return err
}
if err := w.WriteString(1, r.GUID); err != nil {
return err
}
if err := w.WriteInt32(2, 0); err != nil {
return err
}
if err := w.WriteInt32(3, 0); err != nil {
return err
}
if err := w.WriteStructBegin(6); err != nil {
return err
}
if err := w.WriteMapStringString(0, nil); err != nil {
return err
}
if err := w.WriteMapStringString(1, nil); err != nil {
return err
}
if err := w.WriteInt32(2, 0); err != nil {
return err
}
if err := w.WriteStructEnd(); err != nil {
return err
}
if err := w.WriteMapStringString(7, nil); err != nil {
return err
}
return w.WriteInt32(8, 0)
}
type wsUpdateUserExpsReq struct{}
func (r *wsUpdateUserExpsReq) WriteTo(w *taf.Writer) error {
+64
View File
@@ -1,11 +1,15 @@
package huya
import (
"context"
"encoding/base64"
"encoding/hex"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"huya-go/internal/taf"
)
@@ -96,3 +100,63 @@ func TestNewWSSCommandTraceMatchesBrowserShape(t *testing.T) {
t.Fatalf("trace suffix = %q, want :0:0", trace)
}
}
func TestSessionSharesSingleDialPerUID(t *testing.T) {
client := NewWSSClient(nil)
dialStarted := make(chan struct{}, 1)
release := make(chan struct{})
var dials atomic.Int32
client.dialFunc = func(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
dials.Add(1)
select {
case dialStarted <- struct{}{}:
default:
}
<-release
return &WSSSession{
client: client,
uid: uid,
cookie: cookie,
closed: make(chan struct{}),
pending: map[int32]chan rpcResult{},
}, nil
}
const callers = 4
sessions := make([]*WSSSession, callers)
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
session, err := client.session(context.Background(), 42, "yyuid=42")
if err != nil {
t.Errorf("session: %v", err)
return
}
sessions[i] = session
}(i)
}
select {
case <-dialStarted:
case <-time.After(time.Second):
t.Fatal("dial was not started")
}
// 留出时间让其余调用进入等待分支。
time.Sleep(50 * time.Millisecond)
close(release)
wg.Wait()
if got := dials.Load(); got != 1 {
t.Fatalf("dials=%d, want 1", got)
}
for i, session := range sessions {
if session == nil {
t.Fatalf("caller %d got nil session", i)
}
if session != sessions[0] {
t.Fatalf("caller %d got a different session", i)
}
}
}
+77 -25
View File
@@ -24,6 +24,9 @@ const (
CategorySoldOut = "sold_out"
CategoryFailed = "failed"
CategoryError = "error"
// CategoryCondition 表示服务端返回了 precondition 门禁(条件未达成),
// 没有可识别文案,按可重试失败处理但单独分类便于观察。
CategoryCondition = "condition"
)
type Account struct {
@@ -314,7 +317,7 @@ func (r *Runner) exchangeTarget(ctx context.Context, client huya.ActivityClient,
exchange, err := client.ScoreExchangePrize(ctx, account.UID, account.Cookie, sid, pid)
result.Exchange = exchange
result.Category = ClassifyExchange(exchange, err)
result.Message = exchangeMessage(exchange)
result.Message = ExchangeMessage(exchange)
if err != nil {
result.Action = "exchange_error"
result.Error = err.Error()
@@ -354,11 +357,7 @@ func (r *Runner) applyExchangeDecision(state *runState, account Account, index i
retryAt := now.Add(cfg.TooFrequentBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
case CategoryError:
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
case CategoryFailed:
case CategoryError, CategoryFailed, CategoryCondition:
retryAt := now.Add(cfg.ErrorBackoff)
state.setRetryAfter(account.UID, index, retryAt)
result.RetryAfter = retryAt.UnixMilli()
@@ -609,6 +608,64 @@ func autoPrize(prizes []huya.PrizeInfo, target Target) *huya.PrizeInfo {
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
@@ -619,28 +676,23 @@ func ClassifyExchange(resp *huya.ScoreExchangePrizeResp, err error) string {
if resp.Success() {
return CategorySuccess
}
msg := exchangeMessage(resp)
switch {
case containsAny(msg, "操作频繁", "太频繁", "稍后再试"):
return CategoryTooFrequent
case containsAny(msg, "暂未开放", "未开放", "未开始", "活动未开始"):
return CategoryNotOpen
case containsAny(msg, "已抢完", "已兑完", "库存不足", "没有库存", "领完了"):
return CategorySoldOut
case containsAny(msg, "货币不足", "积分不足", "余额不足", "点券不足", "不足"):
return CategoryNotEnough
case containsAny(msg, "已兑换", "重复兑换", "已领取", "超过兑换次数", "达到上限", "限购"):
return CategoryAlreadyDone
case containsAny(msg, "奖品已下架", "已下架", "奖品不存在", "活动不存在"):
return CategoryOffShelf
case resp.Status != 0:
return CategoryFailed
default:
return CategoryFailed
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
}
func exchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
// ExchangeMessage 返回响应里最有代表性的提示文案,供日志与 GUI 展示。
func ExchangeMessage(resp *huya.ScoreExchangePrizeResp) string {
if resp == nil {
return ""
}
+64
View File
@@ -83,6 +83,44 @@ func TestClassifyExchange(t *testing.T) {
{name: "off shelf", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品已下架"}, want: CategoryOffShelf},
{name: "sold out", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "库存不足"}, want: CategorySoldOut},
{name: "error", err: errors.New("timeout"), want: CategoryError},
{
name: "success with order id",
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "sOrderId_2203_12889_1_1783328404_957"},
want: CategorySuccess,
},
{
name: "not enough in precondition tip",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
Msg: "兑换失败",
ActPreCondition: huya.ActPreCondition{Tip: "积分不足,无法兑换"},
},
want: CategoryNotEnough,
},
{
name: "not enough in condition params",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
Msg: "兑换失败",
ActPreCondition: huya.ActPreCondition{ConditionParams: map[string]string{"tip": "余额不足"}},
},
want: CategoryNotEnough,
},
{
name: "condition gate without text",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
ActPreCondition: huya.ActPreCondition{ConditionType: 2},
},
want: CategoryCondition,
},
{name: "empty response", resp: &huya.ScoreExchangePrizeResp{Status: 500}, want: CategoryFailed},
{name: "nil response", resp: nil, want: CategoryFailed},
{
name: "text wins over order id",
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "order", Msg: "库存不足"},
want: CategorySoldOut,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -253,3 +291,29 @@ func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) {
}
}
}
func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{
Status: 500,
ActPreCondition: huya.ActPreCondition{ConditionType: 3},
}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 3,
Delay: 10 * time.Millisecond,
ErrorBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 3 {
t.Fatalf("exchangeCount=%d, want 3", exchangeCount)
}
}