feat(score): 引入积分基线存储与刚性扣分对账体系

- 参考 dygou_go 新增 internal/score/baseline.go:维护 scores.json 并支持原子写盘
- 查询积分时自动保存各账号基线余额;抢购后提供 ReconcileAccounts 刚性比对扣分差值
- App.go 与前端增加 ReconcileScores 绑定与【刚性对账】按钮,解决超时暗中成功漏单痛点
- 补充 BaselineStore 读写、落盘重载与 ReconcileAccounts 完整单测并通过前端编译
This commit is contained in:
yml2213
2026-10-18 21:23:54 +08:00
parent 43b3cb351e
commit 7e0a8ef435
6 changed files with 514 additions and 1 deletions
+21
View File
@@ -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() {
>
兑换记录
</Button>
<Button
icon={<CheckCircle2 size={16} />}
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} 个`)
})
}}
>
刚性对账
</Button>
<Button
icon={<Square size={16} />}
disabled={!running}
+2
View File
@@ -31,6 +31,8 @@ export function PreviewAccounts(arg1:string):Promise<Array<gui.AccountPreview>>;
export function QueryScores(arg1:gui.Config):Promise<Array<gui.ScoreResult>>;
export function ReconcileScores(arg1:gui.Config):Promise<Array<any>>;
export function SetLogLevel(arg1:string):Promise<string>;
export function StartMonitor(arg1:gui.Config):Promise<void>;
+4
View File
@@ -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);
}
+82 -1
View File
@@ -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,
+281
View File
@@ -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()
}
+124
View File
@@ -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)
}
}