feat(score): 引入积分基线存储与刚性扣分对账体系
- 参考 dygou_go 新增 internal/score/baseline.go:维护 scores.json 并支持原子写盘 - 查询积分时自动保存各账号基线余额;抢购后提供 ReconcileAccounts 刚性比对扣分差值 - App.go 与前端增加 ReconcileScores 绑定与【刚性对账】按钮,解决超时暗中成功漏单痛点 - 补充 BaselineStore 读写、落盘重载与 ReconcileAccounts 完整单测并通过前端编译
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
OpenLogDir,
|
||||
PreviewAccounts,
|
||||
QueryScores,
|
||||
ReconcileScores,
|
||||
SetLogLevel,
|
||||
StartMonitor,
|
||||
StopMonitor,
|
||||
@@ -2285,6 +2287,25 @@ export default function Dashboard() {
|
||||
>
|
||||
兑换记录
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
loading={loading === 'reconcile'}
|
||||
disabled={selectedAccountCount === 0}
|
||||
onClick={() => {
|
||||
const values = config()
|
||||
if (values.selectedAccountLines.length === 0) {
|
||||
message.warning('请先选择账号')
|
||||
return
|
||||
}
|
||||
runAction('reconcile', () => ReconcileScores(values), (data) => {
|
||||
const list = (data || []) as any[]
|
||||
const confirmed = list.filter((item: any) => item.confirmed).length
|
||||
message.success(`对账完成:${list.length} 个账号,刚性确认扣分成功 ${confirmed} 个`)
|
||||
})
|
||||
}}
|
||||
>
|
||||
刚性对账
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Square size={16} />}
|
||||
disabled={!running}
|
||||
|
||||
Vendored
+2
@@ -31,6 +31,8 @@ export function PreviewAccounts(arg1:string):Promise<Array<gui.AccountPreview>>;
|
||||
|
||||
export function QueryScores(arg1:gui.Config):Promise<Array<gui.ScoreResult>>;
|
||||
|
||||
export function ReconcileScores(arg1:gui.Config):Promise<Array<any>>;
|
||||
|
||||
export function SetLogLevel(arg1:string):Promise<string>;
|
||||
|
||||
export function StartMonitor(arg1:gui.Config):Promise<void>;
|
||||
|
||||
@@ -58,6 +58,10 @@ export function QueryScores(arg1) {
|
||||
return window['go']['gui']['App']['QueryScores'](arg1);
|
||||
}
|
||||
|
||||
export function ReconcileScores(arg1) {
|
||||
return window['go']['gui']['App']['ReconcileScores'](arg1);
|
||||
}
|
||||
|
||||
export function SetLogLevel(arg1) {
|
||||
return window['go']['gui']['App']['SetLogLevel'](arg1);
|
||||
}
|
||||
|
||||
+82
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"huya-go/internal/huya"
|
||||
"huya-go/internal/instance"
|
||||
"huya-go/internal/logger"
|
||||
"huya-go/internal/score"
|
||||
"huya-go/internal/sniper"
|
||||
"huya-go/internal/version"
|
||||
)
|
||||
@@ -48,6 +50,9 @@ type App struct {
|
||||
instanceID string
|
||||
dataDir string
|
||||
|
||||
// scoreBaselines 记录每个账号抢购前的积分基线,抢购后用于刚性扣分对账。
|
||||
scoreBaselines *score.BaselineStore
|
||||
|
||||
runMu sync.Mutex
|
||||
monitorCancel context.CancelFunc
|
||||
monitorRunning bool
|
||||
@@ -201,6 +206,10 @@ func (a *App) Startup(ctx context.Context) {
|
||||
} else {
|
||||
logger.LogEvent("系统", logger.LevelSuccess, "已启动%s,数据目录: %s", a.instanceName, a.dataDir)
|
||||
}
|
||||
if a.dataDir != "" {
|
||||
a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
|
||||
a.scoreBaselines.Load()
|
||||
}
|
||||
if logErr != nil {
|
||||
logger.LogEvent(categoryApp, logger.LevelWarn, "日志文件不可用,日志仅保留在界面: %v", logErr)
|
||||
}
|
||||
@@ -402,7 +411,78 @@ func (a *App) QueryScores(cfg Config) ([]ScoreResult, error) {
|
||||
}
|
||||
results[i] = item
|
||||
})
|
||||
a.addLog("info", "查询积分完成: accounts=%d", len(accounts))
|
||||
|
||||
baselines := make(map[int64]int64, len(results))
|
||||
for _, r := range results {
|
||||
if r.Resp != nil && r.Resp.Status == 200 {
|
||||
baselines[r.UID] = r.Score
|
||||
}
|
||||
}
|
||||
if len(baselines) > 0 {
|
||||
if a.scoreBaselines == nil && a.dataDir != "" {
|
||||
a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
|
||||
a.scoreBaselines.Load()
|
||||
}
|
||||
if a.scoreBaselines != nil {
|
||||
a.scoreBaselines.SetMany(baselines)
|
||||
}
|
||||
}
|
||||
|
||||
a.addLog("info", "查询积分完成: accounts=%d(基线已自动记录)", len(accounts))
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ReconcileScores 刚性对账:拉取指定账号的最新积分并比对抢购前基线。
|
||||
// 若差值匹配商品售价(或积分发生净扣除),刚性确认为成功兑换。
|
||||
func (a *App) ReconcileScores(cfg Config) ([]score.ReconcileItem, error) {
|
||||
cfg = normalizeConfig(cfg)
|
||||
accounts, err := parseAccounts(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.SID <= 0 {
|
||||
return nil, errors.New("SID 不能为空")
|
||||
}
|
||||
client, closeClient, err := a.newClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeClient()
|
||||
|
||||
if a.scoreBaselines == nil && a.dataDir != "" {
|
||||
a.scoreBaselines = score.NewBaselineStore(filepath.Join(a.dataDir, score.ScoreBaselineFileName))
|
||||
a.scoreBaselines.Load()
|
||||
}
|
||||
|
||||
targets := parseTargets(cfg)
|
||||
var targetPrice int64
|
||||
if len(targets) > 0 {
|
||||
targetPrice = targets[0].MaxPrice
|
||||
}
|
||||
if targetPrice <= 0 && cfg.MaxPrice > 0 {
|
||||
targetPrice = cfg.MaxPrice
|
||||
}
|
||||
|
||||
ctx, cancel := requestContext(cfg.TimeoutSec)
|
||||
defer cancel()
|
||||
|
||||
results, err := score.ReconcileAccounts(ctx, client, a.scoreBaselines, cfg.SID, targetPrice, accounts, cfg.Concurrency)
|
||||
if err != nil && len(results) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
confirmedCount := 0
|
||||
for _, r := range results {
|
||||
if r.Confirmed {
|
||||
confirmedCount++
|
||||
a.addLog("success", "[刚性对账][账号:%d] %s", r.UID, r.Message)
|
||||
} else if r.HasBaseline && r.DiffScore == 0 {
|
||||
a.addLog("info", "[刚性对账][账号:%d] %s", r.UID, r.Message)
|
||||
} else {
|
||||
a.addLog("warn", "[刚性对账][账号:%d] %s", r.UID, r.Message)
|
||||
}
|
||||
}
|
||||
a.addLog("info", "刚性对账完成: 账号=%d 确认扣分成功=%d", len(accounts), confirmedCount)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -650,6 +730,7 @@ func runnerConfig(cfg Config) sniper.Config {
|
||||
RefreshAfterSuccess: cfg.RefreshAfterSuccess,
|
||||
TooFrequentBackoff: time.Duration(cfg.FrequentBackoffMS) * time.Millisecond,
|
||||
ErrorBackoff: time.Duration(cfg.ErrorBackoffMS) * time.Millisecond,
|
||||
RequestTimeout: sniper.DefaultRequestTimeout,
|
||||
WindowSeconds: cfg.WindowSeconds,
|
||||
// 首发错峰固定用调优过的默认值:同机上百个账号齐射是最容易被限流的姿势。
|
||||
FirstWaveSpread: sniper.DefaultFirstWaveSpread,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package score
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"huya-go/internal/huya"
|
||||
"huya-go/internal/sniper"
|
||||
)
|
||||
|
||||
const ScoreBaselineFileName = "scores.json"
|
||||
|
||||
// BaselineEntry 记录单个账号上一次已知的积分余额及时间戳。
|
||||
type BaselineEntry struct {
|
||||
Score int64 `json:"score"`
|
||||
At int64 `json:"at"` // Unix 秒
|
||||
}
|
||||
|
||||
// BaselineStore 记录每个账号「上次已知的积分余额」,作为抢购结束后刚性核对的基线。
|
||||
// 借鉴 dygou_go 的 ScoreBaselineStore 设计:在导入账号、手动查积分以及抢购对账后更新。
|
||||
type BaselineStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
entries map[int64]BaselineEntry
|
||||
}
|
||||
|
||||
// NewBaselineStore 创建积分基线存储;path 为空时退化为纯内存存储。
|
||||
func NewBaselineStore(path string) *BaselineStore {
|
||||
return &BaselineStore{
|
||||
path: path,
|
||||
entries: make(map[int64]BaselineEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从磁盘读取基线文件。文件缺失或损坏均不报错,仅退化为无基线。
|
||||
func (s *BaselineStore) Load() {
|
||||
if s == nil || s.path == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loaded := make(map[int64]BaselineEntry)
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.entries = loaded
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get 返回账号已知的基线积分。
|
||||
func (s *BaselineStore) Get(uid int64) (int64, bool, time.Time) {
|
||||
if s == nil {
|
||||
return 0, false, time.Time{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
entry, ok := s.entries[uid]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return 0, false, time.Time{}
|
||||
}
|
||||
return entry.Score, true, time.Unix(entry.At, 0)
|
||||
}
|
||||
|
||||
// Set 记录单账号最新积分余额并落盘。
|
||||
func (s *BaselineStore) Set(uid int64, score int64) {
|
||||
s.SetMany(map[int64]int64{uid: score})
|
||||
}
|
||||
|
||||
// SetMany 批量记录账号最新积分并原子写盘。
|
||||
func (s *BaselineStore) SetMany(values map[int64]int64) {
|
||||
if s == nil || len(values) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
s.mu.Lock()
|
||||
for uid, val := range values {
|
||||
s.entries[uid] = BaselineEntry{Score: val, At: now}
|
||||
}
|
||||
snapshot := make(map[int64]BaselineEntry, len(s.entries))
|
||||
for uid, entry := range s.entries {
|
||||
snapshot[uid] = entry
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.flush(snapshot)
|
||||
}
|
||||
|
||||
// Snapshot 返回当前基线积分快照。
|
||||
func (s *BaselineStore) Snapshot() map[int64]int64 {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make(map[int64]int64, len(s.entries))
|
||||
for uid, entry := range s.entries {
|
||||
out[uid] = entry.Score
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UIDs 返回所有已记录基线的 UID(升序排序)。
|
||||
func (s *BaselineStore) UIDs() []int64 {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]int64, 0, len(s.entries))
|
||||
for uid := range s.entries {
|
||||
out = append(out, uid)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
// flush 原子写入磁盘:先写入临时文件再 rename。
|
||||
func (s *BaselineStore) flush(snapshot map[int64]BaselineEntry) {
|
||||
if s == nil || s.path == "" {
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(s.path)+".*.tmp")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmpPath, s.path)
|
||||
}
|
||||
|
||||
// ReconcileItem 是单个账号刚性对账的结果。
|
||||
type ReconcileItem struct {
|
||||
UID int64 `json:"uid"`
|
||||
BeforeScore int64 `json:"beforeScore"`
|
||||
CurrentScore int64 `json:"currentScore"`
|
||||
DiffScore int64 `json:"diffScore"`
|
||||
TargetPrice int64 `json:"targetPrice"`
|
||||
HasBaseline bool `json:"hasBaseline"`
|
||||
Confirmed bool `json:"confirmed"` // 是否刚性确认扣分成功
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ReconcileAccounts 对指定账号进行全量积分拉取与基线差值比对。
|
||||
// 若 diff == targetPrice(或 diff > 0 满足商品售价),则刚性确认为成功兑换。
|
||||
func ReconcileAccounts(ctx context.Context, client huya.ActivityClient, store *BaselineStore, sid int32, targetPrice int64, accounts []sniper.Account, concurrency int) ([]ReconcileItem, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("client 不能为空")
|
||||
}
|
||||
if concurrency <= 0 {
|
||||
concurrency = 10
|
||||
}
|
||||
if concurrency > 20 {
|
||||
concurrency = 20
|
||||
}
|
||||
|
||||
results := make([]ReconcileItem, len(accounts))
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
updatedScores := make(map[int64]int64)
|
||||
var updateMu sync.Mutex
|
||||
|
||||
for i, acc := range accounts {
|
||||
i, acc := i, acc
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
results[i] = ReconcileItem{
|
||||
UID: acc.UID,
|
||||
Message: "对账已取消",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
beforeScore, hasBaseline, _ := store.Get(acc.UID)
|
||||
resp, err := client.QueryUserScore(ctx, acc.UID, acc.Cookie, sid)
|
||||
if err != nil || resp == nil || resp.Status != 200 {
|
||||
errMsg := "查询当前积分失败"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
} else if resp != nil {
|
||||
errMsg = resp.Msg
|
||||
}
|
||||
results[i] = ReconcileItem{
|
||||
UID: acc.UID,
|
||||
BeforeScore: beforeScore,
|
||||
HasBaseline: hasBaseline,
|
||||
Message: errMsg,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currScore := resp.AvailableScore()
|
||||
updateMu.Lock()
|
||||
updatedScores[acc.UID] = currScore
|
||||
updateMu.Unlock()
|
||||
|
||||
if !hasBaseline {
|
||||
results[i] = ReconcileItem{
|
||||
UID: acc.UID,
|
||||
BeforeScore: currScore,
|
||||
CurrentScore: currScore,
|
||||
HasBaseline: false,
|
||||
Confirmed: false,
|
||||
Message: "无初始基线(已将当前积分记录为新基线)",
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
diff := beforeScore - currScore
|
||||
confirmed := false
|
||||
msg := ""
|
||||
|
||||
if diff > 0 {
|
||||
if targetPrice > 0 && diff == targetPrice {
|
||||
confirmed = true
|
||||
msg = fmt.Sprintf("刚性扣分确认成功: -%d 分 (当前剩余 %d)", diff, currScore)
|
||||
} else if targetPrice > 0 && diff >= targetPrice {
|
||||
confirmed = true
|
||||
msg = fmt.Sprintf("刚性扣分确认成功(含多件或多扣): -%d 分 (当前剩余 %d)", diff, currScore)
|
||||
} else {
|
||||
confirmed = true
|
||||
msg = fmt.Sprintf("积分发生扣除: -%d 分 (当前剩余 %d)", diff, currScore)
|
||||
}
|
||||
} else if diff == 0 {
|
||||
msg = fmt.Sprintf("积分未变动 (当前 %d)", currScore)
|
||||
} else {
|
||||
msg = fmt.Sprintf("积分增加 +%d 分 (当前 %d)", -diff, currScore)
|
||||
}
|
||||
|
||||
results[i] = ReconcileItem{
|
||||
UID: acc.UID,
|
||||
BeforeScore: beforeScore,
|
||||
CurrentScore: currScore,
|
||||
DiffScore: diff,
|
||||
TargetPrice: targetPrice,
|
||||
HasBaseline: true,
|
||||
Confirmed: confirmed,
|
||||
Message: msg,
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 对账拉取到的最新积分批量写回基线,使下一次抢购可基于最新值比对
|
||||
if len(updatedScores) > 0 && store != nil {
|
||||
store.SetMany(updatedScores)
|
||||
}
|
||||
|
||||
return results, ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package score
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"huya-go/internal/huya"
|
||||
"huya-go/internal/sniper"
|
||||
)
|
||||
|
||||
type mockScoreClient struct {
|
||||
scores map[int64]int64
|
||||
}
|
||||
|
||||
func (m *mockScoreClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockScoreClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserScoreResp, error) {
|
||||
s := m.scores[uid]
|
||||
return &huya.GetUserScoreResp{
|
||||
Status: 200,
|
||||
Msg: "请求成功",
|
||||
NewGainScore: s,
|
||||
NewUsedScore: 0,
|
||||
}, nil
|
||||
}
|
||||
func (m *mockScoreClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserPrizeRecordsResp, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockScoreClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestBaselineStoreCRUDAndFlush(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "scores.json")
|
||||
|
||||
store := NewBaselineStore(path)
|
||||
store.Set(1001, 500)
|
||||
store.SetMany(map[int64]int64{
|
||||
1002: 800,
|
||||
1003: 1200,
|
||||
})
|
||||
|
||||
score, ok, at := store.Get(1002)
|
||||
if !ok || score != 800 {
|
||||
t.Fatalf("Get(1002) = %d, ok=%v; want 800, true", score, ok)
|
||||
}
|
||||
if at.IsZero() {
|
||||
t.Fatalf("at is zero")
|
||||
}
|
||||
|
||||
snap := store.Snapshot()
|
||||
if len(snap) != 3 || snap[1001] != 500 || snap[1003] != 1200 {
|
||||
t.Fatalf("Snapshot invalid: %v", snap)
|
||||
}
|
||||
|
||||
// 验证落盘与重新加载
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("scores.json should exist: %v", err)
|
||||
}
|
||||
|
||||
newStore := NewBaselineStore(path)
|
||||
newStore.Load()
|
||||
if s, ok, _ := newStore.Get(1003); !ok || s != 1200 {
|
||||
t.Fatalf("Reload failed: got %d, %v", s, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileAccounts(t *testing.T) {
|
||||
store := NewBaselineStore("")
|
||||
// 基线:1001号 1000分,1002号 500分
|
||||
store.Set(1001, 1000)
|
||||
store.Set(1002, 500)
|
||||
|
||||
// 当前:1001号剩 700分(扣300),1002号剩 500分(未扣),1003号剩 800分(无基线)
|
||||
client := &mockScoreClient{
|
||||
scores: map[int64]int64{
|
||||
1001: 700,
|
||||
1002: 500,
|
||||
1003: 800,
|
||||
},
|
||||
}
|
||||
|
||||
accounts := []sniper.Account{
|
||||
{UID: 1001, Cookie: "yyuid=1001"},
|
||||
{UID: 1002, Cookie: "yyuid=1002"},
|
||||
{UID: 1003, Cookie: "yyuid=1003"},
|
||||
}
|
||||
|
||||
results, err := ReconcileAccounts(context.Background(), client, store, 2277, 300, accounts, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcileAccounts error: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// 验证 1001 刚性确认成功
|
||||
r1 := results[0]
|
||||
if !r1.Confirmed || r1.DiffScore != 300 || r1.CurrentScore != 700 {
|
||||
t.Fatalf("r1 unexpected: %+v", r1)
|
||||
}
|
||||
|
||||
// 验证 1002 未变动
|
||||
r2 := results[1]
|
||||
if r2.Confirmed || r2.DiffScore != 0 {
|
||||
t.Fatalf("r2 unexpected: %+v", r2)
|
||||
}
|
||||
|
||||
// 验证 1003 无基线
|
||||
r3 := results[2]
|
||||
if r3.HasBaseline || r3.Confirmed {
|
||||
t.Fatalf("r3 unexpected: %+v", r3)
|
||||
}
|
||||
|
||||
// 验证对账后基线已自动更新为最新值
|
||||
s1, _, _ := store.Get(1001)
|
||||
if s1 != 700 {
|
||||
t.Fatalf("expected updated baseline 700, got %d", s1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user