diff --git a/backend/internal/database/migrations/021_rock_auto_purchase_cooldown.sql b/backend/internal/database/migrations/021_rock_auto_purchase_cooldown.sql new file mode 100644 index 0000000..aa3904f --- /dev/null +++ b/backend/internal/database/migrations/021_rock_auto_purchase_cooldown.sql @@ -0,0 +1,13 @@ +-- 自动补货冷却独立计时:手动采购不再重置自动冷却 +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_auto_attempt_at TIMESTAMPTZ; + +-- 保守回填:沿用最近一次采购尝试时间,上线后仍需等待一个冷却周期 +UPDATE rock_configs + SET last_auto_attempt_at = COALESCE(last_attempt_at, last_purchased_at) + WHERE last_auto_attempt_at IS NULL; + +-- 清理历史“冷却中”的空转流水(这些记录没有调用过上游,也没有阶段明细) +DELETE FROM rock_purchase_logs + WHERE status = 'skipped' + AND trigger_type = 'auto' + AND error_message LIKE '%冷却中%'; diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go index 9b8721b..069a031 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -52,6 +52,7 @@ type RockConfig struct { PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"` LastPurchasedAt *time.Time `json:"last_purchased_at"` LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却 + LastAutoAttemptAt *time.Time `json:"last_auto_attempt_at,omitempty"` // 最近一次自动补货尝试时间,自动冷却只认这个 LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空 LastErrorAt *time.Time `json:"last_error_at,omitempty"` LastHeartbeatAt *time.Time `json:"last_heartbeat_at"` diff --git a/backend/internal/service/rock_auto_purchase_cooldown_test.go b/backend/internal/service/rock_auto_purchase_cooldown_test.go new file mode 100644 index 0000000..855b874 --- /dev/null +++ b/backend/internal/service/rock_auto_purchase_cooldown_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/testdb" +) + +func TestAutoPurchaseCooldown_ManualAttemptDoesNotBlock(t *testing.T) { + now := time.Now() + manualJustNow := now.Add(-time.Minute) + autoLongAgo := now.Add(-30 * time.Minute) + cfg := &model.RockConfig{ + PurchaseCooldownMinutes: 10, + LastAttemptAt: &manualJustNow, // 刚刚手动采购过 + LastAutoAttemptAt: &autoLongAgo, // 上一次自动尝试已过冷却 + } + if remain := rockAutoPurchaseCooldownRemaining(cfg, now); remain != 0 { + t.Fatalf("手动采购不应阻塞自动补货,剩余冷却 %v", remain) + } +} + +func TestAutoPurchaseCooldown_UsesLastAutoAttempt(t *testing.T) { + now := time.Now() + recentAuto := now.Add(-2 * time.Minute) + cfg := &model.RockConfig{ + PurchaseCooldownMinutes: 10, + LastAutoAttemptAt: &recentAuto, + } + remain := rockAutoPurchaseCooldownRemaining(cfg, now) + if remain < 7*time.Minute || remain > 8*time.Minute { + t.Fatalf("剩余冷却时间不符: %v", remain) + } +} + +func TestAutoPurchaseCooldown_BlockedAttemptWritesNoLog(t *testing.T) { + db := testdb.New(t, &model.RockConfig{}, &model.RockPurchaseLog{}) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + if _, err := svc.GetConfig(ctx); err != nil { + t.Fatalf("初始化配置失败: %v", err) + } + recent := time.Now().Add(-time.Minute) + if err := db.Model(&model.RockConfig{}).Where("id = ?", 1). + Updates(map[string]interface{}{ + "purchase_cooldown_minutes": 10, + "last_auto_attempt_at": recent, + }).Error; err != nil { + t.Fatalf("写入冷却时间失败: %v", err) + } + + if _, err := svc.PurchaseTokens(ctx, 10, true); !errors.Is(err, ErrAutoPurchaseCooldown) { + t.Fatalf("应命中自动采购冷却,实际 err=%v", err) + } + + var count int64 + if err := db.Model(&model.RockPurchaseLog{}).Count(&count).Error; err != nil { + t.Fatalf("统计采购流水失败: %v", err) + } + if count != 0 { + t.Fatalf("冷却拦截不应生成采购流水,实际 %d 条", count) + } +} + +func TestGetStats_ExposesAutoPurchaseCooldown(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + ctx := context.Background() + + if _, err := svc.GetConfig(ctx); err != nil { + t.Fatalf("初始化配置失败: %v", err) + } + recent := time.Now().Add(-time.Minute) + if err := db.Model(&model.RockConfig{}).Where("id = ?", 1). + Updates(map[string]interface{}{ + "auto_purchase_enabled": true, + "purchase_cooldown_minutes": 10, + "last_auto_attempt_at": recent, + }).Error; err != nil { + t.Fatalf("写入冷却配置失败: %v", err) + } + + stats, err := svc.GetStats(ctx) + if err != nil { + t.Fatalf("查询统计失败: %v", err) + } + if stats.AutoPurchaseCooldownSeconds <= 0 { + t.Fatalf("应返回剩余冷却秒数: %+v", stats) + } + if stats.NextAutoPurchaseAt == "" { + t.Fatalf("应返回下次可自动补货时间: %+v", stats) + } +} diff --git a/backend/internal/service/rock_purchase_package_test.go b/backend/internal/service/rock_purchase_package_test.go index e084a40..2e1cbc6 100644 --- a/backend/internal/service/rock_purchase_package_test.go +++ b/backend/internal/service/rock_purchase_package_test.go @@ -279,6 +279,9 @@ func TestAutoPurchase_FailureCooldown(t *testing.T) { if cfg.LastAttemptAt == nil { t.Fatalf("last_attempt_at 未记录") } + if cfg.LastAutoAttemptAt == nil { + t.Fatalf("last_auto_attempt_at 未记录") + } if !strings.Contains(cfg.LastError, "调用上游采购接口失败") { t.Fatalf("last_error 未记录采购失败: %q", cfg.LastError) } diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index dcde463..e9706c6 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -43,10 +43,13 @@ type RockStatsResponse struct { AutoDeliveryEnabled bool `json:"auto_delivery_enabled"` TokenWarningStock int `json:"token_warning_stock"` AutoPurchaseEnabled bool `json:"auto_purchase_enabled"` - Username string `json:"username"` - LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` - LastPurchaseError string `json:"last_purchase_error,omitempty"` - LastPurchaseErrorAt string `json:"last_purchase_error_at,omitempty"` + // 自动补货冷却状态:供页面提示“下次可补货时间” + AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"` + NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"` + Username string `json:"username"` + LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` + LastPurchaseError string `json:"last_purchase_error,omitempty"` + LastPurchaseErrorAt string `json:"last_purchase_error_at,omitempty"` } type RockDashboardResult struct { @@ -324,5 +327,15 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) } stats.DeletedStock = deletedCount + if cfg.AutoPurchaseEnabled { + if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 { + stats.AutoPurchaseCooldownSeconds = int64((remain + time.Second - 1) / time.Second) + if cfg.LastAutoAttemptAt != nil { + next := cfg.LastAutoAttemptAt.Add(time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute) + stats.NextAutoPurchaseAt = next.Format("2006-01-02 15:04:05") + } + } + } + return &stats, nil } diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go index ac894b5..7fb3a37 100644 --- a/backend/internal/service/rock_stock.go +++ b/backend/internal/service/rock_stock.go @@ -20,6 +20,23 @@ const ( rockPurchaseNoteManualPrefix = "手动采购" ) +// ErrAutoPurchaseCooldown 表示自动补货仍在冷却窗口内,本次不应产生任何采购流水。 +var ErrAutoPurchaseCooldown = errors.New("自动采购处于冷却中") + +// rockAutoPurchaseCooldownRemaining 返回自动补货还需等待的时长,0 表示可以执行。 +// 冷却只认最近一次“自动”尝试:手动采购不影响自动补货节奏。 +func rockAutoPurchaseCooldownRemaining(cfg *model.RockConfig, now time.Time) time.Duration { + if cfg == nil || cfg.PurchaseCooldownMinutes <= 0 || cfg.LastAutoAttemptAt == nil { + return 0 + } + cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute + elapsed := now.Sub(*cfg.LastAutoAttemptAt) + if elapsed >= cooldown { + return 0 + } + return cooldown - elapsed +} + // PurchaseTokens 执行采购并入库,同时记录完整采购流水。 // 流程:积分/库存预检 → 降级计算 → claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地。 // 后续任一步失败都会用 claim 数据兜底入库,保证已扣积分的 Token 不会丢失。 @@ -28,6 +45,16 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool defer s.mu.Unlock() startedAt := time.Now() + // 冷却判断必须在创建流水之前:被拦下的触发不留下任何“未采购”记录。 + if isAuto { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + if remain := rockAutoPurchaseCooldownRemaining(cfg, startedAt); remain > 0 { + return nil, fmt.Errorf("%w(冷却时间 %d 分钟)", ErrAutoPurchaseCooldown, cfg.PurchaseCooldownMinutes) + } + } trigger := "manual" if isAuto { trigger = "auto" @@ -53,20 +80,8 @@ func (s *RockService) purchaseTokensWithRecorder(ctx context.Context, count int, } now := time.Now() - if isAuto { - // 冷却以最近一次“尝试”为准:失败也生效,避免上游故障时每个触发点反复请求。 - last := cfg.LastAttemptAt - if last == nil { - last = cfg.LastPurchasedAt - } - if last != nil && cfg.PurchaseCooldownMinutes > 0 { - cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute - if now.Sub(*last) < cooldown { - return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes) - } - } - } - s.updatePurchaseAttempt(ctx, cfg.ID, now) + // 记录本次尝试;自动补货同时推进自动冷却,手动采购只更新总尝试时间。 + s.markPurchaseAttempt(ctx, cfg.ID, now, isAuto) // 把采购追踪器挂到 context,客户端会把每个上游调用的原始报文交给 rec。 ctx = WithRockTrace(ctx, &RockTrace{OnStage: rec.addStage}) @@ -369,10 +384,14 @@ func purchaseNote(isAuto bool, at time.Time) string { return prefix + "-" + at.Format("20060102-150405") } -func (s *RockService) updatePurchaseAttempt(ctx context.Context, cfgID uint, at time.Time) { +func (s *RockService) markPurchaseAttempt(ctx context.Context, cfgID uint, at time.Time, isAuto bool) { + updates := map[string]interface{}{"last_attempt_at": at} + if isAuto { + updates["last_auto_attempt_at"] = at + } if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). Where("id = ?", cfgID). - Update("last_attempt_at", at).Error; err != nil { + Updates(updates).Error; err != nil { log.Printf("[RockService] 记录采购尝试时间失败: %v", err) } } @@ -468,6 +487,10 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { if !cfg.AutoPurchaseEnabled { return } + // 冷却期内静默跳过:不调用 PurchaseTokens,也不会产生“未采购”流水。 + if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 { + return + } buyCount := cfg.AutoPurchaseCount if buyCount <= 0 { @@ -476,6 +499,10 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) { log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount) tokens, err := s.PurchaseTokens(ctx, buyCount, true) if err != nil { + // 并发触发时后到的请求会被 PurchaseTokens 拦下,这里不再重复记错误日志。 + if errors.Is(err, ErrAutoPurchaseCooldown) { + return + } log.Printf("[RockService] 自动补货失败: %v", err) return } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e6c1a11..73e7b97 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -596,6 +596,8 @@ export interface RockStats { auto_delivery_enabled: boolean; token_warning_stock: number; auto_purchase_enabled: boolean; + auto_purchase_cooldown_seconds?: number; + next_auto_purchase_at?: string; username: string; last_heartbeat_at?: string; last_purchase_error?: string; diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index c771c39..43e900b 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -367,6 +367,8 @@ export default function PlatformRock() { form={form} autoDelivery={autoDelivery} autoPurchaseEnabled={autoPurchaseEnabled} + autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0} + nextAutoPurchaseAt={stats?.next_auto_purchase_at} togglingAutoPurchase={togglingAutoPurchase} testingConn={testingConn} savingConfig={savingConfig} diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx index dcee8cc..38db32b 100644 --- a/frontend/src/pages/platformRock/RockStrategyCard.tsx +++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx @@ -28,6 +28,8 @@ export interface RockStrategyCardProps { form: FormInstance autoDelivery: boolean autoPurchaseEnabled: boolean + autoPurchaseCoolingSeconds?: number + nextAutoPurchaseAt?: string togglingAutoPurchase: boolean testingConn: boolean savingConfig: boolean @@ -41,6 +43,8 @@ export function RockStrategyCard({ form, autoDelivery, autoPurchaseEnabled, + autoPurchaseCoolingSeconds, + nextAutoPurchaseAt, togglingAutoPurchase, testingConn, savingConfig, @@ -245,6 +249,20 @@ export function RockStrategyCard({ 切换后立即保存 + {autoPurchaseEnabled && ( + 0 ? 'warning' : 'secondary'} + style={{ fontSize: 11 }} + > + {(autoPurchaseCoolingSeconds ?? 0) > 0 + ? `冷却中,${ + nextAutoPurchaseAt + ? `${nextAutoPurchaseAt.slice(11, 19)} 后` + : '稍后' + }可再次补货` + : '自动补货就绪'} + + )}