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({ + + + + + diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx index d153fa3..bb2904a 100644 --- a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx +++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx @@ -1,5 +1,5 @@ -import { Badge, Button, Card, Col, Input, Radio, Row, Space, Typography } from 'antd' -import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Badge, Button, Card, Input, Radio, Space, Switch, Tooltip, Typography } from 'antd' +import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons' import type { RockStats } from '../../api' const { Text } = Typography @@ -10,9 +10,14 @@ export interface RockTokenFilterBarProps { filterStatus: string totalTokens: number loadingTokens: boolean + autoRefreshMismatchEnabled: boolean + refreshingMismatch: boolean + togglingAutoRefresh: boolean onSearchKeywordChange: (value: string) => void onFilterStatusChange: (value: string) => void onRefresh: () => void + onRefreshMismatch: () => void + onToggleAutoRefreshMismatch: (checked: boolean) => void } export function RockTokenFilterBar({ @@ -21,71 +26,109 @@ export function RockTokenFilterBar({ filterStatus, totalTokens, loadingTokens, + autoRefreshMismatchEnabled, + refreshingMismatch, + togglingAutoRefresh, onSearchKeywordChange, onFilterStatusChange, onRefresh, + onRefreshMismatch, + onToggleAutoRefreshMismatch, }: RockTokenFilterBarProps) { return ( - - - - } - value={searchKeyword} - onChange={(e) => onSearchKeywordChange(e.target.value)} - onPressEnter={onRefresh} - style={{ width: 400 }} - allowClear - /> - { - onFilterStatusChange(e.target.value) - }} - buttonStyle="solid" - > - 全部 - 可用 - 使用中 - 已绑定 - 已发货 - - - 绑定错误 - - - - - 已上报失效 - - - - - - - - 共检索到 {totalTokens} 条 Token 记录 + {/* 桌面端保持单行:筛选按钮组可横向滚动,移动端自动换行堆叠 */} +
+ } + value={searchKeyword} + onChange={(e) => onSearchKeywordChange(e.target.value)} + onPressEnter={onRefresh} + style={{ width: 320, maxWidth: '100%', flex: '0 0 auto' }} + allowClear + /> +
+ { + onFilterStatusChange(e.target.value) + }} + buttonStyle="solid" + style={{ whiteSpace: 'nowrap' }} + > + 全部 + 可用 + 使用中 + 已绑定 + 已发货 + + + 绑定错误 + + + + + 已上报失效 + + + + + 使用超时 + + + + + 绑定超时 + + + +
+ + + 共 {totalTokens} 条 - - + + + + + + 启用自动刷新 + + +
) } diff --git a/frontend/src/pages/platformRock/RockTokenTable.tsx b/frontend/src/pages/platformRock/RockTokenTable.tsx index 8d90ff1..6cf8773 100644 --- a/frontend/src/pages/platformRock/RockTokenTable.tsx +++ b/frontend/src/pages/platformRock/RockTokenTable.tsx @@ -19,6 +19,7 @@ export interface RockTokenTableProps { totalTokens: number usingWarnDays: number usingDangerDays: number + boundWarnDays: number selectedTokenIds: number[] onSelectedTokenIdsChange: (ids: number[], rows: RockTokenItem[]) => void onBatchDelete: () => void @@ -45,6 +46,7 @@ export function RockTokenTable({ totalTokens, usingWarnDays, usingDangerDays, + boundWarnDays, selectedTokenIds, onSelectedTokenIdsChange, onBatchDelete, @@ -222,6 +224,7 @@ export function RockTokenTable({ copyingVoucherId, usingWarnDays, usingDangerDays, + boundWarnDays, refreshingTokenId, onRefreshBinding: handleRefreshBinding, openingUpstreamId, diff --git a/frontend/src/pages/platformRock/TokenDeleteModal.tsx b/frontend/src/pages/platformRock/TokenDeleteModal.tsx index f8855ed..50f4b49 100644 --- a/frontend/src/pages/platformRock/TokenDeleteModal.tsx +++ b/frontend/src/pages/platformRock/TokenDeleteModal.tsx @@ -31,6 +31,8 @@ const STATUS_LABELS: Record = { redeemed: '已发货', mismatch: '绑定错误', abnormal: '已上报失效', + using_overdue: '使用超时', + bound_overdue: '绑定超时', } const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch']) diff --git a/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx index 995ecc1..4768a1f 100644 --- a/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx +++ b/frontend/src/pages/platformRock/TokenRecycleBinDrawer.tsx @@ -34,6 +34,8 @@ const STATUS_LABELS: Record = { redeemed: '已发货', mismatch: '绑定错误', abnormal: '已上报失效', + using_overdue: '使用超时', + bound_overdue: '绑定超时', } const STATUS_OPTIONS = [ @@ -44,6 +46,8 @@ const STATUS_OPTIONS = [ { value: 'redeemed', label: '已发货' }, { value: 'mismatch', label: '绑定错误' }, { value: 'abnormal', label: '已上报失效' }, + { value: 'using_overdue', label: '使用超时' }, + { value: 'bound_overdue', label: '绑定超时' }, ] const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch']) diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx index b801731..305e5ea 100644 --- a/frontend/src/pages/platformRock/tokenColumns.tsx +++ b/frontend/src/pages/platformRock/tokenColumns.tsx @@ -18,15 +18,20 @@ const { Text } = Typography const MS_PER_DAY = 24 * 60 * 60 * 1000 -// 满 72 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。 -function usingElapsedDays(usingAt?: string): number | null { - if (!usingAt) return null - const startedAt = new Date(usingAt).getTime() +// 满 24 小时算 1 天:超时预警按实际时长计算,不按自然日跨天。 +function elapsedDaysSince(value?: string): number | null { + if (!value) return null + const startedAt = new Date(value).getTime() if (Number.isNaN(startedAt)) return null const diff = Date.now() - startedAt return diff > 0 ? diff / MS_PER_DAY : 0 } +// 使用中超时起点:using_at。 +function usingElapsedDays(usingAt?: string): number | null { + return elapsedDaysSince(usingAt) +} + // Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示, // 两个复制按钮各自只复制兑换码 / 提取码,编号与包 ID 不参与复制。 export function TokenInfoCell({ record }: { record: RockTokenItem }) { @@ -73,6 +78,7 @@ export interface TokenColumnHandlers { copyingVoucherId: number | null usingWarnDays: number usingDangerDays: number + boundWarnDays: number refreshingTokenId: number | null onRefreshBinding: (id: number) => void openingUpstreamId: number | null @@ -91,6 +97,7 @@ export function buildTokenColumns({ copyingVoucherId, usingWarnDays, usingDangerDays, + boundWarnDays, refreshingTokenId, onRefreshBinding, openingUpstreamId, @@ -166,12 +173,24 @@ export function buildTokenColumns({ ) } - case 'bound': + case 'bound': { + const boundDays = elapsedDaysSince(record.bound_at) + if (boundDays !== null && boundDays >= boundWarnDays) { + return ( + + ⏱ 绑定超时 {boundDays.toFixed(1)} 天 + + ) + } return ( ● 已授权绑定 ) + } case 'redeemed': return ( @@ -209,6 +228,7 @@ export function buildTokenColumns({ width: 180, render: (_, record) => { const usingDays = record.status === 'using' ? usingElapsedDays(record.using_at) : null + const boundDays = record.status === 'bound' ? elapsedDaysSince(record.bound_at) : null const usingColor = usingDays === null ? '#64748b' @@ -243,9 +263,10 @@ export function buildTokenColumns({ )} {!record.redeemed_at && record.bound_at && ( -
+
= boundWarnDays ? '#ea580c' : '#0284c7' }}> 🔗 绑定: {formatDateTime(record.bound_at)} + {boundDays !== null ? `(${boundDays.toFixed(1)} 天)` : ''}
)} {!record.bound_at && !record.mismatch_at && record.claimed_at && (