抢购后自动核对积分余额(基线在导入账号时获取),发布0.3.5

兑换成功会扣积分,所以"余额确实少了多少"是一个独立于接口返回的确认证据——
2026-09-30 出现过订单实际已支付、程序却报成失败的情况,只看接口返回不够。

基线的获取时机刻意放在抢购链路之外:轮次/预热期间任何额外请求都会挤占锁单,
账号越多越明显。基线只在三个时机更新:
- 导入账号后自动查一次(后台执行,不阻塞导入返回)
- 用户手动点"查询积分"时
- 每次抢购核对之后把最新余额存回,下一次抢购的基线就是上一次的终值,差值才准
账号被编辑(改名/换 Cookie)时清掉旧基线,删除账号时一并清理。

- 新增 ScoreBaselineStore(scores.json,与账号库同目录,原子写盘):
  记录每个账号上次已知的积分余额与时间;账号库路径为空时退化为纯内存,不写工作目录
- 核对输出示例:
  "积分已扣减,兑换确认: 账号=a 抢购前=1000 抢购后=800 扣减=200"
  并把"|积分-200"附到结果集,账号表里也能直接看到确认信息
- 没有基线时只记录余额并提示"下次抢购即可比对",不会误报"未扣分";
  积分接口失败只记警告,不改变"抢到"这个结论
- 抢购进行中调用导入不会去查询积分(避免与锁单抢连接),提示稍后手动查询
- 预热日志措辞修正:"开始预热: N 个请求" → "N 个账号"(每账号实际不止一个请求)
- 新增测试:TestVerifyRunResultsConfirmsScoreDeduction、TestVerifyRunResultsWithoutBaseline、
  TestVerifyRunResultsIgnoresScoreFailure、TestScoreBaselineStorePersists、
  TestFetchScoreBaselinesRecordsValues
This commit is contained in:
yml2213
2026-10-03 19:57:13 +08:00
parent 67f0b28ab6
commit 5da35ab65e
9 changed files with 627 additions and 10 deletions
@@ -249,6 +249,7 @@ defineExpose({ open, close })
<li><strong>锁单优先</strong>:锁到即占住库存;支付晚几秒无损失,所以支付重试刻意放缓</li>
<li>已锁单的订单即使窗口结束也会把<strong>支付</strong>重试完(最长 180 秒)</li>
<li>首发请求:第一个账号立即发出,其余按几毫秒步长错开,整批不超过 0.3 秒</li>
<li>抢购成功后自动核对积分余额:与<strong>导入账号时记录的基线</strong>比对,扣了多少分即确认兑换真的发生</li>
<li>"活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试</li>
<li>"库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束</li>
</ul>
+48
View File
@@ -43,6 +43,9 @@ func (a *App) ImportAccounts(content string) types.AccountMutationResult {
return types.AccountMutationResult{Error: err.Error()}
}
logger.LogEvent("账号", "success", "已从剪贴板导入 %d 个账号", len(entries))
// 导入完顺手记一次积分余额作为基线。刻意放在这里而不是预热/抢购链路里:
// 账号多的时候,抢购期的任何额外请求都会挤占锁单。后台执行,不阻塞导入返回。
a.fetchScoreBaselinesAsync(scoreAccountsFromLines(entries), "导入账号")
return types.AccountMutationResult{Success: true, Count: len(entries)}
}
@@ -105,6 +108,11 @@ func (a *App) UpdateAccount(originalName, newName, newCookie string) types.Accou
return types.AccountMutationResult{Error: err.Error()}
}
logger.LogEvent("账号", "success", "账号已更新: %s", newName)
// 改过名或换过 Cookie,旧基线都不再适用:改名先删旧键,再按新 Cookie 取一次。
if newName != originalName {
a.scheduler.ForgetScoreBaselines([]string{originalName})
}
a.fetchScoreBaselinesAsync([]types.Account{{Name: newName, Cookie: newCookie}}, "更新账号")
return types.AccountMutationResult{Success: true, Count: 1}
}
@@ -144,6 +152,11 @@ func (a *App) DeleteAccounts(accountNames []string) types.AccountMutationResult
if err := a.saveAccountLines(accountFile, kept); err != nil {
return types.AccountMutationResult{Error: err.Error()}
}
deletedNames := make([]string, 0, deleted)
for name := range nameSet {
deletedNames = append(deletedNames, name)
}
a.scheduler.ForgetScoreBaselines(deletedNames)
logger.LogEvent("账号", "success", "已删除 %d 个账号", deleted)
return types.AccountMutationResult{Success: true, Count: deleted}
}
@@ -199,3 +212,38 @@ func (a *App) saveAccountLines(accountFile string, lines []accountLine) error {
a.scheduler.LoadAccounts()
return nil
}
// scoreAccountsFromLines 从账号行里取出"账号名 + Cookie",用于积分基线查询。
func scoreAccountsFromLines(lines []accountLine) []types.Account {
out := make([]types.Account, 0, len(lines))
for _, line := range lines {
if line.Comment || line.Name == "" {
continue
}
parts := strings.SplitN(strings.TrimSpace(line.Raw), "----", 3)
if len(parts) != 3 {
continue
}
cookie := strings.TrimSpace(parts[2])
if cookie == "" {
continue
}
out = append(out, types.Account{Name: line.Name, Cookie: cookie})
}
return out
}
// fetchScoreBaselinesAsync 后台查询积分基线,不阻塞调用方。
func (a *App) fetchScoreBaselinesAsync(accounts []types.Account, label string) {
if len(accounts) == 0 {
return
}
go func() {
defer func() {
if r := recover(); r != nil {
logger.LogEvent("账号", "warn", "%s后查询积分基线异常: %v", label, r)
}
}()
a.scheduler.FetchScoreBaselines(accounts)
}()
}
+2
View File
@@ -34,6 +34,8 @@ func (a *App) QueryScore(accountName string) types.ScoreResult {
if err != nil {
return types.ScoreResult{Score: "查询失败: " + err.Error()}
}
// 手动查到的余额同样作为最新基线:抢购结束后用它比对是否扣分。
a.scheduler.RecordScoreBaseline(accountName, score)
return types.ScoreResult{Score: strconv.Itoa(score)}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
const Version = "0.3.4"
const Version = "0.3.5"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
+173 -5
View File
@@ -2,6 +2,7 @@ package seckill
import (
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
@@ -70,6 +71,8 @@ type Scheduler struct {
accounts *AccountStore
scoreBaselines *ScoreBaselineStore
resultsMu sync.RWMutex
results map[string]string
@@ -130,10 +133,11 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
attempts = constants.DefaultAttempts
}
scheduler := &Scheduler{
client: cli,
paths: paths,
font: font,
accounts: NewAccountStore(paths.AccountFile, paths.LegacyAccountFile),
client: cli,
paths: paths,
font: font,
accounts: NewAccountStore(paths.AccountFile, paths.LegacyAccountFile),
scoreBaselines: NewScoreBaselineStore(scoreBaselinePath(paths.AccountFile)),
successImages: image.NewSuccessImageSaver(
font,
paths.SuccessDir,
@@ -149,6 +153,7 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
products: make(map[string]types.Product),
calibrationOffsets: make([]time.Duration, 0, calibrationHistorySize),
}
scheduler.scoreBaselines.Load()
scheduler.loadLatestXiaodianReport()
return scheduler
}
@@ -455,9 +460,158 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
wg.Wait()
s.logRunSummary(outcomes, ctl, time.Since(start))
// 先核对积分(会产生"积分余额"请求,一并计入后面的接口统计)。
s.verifyRunResults(outcomes, ctl, s.clientSnapshot())
s.logNetworkSummary(s.clientSnapshot())
}
// verifyRunResults 抢购结束后对成功账号核对积分余额。
//
// 兑换成功会扣积分,所以"余额确实少了多少"是一个独立于接口返回的确认证据:
// 2026-09-30 就出现过订单实际已支付、程序却报成失败的情况,只看接口返回不够。
// 核对在任务结束之后进行,不影响抢购本身;单个账号查询失败只记警告,
// 不改变"抢到"这个结论。
func (s *Scheduler) verifyRunResults(outcomes []accountOutcome, ctl *runControl, cli *client.DouyuClient) {
if cli == nil || cli.Activity.ScoreURL == "" {
return
}
successes := make([]accountOutcome, 0, len(outcomes))
for _, out := range outcomes {
if out.kind == outcomeSuccess {
successes = append(successes, out)
}
}
if len(successes) == 0 {
return
}
logger.LogEvent("核对", "info", "开始核对积分余额: %d 个成功账号", len(successes))
var mu sync.Mutex
confirmed := 0
var wg sync.WaitGroup
for _, out := range successes {
wg.Add(1)
go func(out accountOutcome) {
defer wg.Done()
cookie, ok := s.AccountCookieByName(out.account)
if !ok || cookie == "" {
logger.LogAccountEvent("核对", "warn", out.account, "积分核对跳过: 账号=%s 没有可用 Cookie", out.account)
return
}
if ctl != nil {
if !ctl.enter() {
return
}
defer ctl.leave()
}
score, err := cli.Score(cookie)
if err != nil {
logger.LogAccountEvent("核对", "warn", out.account, "积分核对失败: 账号=%s err=%v", out.account, err)
return
}
before, hasBefore, baselineAt := s.ScoreBaseline(out.account)
// 无论能否比对,都把最新余额存回基线:下一次抢购的"抢购前"就是这一次的终值。
s.RecordScoreBaseline(out.account, score)
if !hasBefore {
logger.LogAccountEvent("核对", "info", out.account,
"抢购后积分余额: 账号=%s 余额=%d(该账号还没有基线,本次先记录,下次抢购即可比对)", out.account, score)
return
}
if score >= before {
logger.LogAccountEvent("核对", "warn", out.account,
"积分未见减少,请核实兑换记录: 账号=%s 抢购前=%d 抢购后=%d 基线时间=%s",
out.account, before, score, baselineAt.In(constants.CST).Format("01-02 15:04:05"))
return
}
mu.Lock()
confirmed++
mu.Unlock()
logger.LogAccountEvent("核对", "success", out.account,
"积分已扣减,兑换确认: 账号=%s 抢购前=%d 抢购后=%d 扣减=%d", out.account, before, score, before-score)
// 把扣分结果附到结果集,账号表里也能直接看到确认信息。
key := resultKey(types.ExchangeAttempt{Account: types.Account{Name: out.account}})
if current := s.Results()[key]; current != "" {
s.setResult(key, fmt.Sprintf("%s|积分-%d", current, before-score))
}
}(out)
}
wg.Wait()
logger.LogEvent("核对", "info", "积分核对完成: %d/%d 个账号确认扣分", confirmed, len(successes))
}
// ScoreBaseline 返回该账号上次已知的积分余额及其记录时间。
func (s *Scheduler) ScoreBaseline(accountName string) (int, bool, time.Time) {
if s.scoreBaselines == nil {
return 0, false, time.Time{}
}
return s.scoreBaselines.Get(accountName)
}
// RecordScoreBaseline 记录账号的最新积分余额(落盘)。
func (s *Scheduler) RecordScoreBaseline(accountName string, score int) {
if s.scoreBaselines != nil {
s.scoreBaselines.Set(accountName, score)
}
}
// ForgetScoreBaselines 删除已移除账号的基线。
func (s *Scheduler) ForgetScoreBaselines(accountNames []string) {
if s.scoreBaselines != nil {
s.scoreBaselines.Forget(accountNames)
}
}
// FetchScoreBaselines 并发查询并记录这批账号的积分基线。
//
// 只在"添加账号"这类抢购之外的操作里调用:账号多的时候,
// 抢购链路(预热或触发时刻)绝不能承担这批额外请求。
func (s *Scheduler) FetchScoreBaselines(accounts []types.Account) {
cli := s.clientSnapshot()
if cli == nil || cli.Activity.ScoreURL == "" || len(accounts) == 0 {
return
}
if s.IsRunning() {
logger.LogEvent("账号", "warn", "抢购进行中,跳过 %d 个账号的积分基线查询(可稍后手动查询积分)", len(accounts))
return
}
settings := s.settingsSnapshot()
limit := make(chan struct{}, settings.concurrency)
var (
wg sync.WaitGroup
mu sync.Mutex
collected = make(map[string]int, len(accounts))
)
for _, account := range accounts {
if account.Cookie == "" {
continue
}
wg.Add(1)
go func(account types.Account) {
defer wg.Done()
limit <- struct{}{}
defer func() { <-limit }()
score, err := cli.Score(account.Cookie)
if err != nil {
logger.LogAccountEvent("账号", "warn", account.Name, "积分基线查询失败: 账号=%s err=%v", account.Name, err)
return
}
mu.Lock()
collected[account.Name] = score
mu.Unlock()
}(account)
}
wg.Wait()
if len(collected) == 0 {
return
}
if s.scoreBaselines != nil {
s.scoreBaselines.SetMany(collected)
}
logger.LogEvent("账号", "success", "已记录 %d 个账号的积分基线(用于抢购后核对是否扣分)", len(collected))
}
// logNetworkSummary 输出本次任务期间各类接口的聚合统计,替代逐条刷屏的网络日志。
func (s *Scheduler) logNetworkSummary(cli *client.DouyuClient) {
if cli == nil {
@@ -520,6 +674,7 @@ const stockOutWarnAfter = 8
// accountOutcome 是单个账号的最终结果。
type accountOutcome struct {
account string
product types.Product
kind string
reason string
locked bool
@@ -528,7 +683,7 @@ type accountOutcome struct {
// runAccountLoop 是单个账号的独立重试循环:锁单失败按配置节奏重试,
// 达到连续被拒上限或账号级终止条件时退出。
func (s *Scheduler) runAccountLoop(task types.ExchangeAttempt, settings runSettings, ctl *runControl) accountOutcome {
out := accountOutcome{account: task.Account.Name}
out := accountOutcome{account: task.Account.Name, product: task.Product}
// rejectStreak 只统计"该商品当前拿不到"的连续次数,用于停止判定;
// failStreak 统计所有连续失败,用于放大退避(两者混用会让频控/传输层错误
// 永远不递增,一路用配置里的最快节奏猛打)。
@@ -1120,3 +1275,16 @@ func (s *Scheduler) enqueueSuccessImage(account types.Account, product types.Pro
}
s.successImages.Enqueue(account, product, roleName)
}
// scoreBaselinePath 返回积分基线文件路径:与账号库同目录。
// 账号库路径为空时返回空串,基线退化为纯内存,避免写到当前工作目录。
func scoreBaselinePath(accountFile string) string {
if strings.TrimSpace(accountFile) == "" {
return ""
}
dir := filepath.Dir(accountFile)
if dir == "" {
dir = "."
}
return filepath.Join(dir, scoreBaselineFileName)
}
+238 -1
View File
@@ -55,6 +55,8 @@ func newOrderTestScheduler(t *testing.T, handler http.HandlerFunc) *Scheduler {
act.PayOrderURL = server.URL + "/pay"
act.BindInfoURL = server.URL + "/bind"
act.CSRFURL = server.URL + "/csrf"
act.ScoreURL = server.URL + "/score"
act.ScoreMethod = "POST"
cli := client.New(&act, "", &trace.Recorder{})
cli.HTTPClient = server.Client()
return New(cli, types.AppPaths{}, nil, 8, 1, nil, types.SeckillModeAggressive)
@@ -694,10 +696,13 @@ func TestRunNowEndToEnd(t *testing.T) {
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
name := ""
if r.URL.Path != "/bind" {
if r.URL.Path == "/create" || r.URL.Path == "/pay" {
name = requestAccount(t, r)
}
switch r.URL.Path {
case "/score":
// 抢购结束后的积分核对(JSON 请求,不走 multipart)
_, _ = w.Write([]byte(`{"error":0,"data":{"balance":900}}`))
case "/create":
if name == "winner" {
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-w","expireSeconds":300}}`))
@@ -1008,3 +1013,235 @@ func dumpMessages(store *logger.MemoryLogStore) string {
}
return b.String()
}
// TestVerifyRunResultsConfirmsScoreDeduction 抢购结束后用"积分扣减"确认兑换:
// 只看接口返回不够——2026-09-30 出现过订单实际已支付却被报成失败。
// 基线来自账号导入/上次查积分时记录的余额(这里预置 1000),抢购后查到 800 → 扣 200。
func TestVerifyRunResultsConfirmsScoreDeduction(t *testing.T) {
speedUpPeace(t)
seckillMaxDuration = 1500 * time.Millisecond
var scoreCalls int32
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/create":
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
case "/pay":
_, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
case "/bind":
_, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
case "/score":
// 抢购后的余额:比基线少 200
atomic.AddInt32(&scoreCalls, 1)
_, _ = w.Write([]byte(`{"error":0,"data":{"balance":800}}`))
}
})
// 预置抢购前基线(真实流程里由"导入账号"或手动查积分写入)
scheduler.RecordScoreBaseline("a", 1000)
scheduler.paths = writeAccountFile(t, "a")
scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
scheduler.LoadAccounts()
scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
store := logger.NewMemoryLogStore(4000)
cleanup := logger.InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test")
defer cleanup()
scheduler.RunNow(map[string]string{"a": "goods-1"})
var deducted, done, result string
for _, entry := range store.Since(0) {
switch {
case strings.Contains(entry.Message, "积分已扣减,兑换确认"):
deducted = entry.Message
case strings.Contains(entry.Message, "积分核对完成"):
done = entry.Message
}
}
key := resultKey(types.ExchangeAttempt{Account: types.Account{Name: "a"}})
result = scheduler.Results()[key]
if deducted == "" {
t.Fatalf("缺少积分扣减确认;实际日志:\n%s", dumpMessages(store))
}
if !strings.Contains(deducted, "抢购前=1000") || !strings.Contains(deducted, "抢购后=800") || !strings.Contains(deducted, "扣减=200") {
t.Fatalf("扣分确认信息不完整: %q", deducted)
}
if !strings.Contains(done, "1/1") {
t.Fatalf("核对汇总 = %q, want 1/1", done)
}
if !strings.HasSuffix(result, "|积分-200") {
t.Fatalf("结果集应附带扣分信息, got %q", result)
}
if got := atomic.LoadInt32(&scoreCalls); got != 1 {
t.Fatalf("积分查询次数 = %d, want 1(只在抢购结束后核对一次)", got)
}
// 最新余额要存回基线,下一次抢购才能算对差值
if after, ok, _ := scheduler.ScoreBaseline("a"); !ok || after != 800 {
t.Fatalf("核对后应把 800 存回基线, got %d ok=%v", after, ok)
}
}
// TestVerifyRunResultsWithoutBaseline 没有基线时先记录余额,不误报"未扣分"。
func TestVerifyRunResultsWithoutBaseline(t *testing.T) {
speedUpPeace(t)
seckillMaxDuration = 1200 * time.Millisecond
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/create":
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
case "/pay":
_, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
case "/bind":
_, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
case "/score":
_, _ = w.Write([]byte(`{"error":0,"data":{"balance":1234}}`))
}
})
scheduler.paths = writeAccountFile(t, "a")
scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
scheduler.LoadAccounts()
scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
store := logger.NewMemoryLogStore(4000)
cleanup := logger.InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test")
defer cleanup()
scheduler.RunNow(map[string]string{"a": "goods-1"})
var recorded, confirmed string
for _, entry := range store.Since(0) {
if strings.Contains(entry.Message, "还没有基线") {
recorded = entry.Message
}
if strings.Contains(entry.Message, "确认扣分") {
confirmed = entry.Message
}
}
if recorded == "" {
t.Fatalf("缺基线时应记录余额而不是误报未扣分;实际日志:\n%s", dumpMessages(store))
}
if !strings.Contains(confirmed, "0/1") {
t.Fatalf("无基线时不应算作确认扣分: %q", confirmed)
}
if score, ok, _ := scheduler.ScoreBaseline("a"); !ok || score != 1234 {
t.Fatalf("应把查到的余额记成基线, got %d ok=%v", score, ok)
}
}
// TestScoreBaselineStorePersists 基线要落盘,重启后仍能用来比对。
func TestScoreBaselineStorePersists(t *testing.T) {
path := filepath.Join(t.TempDir(), scoreBaselineFileName)
store := NewScoreBaselineStore(path)
store.Set("a", 1000)
store.SetMany(map[string]int{"b": 2000, "c": 3000})
reloaded := NewScoreBaselineStore(path)
reloaded.Load()
for name, want := range map[string]int{"a": 1000, "b": 2000, "c": 3000} {
got, ok, at := reloaded.Get(name)
if !ok || got != want {
t.Fatalf("%s 基线 = %d ok=%v, want %d", name, got, ok, want)
}
if at.IsZero() {
t.Fatalf("%s 基线时间缺失", name)
}
}
reloaded.Forget([]string{"b"})
if _, ok, _ := reloaded.Get("b"); ok {
t.Fatal("Forget 后不应还有 b 的基线")
}
if names := reloaded.Names(); len(names) != 2 || names[0] != "a" || names[1] != "c" {
t.Fatalf("Names = %v, want [a c]", names)
}
}
// TestVerifyRunResultsIgnoresScoreFailure 积分核对失败不能影响"抢到"这个结论。
func TestVerifyRunResultsIgnoresScoreFailure(t *testing.T) {
speedUpPeace(t)
seckillMaxDuration = 1200 * time.Millisecond
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/create":
_, _ = w.Write([]byte(`{"error":0,"data":{"orderId":"order-1","expireSeconds":300}}`))
case "/pay":
_, _ = w.Write([]byte(`{"error":0,"data":{"exchangeId":9,"exchangeNum":"1"}}`))
case "/bind":
_, _ = w.Write([]byte(`{"error":0,"data":{"gameRole":{"roleName":"测试角色"}}}`))
case "/score":
_, _ = w.Write([]byte(`{"error":9,"msg":"积分接口炸了"}`))
}
})
scheduler.paths = writeAccountFile(t, "a")
scheduler.accounts = NewAccountStore(scheduler.paths.AccountFile)
scheduler.LoadAccounts()
scheduler.UpdateProducts([]types.Product{{CommodityID: "goods-1", CommodityName: "测试商品"}})
store := logger.NewMemoryLogStore(4000)
cleanup := logger.InitLogging(store, t.TempDir(), "测试实例", "/tmp/douyu-go-test")
defer cleanup()
scheduler.RunNow(map[string]string{"a": "goods-1"})
var summarize, warn string
for _, entry := range store.Since(0) {
if strings.Contains(entry.Message, "抢购任务结束: 成功=1/1") {
summarize = entry.Message
}
if strings.Contains(entry.Message, "积分核对失败") {
warn = entry.Message
}
}
if summarize == "" {
t.Fatalf("核对失败不应影响成功结论;实际日志:\n%s", dumpMessages(store))
}
if warn == "" {
t.Fatalf("积分核对失败应记警告;实际日志:\n%s", dumpMessages(store))
}
}
// TestFetchScoreBaselinesRecordsValues 导入账号时自动记一次积分作为基线:
// 这批请求刻意放在抢购之外,账号多也不会挤占锁单。
func TestFetchScoreBaselinesRecordsValues(t *testing.T) {
var mu sync.Mutex
balances := map[string]int{"a": 1111, "b": 2222}
scheduler := newOrderTestScheduler(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/score" {
return
}
if err := r.ParseForm(); err != nil {
t.Errorf("ParseForm: %v", err)
}
// 用 Cookie 里的账号名区分(测试账号的 Cookie 就是 cvl_csrf_token=<name>)
name := ""
if c, err := r.Cookie("cvl_csrf_token"); err == nil {
name = c.Value
}
mu.Lock()
balance := balances[name]
mu.Unlock()
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":0,"data":{"balance":%d}}`, balance)))
})
scheduler.FetchScoreBaselines([]types.Account{
{Name: "a", Cookie: "cvl_csrf_token=a"},
{Name: "b", Cookie: "cvl_csrf_token=b"},
{Name: "empty", Cookie: ""},
})
if got, ok, _ := scheduler.ScoreBaseline("a"); !ok || got != 1111 {
t.Fatalf("a 基线 = %d ok=%v, want 1111", got, ok)
}
if got, ok, _ := scheduler.ScoreBaseline("b"); !ok || got != 2222 {
t.Fatalf("b 基线 = %d ok=%v, want 2222", got, ok)
}
if _, ok, _ := scheduler.ScoreBaseline("empty"); ok {
t.Fatal("空 Cookie 的账号不应有基线")
}
}
+161
View File
@@ -0,0 +1,161 @@
package seckill
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"douyu_go/internal/logger"
)
// scoreBaselineFileName 是积分基线文件名,和账号库放在同一个数据目录。
const scoreBaselineFileName = "scores.json"
// scoreBaselineEntry 是单个账号上一次已知的积分余额。
type scoreBaselineEntry struct {
Score int `json:"score"`
At int64 `json:"at"` // Unix 秒,便于判断基线有多旧
}
// ScoreBaselineStore 记录每个账号"上次已知的积分余额",作为抢购结束后核对的基线。
//
// 刻意不在抢购链路里取基线:账号多的时候,预热或触发时刻的任何额外请求都会挤占抢购。
// 基线只在三个时机更新——导入账号时自动查一次、用户手动查积分时、以及每次抢购核对之后
// (把最新的余额存回去,下一次抢购的基线就是上一次的终值,差值才准)。
type ScoreBaselineStore struct {
mu sync.RWMutex
path string
entries map[string]scoreBaselineEntry
}
// NewScoreBaselineStore 创建积分基线存储;path 为空时退化为纯内存。
func NewScoreBaselineStore(path string) *ScoreBaselineStore {
return &ScoreBaselineStore{
path: path,
entries: make(map[string]scoreBaselineEntry),
}
}
// Load 从磁盘读取基线。文件缺失或损坏都不算错误,只是没有基线可用。
func (s *ScoreBaselineStore) Load() {
if s == nil || s.path == "" {
return
}
data, err := os.ReadFile(s.path)
if err != nil {
return
}
loaded := make(map[string]scoreBaselineEntry)
if err := json.Unmarshal(data, &loaded); err != nil {
logger.LogEvent("核对", "warn", "积分基线文件解析失败,将重新累积: %v", err)
return
}
s.mu.Lock()
s.entries = loaded
s.mu.Unlock()
}
// Get 返回账号上一次已知的积分余额。
func (s *ScoreBaselineStore) Get(accountName string) (int, bool, time.Time) {
if s == nil {
return 0, false, time.Time{}
}
s.mu.RLock()
entry, ok := s.entries[accountName]
s.mu.RUnlock()
if !ok {
return 0, false, time.Time{}
}
return entry.Score, true, time.Unix(entry.At, 0)
}
// Set 记录一个账号的最新积分余额,并落盘。
func (s *ScoreBaselineStore) Set(accountName string, score int) {
s.SetMany(map[string]int{accountName: score})
}
// SetMany 批量记录并落盘(一次写盘,避免每个账号都写一次文件)。
func (s *ScoreBaselineStore) SetMany(values map[string]int) {
if s == nil || len(values) == 0 {
return
}
now := time.Now().Unix()
s.mu.Lock()
for name, score := range values {
s.entries[name] = scoreBaselineEntry{Score: score, At: now}
}
snapshot := make(map[string]scoreBaselineEntry, len(s.entries))
for name, entry := range s.entries {
snapshot[name] = entry
}
s.mu.Unlock()
s.flush(snapshot)
}
// Forget 删除已不存在账号的基线。
func (s *ScoreBaselineStore) Forget(accountNames []string) {
if s == nil || len(accountNames) == 0 {
return
}
s.mu.Lock()
for _, name := range accountNames {
delete(s.entries, name)
}
snapshot := make(map[string]scoreBaselineEntry, len(s.entries))
for name, entry := range s.entries {
snapshot[name] = entry
}
s.mu.Unlock()
s.flush(snapshot)
}
// Names 返回已记录基线的账号名(按字典序),便于测试与排查。
func (s *ScoreBaselineStore) Names() []string {
if s == nil {
return nil
}
s.mu.RLock()
out := make([]string, 0, len(s.entries))
for name := range s.entries {
out = append(out, name)
}
s.mu.RUnlock()
sort.Strings(out)
return out
}
// flush 原子写盘:先写临时文件再改名,避免半写入损坏。
func (s *ScoreBaselineStore) flush(snapshot map[string]scoreBaselineEntry) {
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
}
if err := os.Rename(tmpPath, s.path); err != nil {
logger.LogEvent("核对", "warn", "积分基线写入失败: %v", err)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func (s *Scheduler) warmupAttempts(attempts []types.ExchangeAttempt) {
return
}
s.clearWarmups()
logger.LogEvent("预热", "info", "开始预热: %d 个请求", len(attempts))
logger.LogEvent("预热", "info", "开始预热: %d 个账号", len(attempts))
settings := s.settingsSnapshot()
cli := s.clientSnapshot()
+2 -2
View File
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
"version": "0.3.4",
"version": "0.3.5",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",
@@ -15,6 +15,6 @@
"email": "yml@example.com"
},
"info": {
"productVersion": "0.3.4"
"productVersion": "0.3.5"
}
}