diff --git a/frontend/src/components/modal/SettingsModal.vue b/frontend/src/components/modal/SettingsModal.vue
index 2c2f110..d9682c1 100644
--- a/frontend/src/components/modal/SettingsModal.vue
+++ b/frontend/src/components/modal/SettingsModal.vue
@@ -249,6 +249,7 @@ defineExpose({ open, close })
锁单优先:锁到即占住库存;支付晚几秒无损失,所以支付重试刻意放缓
已锁单的订单即使窗口结束也会把支付重试完(最长 180 秒)
首发请求:第一个账号立即发出,其余按几毫秒步长错开,整批不超过 0.3 秒
+ 抢购成功后自动核对积分余额:与导入账号时记录的基线比对,扣了多少分即确认兑换真的发生
"活动火爆/网络繁忙"是请求级限流(同一秒别的账号仍能锁单),会继续重试
"库存已耗完"也不提前停止:别人锁单未支付时库存会回流,会降频探测到窗口结束
diff --git a/internal/app/app_accounts.go b/internal/app/app_accounts.go
index 94a1429..4d30169 100644
--- a/internal/app/app_accounts.go
+++ b/internal/app/app_accounts.go
@@ -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)
+ }()
+}
diff --git a/internal/app/app_verify.go b/internal/app/app_verify.go
index ffeca94..5c6b22a 100644
--- a/internal/app/app_verify.go
+++ b/internal/app/app_verify.go
@@ -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)}
}
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 22049ef..7ee9c0b 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -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"
diff --git a/internal/seckill/scheduler.go b/internal/seckill/scheduler.go
index 5b241cf..3670565 100644
--- a/internal/seckill/scheduler.go
+++ b/internal/seckill/scheduler.go
@@ -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)
+}
diff --git a/internal/seckill/scheduler_test.go b/internal/seckill/scheduler_test.go
index 6dbc27f..0d261d4 100644
--- a/internal/seckill/scheduler_test.go
+++ b/internal/seckill/scheduler_test.go
@@ -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 := ""
+ 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 的账号不应有基线")
+ }
+}
diff --git a/internal/seckill/score_baseline.go b/internal/seckill/score_baseline.go
new file mode 100644
index 0000000..324c97b
--- /dev/null
+++ b/internal/seckill/score_baseline.go
@@ -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)
+ }
+}
diff --git a/internal/seckill/warmup.go b/internal/seckill/warmup.go
index ff444b5..e597eea 100644
--- a/internal/seckill/warmup.go
+++ b/internal/seckill/warmup.go
@@ -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()
diff --git a/wails.json b/wails.json
index c5771b3..b1bf07b 100644
--- a/wails.json
+++ b/wails.json
@@ -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"
}
}