diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3e40199..4caf972 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -19,6 +19,7 @@ import {
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
+ CheckCircle2,
ClipboardList,
ClipboardPaste,
Copy,
@@ -52,6 +53,7 @@ import {
OpenLogDir,
PreviewAccounts,
QueryScores,
+ ReconcileScores,
SetLogLevel,
StartMonitor,
StopMonitor,
@@ -2285,6 +2287,25 @@ export default function Dashboard() {
>
兑换记录
+ }
+ loading={loading === 'reconcile'}
+ disabled={selectedAccountCount === 0}
+ onClick={() => {
+ const values = config()
+ if (values.selectedAccountLines.length === 0) {
+ message.warning('请先选择账号')
+ return
+ }
+ runAction('reconcile', () => ReconcileScores(values), (data) => {
+ const list = (data || []) as any[]
+ const confirmed = list.filter((item: any) => item.confirmed).length
+ message.success(`对账完成:${list.length} 个账号,刚性确认扣分成功 ${confirmed} 个`)
+ })
+ }}
+ >
+ 刚性对账
+
}
disabled={!running}
diff --git a/frontend/wailsjs/go/gui/App.d.ts b/frontend/wailsjs/go/gui/App.d.ts
index c18406e..3091c30 100755
--- a/frontend/wailsjs/go/gui/App.d.ts
+++ b/frontend/wailsjs/go/gui/App.d.ts
@@ -31,6 +31,8 @@ export function PreviewAccounts(arg1:string):Promise>;
export function QueryScores(arg1:gui.Config):Promise>;
+export function ReconcileScores(arg1:gui.Config):Promise>;
+
export function SetLogLevel(arg1:string):Promise;
export function StartMonitor(arg1:gui.Config):Promise;
diff --git a/frontend/wailsjs/go/gui/App.js b/frontend/wailsjs/go/gui/App.js
index 7378b58..f4c52fe 100755
--- a/frontend/wailsjs/go/gui/App.js
+++ b/frontend/wailsjs/go/gui/App.js
@@ -58,6 +58,10 @@ export function QueryScores(arg1) {
return window['go']['gui']['App']['QueryScores'](arg1);
}
+export function ReconcileScores(arg1) {
+ return window['go']['gui']['App']['ReconcileScores'](arg1);
+}
+
export function SetLogLevel(arg1) {
return window['go']['gui']['App']['SetLogLevel'](arg1);
}
diff --git a/internal/gui/app.go b/internal/gui/app.go
index f91dddf..7eeacc5 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "path/filepath"
"strconv"
"strings"
"sync"
@@ -14,6 +15,7 @@ import (
"huya-go/internal/huya"
"huya-go/internal/instance"
"huya-go/internal/logger"
+ "huya-go/internal/score"
"huya-go/internal/sniper"
"huya-go/internal/version"
)
@@ -48,6 +50,9 @@ type App struct {
instanceID string
dataDir string
+ // scoreBaselines 记录每个账号抢购前的积分基线,抢购后用于刚性扣分对账。
+ scoreBaselines *score.BaselineStore
+
runMu sync.Mutex
monitorCancel context.CancelFunc
monitorRunning bool
@@ -201,6 +206,10 @@ func (a *App) Startup(ctx context.Context) {
} else {
logger.LogEvent("系统", logger.LevelSuccess, "已启动%s,数据目录: %s", a.instanceName, a.dataDir)
}
+ if a.dataDir != "" {
+ a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
+ a.scoreBaselines.Load()
+ }
if logErr != nil {
logger.LogEvent(categoryApp, logger.LevelWarn, "日志文件不可用,日志仅保留在界面: %v", logErr)
}
@@ -402,7 +411,78 @@ func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
}
results[i] = item
})
- a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
+
+ baselines := make(map[int64]int64, len(results))
+ for _, r := range results {
+ if r.Resp != nil && r.Resp.Status == 200 {
+ baselines[r.UID] = r.Score
+ }
+ }
+ if len(baselines) > 0 {
+ if a.scoreBaselines == nil && a.dataDir != "" {
+ a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
+ a.scoreBaselines.Load()
+ }
+ if a.scoreBaselines != nil {
+ a.scoreBaselines.SetMany(baselines)
+ }
+ }
+
+ a.addLog("info", "查询积分完成: accounts=%d(基线已自动记录)", len(accounts))
+ return results, nil
+}
+
+// ReconcileScores 刚性对账:拉取指定账号的最新积分并比对抢购前基线。
+// 若差值匹配商品售价(或积分发生净扣除),刚性确认为成功兑换。
+func (a *App) ReconcileScores(cfg Config) ([]score.ReconcileItem, 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()
+
+ if a.scoreBaselines == nil && a.dataDir != "" {
+ a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
+ a.scoreBaselines.Load()
+ }
+
+ targets := parseTargets(cfg)
+ var targetPrice int64
+ if len(targets) > 0 {
+ targetPrice = targets[0].MaxPrice
+ }
+ if targetPrice <= 0 && cfg.MaxPrice > 0 {
+ targetPrice = cfg.MaxPrice
+ }
+
+ ctx, cancel := requestContext(cfg.TimeoutSec)
+ defer cancel()
+
+ results, err := score.ReconcileAccounts(ctx, client, a.scoreBaselines, cfg.SID, targetPrice, accounts, cfg.Concurrency)
+ if err != nil && len(results) == 0 {
+ return nil, err
+ }
+
+ confirmedCount := 0
+ for _, r := range results {
+ if r.Confirmed {
+ confirmedCount++
+ a.addLog("success", "[刚性对账][账号:%d] %s", r.UID, r.Message)
+ } else if r.HasBaseline && r.DiffScore == 0 {
+ a.addLog("info", "[刚性对账][账号:%d] %s", r.UID, r.Message)
+ } else {
+ a.addLog("warn", "[刚性对账][账号:%d] %s", r.UID, r.Message)
+ }
+ }
+ a.addLog("info", "刚性对账完成: 账号=%d 确认扣分成功=%d", len(accounts), confirmedCount)
return results, nil
}
@@ -650,6 +730,7 @@ func runnerConfig(cfg Config) sniper.Config {
RefreshAfterSuccess: cfg.RefreshAfterSuccess,
TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond,
ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond,
+ RequestTimeout: sniper.DefaultRequestTimeout,
WindowSeconds: cfg.WindowSeconds,
// 首发错峰固定用调优过的默认值:同机上百个账号齐射是最容易被限流的姿势。
FirstWaveSpread: sniper.DefaultFirstWaveSpread,
diff --git a/internal/score/baseline.go b/internal/score/baseline.go
new file mode 100644
index 0000000..baf0563
--- /dev/null
+++ b/internal/score/baseline.go
@@ -0,0 +1,281 @@
+package score
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "huya-go/internal/huya"
+ "huya-go/internal/sniper"
+)
+
+const ScoreBaselineFileName = "scores.json"
+
+// BaselineEntry 记录单个账号上一次已知的积分余额及时间戳。
+type BaselineEntry struct {
+ Score int64 `json:"score"`
+ At int64 `json:"at"` // Unix 秒
+}
+
+// BaselineStore 记录每个账号「上次已知的积分余额」,作为抢购结束后刚性核对的基线。
+// 借鉴 dygou_go 的 ScoreBaselineStore 设计:在导入账号、手动查积分以及抢购对账后更新。
+type BaselineStore struct {
+ mu sync.RWMutex
+ path string
+ entries map[int64]BaselineEntry
+}
+
+// NewBaselineStore 创建积分基线存储;path 为空时退化为纯内存存储。
+func NewBaselineStore(path string) *BaselineStore {
+ return &BaselineStore{
+ path: path,
+ entries: make(map[int64]BaselineEntry),
+ }
+}
+
+// Load 从磁盘读取基线文件。文件缺失或损坏均不报错,仅退化为无基线。
+func (s *BaselineStore) Load() {
+ if s == nil || s.path == "" {
+ return
+ }
+ data, err := os.ReadFile(s.path)
+ if err != nil {
+ return
+ }
+ loaded := make(map[int64]BaselineEntry)
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ return
+ }
+ s.mu.Lock()
+ s.entries = loaded
+ s.mu.Unlock()
+}
+
+// Get 返回账号已知的基线积分。
+func (s *BaselineStore) Get(uid int64) (int64, bool, time.Time) {
+ if s == nil {
+ return 0, false, time.Time{}
+ }
+ s.mu.RLock()
+ entry, ok := s.entries[uid]
+ s.mu.RUnlock()
+ if !ok {
+ return 0, false, time.Time{}
+ }
+ return entry.Score, true, time.Unix(entry.At, 0)
+}
+
+// Set 记录单账号最新积分余额并落盘。
+func (s *BaselineStore) Set(uid int64, score int64) {
+ s.SetMany(map[int64]int64{uid: score})
+}
+
+// SetMany 批量记录账号最新积分并原子写盘。
+func (s *BaselineStore) SetMany(values map[int64]int64) {
+ if s == nil || len(values) == 0 {
+ return
+ }
+ now := time.Now().Unix()
+ s.mu.Lock()
+ for uid, val := range values {
+ s.entries[uid] = BaselineEntry{Score: val, At: now}
+ }
+ snapshot := make(map[int64]BaselineEntry, len(s.entries))
+ for uid, entry := range s.entries {
+ snapshot[uid] = entry
+ }
+ s.mu.Unlock()
+ s.flush(snapshot)
+}
+
+// Snapshot 返回当前基线积分快照。
+func (s *BaselineStore) Snapshot() map[int64]int64 {
+ if s == nil {
+ return nil
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make(map[int64]int64, len(s.entries))
+ for uid, entry := range s.entries {
+ out[uid] = entry.Score
+ }
+ return out
+}
+
+// UIDs 返回所有已记录基线的 UID(升序排序)。
+func (s *BaselineStore) UIDs() []int64 {
+ if s == nil {
+ return nil
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]int64, 0, len(s.entries))
+ for uid := range s.entries {
+ out = append(out, uid)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
+
+// flush 原子写入磁盘:先写入临时文件再 rename。
+func (s *BaselineStore) flush(snapshot map[int64]BaselineEntry) {
+ if s == nil || s.path == "" {
+ return
+ }
+ data, err := json.MarshalIndent(snapshot, "", " ")
+ if err != nil {
+ return
+ }
+ dir := filepath.Dir(s.path)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return
+ }
+ }
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(s.path)+".*.tmp")
+ if err != nil {
+ return
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ return
+ }
+ if err := tmp.Close(); err != nil {
+ return
+ }
+ _ = os.Rename(tmpPath, s.path)
+}
+
+// ReconcileItem 是单个账号刚性对账的结果。
+type ReconcileItem struct {
+ UID int64 `json:"uid"`
+ BeforeScore int64 `json:"beforeScore"`
+ CurrentScore int64 `json:"currentScore"`
+ DiffScore int64 `json:"diffScore"`
+ TargetPrice int64 `json:"targetPrice"`
+ HasBaseline bool `json:"hasBaseline"`
+ Confirmed bool `json:"confirmed"` // 是否刚性确认扣分成功
+ Message string `json:"message"`
+}
+
+// ReconcileAccounts 对指定账号进行全量积分拉取与基线差值比对。
+// 若 diff == targetPrice(或 diff > 0 满足商品售价),则刚性确认为成功兑换。
+func ReconcileAccounts(ctx context.Context, client huya.ActivityClient, store *BaselineStore, sid int32, targetPrice int64, accounts []sniper.Account, concurrency int) ([]ReconcileItem, error) {
+ if client == nil {
+ return nil, fmt.Errorf("client 不能为空")
+ }
+ if concurrency <= 0 {
+ concurrency = 10
+ }
+ if concurrency > 20 {
+ concurrency = 20
+ }
+
+ results := make([]ReconcileItem, len(accounts))
+ sem := make(chan struct{}, concurrency)
+ var wg sync.WaitGroup
+
+ updatedScores := make(map[int64]int64)
+ var updateMu sync.Mutex
+
+ for i, acc := range accounts {
+ i, acc := i, acc
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ results[i] = ReconcileItem{
+ UID: acc.UID,
+ Message: "对账已取消",
+ }
+ return
+ }
+
+ beforeScore, hasBaseline, _ := store.Get(acc.UID)
+ resp, err := client.QueryUserScore(ctx, acc.UID, acc.Cookie, sid)
+ if err != nil || resp == nil || resp.Status != 200 {
+ errMsg := "查询当前积分失败"
+ if err != nil {
+ errMsg = err.Error()
+ } else if resp != nil {
+ errMsg = resp.Msg
+ }
+ results[i] = ReconcileItem{
+ UID: acc.UID,
+ BeforeScore: beforeScore,
+ HasBaseline: hasBaseline,
+ Message: errMsg,
+ }
+ return
+ }
+
+ currScore := resp.AvailableScore()
+ updateMu.Lock()
+ updatedScores[acc.UID] = currScore
+ updateMu.Unlock()
+
+ if !hasBaseline {
+ results[i] = ReconcileItem{
+ UID: acc.UID,
+ BeforeScore: currScore,
+ CurrentScore: currScore,
+ HasBaseline: false,
+ Confirmed: false,
+ Message: "无初始基线(已将当前积分记录为新基线)",
+ }
+ return
+ }
+
+ diff := beforeScore - currScore
+ confirmed := false
+ msg := ""
+
+ if diff > 0 {
+ if targetPrice > 0 && diff == targetPrice {
+ confirmed = true
+ msg = fmt.Sprintf("刚性扣分确认成功: -%d 分 (当前剩余 %d)", diff, currScore)
+ } else if targetPrice > 0 && diff >= targetPrice {
+ confirmed = true
+ msg = fmt.Sprintf("刚性扣分确认成功(含多件或多扣): -%d 分 (当前剩余 %d)", diff, currScore)
+ } else {
+ confirmed = true
+ msg = fmt.Sprintf("积分发生扣除: -%d 分 (当前剩余 %d)", diff, currScore)
+ }
+ } else if diff == 0 {
+ msg = fmt.Sprintf("积分未变动 (当前 %d)", currScore)
+ } else {
+ msg = fmt.Sprintf("积分增加 +%d 分 (当前 %d)", -diff, currScore)
+ }
+
+ results[i] = ReconcileItem{
+ UID: acc.UID,
+ BeforeScore: beforeScore,
+ CurrentScore: currScore,
+ DiffScore: diff,
+ TargetPrice: targetPrice,
+ HasBaseline: true,
+ Confirmed: confirmed,
+ Message: msg,
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ // 对账拉取到的最新积分批量写回基线,使下一次抢购可基于最新值比对
+ if len(updatedScores) > 0 && store != nil {
+ store.SetMany(updatedScores)
+ }
+
+ return results, ctx.Err()
+}
diff --git a/internal/score/baseline_test.go b/internal/score/baseline_test.go
new file mode 100644
index 0000000..556a90c
--- /dev/null
+++ b/internal/score/baseline_test.go
@@ -0,0 +1,124 @@
+package score
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "huya-go/internal/huya"
+ "huya-go/internal/sniper"
+)
+
+type mockScoreClient struct {
+ scores map[int64]int64
+}
+
+func (m *mockScoreClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) {
+ return nil, nil
+}
+func (m *mockScoreClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserScoreResp, error) {
+ s := m.scores[uid]
+ return &huya.GetUserScoreResp{
+ Status: 200,
+ Msg: "请求成功",
+ NewGainScore: s,
+ NewUsedScore: 0,
+ }, nil
+}
+func (m *mockScoreClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserPrizeRecordsResp, error) {
+ return nil, nil
+}
+func (m *mockScoreClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
+ return nil, nil
+}
+
+func TestBaselineStoreCRUDAndFlush(t *testing.T) {
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "scores.json")
+
+ store := NewBaselineStore(path)
+ store.Set(1001, 500)
+ store.SetMany(map[int64]int64{
+ 1002: 800,
+ 1003: 1200,
+ })
+
+ score, ok, at := store.Get(1002)
+ if !ok || score != 800 {
+ t.Fatalf("Get(1002) = %d, ok=%v; want 800, true", score, ok)
+ }
+ if at.IsZero() {
+ t.Fatalf("at is zero")
+ }
+
+ snap := store.Snapshot()
+ if len(snap) != 3 || snap[1001] != 500 || snap[1003] != 1200 {
+ t.Fatalf("Snapshot invalid: %v", snap)
+ }
+
+ // 验证落盘与重新加载
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("scores.json should exist: %v", err)
+ }
+
+ newStore := NewBaselineStore(path)
+ newStore.Load()
+ if s, ok, _ := newStore.Get(1003); !ok || s != 1200 {
+ t.Fatalf("Reload failed: got %d, %v", s, ok)
+ }
+}
+
+func TestReconcileAccounts(t *testing.T) {
+ store := NewBaselineStore("")
+ // 基线:1001号 1000分,1002号 500分
+ store.Set(1001, 1000)
+ store.Set(1002, 500)
+
+ // 当前:1001号剩 700分(扣300),1002号剩 500分(未扣),1003号剩 800分(无基线)
+ client := &mockScoreClient{
+ scores: map[int64]int64{
+ 1001: 700,
+ 1002: 500,
+ 1003: 800,
+ },
+ }
+
+ accounts := []sniper.Account{
+ {UID: 1001, Cookie: "yyuid=1001"},
+ {UID: 1002, Cookie: "yyuid=1002"},
+ {UID: 1003, Cookie: "yyuid=1003"},
+ }
+
+ results, err := ReconcileAccounts(context.Background(), client, store, 2277, 300, accounts, 5)
+ if err != nil {
+ t.Fatalf("ReconcileAccounts error: %v", err)
+ }
+ if len(results) != 3 {
+ t.Fatalf("expected 3 results, got %d", len(results))
+ }
+
+ // 验证 1001 刚性确认成功
+ r1 := results[0]
+ if !r1.Confirmed || r1.DiffScore != 300 || r1.CurrentScore != 700 {
+ t.Fatalf("r1 unexpected: %+v", r1)
+ }
+
+ // 验证 1002 未变动
+ r2 := results[1]
+ if r2.Confirmed || r2.DiffScore != 0 {
+ t.Fatalf("r2 unexpected: %+v", r2)
+ }
+
+ // 验证 1003 无基线
+ r3 := results[2]
+ if r3.HasBaseline || r3.Confirmed {
+ t.Fatalf("r3 unexpected: %+v", r3)
+ }
+
+ // 验证对账后基线已自动更新为最新值
+ s1, _, _ := store.Get(1001)
+ if s1 != 700 {
+ t.Fatalf("expected updated baseline 700, got %d", s1)
+ }
+}