GUI 日志落盘,修复同一奖品重复兑换与监控结束竞态

- 日志落盘:新增 internal/gui/logfile.go,写到 <用户配置目录>/huya-go/logs/huya-go.log,单文件 5MB 后滚动保留 5 个备份;「日志」页签顶部显示路径并支持打开目录/复制路径;写盘失败自动降级为仅界面输出
- 修复重复兑换:界面选品会同时提交 pid、名称与 targetText 里的同一 pid,normalizeTargets 现按 pid/名称去重;runState 再按「账号+奖品」记账,多个 target 命中同一奖品时只兑换一次(新增 skip_duplicate 结果)
- 修复 use of closed network connection:成功后的异步刷新改为 runState.refresh 等待,RunTargets 返回前不再提前关闭连接
- queryHttpDns 增加 single-flight 与失败冷却,多账号并发建连只解析一次
- 新增 internal/gui/logfile_test.go 与 sniper/huya 回归测试;README 补日志说明
This commit is contained in:
yml2213
2026-10-13 20:34:43 +08:00
parent 36aadae90a
commit 41565566c5
13 changed files with 769 additions and 18 deletions
+8
View File
@@ -72,6 +72,14 @@ CLI 示例:
./huya-go -cmd monitor -sid 2203 -pid 12889 -cookie-file cookie.txt -transport http -at "2026-07-06 20:00:00"
```
## 日志
GUI 日志同时输出到界面和磁盘:
- 路径:`<系统用户配置目录>/huya-go/logs/huya-go.log`(macOS 为 `~/Library/Application Support/huya-go/logs/huya-go.log`,Windows 为 `%AppData%\huya-go\logs\huya-go.log`)。
- 单文件上限 5MB,超出后滚动为 `huya-go.log.1` ~ `huya-go.log.5`,最旧的被丢弃。
- 界面「日志」页签顶部显示当前路径,可一键打开日志目录或复制路径。
## 参数说明
- `-cmd`:`list`、`score`、`records`、`exchange`、`monitor`。
+42 -5
View File
@@ -21,6 +21,8 @@ import type { ColumnsType } from 'antd/es/table'
import {
ClipboardList,
ClipboardPaste,
Copy,
FolderOpen,
ListChecks,
Pencil,
Play,
@@ -36,16 +38,18 @@ import {
import {
DefaultConfig,
Exchange,
GetLogFilePath,
GetLogs,
GetRecords,
IsRunning,
ListPrizes,
OpenLogDir,
PreviewAccounts,
QueryScores,
StartMonitor,
StopMonitor,
} from '../wailsjs/go/gui/App'
import { ClipboardGetText, EventsOn } from '../wailsjs/runtime/runtime'
import { ClipboardGetText, ClipboardSetText, EventsOn } from '../wailsjs/runtime/runtime'
const storageKey = 'huya-go.gui.config.v2'
const cookieStorageKey = 'huya-go.gui.cookies.v1'
@@ -482,6 +486,7 @@ export default function Dashboard() {
const [cookieEntries, setCookieEntries] = useState<CookieEntry[]>([])
const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0)
const [configLoaded, setConfigLoaded] = useState(false)
const [logPath, setLogPath] = useState('')
const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => {
const normalizedEntries = entries
@@ -526,8 +531,8 @@ export default function Dashboard() {
useEffect(() => {
let mounted = true
Promise.all([DefaultConfig(), GetLogs(), IsRunning()])
.then(([defaults, logItems, isRunning]) => {
Promise.all([DefaultConfig(), GetLogs(), IsRunning(), GetLogFilePath()])
.then(([defaults, logItems, isRunning, savedLogPath]) => {
if (!mounted) return
const savedCookies = readSavedCookies()
const savedPrizeCache = readSavedPrizeCache()
@@ -540,6 +545,7 @@ export default function Dashboard() {
setPrizes(savedPrizeCache.prizes)
setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt)
setLogs((logItems || []) as LogEntry[])
setLogPath(String(savedLogPath || ''))
setRunning(Boolean(isRunning))
setConfigLoaded(true)
if (savedCookies.length > 0) {
@@ -882,7 +888,8 @@ export default function Dashboard() {
form.setFieldsValue({
pid: selected[0].id,
name: selected[0].name,
targetText: selected.map((item) => String(item.id)).join('\n'),
// pid/名称已经代表第一个奖品,这里只补其余奖品,避免同一奖品被提交两次。
targetText: selected.slice(1).map((item) => String(item.id)).join('\n'),
autoSelect: false,
})
}
@@ -932,6 +939,14 @@ export default function Dashboard() {
)
}
const openLogDir = () => runAction('logdir', () => OpenLogDir())
const copyLogPath = async () => {
if (!logPath) return
await ClipboardSetText(logPath)
message.success('已复制日志路径')
}
const selectAllAccounts = () => {
setSelectedAccountLines(validAccounts.map((item) => item.line))
}
@@ -1107,7 +1122,29 @@ export default function Dashboard() {
{
key: 'logs',
label: '日志',
children: <LogView logs={logs} />,
children: (
<div className="log-pane">
<div className="log-tools">
<span className="log-path" title={logPath || '日志文件不可用'}>
{logPath || '日志文件不可用'}
</span>
<Space size={6}>
<Tooltip title="打开日志目录">
<Button
size="small"
icon={<FolderOpen size={14} />}
disabled={!logPath}
onClick={openLogDir}
/>
</Tooltip>
<Tooltip title="复制日志路径">
<Button size="small" icon={<Copy size={14} />} disabled={!logPath} onClick={copyLogPath} />
</Tooltip>
</Space>
</div>
<LogView logs={logs} />
</div>
),
},
{
key: 'results',
+27 -1
View File
@@ -386,8 +386,34 @@ body {
margin-bottom: 8px;
}
.log-pane {
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
min-height: 0;
}
.log-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.log-path {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: #6b7280;
font-size: 12px;
}
.log-view {
height: calc(100% - 2px);
flex: 1;
height: auto;
min-height: 300px;
overflow: auto;
border: 1px solid #e6edf3;
+4
View File
@@ -6,6 +6,8 @@ export function DefaultConfig():Promise<gui.Config>;
export function Exchange(arg1:gui.Config):Promise<Array<gui.ExchangeResult>>;
export function GetLogFilePath():Promise<string>;
export function GetLogs():Promise<Array<gui.LogEntry>>;
export function GetRecords(arg1:gui.Config):Promise<Array<gui.RecordResult>>;
@@ -14,6 +16,8 @@ export function IsRunning():Promise<boolean>;
export function ListPrizes(arg1:gui.Config):Promise<gui.PrizeListResult>;
export function OpenLogDir():Promise<void>;
export function PreviewAccounts(arg1:string):Promise<Array<gui.AccountPreview>>;
export function QueryScores(arg1:gui.Config):Promise<Array<gui.ScoreResult>>;
+8
View File
@@ -10,6 +10,10 @@ export function Exchange(arg1) {
return window['go']['gui']['App']['Exchange'](arg1);
}
export function GetLogFilePath() {
return window['go']['gui']['App']['GetLogFilePath']();
}
export function GetLogs() {
return window['go']['gui']['App']['GetLogs']();
}
@@ -26,6 +30,10 @@ export function ListPrizes(arg1) {
return window['go']['gui']['App']['ListPrizes'](arg1);
}
export function OpenLogDir() {
return window['go']['gui']['App']['OpenLogDir']();
}
export function PreviewAccounts(arg1) {
return window['go']['gui']['App']['PreviewAccounts'](arg1);
}
+84 -3
View File
@@ -35,6 +35,10 @@ type App struct {
logWorkerOnce sync.Once
logStopOnce sync.Once
droppedLogs atomic.Int64
// logSink 为 nil 表示当前环境无法写日志文件,日志只留在内存与界面上。
logSink *logSink
logSinkErr error
logWriteFailures atomic.Int64
runMu sync.Mutex
monitorCancel context.CancelFunc
@@ -80,6 +84,9 @@ type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
// at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。
at time.Time
}
type PrizeListResult struct {
@@ -123,21 +130,57 @@ type cookieLine struct {
}
func NewApp() *App {
return &App{
app := &App{
logCh: make(chan LogEntry, logQueueSize),
logStop: make(chan struct{}),
}
// 日志文件不可用时只降级为内存日志,不影响主流程。
sink, err := newLogSink()
if err != nil {
app.logSinkErr = err
} else {
app.logSink = sink
}
return app
}
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.startLogWorker()
a.addLog("info", "GUI 已启动")
if a.logSink != nil {
a.addLog("info", "日志文件: %s", a.logSink.Path())
} else if a.logSinkErr != nil {
a.addLog("warn", "日志文件不可用,日志仅保留在界面: %v", a.logSinkErr)
}
}
// GetLogFilePath 返回当前日志文件路径(供界面展示/复制)。
func (a *App) GetLogFilePath() string {
if a.logSink == nil {
return ""
}
return a.logSink.Path()
}
// OpenLogDir 用系统文件管理器打开日志目录。
func (a *App) OpenLogDir() error {
if a.logSink == nil {
if a.logSinkErr != nil {
return fmt.Errorf("日志目录不可用: %w", a.logSinkErr)
}
return errors.New("日志目录不可用")
}
if err := openDirectory(a.logSink.Dir()); err != nil {
return fmt.Errorf("打开日志目录失败: %w", err)
}
return nil
}
func (a *App) Shutdown(ctx context.Context) {
a.StopMonitor()
a.stopLogWorker()
a.closeLogSink()
}
func (a *App) DefaultConfig() Config {
@@ -442,8 +485,10 @@ func (a *App) finishMonitor(message string) {
}
func (a *App) addLog(level, format string, args ...any) {
now := time.Now()
entry := LogEntry{
Time: time.Now().Format("15:04:05.000"),
Time: now.Format("15:04:05.000"),
at: now,
Level: level,
Message: fmt.Sprintf(format, args...),
}
@@ -510,14 +555,21 @@ func (a *App) flushDroppedLogs() {
if dropped <= 0 {
return
}
now := time.Now()
a.writeLogEntry(LogEntry{
Time: time.Now().Format("15:04:05.000"),
Time: now.Format("15:04:05.000"),
at: now,
Level: "warn",
Message: fmt.Sprintf("日志队列繁忙,已异步丢弃 %d 条日志", dropped),
})
}
func (a *App) writeLogEntry(entry LogEntry) {
a.emitLogEntry(entry, true)
}
// emitLogEntry 写入内存缓存并推给界面;persist 为 true 时同时落盘。
func (a *App) emitLogEntry(entry LogEntry, persist bool) {
a.logMu.Lock()
a.logs = append(a.logs, entry)
if len(a.logs) > maxLogEntries {
@@ -525,6 +577,35 @@ func (a *App) writeLogEntry(entry LogEntry) {
}
a.logMu.Unlock()
a.emit(eventLog, entry)
if persist {
a.appendLogFile(entry)
}
}
func (a *App) appendLogFile(entry LogEntry) {
sink := a.logSink
if sink == nil {
return
}
if err := sink.Write(entry); err != nil {
// 只在第一次失败时提示,且不再落盘,避免刷屏与递归。
if a.logWriteFailures.Add(1) == 1 {
now := time.Now()
a.emitLogEntry(LogEntry{
Time: now.Format("15:04:05.000"),
at: now,
Level: "warn",
Message: fmt.Sprintf("日志写入文件失败,已改为仅界面输出: %v", err),
}, false)
}
}
}
func (a *App) closeLogSink() {
if a.logSink == nil {
return
}
_ = a.logSink.Close()
}
func (a *App) emitState(running bool, message string) {
+145
View File
@@ -0,0 +1,145 @@
package gui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
const (
// appDirName 是用户配置目录下的应用目录名。
appDirName = "huya-go"
logSubDir = "logs"
logName = "huya-go.log"
defaultMaxLogFileBytes = 5 << 20 // 单个日志文件上限 5MB
defaultMaxLogBackups = 5 // 最多保留 5 个滚动文件
)
// logSink 把日志追加写到磁盘,超过大小上限时按 .1/.2/... 滚动,避免长期运行写满磁盘。
type logSink struct {
dir string
path string
file *os.File
size int64
maxBytes int64
backups int
}
// newLogSink 在系统用户配置目录下创建 <config>/huya-go/logs/huya-go.log。
func newLogSink() (*logSink, error) {
base, err := os.UserConfigDir()
if err != nil {
return nil, err
}
return newLogSinkAt(filepath.Join(base, appDirName, logSubDir))
}
func newLogSinkAt(dir string) (*logSink, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
sink := &logSink{
dir: dir,
path: filepath.Join(dir, logName),
maxBytes: defaultMaxLogFileBytes,
backups: defaultMaxLogBackups,
}
if err := sink.open(); err != nil {
return nil, err
}
return sink, nil
}
// Dir 返回日志目录。
func (s *logSink) Dir() string { return s.dir }
// Path 返回当前日志文件路径。
func (s *logSink) Path() string { return s.path }
func (s *logSink) open() error {
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return err
}
s.file = file
s.size = info.Size()
return nil
}
// Write 追加一条日志,必要时先滚动。
func (s *logSink) Write(entry LogEntry) error {
if s.file == nil {
if err := s.open(); err != nil {
return err
}
}
line := formatLogLine(entry)
if s.maxBytes > 0 && s.size+int64(len(line)) > s.maxBytes {
if err := s.rotate(); err != nil {
return err
}
}
n, err := s.file.WriteString(line)
s.size += int64(n)
return err
}
func (s *logSink) rotate() error {
if s.file != nil {
_ = s.file.Close()
s.file = nil
}
for i := s.backups - 1; i >= 1; i-- {
from := fmt.Sprintf("%s.%d", s.path, i)
if _, err := os.Stat(from); err != nil {
continue
}
_ = os.Rename(from, fmt.Sprintf("%s.%d", s.path, i+1))
}
if _, err := os.Stat(s.path); err == nil {
_ = os.Rename(s.path, s.path+".1")
}
s.size = 0
return s.open()
}
// Close 关闭当前文件。
func (s *logSink) Close() error {
if s.file == nil {
return nil
}
err := s.file.Close()
s.file = nil
return err
}
func formatLogLine(entry LogEntry) string {
at := entry.at
if at.IsZero() {
at = time.Now()
}
return fmt.Sprintf("%s [%s] %s\n", at.Format("2006-01-02 15:04:05.000"), entry.Level, entry.Message)
}
// openDirectory 调用系统文件管理器打开目录。
func openDirectory(dir string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", dir)
case "windows":
cmd = exec.Command("explorer", dir)
default:
cmd = exec.Command("xdg-open", dir)
}
return cmd.Start()
}
+138
View File
@@ -0,0 +1,138 @@
package gui
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLogSinkWritesFormattedLines(t *testing.T) {
sink, err := newLogSinkAt(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer sink.Close()
at := time.Date(2026, 10, 13, 20, 21, 57, 822000000, time.Local)
if err := sink.Write(LogEntry{
Time: "20:21:57.822",
at: at,
Level: "debug",
Message: "[WSS] 请求 launch.pingSignal requestID=2",
}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Path())
if err != nil {
t.Fatal(err)
}
want := "2026-10-13 20:21:57.822 [debug] [WSS] 请求 launch.pingSignal requestID=2\n"
if string(data) != want {
t.Fatalf("日志内容=%q, want %q", data, want)
}
if sink.Dir() != filepath.Dir(sink.Path()) {
t.Fatalf("Dir=%q Path=%q", sink.Dir(), sink.Path())
}
}
func TestLogSinkRotatesAndDropsOldest(t *testing.T) {
dir := t.TempDir()
sink, err := newLogSinkAt(dir)
if err != nil {
t.Fatal(err)
}
defer sink.Close()
sink.maxBytes = 200
sink.backups = 2
for i := 0; i < 60; i++ {
if err := sink.Write(LogEntry{Level: "info", Message: fmt.Sprintf("第 %d 条日志,用于触发滚动", i)}); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(sink.Path() + ".1"); err != nil {
t.Fatalf("期望存在滚动文件 .1: %v", err)
}
if _, err := os.Stat(sink.Path() + ".2"); err != nil {
t.Fatalf("期望存在滚动文件 .2: %v", err)
}
if _, err := os.Stat(sink.Path() + ".3"); err == nil {
t.Fatal("backups=2 时不应出现 .3")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
t.Fatal(err)
}
if info.Size() > int64(sink.maxBytes)+200 {
t.Fatalf("%s 体积异常: %d", entry.Name(), info.Size())
}
}
}
func TestLogSinkAppendsAcrossReopen(t *testing.T) {
dir := t.TempDir()
sink, err := newLogSinkAt(dir)
if err != nil {
t.Fatal(err)
}
if err := sink.Write(LogEntry{Level: "info", Message: "第一条"}); err != nil {
t.Fatal(err)
}
if err := sink.Close(); err != nil {
t.Fatal(err)
}
reopened, err := newLogSinkAt(dir)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err := reopened.Write(LogEntry{Level: "info", Message: "第二条"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(reopened.Path())
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") {
t.Fatalf("追加写入失败: %q", text)
}
if strings.Index(text, "第一条") > strings.Index(text, "第二条") {
t.Fatalf("日志顺序异常: %q", text)
}
}
func TestAppWritesLogsToFile(t *testing.T) {
sink, err := newLogSinkAt(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer sink.Close()
app := &App{logSink: sink}
app.addLog("info", "日志文件: %s", sink.Path())
app.addLog("debug", "[WSS] pingSignal 往返=28ms 服务端=17ms")
data, err := os.ReadFile(sink.Path())
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, "日志文件: ") || !strings.Contains(text, "pingSignal") {
t.Fatalf("日志未落盘: %q", text)
}
if !strings.Contains(text, "[info]") || !strings.Contains(text, "[debug]") {
t.Fatalf("日志级别未写入: %q", text)
}
}
+2
View File
@@ -21,6 +21,8 @@ const (
// DefaultDNSTTL 是响应没有 ttl 时的兜底缓存时间。
DefaultDNSTTL = 10 * time.Minute
// dnsFailureCooldown 是 queryHttpDns 失败后的冷却时间。
dnsFailureCooldown = 30 * time.Second
)
// QueryHttpDnsReq 对应浏览器 launch.queryHttpDns 的 tReq。
+90
View File
@@ -3,8 +3,14 @@ package huya
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -347,3 +353,87 @@ func TestNewActivityClientWSSHostSemantics(t *testing.T) {
})
}
}
// buildWupResponse 用 TAF 拼一个最小 WUP 响应,用于本地 httptest 桩服务。
func buildWupResponse(t *testing.T, servant, fn string, requestID int32, payload []byte) []byte {
t.Helper()
inner := taf.NewWriter()
if err := inner.WriteMapStringBytes(0, map[string][]byte{"tRsp": payload}); err != nil {
t.Fatal(err)
}
body := taf.NewWriter()
for _, err := range []error{
body.WriteInt16(1, 3),
body.WriteInt8(2, 0),
body.WriteInt32(3, 0),
body.WriteInt32(4, int64(requestID)),
body.WriteString(5, servant),
body.WriteString(6, fn),
body.WriteBytes(7, inner.Bytes()),
} {
if err != nil {
t.Fatal(err)
}
}
raw := body.Bytes()
out := make([]byte, 0, len(raw)+4)
var size [4]byte
binary.BigEndian.PutUint32(size[:], uint32(len(raw)+4))
out = append(out, size[:]...)
return append(out, raw...)
}
func TestResolveHostSingleFlightAndCache(t *testing.T) {
// 先准备好响应,避免在 handler 里调用 t.Fatal。
responseBytes := buildWupResponse(t, ServantLaunch, FuncQueryHttpDns, -1, mustDecodeHex(t, realQueryHttpDnsRspHex))
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
// 留出时间让并发协程都进入等待分支。
time.Sleep(30 * time.Millisecond)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(responseBytes)
}))
defer server.Close()
client := NewWSSClient(nil)
client.DNSEndpoint = server.URL + "/"
const callers = 4
hosts := make([]string, callers)
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
hosts[i] = client.resolveHost(context.Background(), int64(i+1), "yyuid=1")
}(i)
}
wg.Wait()
if got := hits.Load(); got != 1 {
t.Fatalf("queryHttpDns 请求次数=%d, want 1(并发建连应共用一次解析)", got)
}
for i, host := range hosts {
if !strings.HasSuffix(host, "-ws.va.huya.com") {
t.Fatalf("caller %d 解析结果异常: %q", i, host)
}
}
// TTL 内命中缓存,不再请求解析接口。
if got := client.resolveHost(context.Background(), 99, "yyuid=1"); !strings.HasSuffix(got, "-ws.va.huya.com") {
t.Fatalf("缓存 host 异常: %q", got)
}
if got := hits.Load(); got != 1 {
t.Fatalf("TTL 内重复请求解析接口: %d 次", got)
}
// 多 host 轮询:连续 4 次应覆盖响应里的全部 4 个 host。
seen := map[string]bool{}
for i := 0; i < 4; i++ {
seen[client.resolveHost(context.Background(), 99, "yyuid=1")] = true
}
if len(seen) != 4 {
t.Fatalf("host 轮询未覆盖全部节点: %v", seen)
}
}
+40 -2
View File
@@ -54,6 +54,10 @@ type WSSClient struct {
dnsHosts []string
dnsIndex int
dnsExpireAt time.Time
// dnsInflight 保证并发建连时只发起一次 queryHttpDns。
dnsInflight chan struct{}
// dnsFailedAt 记录解析失败时间,冷却期内不再重复请求。
dnsFailedAt time.Time
}
type WSSSession struct {
@@ -151,6 +155,7 @@ func (c *WSSClient) PingSignal(ctx context.Context, uid int64, cookie string) (*
// resolveHost 返回本次建连使用的 host。
//
// 优先级:显式配置的 Host > queryHttpDns 结果(TTL 内缓存、多 host 轮询)> DefaultWSSHost。
// 并发建连时只会发起一次 queryHttpDns,失败后短时间冷却,避免多账号同时打爆解析接口。
func (c *WSSClient) resolveHost(ctx context.Context, uid int64, cookie string) string {
if host := strings.TrimSpace(c.Host); host != "" {
return host
@@ -158,10 +163,43 @@ func (c *WSSClient) resolveHost(ctx context.Context, uid int64, cookie string) s
if !c.AutoResolveHost {
return DefaultWSSHost
}
if host := c.nextCachedDNSHost(); host != "" {
return host
for {
if host := c.nextCachedDNSHost(); host != "" {
return host
}
c.mu.Lock()
if time.Since(c.dnsFailedAt) < dnsFailureCooldown {
c.mu.Unlock()
c.logf("[WSS] queryHttpDns 冷却中,暂时回退 %s", DefaultWSSHost)
return DefaultWSSHost
}
if c.dnsInflight == nil {
c.dnsInflight = make(chan struct{})
c.mu.Unlock()
break
}
wait := c.dnsInflight
c.mu.Unlock()
select {
case <-wait:
case <-ctx.Done():
return DefaultWSSHost
}
}
resp, err := c.queryHttpDns(ctx, uid, cookie)
c.mu.Lock()
wait := c.dnsInflight
c.dnsInflight = nil
if err != nil {
c.dnsFailedAt = time.Now()
}
c.mu.Unlock()
if wait != nil {
close(wait)
}
if err != nil {
c.logf("[WSS] queryHttpDns 失败,回退 %s: %v", DefaultWSSHost, err)
return DefaultWSSHost
+88 -7
View File
@@ -91,6 +91,10 @@ 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
}
type targetState struct {
@@ -128,6 +132,8 @@ func (r *Runner) RunTargets(ctx context.Context, accounts []Account, targets []T
}
state := newRunState(accounts, len(targets))
// 成功后的刷新是异步的,等它们结束再返回,避免调用方提前关闭连接。
defer state.refresh.Wait()
for loop := 1; loop <= cfg.Loops; loop++ {
if ctx.Err() != nil {
return ctx.Err()
@@ -223,12 +229,20 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient,
for _, index := range active {
target := targets[index]
if shouldDirectExchange(target, cfg) {
// 直兑路径没有奖品列表,用 target.PID 判断是否已经兑换过同一奖品。
if state.prizeExchanged(account.UID, int64(target.PID)) {
emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, nil)
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(client, account, target, index, loop, cfg, result, emit)
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
@@ -289,18 +303,42 @@ func (r *Runner) runAccountLoop(ctx context.Context, client huya.ActivityClient,
}
continue
}
// 多个 target 可能命中同一个奖品(例如按不同名称匹配),已成功兑换过就跳过。
if state.prizeExchanged(account.UID, prize.ID) {
emitSkippedDuplicate(emit, loop, account.UID, index, target)
continue
}
result := r.exchangeTarget(ctx, client, account, target, index, loop, cfg.SID, prize)
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(client, account, target, index, loop, cfg, result, emit)
r.refreshAfterSuccess(state, client, account, target, index, loop, cfg, result, emit)
if ctx.Err() != nil {
return
}
}
}
// emitSkippedDuplicate 在跳过同一奖品的重复兑换时给出明确结果,便于界面观察。
func emitSkippedDuplicate(emit func(Result), loop int, uid int64, index int, target Target) {
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, account Account, target Target, index int, loop int, sid int32, prize *huya.PrizeInfo) Result {
pid := target.PID
if prize != nil {
@@ -365,11 +403,13 @@ func (r *Runner) applyExchangeDecision(state *runState, account Account, index i
return result
}
func (r *Runner) refreshAfterSuccess(client huya.ActivityClient, account Account, target Target, index int, loop int, cfg Config, result Result, emit func(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()
@@ -447,6 +487,7 @@ func newRunState(accounts []Account, targetCount int) *runState {
state := &runState{
accounts: make(map[int64][]targetState, len(accounts)),
targetDone: make([]bool, targetCount),
exchanged: make(map[int64]map[int64]bool, len(accounts)),
}
for _, account := range accounts {
state.accounts[account.UID] = make([]targetState, targetCount)
@@ -454,6 +495,28 @@ func newRunState(accounts []Account, targetCount int) *runState {
return state
}
// 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()
@@ -545,19 +608,37 @@ func normalizeConfig(cfg Config) Config {
return cfg
}
// normalizeTargets 去掉空目标与重复目标。
//
// 除了完全相同的 target,还按 pid / 名称去重:界面选品会同时提交 pid、名称,
// 以及 targetText 里的同一个 pid,不去重就会对同一奖品重复兑换(实测踩过)。
func normalizeTargets(targets []Target) []Target {
out := make([]Target, 0, len(targets))
seen := map[string]bool{}
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
}
key := targetKey(target)
if seen[key] {
if target.PID > 0 && seenPID[target.PID] {
continue
}
seen[key] = true
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
+93
View File
@@ -317,3 +317,96 @@ func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) {
t.Fatalf("exchangeCount=%d, want 3", exchangeCount)
}
}
func TestNormalizeTargetsDropsDuplicatePrizeTargets(t *testing.T) {
targets := normalizeTargets([]Target{
{PID: 13409, Name: "幸运币礼包(小)"},
{PID: 13409},
{Name: "幸运币礼包(小)"},
{PID: 12889, Name: "套装-潮萌小黄鸭"},
})
if len(targets) != 2 {
t.Fatalf("targets=%d, want 2: %+v", len(targets), targets)
}
if targets[0].PID != 13409 || targets[1].PID != 12889 {
t.Fatalf("unexpected targets: %+v", targets)
}
}
func TestRunTargetsExchangesPrizeOnceWhenTargetsDuplicated(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{PID: 13409, Name: "幸运币礼包(小)"},
{PID: 13409},
}, Config{
SID: 2277,
Loops: 3,
Delay: time.Millisecond,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount)
}
}
func TestRunTargetsWaitsForRefreshBeforeReturn(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
var mu sync.Mutex
seen := map[string]bool{}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 13409}}, Config{
SID: 2277,
Loops: 1,
StopAccountAfterSuccess: true,
RefreshAfterSuccess: true,
}, func(result Result) {
mu.Lock()
seen[result.Action] = true
mu.Unlock()
})
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !seen["refresh_score"] || !seen["refresh_records"] {
t.Fatalf("RunTargets 返回时异步刷新还没结束: %v", seen)
}
}
func TestRunTargetsDoesNotExchangeSamePrizeTwiceViaNameMatch(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Prizes: []huya.PrizeInfo{{ID: 13409, Name: "幸运币礼包(小)", NewScore: 20}},
},
}
runner := Runner{Client: client}
// 两个不同名称的 target 都会匹配到同一个奖品。
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{Name: "幸运币"},
{Name: "礼包"},
}, Config{
SID: 2277,
Loops: 2,
Delay: time.Millisecond,
DirectExchange: false,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount)
}
}