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