Files
huya-go/internal/score/baseline.go
T
yml2213 7e0a8ef435 feat(score): 引入积分基线存储与刚性扣分对账体系
- 参考 dygou_go 新增 internal/score/baseline.go:维护 scores.json 并支持原子写盘
- 查询积分时自动保存各账号基线余额;抢购后提供 ReconcileAccounts 刚性比对扣分差值
- App.go 与前端增加 ReconcileScores 绑定与【刚性对账】按钮,解决超时暗中成功漏单痛点
- 补充 BaselineStore 读写、落盘重载与 ReconcileAccounts 完整单测并通过前端编译
2026-10-18 21:23:54 +08:00

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()
}