补全过滤入口与直接兑换,优化兑换分类和 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
+1
View File
@@ -5,3 +5,4 @@
*.test
build/
frontend/dist/
.DS_Store
+5 -3
View File
@@ -1,9 +1,9 @@
# huya-go
虎牙活动积分兑换/抢购的 Go 复刻版。当前支持两种传输层:
虎牙活动积分兑换/抢购的 Go 复刻版。支持两种传输层,CLI 与 GUI 默认都走 `http`:
- `http`:走 `https://cdnws.api.huya.com/`,兼容原有命令。
- `wss`:实验实现。已按浏览器 HAR 改为 TAF `WebSocketCommand` 外层和 WSS 初始化流程,仍建议先用真实 Cookie 小流量验证。
- `http`(默认):走 `https://cdnws.api.huya.com/`,兼容原有命令。
- `wss`:实验实现。CLI 需要显式指定 `-transport wss`,GUI 在设置里切换;已按浏览器 HAR 改为 TAF `WebSocketCommand` 外层和 WSS 初始化流程,仍建议先用真实 Cookie 小流量验证。
## 构建
@@ -77,6 +77,8 @@ CLI 示例:
- `-cmd`:`list`、`score`、`records`、`exchange`、`monitor`。
- `-sid`:活动 SID,必填。
- `-pid`:奖品 PID。`exchange` 必填,`monitor` 可用 `-pid` 或 `-name`。
- `-max-price`:目标奖品最高积分,`0` 表示不限制(CLI 与 GUI 设置都支持)。
- `-min-left`:目标奖品最少剩余库存,`0` 表示不限制(CLI 与 GUI 设置都支持)。
- `-cookie`:单账号 Cookie。
- `-cookie-file`:多账号 Cookie 文件。
- `-transport`:`http` 或 `wss`,默认 `http`;WSS 仍为实验通道。
+9 -2
View File
@@ -27,6 +27,8 @@ func main() {
pids = flag.String("pids", "", "多个奖品 PID,逗号分隔")
name = flag.String("name", "", "按奖品名包含匹配")
names = flag.String("names", "", "多个奖品名,逗号分隔")
maxPrice = flag.Int64("max-price", 0, "目标奖品最高积分,0 表示不限制")
minLeft = flag.Int64("min-left", 0, "目标奖品最少剩余库存,0 表示不限制")
autoSelect = flag.Bool("auto", false, "monitor 自动选择列表里的可兑奖品")
loops = flag.Int("loops", 1, "monitor 循环次数")
delayMS = flag.Int("delay", 150, "monitor 每轮间隔毫秒")
@@ -126,7 +128,7 @@ func main() {
waitForCommand(ctx, startAt)
runExchange(ctx, client, accounts, int32(*sid), int32(*pid), *concurrency)
case "monitor":
targets := parseTargets(*pid, *pids, *name, *names, *autoSelect)
targets := parseTargets(*pid, *pids, *name, *names, *autoSelect, *maxPrice, *minLeft)
if len(targets) == 0 {
exitErr(errors.New("monitor 必须提供 -pid 或 -name"))
}
@@ -237,7 +239,7 @@ func loadCookies(cookie, file string) ([]string, error) {
return out, nil
}
func parseTargets(pid int, pids, name, names string, autoSelect bool) []sniper.Target {
func parseTargets(pid int, pids, name, names string, autoSelect bool, maxPrice, minLeft int64) []sniper.Target {
targets := make([]sniper.Target, 0)
name = strings.TrimSpace(name)
if pid > 0 || name != "" {
@@ -259,6 +261,11 @@ func parseTargets(pid int, pids, name, names string, autoSelect bool) []sniper.T
}
targets = append(targets, sniper.Target{Name: text})
}
// 价格/库存过滤对所有目标统一生效。
for i := range targets {
targets[i].MaxPrice = maxPrice
targets[i].MinLeft = minLeft
}
return targets
}
+63 -3
View File
@@ -31,9 +31,11 @@ import {
Square,
Settings,
Trash2,
Zap,
} from 'lucide-react'
import {
DefaultConfig,
Exchange,
GetLogs,
GetRecords,
IsRunning,
@@ -56,6 +58,8 @@ type UiConfig = {
pid: number
name: string
targetText: string
maxPrice: number
minLeft: number
cookieText: string
selectedAccountLines: number[]
loops: number
@@ -167,6 +171,14 @@ type ExchangeResp = {
actPreCondition?: ActPreCondition
}
type ExchangeResult = {
uid: number
resp?: ExchangeResp
category?: string
message?: string
error?: string
}
type MonitorResult = {
key?: string
loop?: number
@@ -300,6 +312,7 @@ function categoryTag(category?: string): ReactNode {
already_done: { color: 'purple', label: '已兑' },
off_shelf: { color: 'error', label: '下架' },
sold_out: { color: 'error', label: '抢完' },
condition: { color: 'geekblue', label: '条件' },
failed: { color: 'warning', label: '失败' },
error: { color: 'error', label: '异常' },
}
@@ -399,12 +412,14 @@ function newCookieID(): string {
function pickConfig(values: Partial<UiConfig>): UiConfig {
return {
transport: values.transport || 'wss',
transport: values.transport || 'http',
wssHost: values.wssHost || '',
sid: Number(values.sid || 0),
pid: Number(values.pid || 0),
name: values.name || '',
targetText: values.targetText || '',
maxPrice: Number(values.maxPrice || 0),
minLeft: Number(values.minLeft || 0),
cookieText: values.cookieText || '',
selectedAccountLines: values.selectedAccountLines || [],
loops: Number(values.loops || 1),
@@ -596,7 +611,9 @@ export default function Dashboard() {
const errorCount = useMemo(
() =>
results.filter((item) =>
['error', 'failed', 'not_enough', 'off_shelf', 'sold_out'].includes(item.category || ''),
['error', 'failed', 'not_enough', 'off_shelf', 'sold_out', 'condition'].includes(
item.category || '',
),
).length,
[results],
)
@@ -827,6 +844,8 @@ export default function Dashboard() {
pid: values.pid,
name: values.name,
targetText: values.targetText,
maxPrice: values.maxPrice,
minLeft: values.minLeft,
loops: values.loops,
delayMs: values.delayMs,
concurrency: values.concurrency,
@@ -881,6 +900,33 @@ export default function Dashboard() {
})
}
const directExchange = () => {
const values = config()
if (Number(values.pid || 0) <= 0) {
message.warning('请先在左侧商品列表中选择要兑换的商品')
return undefined
}
return runAction(
'exchange',
() => Exchange(values),
(data) => {
const items = (data || []) as ExchangeResult[]
setResults(
items.map((item, index) => ({
key: `exchange-${item.uid}-${index}-${Date.now()}`,
uid: item.uid,
action: 'exchange',
category: item.category || (item.error ? 'error' : 'failed'),
message: item.message || item.error || item.resp?.msg || '',
exchange: item.resp,
})),
)
const success = items.filter((item) => item.category === 'success').length
message.info(`直接兑换:成功 ${success}/${items.length}`)
},
)
}
const selectAllAccounts = () => {
setSelectedAccountLines(validAccounts.map((item) => item.line))
}
@@ -1180,7 +1226,7 @@ export default function Dashboard() {
</div>
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
<Space size={12}>
<Tag>{String(watchedTransport || 'wss').toUpperCase()}</Tag>
<Tag>{String(watchedTransport || 'http').toUpperCase()}</Tag>
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
设置
</Button>
@@ -1225,6 +1271,14 @@ export default function Dashboard() {
>
开启定时
</Button>
<Button
icon={<Zap size={16} />}
loading={loading === 'exchange'}
disabled={running || validAccounts.length === 0}
onClick={directExchange}
>
直接兑换
</Button>
<Button
icon={<ClipboardPaste size={16} />}
loading={loading === 'clipboard'}
@@ -1456,6 +1510,12 @@ export default function Dashboard() {
<Form.Item name="pid" label="PID">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="maxPrice" label="最高积分">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="minLeft" label="最少库存">
<InputNumber min={0} />
</Form.Item>
<Form.Item name="loops" label="次数">
<InputNumber min={1} max={9999} />
</Form.Item>
+8
View File
@@ -25,6 +25,8 @@ export namespace gui {
pid: number;
name: string;
targetText: string;
maxPrice: number;
minLeft: number;
cookieText: string;
selectedAccountLines: number[];
loops: number;
@@ -55,6 +57,8 @@ export namespace gui {
this.pid = source["pid"];
this.name = source["name"];
this.targetText = source["targetText"];
this.maxPrice = source["maxPrice"];
this.minLeft = source["minLeft"];
this.cookieText = source["cookieText"];
this.selectedAccountLines = source["selectedAccountLines"];
this.loops = source["loops"];
@@ -77,6 +81,8 @@ export namespace gui {
export class ExchangeResult {
uid: number;
resp?: huya.ScoreExchangePrizeResp;
category?: string;
message?: string;
error?: string;
static createFrom(source: any = {}) {
@@ -87,6 +93,8 @@ export namespace gui {
if ('string' === typeof source) source = JSON.parse(source);
this.uid = source["uid"];
this.resp = this.convertValues(source["resp"], huya.ScoreExchangePrizeResp);
this.category = source["category"];
this.message = source["message"];
this.error = source["error"];
}
+5 -5
View File
@@ -4,7 +4,7 @@ go 1.26
require (
github.com/gorilla/websocket v1.5.3
github.com/wailsapp/wails/v2 v2.12.0
github.com/wailsapp/wails/v2 v2.15.0
)
require (
@@ -31,8 +31,8 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
+12 -12
View File
@@ -47,8 +47,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -59,25 +59,25 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+18 -3
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"`
@@ -105,6 +107,8 @@ type RecordResult struct {
type ExchangeResult struct {
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"`
}
@@ -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])
}
}
+43 -57
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)
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()
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()
c.mu.Lock()
defer c.mu.Unlock()
if session := c.sessions[key]; session != nil && !session.isClosed() {
return session, nil
// 已有同 uid 的建连在途,等它结束后复用结果或自行重试。
select {
case <-dialing:
case <-ctx.Done():
return nil, ctx.Err()
}
session, err := c.dialSession(ctx, uid, cookie)
if err != nil {
return nil, err
}
c.sessions[key] = session
return session, nil
}
func (c *WSSClient) dial(ctx context.Context, uid int64, cookie string) (*WSSSession, error) {
if c.dialFunc != nil {
return c.dialFunc(ctx, uid, cookie)
}
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)
}
}