diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index c8ce5b5..84c3b72 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -175,6 +175,8 @@ func main() {
log.Printf("[Rock] Token 检查失败: %v", err)
}
rockSvc.CheckStockAndAutoPurchase(context.Background())
+ // 绑定错误自动刷新:内部会检查 auto_refresh_mismatch_enabled 开关,关闭时直接跳过。
+ go rockSvc.RefreshMismatchTokens(context.Background())
}
run()
ticker := time.NewTicker(5 * time.Minute)
diff --git a/backend/internal/database/migrations/023_rock_bound_overdue_and_auto_refresh.sql b/backend/internal/database/migrations/023_rock_bound_overdue_and_auto_refresh.sql
new file mode 100644
index 0000000..8407234
--- /dev/null
+++ b/backend/internal/database/migrations/023_rock_bound_overdue_and_auto_refresh.sql
@@ -0,0 +1,6 @@
+-- 023: Rock 绑定过期预警阈值 + 绑定错误自动刷新开关
+ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS bound_warn_days integer NOT NULL DEFAULT 2;
+ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS auto_refresh_mismatch_enabled boolean NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN rock_configs.bound_warn_days IS '已绑定超过 N 天视为绑定过期预警';
+COMMENT ON COLUMN rock_configs.auto_refresh_mismatch_enabled IS '绑定错误自动刷新开关(默认关闭)';
diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go
index 9f5d001..c778c0d 100644
--- a/backend/internal/handler/rock.go
+++ b/backend/internal/handler/rock.go
@@ -416,6 +416,29 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
}
+// RefreshMismatchTokens 一键刷新全部绑定错误的 Token(同步执行,单次最多 20 条)。
+func (h *RockHandler) RefreshMismatchTokens(c *gin.Context) {
+ result, err := h.rockSvc.RefreshAllMismatchTokens(c.Request.Context())
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ message := fmt.Sprintf("已扫描 %d 条绑定错误 Token,修复 %d 条", result.Scanned, result.Fixed)
+ if result.Failed > 0 {
+ message += fmt.Sprintf(",失败 %d 条", result.Failed)
+ }
+ if result.Remaining > 0 {
+ message += fmt.Sprintf(",仍异常 %d 条", result.Remaining)
+ }
+ response.OK(c, gin.H{
+ "scanned": result.Scanned,
+ "fixed": result.Fixed,
+ "failed": result.Failed,
+ "remaining": result.Remaining,
+ "message": message,
+ })
+}
+
// RefreshBinding 人工刷新指定 Token 的上游绑定状态
func (h *RockHandler) RefreshBinding(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go
index 79ece91..0491854 100644
--- a/backend/internal/model/rock.go
+++ b/backend/internal/model/rock.go
@@ -36,33 +36,35 @@ const (
// RockConfig 平台配置项
type RockConfig struct {
- ID uint `gorm:"primaryKey;default:1" json:"id"`
- AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
- BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
- BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
- LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"`
- LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"`
- LoginPasswordSet bool `gorm:"-" json:"login_password_set"`
- LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"`
- LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"`
- Username string `gorm:"size:128;not null;default:''" json:"username"`
- Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
- MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
- BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"`
- TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
- UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
- UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_days"` // 使用中超过 N 天红色预警
- AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
- AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
- 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"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primaryKey;default:1" json:"id"`
+ AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
+ BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
+ BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
+ LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"`
+ LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"`
+ LoginPasswordSet bool `gorm:"-" json:"login_password_set"`
+ LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"`
+ LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"`
+ Username string `gorm:"size:128;not null;default:''" json:"username"`
+ Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
+ MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
+ BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"`
+ TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
+ UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
+ UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_days"` // 使用中超过 N 天红色预警
+ BoundWarnDays int `gorm:"not null;default:2" json:"bound_warn_days"` // 已绑定超过 N 天预警
+ AutoRefreshMismatchEnabled bool `gorm:"not null;default:false" json:"auto_refresh_mismatch_enabled"` // 绑定错误自动刷新
+ AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
+ AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
+ 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"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func (RockConfig) TableName() string {
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 387436f..ae45711 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -299,6 +299,7 @@ func Setup(h *Handlers) *gin.Engine {
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
+ platform.POST("/platform/rock/token-batches/refresh-mismatch", h.Rock.RefreshMismatchTokens)
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
diff --git a/backend/internal/service/rock_binding_refresh.go b/backend/internal/service/rock_binding_refresh.go
index b6235df..11c1f91 100644
--- a/backend/internal/service/rock_binding_refresh.go
+++ b/backend/internal/service/rock_binding_refresh.go
@@ -35,10 +35,9 @@ func canRefreshRockBinding(status string) bool {
// RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。
//
-// 规则:
+// 规则(只同步上游实时状态,不再用本地预期 UID 拦截):
// - 上游已发货 → 本地同步 redeemed;
-// - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch;
-// - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变;
+// - 上游已绑定 → 本地直接同步 bound,并清空绑定错误标记;
// - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。
func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) {
var token model.RockToken
@@ -91,13 +90,13 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
actualRoleName := ref.GetRoleName()
actualRoleID := ref.GetRoleID()
actualChannel := ref.GetChannel()
- expectedUID := strings.TrimSpace(token.ExpectedUID)
+ upstreamBound := rockUpstreamBound(ref)
updates := map[string]interface{}{
"updated_at": now,
}
// 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。
- if ref.IsBound() {
+ if upstreamBound {
if actualRoleName != "" {
updates["actual_role_name"] = actualRoleName
}
@@ -111,9 +110,9 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
result := &TokenBindingRefreshResult{
Status: token.Status,
- Bound: ref.IsBound(),
+ Bound: upstreamBound,
}
- if ref.IsBound() {
+ if upstreamBound {
result.RoleName = actualRoleName
result.RoleID = actualRoleID
result.Channel = actualChannel
@@ -139,19 +138,9 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05")
result.Message = "上游已发货,本地状态已同步为已交付"
- case ref.IsBound() && actualRoleID == "":
- result.Warning = true
- result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对"
-
- case ref.IsBound() && expectedUID == "":
- // 没有预期 UID 无法核验,只同步角色信息。
- result.Warning = true
- result.Message = fmt.Sprintf(
- "上游已绑定「%s」(UID %s),但本地缺少预期 UID,已同步角色信息,请人工核对",
- displayRockRole(actualRoleName, actualRoleID), actualRoleID,
- )
-
- case ref.IsBound() && token.Status == model.RockStatusMismatch && actualRoleID == expectedUID:
+ case upstreamBound:
+ // 上游已绑定即同步为 bound:不以本地预期 UID 做拦截,
+ // 否则历史 UID 不一致的 Token 永远无法把真实状态刷新回本地。
updates["status"] = model.RockStatusBound
updates["mismatch_at"] = nil
updates["mismatch_reason"] = ""
@@ -159,44 +148,24 @@ func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*T
updates["bound_at"] = resolveRockBoundAt(ref, now)
}
result.Status = model.RockStatusBound
- result.Message = "上游绑定 UID 与预期一致,已解除绑定错误"
-
- case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref):
- result.Warning = true
- result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变"
-
- case ref.IsBound() && actualRoleID != expectedUID:
- reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID)
- updates["status"] = model.RockStatusMismatch
- updates["mismatch_reason"] = reason
- if token.MismatchAt == nil {
- updates["mismatch_at"] = now
+ if actualRoleID == "" {
+ result.Warning = true
+ result.Message = "上游已绑定,但未返回角色 UID,已同步为已绑定,请人工核对"
+ } else {
+ result.Message = fmt.Sprintf(
+ "上游已绑定「%s」(UID %s),已同步为已绑定",
+ displayRockRole(actualRoleName, actualRoleID), actualRoleID,
+ )
}
- result.Status = model.RockStatusMismatch
- result.Warning = true
- result.Message = fmt.Sprintf(
- "上游绑定「%s」(UID %s) 与预期 UID(%s) 不一致,已标记绑定错误",
- displayRockRole(actualRoleName, actualRoleID), actualRoleID, expectedUID,
- )
-
- case ref.IsBound():
- updates["status"] = model.RockStatusBound
- updates["mismatch_at"] = nil
- updates["mismatch_reason"] = ""
- if token.BoundAt == nil {
- updates["bound_at"] = resolveRockBoundAt(ref, now)
- }
- result.Status = model.RockStatusBound
- result.Message = "上游已绑定,UID 核验一致"
default:
result.Message = "上游仍未绑定,本地状态保持不变"
}
result.Changed = result.Status != originalStatus ||
- (ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) ||
- (ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) ||
- (ref.IsBound() && actualChannel != "" && actualChannel != originalChannel)
+ (upstreamBound && actualRoleName != "" && actualRoleName != originalRoleName) ||
+ (upstreamBound && actualRoleID != "" && actualRoleID != originalRoleID) ||
+ (upstreamBound && actualChannel != "" && actualChannel != originalChannel)
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
return nil, err
diff --git a/backend/internal/service/rock_binding_refresh_test.go b/backend/internal/service/rock_binding_refresh_test.go
index 0de9058..3ec3c80 100644
--- a/backend/internal/service/rock_binding_refresh_test.go
+++ b/backend/internal/service/rock_binding_refresh_test.go
@@ -79,7 +79,8 @@ func TestRefreshTokenBinding_BoundMatchesExpectedUID(t *testing.T) {
}
}
-func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
+// 刷新链路只同步上游实时状态:即使本地预期 UID 与扫码 UID 不一致,也应直接标记为已绑定。
+func TestRefreshTokenBinding_IgnoresUIDMismatchAndMarksBound(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -115,20 +116,27 @@ func TestRefreshTokenBinding_MarksMismatch(t *testing.T) {
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
- if res.Status != model.RockStatusMismatch || !strings.Contains(res.Message, "不一致") {
- t.Fatalf("期望 mismatch 提示,实际 %+v", res)
+ if res.Status != model.RockStatusBound || !res.Bound {
+ t.Fatalf("UID 不一致时也应同步为 bound,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
- if saved.Status != model.RockStatusMismatch || saved.MismatchAt == nil || !strings.Contains(saved.MismatchReason, "3132710334") {
- t.Fatalf("数据库 mismatch 字段不符: %+v", saved)
+ if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" {
+ t.Fatalf("应同步为 bound 并写入实际 UID,实际 %+v", saved)
+ }
+ if saved.MismatchAt != nil || saved.MismatchReason != "" {
+ t.Fatalf("同步为 bound 后应清空绑定错误标记,实际 %+v", saved)
+ }
+ if saved.BoundAt == nil {
+ t.Fatalf("bound_at 未写入")
}
}
-func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
+// 本地没有预期 UID 时同样直接按上游状态落库,不再保留 using。
+func TestRefreshTokenBinding_EmptyExpectedUIDMarksBound(t *testing.T) {
db := newRockTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -160,22 +168,19 @@ func TestRefreshTokenBinding_EmptyExpectedUIDKeepsStatus(t *testing.T) {
if err != nil {
t.Fatalf("刷新绑定状态失败: %v", err)
}
- if res.Status != model.RockStatusUsing {
- t.Fatalf("缺少预期 UID 时状态应保持 using,实际 %s", res.Status)
+ if res.Status != model.RockStatusBound {
+ t.Fatalf("缺少预期 UID 时也应同步为 bound,实际 %s", res.Status)
}
- if !strings.Contains(res.Message, "人工核对") {
- t.Fatalf("应提示人工核对,实际 %q", res.Message)
- }
- if !res.Warning {
- t.Fatalf("缺少预期 UID 时应返回 warning=true")
+ if !res.Bound {
+ t.Fatalf("上游已绑定时 bound 应为 true,实际 %+v", res)
}
var saved model.RockToken
if err := db.First(&saved, token.ID).Error; err != nil {
t.Fatalf("读取 Token 失败: %v", err)
}
- if saved.Status != model.RockStatusUsing || saved.ActualRoleID != "3132710334" {
- t.Fatalf("只应同步角色信息,实际 %+v", saved)
+ if saved.Status != model.RockStatusBound || saved.ActualRoleID != "3132710334" || saved.BoundAt == nil {
+ t.Fatalf("应同步为 bound 并写入角色信息,实际 %+v", saved)
}
}
diff --git a/backend/internal/service/rock_mismatch_scan.go b/backend/internal/service/rock_mismatch_scan.go
new file mode 100644
index 0000000..93010be
--- /dev/null
+++ b/backend/internal/service/rock_mismatch_scan.go
@@ -0,0 +1,113 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "log"
+ "sync/atomic"
+ "time"
+
+ "affiliate_dash/internal/model"
+)
+
+const (
+ // 每次巡检/一键刷新最多处理 20 条,避免手动请求超时。
+ rockMismatchScanLimit = 20
+ // 同一 Token 的处理冷却:10 分钟内不重复调用上游。
+ rockMismatchScanCooldown = 10 * time.Minute
+ // 逐条刷新间隔,避免触发上游限流。
+ rockMismatchScanGap = 1500 * time.Millisecond
+)
+
+// errRockMismatchScanBusy 表示已有巡检/一键刷新在跑,本次直接跳过。
+var errRockMismatchScanBusy = errors.New("绑定错误刷新任务正在执行")
+
+var rockMismatchScanRunning atomic.Bool
+
+// RockMismatchRefreshResult 是绑定错误刷新(巡检/一键)的汇总结果。
+type RockMismatchRefreshResult struct {
+ Scanned int `json:"scanned"` // 本次扫描条数
+ Fixed int `json:"fixed"` // 已同步为正常状态的条数
+ Failed int `json:"failed"` // 调用上游失败的条数
+ Remaining int `json:"remaining"` // 仍停留在绑定错误的条数
+}
+
+// RefreshMismatchTokens 定时巡检入口:受「自动刷新」开关控制,关闭时直接跳过。
+func (s *RockService) RefreshMismatchTokens(ctx context.Context) {
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ log.Printf("[Rock] 绑定错误巡检读取配置失败: %v", err)
+ return
+ }
+ if !cfg.AutoRefreshMismatchEnabled {
+ return
+ }
+ result, err := s.refreshMismatchTokens(ctx)
+ if err != nil {
+ if errors.Is(err, errRockMismatchScanBusy) {
+ return
+ }
+ log.Printf("[Rock] 绑定错误巡检失败: %v", err)
+ return
+ }
+ if result.Scanned == 0 {
+ return
+ }
+ log.Printf("[Rock] 绑定错误巡检完成: 扫描=%d 修复=%d 仍异常=%d 失败=%d",
+ result.Scanned, result.Fixed, result.Remaining, result.Failed)
+}
+
+// RefreshAllMismatchTokens 手动「一键刷新绑定错误」:忽略自动开关,同步执行并返回结果。
+func (s *RockService) RefreshAllMismatchTokens(ctx context.Context) (*RockMismatchRefreshResult, error) {
+ return s.refreshMismatchTokens(ctx)
+}
+
+// refreshMismatchTokens 扫描绑定错误 Token,逐条刷新上游状态。
+//
+// 选取条件:status=mismatch 且 updated_at 早于 10 分钟前,按最久未处理优先,单次最多 20 条。
+// 刷新失败或上游仍未绑定时,RefreshTokenBinding 会写 updated_at,从而进入下一轮冷却。
+func (s *RockService) refreshMismatchTokens(ctx context.Context) (*RockMismatchRefreshResult, error) {
+ if !rockMismatchScanRunning.CompareAndSwap(false, true) {
+ return nil, errRockMismatchScanBusy
+ }
+ defer rockMismatchScanRunning.Store(false)
+
+ cutoff := time.Now().Add(-rockMismatchScanCooldown)
+ var tokens []model.RockToken
+ if err := s.db.WithContext(ctx).
+ Where("status = ?", model.RockStatusMismatch).
+ Where("updated_at <= ?", cutoff).
+ Order("updated_at ASC").
+ Limit(rockMismatchScanLimit).
+ Find(&tokens).Error; err != nil {
+ return nil, err
+ }
+
+ result := &RockMismatchRefreshResult{Scanned: len(tokens)}
+ for i, token := range tokens {
+ if i > 0 {
+ select {
+ case <-ctx.Done():
+ return result, ctx.Err()
+ case <-time.After(rockMismatchScanGap):
+ }
+ }
+
+ res, err := s.RefreshTokenBinding(ctx, token.ID)
+ if err != nil {
+ result.Failed++
+ // 失败也推进 updated_at,保证同一 Token 至少 10 分钟后才会再次尝试。
+ _ = s.db.WithContext(ctx).Model(&model.RockToken{}).
+ Where("id = ?", token.ID).
+ Update("updated_at", time.Now()).Error
+ log.Printf("[Rock] 刷新绑定错误 Token %d 失败: %v", token.ID, err)
+ continue
+ }
+ if res.Status == model.RockStatusMismatch {
+ result.Remaining++
+ continue
+ }
+ result.Fixed++
+ }
+ return result, nil
+}
diff --git a/backend/internal/service/rock_overdue_test.go b/backend/internal/service/rock_overdue_test.go
new file mode 100644
index 0000000..2116067
--- /dev/null
+++ b/backend/internal/service/rock_overdue_test.go
@@ -0,0 +1,186 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "affiliate_dash/internal/model"
+)
+
+func TestUpdateConfig_BoundWarnDaysAndAutoRefresh(t *testing.T) {
+ db := newRockTestDB(t)
+ svc := NewRockService(db, NewRockClient("https://example.com"))
+ ctx := context.Background()
+
+ cfg, err := svc.GetConfig(ctx)
+ if err != nil {
+ t.Fatalf("获取配置失败: %v", err)
+ }
+ if cfg.BoundWarnDays != 2 {
+ t.Fatalf("绑定过期预警默认值应为 2,实际 %d", cfg.BoundWarnDays)
+ }
+ if cfg.AutoRefreshMismatchEnabled {
+ t.Fatalf("自动刷新开关默认必须关闭")
+ }
+
+ zero := 0
+ if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BoundWarnDays: &zero}); err == nil {
+ t.Fatalf("绑定过期预警天数小于 1 时必须报错")
+ }
+ tooMany := 366
+ if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BoundWarnDays: &tooMany}); err == nil {
+ t.Fatalf("绑定过期预警天数大于 365 时必须报错")
+ }
+
+ seven := 7
+ enabled := true
+ updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{
+ BoundWarnDays: &seven,
+ AutoRefreshMismatchEnabled: &enabled,
+ })
+ if err != nil {
+ t.Fatalf("保存绑定过期预警失败: %v", err)
+ }
+ if updated.BoundWarnDays != 7 || !updated.AutoRefreshMismatchEnabled {
+ t.Fatalf("绑定过期预警/自动刷新未保存: %+v", updated)
+ }
+}
+
+func TestListTokens_OverdueFilters(t *testing.T) {
+ db := newRockTestDB(t)
+ svc := NewRockService(db, NewRockClient("https://example.com"))
+ ctx := context.Background()
+
+ now := time.Now()
+ old := now.AddDate(0, 0, -10)
+ fresh := now.Add(-time.Hour)
+
+ usingOld := model.RockToken{UpstreamTokenID: "overdue-using", RedeemCode: "C1", Status: model.RockStatusUsing, UsingAt: &old}
+ usingFresh := model.RockToken{UpstreamTokenID: "fresh-using", RedeemCode: "C2", Status: model.RockStatusUsing, UsingAt: &fresh}
+ boundOld := model.RockToken{UpstreamTokenID: "overdue-bound", RedeemCode: "C3", Status: model.RockStatusBound, BoundAt: &old}
+ boundFresh := model.RockToken{UpstreamTokenID: "fresh-bound", RedeemCode: "C4", Status: model.RockStatusBound, BoundAt: &fresh}
+ redeemedOld := model.RockToken{UpstreamTokenID: "old-redeemed", RedeemCode: "C5", Status: model.RockStatusRedeemed, BoundAt: &old}
+ for _, token := range []*model.RockToken{&usingOld, &usingFresh, &boundOld, &boundFresh, &redeemedOld} {
+ if err := db.Create(token).Error; err != nil {
+ t.Fatalf("创建 Token 失败: %v", err)
+ }
+ }
+
+ cases := []struct {
+ status string
+ wantID uint
+ }{
+ {rockTokenStatusUsingOverdue, usingOld.ID},
+ {rockTokenStatusBoundOverdue, boundOld.ID},
+ }
+ for _, tc := range cases {
+ list, total, err := svc.ListTokens(ctx, 1, 20, tc.status, "")
+ if err != nil {
+ t.Fatalf("查询 %s 失败: %v", tc.status, err)
+ }
+ if total != 1 || len(list) != 1 || list[0].ID != tc.wantID {
+ t.Fatalf("%s 应只返回 ID=%d,实际 total=%d list=%+v", tc.status, tc.wantID, total, list)
+ }
+ }
+
+ stats, err := svc.GetStats(ctx)
+ if err != nil {
+ t.Fatalf("获取统计失败: %v", err)
+ }
+ if stats.UsingOverdueStock != 1 || stats.UsingDangerStock != 1 || stats.BoundOverdueStock != 1 {
+ t.Fatalf("过期预警统计不符: using_overdue=%d using_danger=%d bound_overdue=%d",
+ stats.UsingOverdueStock, stats.UsingDangerStock, stats.BoundOverdueStock)
+ }
+}
+
+func TestRefreshMismatchTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
+ db := newRockTestDB(t)
+ var calls int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&calls, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ svc := NewRockService(db, NewRockClient(server.URL))
+ setRockTestBearer(t, svc, "bearer-test")
+
+ token := model.RockToken{
+ UpstreamTokenID: "scan-off",
+ RedeemCode: "CODE-SCAN-OFF",
+ Status: model.RockStatusMismatch,
+ }
+ if err := db.Create(&token).Error; err != nil {
+ t.Fatalf("创建 Token 失败: %v", err)
+ }
+ if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
+ UpdateColumn("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
+ t.Fatalf("调整 updated_at 失败: %v", err)
+ }
+
+ svc.RefreshMismatchTokens(context.Background())
+
+ if atomic.LoadInt32(&calls) != 0 {
+ t.Fatalf("自动刷新关闭时不应调用上游")
+ }
+ var saved model.RockToken
+ if err := db.First(&saved, token.ID).Error; err != nil {
+ t.Fatalf("读取 Token 失败: %v", err)
+ }
+ if saved.Status != model.RockStatusMismatch {
+ t.Fatalf("自动刷新关闭时应保持 mismatch,实际 %s", saved.Status)
+ }
+}
+
+func TestRefreshAllMismatchTokens_FixesMismatch(t *testing.T) {
+ db := newRockTestDB(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "token": map[string]interface{}{
+ "status": "bound",
+ "bound_account": "痛写心扉",
+ "bound_account_id": "3132710334",
+ },
+ })
+ }))
+ defer server.Close()
+
+ svc := NewRockService(db, NewRockClient(server.URL))
+ setRockTestBearer(t, svc, "bearer-test")
+
+ token := model.RockToken{
+ UpstreamTokenID: "scan-on",
+ RedeemCode: "CODE-SCAN-ON",
+ Status: model.RockStatusMismatch,
+ ExpectedUID: "1111111111",
+ }
+ if err := db.Create(&token).Error; err != nil {
+ t.Fatalf("创建 Token 失败: %v", err)
+ }
+ if err := db.Model(&model.RockToken{}).Where("id = ?", token.ID).
+ UpdateColumn("updated_at", time.Now().Add(-time.Hour)).Error; err != nil {
+ t.Fatalf("调整 updated_at 失败: %v", err)
+ }
+
+ result, err := svc.RefreshAllMismatchTokens(context.Background())
+ if err != nil {
+ t.Fatalf("一键刷新失败: %v", err)
+ }
+ if result.Scanned != 1 || result.Fixed != 1 || result.Remaining != 0 || result.Failed != 0 {
+ t.Fatalf("一键刷新结果不符: %+v", result)
+ }
+
+ var saved model.RockToken
+ if err := db.First(&saved, token.ID).Error; err != nil {
+ t.Fatalf("读取 Token 失败: %v", err)
+ }
+ if saved.Status != model.RockStatusBound || saved.MismatchAt != nil {
+ t.Fatalf("应修复为 bound 并清空 mismatch 标记,实际 %+v", saved)
+ }
+}
diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go
index 219fa5e..6963005 100644
--- a/backend/internal/service/rock_service.go
+++ b/backend/internal/service/rock_service.go
@@ -38,17 +38,23 @@ func NewRockService(db *gorm.DB, client *RockClient, codecs ...*SecretCodec) *Ro
// RockStatsResponse 看板与库存统计返回
type RockStatsResponse struct {
- AvailableStock int64 `json:"available_stock"`
- UsingStock int64 `json:"using_stock"`
- BoundStock int64 `json:"bound_stock"`
- RedeemedStock int64 `json:"redeemed_stock"`
- MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
- AbnormalStock int64 `json:"abnormal_stock"`
- DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
+ AvailableStock int64 `json:"available_stock"`
+ UsingStock int64 `json:"using_stock"`
+ BoundStock int64 `json:"bound_stock"`
+ RedeemedStock int64 `json:"redeemed_stock"`
+ MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
+ AbnormalStock int64 `json:"abnormal_stock"`
+ DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量
+ // 过期预警计数:仅统计对应主状态(使用中 / 已绑定),用于筛选栏徽标。
+ UsingOverdueStock int64 `json:"using_overdue_stock"`
+ UsingDangerStock int64 `json:"using_danger_stock"`
+ BoundOverdueStock int64 `json:"bound_overdue_stock"`
UpstreamPoints float64 `json:"upstream_points"`
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
TokenWarningStock int `json:"token_warning_stock"`
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
+ // 自动刷新绑定错误开关状态,供筛选栏 Switch 回显
+ AutoRefreshMismatchEnabled bool `json:"auto_refresh_mismatch_enabled"`
// 自动补货冷却状态:供页面提示“下次可补货时间”
AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"`
NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"`
@@ -76,18 +82,20 @@ func (e *RockConfigValidationError) Error() string {
}
type UpdateRockConfigReq struct {
- AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
- BaseURL *string `json:"base_url"`
- BearerToken *string `json:"bearer_token"`
- MinPointsWarning *float64 `json:"min_points_warning"`
- TokenWarningStock *int `json:"token_warning_stock"`
- UsingWarnDays *int `json:"using_warn_days"`
- UsingDangerDays *int `json:"using_danger_days"`
- AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
- AutoPurchaseCount *int `json:"auto_purchase_count"`
- PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
- LoginUsername *string `json:"login_username"`
- LoginPassword *string `json:"login_password"`
+ AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
+ BaseURL *string `json:"base_url"`
+ BearerToken *string `json:"bearer_token"`
+ MinPointsWarning *float64 `json:"min_points_warning"`
+ TokenWarningStock *int `json:"token_warning_stock"`
+ UsingWarnDays *int `json:"using_warn_days"`
+ UsingDangerDays *int `json:"using_danger_days"`
+ BoundWarnDays *int `json:"bound_warn_days"`
+ AutoRefreshMismatchEnabled *bool `json:"auto_refresh_mismatch_enabled"`
+ AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
+ AutoPurchaseCount *int `json:"auto_purchase_count"`
+ PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
+ LoginUsername *string `json:"login_username"`
+ LoginPassword *string `json:"login_password"`
}
// GetConfig 获取当前配置,若无则初始化
@@ -105,6 +113,7 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error)
TokenWarningStock: 10,
UsingWarnDays: 3,
UsingDangerDays: 5,
+ BoundWarnDays: 2,
AutoPurchaseEnabled: false,
AutoPurchaseCount: 50,
PurchaseCooldownMinutes: 10,
@@ -209,6 +218,19 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"}
}
+ // 绑定过期预警:已绑定超过 N 天视为绑定超时,独立于使用中两级预警。
+ boundWarnDays := cfg.BoundWarnDays
+ if req.BoundWarnDays != nil {
+ boundWarnDays = *req.BoundWarnDays
+ updates["bound_warn_days"] = boundWarnDays
+ }
+ if boundWarnDays < 1 || boundWarnDays > 365 {
+ return nil, &RockConfigValidationError{Message: "绑定过期预警天数必须在 1-365 之间"}
+ }
+ if req.AutoRefreshMismatchEnabled != nil {
+ updates["auto_refresh_mismatch_enabled"] = *req.AutoRefreshMismatchEnabled
+ }
+
if len(updates) > 0 {
updates["updated_at"] = time.Now()
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
@@ -354,6 +376,26 @@ func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error)
return nil, err
}
stats.DeletedStock = deletedCount
+ stats.AutoRefreshMismatchEnabled = cfg.AutoRefreshMismatchEnabled
+
+ // 过期预警计数:仅统计 using / bound 主状态,避免把其它状态混进筛选徽标。
+ now := time.Now()
+ warnDays, dangerDays, boundWarnDays := rockOverdueThresholds(cfg)
+ if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
+ Where("status = ? AND using_at IS NOT NULL AND using_at <= ?", model.RockStatusUsing, rockOverdueCutoff(now, warnDays)).
+ Count(&stats.UsingOverdueStock).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
+ Where("status = ? AND using_at IS NOT NULL AND using_at <= ?", model.RockStatusUsing, rockOverdueCutoff(now, dangerDays)).
+ Count(&stats.UsingDangerStock).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
+ Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?", model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays)).
+ Count(&stats.BoundOverdueStock).Error; err != nil {
+ return nil, err
+ }
if cfg.AutoPurchaseEnabled {
if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 {
diff --git a/backend/internal/service/rock_snapshot.go b/backend/internal/service/rock_snapshot.go
index 6e1ce00..bdaea9e 100644
--- a/backend/internal/service/rock_snapshot.go
+++ b/backend/internal/service/rock_snapshot.go
@@ -35,6 +35,25 @@ func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool
return true
}
+// rockUpstreamBound 判断上游「当前」是否已绑定。
+//
+// 只看本次实时返回的绑定信号(status / bind_changed_at / binding),
+// 不用 bound_account_id 判断:上游可能保留上一次绑定的账号残留,
+// 用它会导致刷新按钮把「未扫码」误判成已绑定,也无法把真实绑定同步回本地。
+func rockUpstreamBound(ref *RockRefreshResponse) bool {
+ if ref == nil {
+ return false
+ }
+ switch strings.ToLower(strings.TrimSpace(ref.Token.Status)) {
+ case model.RockStatusBound, model.RockStatusRedeemed:
+ return true
+ }
+ if strings.TrimSpace(ref.Token.BindChangedAt) != "" {
+ return true
+ }
+ return ref.Binding.Bound || strings.TrimSpace(ref.Binding.RoleID) != ""
+}
+
func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
if ref == nil || len(ref.DeliveryList) == 0 {
return false
diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go
index 7fb3a37..e3a71ec 100644
--- a/backend/internal/service/rock_stock.go
+++ b/backend/internal/service/rock_stock.go
@@ -518,7 +518,12 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
pageSize = 20
}
- db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return nil, 0, err
+ }
+ warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
+ db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search, time.Now(), warnDays, boundWarnDays)
var total int64
if err := db.Count(&total).Error; err != nil {
@@ -527,7 +532,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
var tokens []model.RockToken
// 按本地 ID 倒序:ID 自增,等价于按入库时间倒序;分页稳定,且与列表首列 ID 的降序一致
- err := db.Order("id DESC").
+ err = db.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&tokens).Error
diff --git a/backend/internal/service/rock_token_delete.go b/backend/internal/service/rock_token_delete.go
index a925139..39c708b 100644
--- a/backend/internal/service/rock_token_delete.go
+++ b/backend/internal/service/rock_token_delete.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
+ "time"
"affiliate_dash/internal/model"
@@ -15,6 +16,10 @@ import (
const (
rockTokenBatchDeleteMax = 500
rockTokenDeleteAllMax = 5000
+
+ // 虚拟筛选状态:不落库,只在查询时换算成「主状态 + 时间阈值」。
+ rockTokenStatusUsingOverdue = "using_overdue"
+ rockTokenStatusBoundOverdue = "bound_overdue"
)
var rockTokenDangerousStatuses = map[string]bool{
@@ -80,17 +85,56 @@ func (e *RockTokenDeleteConflictError) Error() string {
func isKnownRockTokenStatus(status string) bool {
switch status {
case "", model.RockStatusAvailable, model.RockStatusUsing, model.RockStatusBound,
- model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal:
+ model.RockStatusRedeemed, model.RockStatusMismatch, model.RockStatusAbnormal,
+ rockTokenStatusUsingOverdue, rockTokenStatusBoundOverdue:
return true
default:
return false
}
}
+// rockOverdueThresholds 返回生效的预警阈值(含默认值兜底),统计与筛选共用。
+func rockOverdueThresholds(cfg *model.RockConfig) (warnDays, dangerDays, boundWarnDays int) {
+ warnDays, dangerDays, boundWarnDays = 3, 5, 2
+ if cfg == nil {
+ return
+ }
+ if cfg.UsingWarnDays > 0 {
+ warnDays = cfg.UsingWarnDays
+ }
+ if cfg.UsingDangerDays > warnDays {
+ dangerDays = cfg.UsingDangerDays
+ } else {
+ dangerDays = warnDays + 2
+ }
+ if cfg.BoundWarnDays > 0 {
+ boundWarnDays = cfg.BoundWarnDays
+ }
+ return
+}
+
+// rockOverdueCutoff 返回「超过 days 天」的时间分界点。
+func rockOverdueCutoff(now time.Time, days int) time.Time {
+ if days <= 0 {
+ days = 1
+ }
+ return now.AddDate(0, 0, -days)
+}
+
// applyRockTokenListFilters 与 Token 列表使用同一套筛选条件,避免“看到多少、删多少”不一致。
-func applyRockTokenListFilters(db *gorm.DB, status, search string) *gorm.DB {
- if value := strings.TrimSpace(status); value != "" {
- db = db.Where("status = ?", value)
+// 列表、删除预览、全部删除、回收站共用;now 由调用方传入以便同一请求内阈值一致。
+func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time, warnDays, boundWarnDays int) *gorm.DB {
+ switch strings.TrimSpace(status) {
+ case rockTokenStatusUsingOverdue:
+ db = db.Where("status = ? AND using_at IS NOT NULL AND using_at <= ?",
+ model.RockStatusUsing, rockOverdueCutoff(now, warnDays))
+ case rockTokenStatusBoundOverdue:
+ db = db.Where("status = ? AND bound_at IS NOT NULL AND bound_at <= ?",
+ model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays))
+ default:
+ if value := strings.TrimSpace(status); value != "" {
+ db = db.Where("status = ?", value)
+ }
}
if value := strings.TrimSpace(search); value != "" {
term := "%" + value + "%"
@@ -157,7 +201,12 @@ func (s *RockService) PreviewRockTokenDelete(ctx context.Context, status, search
return nil, errors.New("无效的 Token 状态筛选")
}
var rows []model.RockToken
- query := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search)
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
+ query := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search, time.Now(), warnDays, boundWarnDays)
if err := query.Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
@@ -250,9 +299,15 @@ func (s *RockService) DeleteAllRockTokens(ctx context.Context, status, search st
}
result := &RockTokenDeleteResult{Forced: force}
- err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
+ filterNow := time.Now()
+ err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var rows []model.RockToken
- query := applyRockTokenListFilters(tx.Model(&model.RockToken{}), status, search)
+ query := applyRockTokenListFilters(tx.Model(&model.RockToken{}), status, search, filterNow, warnDays, boundWarnDays)
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&rows).Error; err != nil {
return err
}
diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go
index 6ead60f..981aecf 100644
--- a/backend/internal/service/rock_token_restore.go
+++ b/backend/internal/service/rock_token_restore.go
@@ -51,9 +51,14 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
return nil, 0, errors.New("无效的 Token 状态筛选")
}
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return nil, 0, err
+ }
+ warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
db := applyRockTokenListFilters(
s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).Where("deleted_at IS NOT NULL"),
- status, search,
+ status, search, time.Now(), warnDays, boundWarnDays,
)
var total int64
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 0bf024b..36ed211 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -577,6 +577,8 @@ export interface RockConfig {
token_warning_stock: number;
using_warn_days: number;
using_danger_days: number;
+ bound_warn_days: number;
+ auto_refresh_mismatch_enabled: boolean;
auto_purchase_enabled: boolean;
auto_purchase_count: number;
purchase_cooldown_minutes: number;
@@ -596,6 +598,10 @@ export interface RockStats {
mismatch_stock: number;
abnormal_stock: number;
deleted_stock?: number;
+ using_overdue_stock?: number;
+ using_danger_stock?: number;
+ bound_overdue_stock?: number;
+ auto_refresh_mismatch_enabled?: boolean;
upstream_points: number;
auto_delivery_enabled: boolean;
token_warning_stock: number;
@@ -779,6 +785,14 @@ export interface RockTokenRestoreResult {
skipped_items: RockTokenRestoreSkipItem[];
}
+export interface RockMismatchRefreshResult {
+ scanned: number;
+ fixed: number;
+ failed: number;
+ remaining: number;
+ message: string;
+}
+
export const rockApi = {
getConfig: () =>
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
@@ -853,6 +867,10 @@ export const rockApi = {
page_size: number;
},
),
+ refreshMismatchTokens: () =>
+ request
+ .post("/platform/rock/token-batches/refresh-mismatch")
+ .then((r) => r.data.data as RockMismatchRefreshResult),
previewTokenDelete: (params?: { status?: string; search?: string }) =>
request
.get("/platform/rock/token-batches/preview", { params })
diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx
index 895d506..ec70dfc 100644
--- a/frontend/src/pages/PlatformRock.tsx
+++ b/frontend/src/pages/PlatformRock.tsx
@@ -36,6 +36,9 @@ export default function PlatformRock() {
const [savingConfig, setSavingConfig] = useState(false)
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
+ const [autoRefreshMismatchEnabled, setAutoRefreshMismatchEnabled] = useState(false)
+ const [togglingAutoRefresh, setTogglingAutoRefresh] = useState(false)
+ const [refreshingMismatch, setRefreshingMismatch] = useState(false)
const [purchasing, setPurchasing] = useState(false)
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
const [purchaseCount, setPurchaseCount] = useState(50)
@@ -79,6 +82,7 @@ export default function PlatformRock() {
setConfig(cfg)
setStats(st)
setAutoPurchaseEnabled(cfg.auto_purchase_enabled)
+ setAutoRefreshMismatchEnabled(cfg.auto_refresh_mismatch_enabled ?? false)
form.setFieldsValue({
auto_delivery_enabled: cfg.auto_delivery_enabled,
base_url: cfg.base_url,
@@ -88,6 +92,7 @@ export default function PlatformRock() {
token_warning_stock: cfg.token_warning_stock,
using_warn_days: cfg.using_warn_days ?? 3,
using_danger_days: cfg.using_danger_days ?? 5,
+ bound_warn_days: cfg.bound_warn_days ?? 2,
auto_purchase_count: cfg.auto_purchase_count,
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
})
@@ -172,6 +177,47 @@ export default function PlatformRock() {
}
}
+ // 一键刷新全部绑定错误的 Token:同步执行,单次最多 20 条
+ const handleRefreshMismatch = async () => {
+ setRefreshingMismatch(true)
+ try {
+ const res = await rockApi.refreshMismatchTokens()
+ if (res.scanned === 0) {
+ message.info('暂无可刷新的绑定错误 Token(10 分钟内已刷新过的会自动跳过)')
+ } else if (res.failed > 0 || res.remaining > 0) {
+ message.warning(res.message)
+ } else {
+ message.success(res.message)
+ }
+ loadData()
+ loadTokens()
+ } catch (e) {
+ message.error('刷新绑定错误失败: ' + (e instanceof Error ? e.message : '未知错误'))
+ } finally {
+ setRefreshingMismatch(false)
+ }
+ }
+
+ // 自动刷新绑定错误开关:切换即保存,后端每 5 分钟巡检一次
+ const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
+ const previous = autoRefreshMismatchEnabled
+ setAutoRefreshMismatchEnabled(checked)
+ setTogglingAutoRefresh(true)
+ try {
+ const updated = await rockApi.updateConfig({ auto_refresh_mismatch_enabled: checked })
+ setConfig(updated)
+ setAutoRefreshMismatchEnabled(updated.auto_refresh_mismatch_enabled)
+ message.success(
+ checked ? '自动刷新已开启:每 5 分钟最多刷新 20 条绑定错误 Token' : '自动刷新已关闭',
+ )
+ } catch (e) {
+ setAutoRefreshMismatchEnabled(previous)
+ message.error('更新自动刷新开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
+ } finally {
+ setTogglingAutoRefresh(false)
+ }
+ }
+
// 测试连通性
const handleTestConnection = async () => {
const bearerToken = form.getFieldValue('bearer_token')
@@ -336,6 +382,7 @@ export default function PlatformRock() {
const usingWarnDays = config?.using_warn_days && config.using_warn_days > 0 ? config.using_warn_days : 3
const usingDangerDays =
config?.using_danger_days && config.using_danger_days > usingWarnDays ? config.using_danger_days : 5
+ const boundWarnDays = config?.bound_warn_days && config.bound_warn_days > 0 ? config.bound_warn_days : 2
const maxPurchasable = Math.max(0, Math.floor(upstreamPoints / tokenCost))
const estimatedCost = (purchaseCount || 0) * tokenCost
const isPointsInsufficient = estimatedCost > upstreamPoints
@@ -428,9 +475,14 @@ export default function PlatformRock() {
filterStatus={filterStatus}
totalTokens={totalTokens}
loadingTokens={loadingTokens}
+ autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
+ refreshingMismatch={refreshingMismatch}
+ togglingAutoRefresh={togglingAutoRefresh}
onSearchKeywordChange={setSearchKeyword}
onFilterStatusChange={handleFilterStatusChange}
onRefresh={loadTokens}
+ onRefreshMismatch={handleRefreshMismatch}
+ onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
/>
{/* 主库存与错单审计表格 */}
@@ -439,6 +491,7 @@ export default function PlatformRock() {
loadingTokens={loadingTokens}
usingWarnDays={usingWarnDays}
usingDangerDays={usingDangerDays}
+ boundWarnDays={boundWarnDays}
page={page}
pageSize={pageSize}
totalTokens={totalTokens}
diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx
index fda9357..5cd4147 100644
--- a/frontend/src/pages/platformRock/RockStrategyCard.tsx
+++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx
@@ -365,6 +365,15 @@ export function RockStrategyCard({