抢购后自动核对积分余额(基线在导入账号时获取),发布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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 的账号不应有基线")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user