Files
huya-go/internal/gui/app.go
T
yml2213 41565566c5 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 补日志说明
2026-10-13 20:34:43 +08:00

839 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gui
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"huya-go/internal/huya"
"huya-go/internal/sniper"
)
const (
eventLog = "gui:log"
eventMonitorResult = "gui:monitor-result"
eventMonitorState = "gui:monitor-state"
maxLogEntries = 500
logQueueSize = 2048
)
type App struct {
ctx context.Context
logMu sync.Mutex
logs []LogEntry
logCh chan LogEntry
logStop chan 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
monitorRunning bool
}
type Config struct {
Transport string `json:"transport"`
WSSHost string `json:"wssHost"`
SID int32 `json:"sid"`
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"`
DelayMS int `json:"delayMs"`
Concurrency int `json:"concurrency"`
StopAfterSuccess bool `json:"stopAfterSuccess"`
StopAccountAfterSuccess bool `json:"stopAccountAfterSuccess"`
Prewarm bool `json:"prewarm"`
DirectExchange bool `json:"directExchange"`
SharePrizeList bool `json:"sharePrizeList"`
AutoSelect bool `json:"autoSelect"`
RefreshAfterSuccess bool `json:"refreshAfterSuccess"`
LeadMS int `json:"leadMs"`
FrequentBackoffMS int `json:"frequentBackoffMs"`
ErrorBackoffMS int `json:"errorBackoffMs"`
TimeoutSec int `json:"timeoutSec"`
StartAt string `json:"startAt"`
}
type AccountPreview struct {
Line int `json:"line"`
UID int64 `json:"uid"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
type LogEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
// at 只用于落盘,保证文件里带完整日期(不参与 JSON 序列化)。
at time.Time
}
type PrizeListResult struct {
UID int64 `json:"uid"`
Status int32 `json:"status"`
Msg string `json:"msg"`
Prizes []huya.PrizeInfo `json:"prizes"`
Raw *huya.GetActPrizeListResp `json:"raw,omitempty"`
}
type ScoreResult struct {
UID int64 `json:"uid"`
Score int64 `json:"score"`
Resp *huya.GetUserScoreResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
type RecordResult struct {
UID int64 `json:"uid"`
Records []huya.UserPrizeInfo `json:"records"`
Resp *huya.GetUserPrizeRecordsResp `json:"resp,omitempty"`
Error string `json:"error,omitempty"`
}
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"`
}
type MonitorState struct {
Running bool `json:"running"`
Message string `json:"message"`
}
type cookieLine struct {
lineNo int
text string
}
func NewApp() *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 {
return Config{
Transport: "http",
WSSHost: "",
SID: 2203,
Loops: 20,
DelayMS: 120,
Concurrency: 5,
StopAfterSuccess: false,
StopAccountAfterSuccess: true,
Prewarm: true,
DirectExchange: true,
SharePrizeList: true,
AutoSelect: false,
RefreshAfterSuccess: true,
LeadMS: 2500,
FrequentBackoffMS: 300,
ErrorBackoffMS: 300,
TimeoutSec: 15,
}
}
func (a *App) PreviewAccounts(cookieText string) []AccountPreview {
previews := make([]AccountPreview, 0)
for _, line := range splitCookieLines(cookieText) {
uid := huya.ResolveUID(line.text)
item := AccountPreview{Line: line.lineNo, UID: uid, Valid: uid > 0}
if uid <= 0 {
item.Error = "无法解析 yyuid/udb_uid"
}
previews = append(previews, item)
}
return previews
}
func (a *App) GetLogs() []LogEntry {
a.logMu.Lock()
defer a.logMu.Unlock()
return append([]LogEntry(nil), a.logs...)
}
func (a *App) IsRunning() bool {
a.runMu.Lock()
defer a.runMu.Unlock()
return a.monitorRunning
}
func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return PrizeListResult{}, err
}
if cfg.SID <= 0 {
return PrizeListResult{}, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return PrizeListResult{}, err
}
defer closeClient()
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.GetActPrizeList(ctx, accounts[0].UID, accounts[0].Cookie, cfg.SID)
if err != nil {
a.addLog("error", "查询奖品失败: %v", err)
return PrizeListResult{}, err
}
left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes)
a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg)
return PrizeListResult{
UID: accounts[0].UID,
Status: resp.Status,
Msg: resp.Msg,
Prizes: resp.Prizes,
Raw: resp,
}, nil
}
func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
if cfg.SID <= 0 {
return nil, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]ScoreResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.QueryUserScore(ctx, account.UID, account.Cookie, cfg.SID)
item := ScoreResult{UID: account.UID, Resp: resp, Error: errorString(err)}
if resp != nil {
item.Score = resp.AvailableScore()
}
results[i] = item
})
a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
return results, nil
}
func (a *App) GetRecords(cfg Config) ([]RecordResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
if cfg.SID <= 0 {
return nil, errors.New("SID 不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]RecordResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
ctx, cancel := requestContext(cfg.TimeoutSec)
defer cancel()
resp, err := client.GetUserPrizeRecords(ctx, account.UID, account.Cookie, cfg.SID)
item := RecordResult{UID: account.UID, Resp: resp, Error: errorString(err)}
if resp != nil {
item.Records = resp.Records
}
results[i] = item
})
a.addLog("info", "查询兑换记录完成: accounts=%d", len(accounts))
return results, nil
}
func (a *App) Exchange(cfg Config) ([]ExchangeResult, error) {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return nil, err
}
if cfg.SID <= 0 || cfg.PID <= 0 {
return nil, errors.New("SID 和 PID 都不能为空")
}
client, closeClient, err := a.newClient(cfg)
if err != nil {
return nil, err
}
defer closeClient()
results := make([]ExchangeResult, len(accounts))
a.runConcurrent(accounts, cfg.Concurrency, func(i int, account sniper.Account) {
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,
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
}
func (a *App) StartMonitor(cfg Config) error {
cfg = normalizeConfig(cfg)
accounts, err := parseAccounts(cfg)
if err != nil {
return err
}
if cfg.SID <= 0 {
return errors.New("SID 不能为空")
}
targets := parseTargets(cfg)
if len(targets) == 0 {
return errors.New("PID 和名称至少填写一个")
}
startAt, err := parseStartTime(cfg.StartAt)
if err != nil {
return err
}
a.runMu.Lock()
if a.monitorRunning {
a.runMu.Unlock()
return errors.New("监控已在运行")
}
ctx, cancel := context.WithCancel(context.Background())
a.monitorCancel = cancel
a.monitorRunning = true
a.runMu.Unlock()
client, closeClient, err := a.newClient(cfg)
if err != nil {
cancel()
a.finishMonitor("启动失败")
return err
}
a.emitState(true, "运行中")
a.addLog("info", "启动监控: sid=%d targets=%d accounts=%d prewarm=%v direct=%v", cfg.SID, len(targets), len(accounts), cfg.Prewarm, cfg.DirectExchange)
go func() {
defer closeClient()
defer a.finishMonitor("已停止")
runner := sniper.Runner{Client: client}
emit := func(result sniper.Result) {
a.emit(eventMonitorResult, result)
a.addLog("info", "监控结果: loop=%d uid=%d action=%s category=%s msg=%s error=%s", result.Loop, result.UID, result.Action, result.Category, result.Message, result.Error)
}
runnerCfg := sniper.Config{
SID: cfg.SID,
Loops: cfg.Loops,
Delay: time.Duration(cfg.DelayMS) * time.Millisecond,
Concurrency: cfg.Concurrency,
StopAfterSuccess: cfg.StopAfterSuccess,
StopAccountAfterSuccess: cfg.StopAccountAfterSuccess,
Prewarm: false,
DirectExchange: cfg.DirectExchange,
SharePrizeList: cfg.SharePrizeList,
RefreshAfterSuccess: cfg.RefreshAfterSuccess,
TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond,
ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond,
}
if cfg.Prewarm {
a.addLog("info", "开始预热连接: accounts=%d", len(accounts))
if err := runner.Prewarm(ctx, accounts, runnerCfg, emit); err != nil {
a.addLog("error", "连接预热异常: %v", err)
return
}
}
if !startAt.IsZero() {
when := startAt.Add(-time.Duration(cfg.LeadMS) * time.Millisecond)
a.addLog("info", "等待定时开始: %s 提前=%dms", when.Format("2006-01-02 15:04:05.000"), cfg.LeadMS)
if err := waitUntil(ctx, when); err != nil {
a.addLog("info", "定时等待已取消")
return
}
}
err := runner.RunTargets(ctx, accounts, targets, runnerCfg, emit)
if err != nil && !errors.Is(err, context.Canceled) {
a.addLog("error", "监控结束异常: %v", err)
}
}()
return nil
}
func (a *App) StopMonitor() bool {
a.runMu.Lock()
cancel := a.monitorCancel
a.runMu.Unlock()
if cancel == nil {
return false
}
cancel()
return true
}
func (a *App) newClient(cfg Config) (huya.ActivityClient, func(), error) {
return huya.NewActivityClient(cfg.Transport, cfg.WSSHost, time.Duration(cfg.TimeoutSec)*time.Second, func(msg string) {
a.addLog("debug", "%s", msg)
})
}
func (a *App) runConcurrent(accounts []sniper.Account, concurrency int, fn func(i int, account sniper.Account)) {
if concurrency <= 0 {
concurrency = 1
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i, account := range accounts {
i, account := i, account
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fn(i, account)
}()
}
wg.Wait()
}
func (a *App) finishMonitor(message string) {
a.runMu.Lock()
a.monitorCancel = nil
a.monitorRunning = false
a.runMu.Unlock()
a.emitState(false, message)
}
func (a *App) addLog(level, format string, args ...any) {
now := time.Now()
entry := LogEntry{
Time: now.Format("15:04:05.000"),
at: now,
Level: level,
Message: fmt.Sprintf(format, args...),
}
if a.logCh == nil {
a.writeLogEntry(entry)
return
}
select {
case a.logCh <- entry:
default:
a.droppedLogs.Add(1)
}
}
func (a *App) startLogWorker() {
if a.logCh == nil {
a.logCh = make(chan LogEntry, logQueueSize)
}
if a.logStop == nil {
a.logStop = make(chan struct{})
}
a.logWorkerOnce.Do(func() {
go a.logWorker()
})
}
func (a *App) stopLogWorker() {
if a.logStop == nil {
return
}
a.logStopOnce.Do(func() {
close(a.logStop)
})
}
func (a *App) logWorker() {
for {
select {
case entry := <-a.logCh:
a.flushDroppedLogs()
a.writeLogEntry(entry)
case <-a.logStop:
a.drainLogQueue()
a.flushDroppedLogs()
return
}
}
}
func (a *App) drainLogQueue() {
for {
select {
case entry := <-a.logCh:
a.flushDroppedLogs()
a.writeLogEntry(entry)
default:
return
}
}
}
func (a *App) flushDroppedLogs() {
dropped := a.droppedLogs.Swap(0)
if dropped <= 0 {
return
}
now := time.Now()
a.writeLogEntry(LogEntry{
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 {
a.logs = append([]LogEntry(nil), a.logs[len(a.logs)-maxLogEntries:]...)
}
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) {
a.emit(eventMonitorState, MonitorState{Running: running, Message: message})
}
func (a *App) emit(event string, data any) {
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, event, data)
}
func normalizeConfig(cfg Config) Config {
if strings.TrimSpace(cfg.Transport) == "" {
cfg.Transport = "http"
}
// WSSHost 留空表示走 launch.queryHttpDns 自动解析,不在这里补默认值。
if cfg.Loops <= 0 {
cfg.Loops = 1
}
if cfg.DelayMS <= 0 {
cfg.DelayMS = 150
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = 1
}
if cfg.LeadMS < 0 {
cfg.LeadMS = 0
}
if cfg.FrequentBackoffMS <= 0 {
cfg.FrequentBackoffMS = 300
}
if cfg.ErrorBackoffMS <= 0 {
cfg.ErrorBackoffMS = 300
}
if cfg.TimeoutSec <= 0 {
cfg.TimeoutSec = 15
}
cfg.Name = strings.TrimSpace(cfg.Name)
cfg.TargetText = strings.TrimSpace(cfg.TargetText)
cfg.CookieText = strings.TrimSpace(cfg.CookieText)
cfg.StartAt = strings.TrimSpace(cfg.StartAt)
return cfg
}
func parseTargets(cfg Config) []sniper.Target {
targets := make([]sniper.Target, 0)
if cfg.PID > 0 || strings.TrimSpace(cfg.Name) != "" {
targets = append(targets, sniper.Target{PID: cfg.PID, Name: strings.TrimSpace(cfg.Name)})
}
if cfg.AutoSelect {
targets = append(targets, sniper.Target{Auto: true})
}
for _, item := range splitTargetText(cfg.TargetText) {
if item == "auto" || item == "自动" || item == "自动选择" {
targets = append(targets, sniper.Target{Auto: true})
continue
}
if pid, err := strconv.ParseInt(item, 10, 32); err == nil && pid > 0 {
targets = append(targets, sniper.Target{PID: int32(pid)})
continue
}
targets = append(targets, sniper.Target{Name: item})
}
// 价格/库存过滤对所有目标统一生效,避免逐处重复赋值。
for i := range targets {
targets[i].MaxPrice = cfg.MaxPrice
targets[i].MinLeft = cfg.MinLeft
}
return targets
}
func splitTargetText(text string) []string {
replacer := strings.NewReplacer(",", ",", "\n", ",", "\t", ",")
parts := strings.Split(replacer.Replace(text), ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
out = append(out, item)
}
}
return out
}
func parseAccounts(cfg Config) ([]sniper.Account, error) {
lines := splitCookieLines(cfg.CookieText)
if len(lines) == 0 {
return nil, errors.New("Cookie 不能为空")
}
selected := selectedLineSet(cfg.SelectedAccountLines)
accounts := make([]sniper.Account, 0, len(lines))
for _, cookie := range lines {
if len(selected) > 0 && !selected[cookie.lineNo] {
continue
}
uid := huya.ResolveUID(cookie.text)
if uid <= 0 {
return nil, fmt.Errorf("第 %d 行 Cookie 无法解析 yyuid/udb_uid", cookie.lineNo)
}
accounts = append(accounts, sniper.Account{UID: uid, Cookie: cookie.text})
}
if len(accounts) == 0 {
return nil, errors.New("未选择有效账号")
}
return accounts, nil
}
func selectedLineSet(lines []int) map[int]bool {
out := make(map[int]bool, len(lines))
for _, line := range lines {
if line > 0 {
out[line] = true
}
}
return out
}
func splitCookieLines(cookieText string) []cookieLine {
lines := strings.Split(normalizeCookieText(cookieText), "\n")
out := make([]cookieLine, 0, len(lines))
for i, line := range lines {
item := strings.TrimSpace(line)
if item == "" || strings.HasPrefix(item, "#") {
continue
}
out = append(out, cookieLine{lineNo: i + 1, text: item})
}
return out
}
func normalizeCookieText(cookieText string) string {
normalized := strings.NewReplacer(
"\r\n", "\n",
"\r", "\n",
`\r\n`, "\n",
`\r`, "\n",
`\n`, "\n",
).Replace(cookieText)
if strings.Contains(normalized, "\n") ||
(!strings.Contains(normalized, "/r/n") && !strings.Contains(normalized, "/r")) {
return normalized
}
return strings.NewReplacer(
"/r/n", "\n",
"/r", "\n",
"/n", "\n",
).Replace(normalized)
}
func requestContext(timeoutSec int) (context.Context, context.CancelFunc) {
if timeoutSec <= 0 {
timeoutSec = 15
}
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
}
func parseStartTime(text string) (time.Time, error) {
text = strings.TrimSpace(text)
if text == "" {
return time.Time{}, nil
}
if ts, err := strconv.ParseInt(text, 10, 64); err == nil {
return time.Unix(ts, 0), nil
}
for _, layout := range []string{"15:04:05", "15:04"} {
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return nextClockTime(time.Now(), t.Hour(), t.Minute(), t.Second()), nil
}
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析开始时间: %s", text)
}
func nextClockTime(now time.Time, hour, minute, second int) time.Time {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, second, 0, now.Location())
if !target.After(now) {
target = target.Add(24 * time.Hour)
}
return target
}
func waitUntil(ctx context.Context, when time.Time) error {
for {
remaining := time.Until(when)
if remaining <= 0 {
return nil
}
timer := time.NewTimer(minDuration(remaining, 200*time.Millisecond))
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return ctx.Err()
}
}
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func prizeInventorySummary(prizes []huya.PrizeInfo) (left, total int64, leftKnown, totalKnown int) {
for _, prize := range prizes {
if prize.Num > 0 {
total += prize.Num
totalKnown++
}
if itemLeft, ok := prize.InventoryLeft(); ok {
left += itemLeft
leftKnown++
}
}
return left, total, leftKnown, totalKnown
}