- 参考 dygou_go 新增 internal/score/baseline.go:维护 scores.json 并支持原子写盘 - 查询积分时自动保存各账号基线余额;抢购后提供 ReconcileAccounts 刚性比对扣分差值 - App.go 与前端增加 ReconcileScores 绑定与【刚性对账】按钮,解决超时暗中成功漏单痛点 - 补充 BaselineStore 读写、落盘重载与 ReconcileAccounts 完整单测并通过前端编译
282 lines
7.0 KiB
Go
282 lines
7.0 KiB
Go
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()
|
|
}
|