fix:自动补货更新
This commit is contained in:
@@ -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 '%冷却中%';
|
||||||
@@ -52,6 +52,7 @@ type RockConfig struct {
|
|||||||
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
||||||
LastPurchasedAt *time.Time `json:"last_purchased_at"`
|
LastPurchasedAt *time.Time `json:"last_purchased_at"`
|
||||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却
|
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"` // 最近一次补货/打包错误,成功后清空
|
LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空
|
||||||
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
||||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,6 +279,9 @@ func TestAutoPurchase_FailureCooldown(t *testing.T) {
|
|||||||
if cfg.LastAttemptAt == nil {
|
if cfg.LastAttemptAt == nil {
|
||||||
t.Fatalf("last_attempt_at 未记录")
|
t.Fatalf("last_attempt_at 未记录")
|
||||||
}
|
}
|
||||||
|
if cfg.LastAutoAttemptAt == nil {
|
||||||
|
t.Fatalf("last_auto_attempt_at 未记录")
|
||||||
|
}
|
||||||
if !strings.Contains(cfg.LastError, "调用上游采购接口失败") {
|
if !strings.Contains(cfg.LastError, "调用上游采购接口失败") {
|
||||||
t.Fatalf("last_error 未记录采购失败: %q", cfg.LastError)
|
t.Fatalf("last_error 未记录采购失败: %q", cfg.LastError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ type RockStatsResponse struct {
|
|||||||
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
||||||
TokenWarningStock int `json:"token_warning_stock"`
|
TokenWarningStock int `json:"token_warning_stock"`
|
||||||
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
||||||
Username string `json:"username"`
|
// 自动补货冷却状态:供页面提示“下次可补货时间”
|
||||||
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"`
|
||||||
LastPurchaseError string `json:"last_purchase_error,omitempty"`
|
NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"`
|
||||||
LastPurchaseErrorAt string `json:"last_purchase_error_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 {
|
type RockDashboardResult struct {
|
||||||
@@ -324,5 +327,15 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
|
|||||||
}
|
}
|
||||||
stats.DeletedStock = deletedCount
|
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
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ const (
|
|||||||
rockPurchaseNoteManualPrefix = "手动采购"
|
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 执行采购并入库,同时记录完整采购流水。
|
// PurchaseTokens 执行采购并入库,同时记录完整采购流水。
|
||||||
// 流程:积分/库存预检 → 降级计算 → claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地。
|
// 流程:积分/库存预检 → 降级计算 → claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地。
|
||||||
// 后续任一步失败都会用 claim 数据兜底入库,保证已扣积分的 Token 不会丢失。
|
// 后续任一步失败都会用 claim 数据兜底入库,保证已扣积分的 Token 不会丢失。
|
||||||
@@ -28,6 +45,16 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
startedAt := time.Now()
|
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"
|
trigger := "manual"
|
||||||
if isAuto {
|
if isAuto {
|
||||||
trigger = "auto"
|
trigger = "auto"
|
||||||
@@ -53,20 +80,8 @@ func (s *RockService) purchaseTokensWithRecorder(ctx context.Context, count int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if isAuto {
|
// 记录本次尝试;自动补货同时推进自动冷却,手动采购只更新总尝试时间。
|
||||||
// 冷却以最近一次“尝试”为准:失败也生效,避免上游故障时每个触发点反复请求。
|
s.markPurchaseAttempt(ctx, cfg.ID, now, 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)
|
|
||||||
|
|
||||||
// 把采购追踪器挂到 context,客户端会把每个上游调用的原始报文交给 rec。
|
// 把采购追踪器挂到 context,客户端会把每个上游调用的原始报文交给 rec。
|
||||||
ctx = WithRockTrace(ctx, &RockTrace{OnStage: rec.addStage})
|
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")
|
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{}).
|
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).
|
||||||
Where("id = ?", cfgID).
|
Where("id = ?", cfgID).
|
||||||
Update("last_attempt_at", at).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
log.Printf("[RockService] 记录采购尝试时间失败: %v", err)
|
log.Printf("[RockService] 记录采购尝试时间失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +487,10 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
|
|||||||
if !cfg.AutoPurchaseEnabled {
|
if !cfg.AutoPurchaseEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 冷却期内静默跳过:不调用 PurchaseTokens,也不会产生“未采购”流水。
|
||||||
|
if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
buyCount := cfg.AutoPurchaseCount
|
buyCount := cfg.AutoPurchaseCount
|
||||||
if buyCount <= 0 {
|
if buyCount <= 0 {
|
||||||
@@ -476,6 +499,10 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
|
|||||||
log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount)
|
log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount)
|
||||||
tokens, err := s.PurchaseTokens(ctx, buyCount, true)
|
tokens, err := s.PurchaseTokens(ctx, buyCount, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 并发触发时后到的请求会被 PurchaseTokens 拦下,这里不再重复记错误日志。
|
||||||
|
if errors.Is(err, ErrAutoPurchaseCooldown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("[RockService] 自动补货失败: %v", err)
|
log.Printf("[RockService] 自动补货失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -596,6 +596,8 @@ export interface RockStats {
|
|||||||
auto_delivery_enabled: boolean;
|
auto_delivery_enabled: boolean;
|
||||||
token_warning_stock: number;
|
token_warning_stock: number;
|
||||||
auto_purchase_enabled: boolean;
|
auto_purchase_enabled: boolean;
|
||||||
|
auto_purchase_cooldown_seconds?: number;
|
||||||
|
next_auto_purchase_at?: string;
|
||||||
username: string;
|
username: string;
|
||||||
last_heartbeat_at?: string;
|
last_heartbeat_at?: string;
|
||||||
last_purchase_error?: string;
|
last_purchase_error?: string;
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ export default function PlatformRock() {
|
|||||||
form={form}
|
form={form}
|
||||||
autoDelivery={autoDelivery}
|
autoDelivery={autoDelivery}
|
||||||
autoPurchaseEnabled={autoPurchaseEnabled}
|
autoPurchaseEnabled={autoPurchaseEnabled}
|
||||||
|
autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0}
|
||||||
|
nextAutoPurchaseAt={stats?.next_auto_purchase_at}
|
||||||
togglingAutoPurchase={togglingAutoPurchase}
|
togglingAutoPurchase={togglingAutoPurchase}
|
||||||
testingConn={testingConn}
|
testingConn={testingConn}
|
||||||
savingConfig={savingConfig}
|
savingConfig={savingConfig}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface RockStrategyCardProps {
|
|||||||
form: FormInstance
|
form: FormInstance
|
||||||
autoDelivery: boolean
|
autoDelivery: boolean
|
||||||
autoPurchaseEnabled: boolean
|
autoPurchaseEnabled: boolean
|
||||||
|
autoPurchaseCoolingSeconds?: number
|
||||||
|
nextAutoPurchaseAt?: string
|
||||||
togglingAutoPurchase: boolean
|
togglingAutoPurchase: boolean
|
||||||
testingConn: boolean
|
testingConn: boolean
|
||||||
savingConfig: boolean
|
savingConfig: boolean
|
||||||
@@ -41,6 +43,8 @@ export function RockStrategyCard({
|
|||||||
form,
|
form,
|
||||||
autoDelivery,
|
autoDelivery,
|
||||||
autoPurchaseEnabled,
|
autoPurchaseEnabled,
|
||||||
|
autoPurchaseCoolingSeconds,
|
||||||
|
nextAutoPurchaseAt,
|
||||||
togglingAutoPurchase,
|
togglingAutoPurchase,
|
||||||
testingConn,
|
testingConn,
|
||||||
savingConfig,
|
savingConfig,
|
||||||
@@ -245,6 +249,20 @@ export function RockStrategyCard({
|
|||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
切换后立即保存
|
切换后立即保存
|
||||||
</Text>
|
</Text>
|
||||||
|
{autoPurchaseEnabled && (
|
||||||
|
<Text
|
||||||
|
type={(autoPurchaseCoolingSeconds ?? 0) > 0 ? 'warning' : 'secondary'}
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{(autoPurchaseCoolingSeconds ?? 0) > 0
|
||||||
|
? `冷却中,${
|
||||||
|
nextAutoPurchaseAt
|
||||||
|
? `${nextAutoPurchaseAt.slice(11, 19)} 后`
|
||||||
|
: '稍后'
|
||||||
|
}可再次补货`
|
||||||
|
: '自动补货就绪'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
Reference in New Issue
Block a user