From 5da35ab65ef1dae54c7221c630c00637516d33bc Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 19:57:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A2=E8=B4=AD=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E7=A7=AF=E5=88=86=E4=BD=99=E9=A2=9D=EF=BC=88?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E5=9C=A8=E5=AF=BC=E5=85=A5=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=97=B6=E8=8E=B7=E5=8F=96=EF=BC=89=EF=BC=8C=E5=8F=91=E5=B8=83?= =?UTF-8?q?0.3.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兑换成功会扣积分,所以"余额确实少了多少"是一个独立于接口返回的确认证据—— 2026-09-30 出现过订单实际已支付、程序却报成失败的情况,只看接口返回不够。 基线的获取时机刻意放在抢购链路之外:轮次/预热期间任何额外请求都会挤占锁单, 账号越多越明显。基线只在三个时机更新: - 导入账号后自动查一次(后台执行,不阻塞导入返回) - 用户手动点"查询积分"时 - 每次抢购核对之后把最新余额存回,下一次抢购的基线就是上一次的终值,差值才准 账号被编辑(改名/换 Cookie)时清掉旧基线,删除账号时一并清理。 - 新增 ScoreBaselineStore(scores.json,与账号库同目录,原子写盘): 记录每个账号上次已知的积分余额与时间;账号库路径为空时退化为纯内存,不写工作目录 - 核对输出示例: "积分已扣减,兑换确认: 账号=a 抢购前=1000 抢购后=800 扣减=200" 并把"|积分-200"附到结果集,账号表里也能直接看到确认信息 - 没有基线时只记录余额并提示"下次抢购即可比对",不会误报"未扣分"; 积分接口失败只记警告,不改变"抢到"这个结论 - 抢购进行中调用导入不会去查询积分(避免与锁单抢连接),提示稍后手动查询 - 预热日志措辞修正:"开始预热: N 个请求" → "N 个账号"(每账号实际不止一个请求) - 新增测试:TestVerifyRunResultsConfirmsScoreDeduction、TestVerifyRunResultsWithoutBaseline、 TestVerifyRunResultsIgnoresScoreFailure、TestScoreBaselineStorePersists、 TestFetchScoreBaselinesRecordsValues --- .../src/components/modal/SettingsModal.vue | 1 + internal/app/app_accounts.go | 48 ++++ internal/app/app_verify.go | 2 + internal/constants/constants.go | 2 +- internal/seckill/scheduler.go | 178 ++++++++++++- internal/seckill/scheduler_test.go | 239 +++++++++++++++++- internal/seckill/score_baseline.go | 161 ++++++++++++ internal/seckill/warmup.go | 2 +- wails.json | 4 +- 9 files changed, 627 insertions(+), 10 deletions(-) create mode 100644 internal/seckill/score_baseline.go 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" } }