feat:一键刷新与自动刷新覆盖绑定错误/使用超时/绑定超时
This commit is contained in:
@@ -175,8 +175,8 @@ func main() {
|
|||||||
log.Printf("[Rock] Token 检查失败: %v", err)
|
log.Printf("[Rock] Token 检查失败: %v", err)
|
||||||
}
|
}
|
||||||
rockSvc.CheckStockAndAutoPurchase(context.Background())
|
rockSvc.CheckStockAndAutoPurchase(context.Background())
|
||||||
// 绑定错误自动刷新:内部会检查 auto_refresh_mismatch_enabled 开关,关闭时直接跳过。
|
// 异常 Token 自动刷新:内部会检查 auto_refresh_mismatch_enabled 开关,关闭时直接跳过。
|
||||||
go rockSvc.RefreshMismatchTokens(context.Background())
|
go rockSvc.RefreshStaleTokens(context.Background())
|
||||||
}
|
}
|
||||||
run()
|
run()
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
|||||||
@@ -416,14 +416,14 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshMismatchTokens 一键刷新全部绑定错误的 Token(同步执行,单次最多 20 条)。
|
// RefreshStaleTokens 一键刷新异常 Token(绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条)。
|
||||||
func (h *RockHandler) RefreshMismatchTokens(c *gin.Context) {
|
func (h *RockHandler) RefreshStaleTokens(c *gin.Context) {
|
||||||
result, err := h.rockSvc.RefreshAllMismatchTokens(c.Request.Context())
|
result, err := h.rockSvc.RefreshAllStaleTokens(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message := fmt.Sprintf("已扫描 %d 条绑定错误 Token,修复 %d 条", result.Scanned, result.Fixed)
|
message := fmt.Sprintf("已扫描 %d 条异常 Token,修复 %d 条", result.Scanned, result.Fixed)
|
||||||
if result.Failed > 0 {
|
if result.Failed > 0 {
|
||||||
message += fmt.Sprintf(",失败 %d 条", result.Failed)
|
message += fmt.Sprintf(",失败 %d 条", result.Failed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
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/release", h.Rock.ReleaseToken)
|
||||||
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
|
||||||
platform.POST("/platform/rock/token-batches/refresh-mismatch", h.Rock.RefreshMismatchTokens)
|
platform.POST("/platform/rock/token-batches/refresh-stale", h.Rock.RefreshStaleTokens)
|
||||||
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
|
||||||
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
|
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
|
||||||
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
|
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -98,7 +98,7 @@ func TestListTokens_OverdueFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshMismatchTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
|
func TestRefreshStaleTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
|
||||||
db := newRockTestDB(t)
|
db := newRockTestDB(t)
|
||||||
var calls int32
|
var calls int32
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -123,7 +123,7 @@ func TestRefreshMismatchTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
|
|||||||
t.Fatalf("调整 updated_at 失败: %v", err)
|
t.Fatalf("调整 updated_at 失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.RefreshMismatchTokens(context.Background())
|
svc.RefreshStaleTokens(context.Background())
|
||||||
|
|
||||||
if atomic.LoadInt32(&calls) != 0 {
|
if atomic.LoadInt32(&calls) != 0 {
|
||||||
t.Fatalf("自动刷新关闭时不应调用上游")
|
t.Fatalf("自动刷新关闭时不应调用上游")
|
||||||
@@ -137,7 +137,7 @@ func TestRefreshMismatchTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshAllMismatchTokens_FixesMismatch(t *testing.T) {
|
func TestRefreshAllStaleTokens_FixesMismatch(t *testing.T) {
|
||||||
db := newRockTestDB(t)
|
db := newRockTestDB(t)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -168,7 +168,7 @@ func TestRefreshAllMismatchTokens_FixesMismatch(t *testing.T) {
|
|||||||
t.Fatalf("调整 updated_at 失败: %v", err)
|
t.Fatalf("调整 updated_at 失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := svc.RefreshAllMismatchTokens(context.Background())
|
result, err := svc.RefreshAllStaleTokens(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("一键刷新失败: %v", err)
|
t.Fatalf("一键刷新失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -184,3 +184,129 @@ func TestRefreshAllMismatchTokens_FixesMismatch(t *testing.T) {
|
|||||||
t.Fatalf("应修复为 bound 并清空 mismatch 标记,实际 %+v", saved)
|
t.Fatalf("应修复为 bound 并清空 mismatch 标记,实际 %+v", saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动一键刷新除了绑定错误,还要覆盖「使用超时」和「绑定超时」两类 Token。
|
||||||
|
func TestRefreshAllStaleTokens_IncludesOverdueTokens(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")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/redeem/CODE-USING-OLD/refresh":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"token": map[string]interface{}{
|
||||||
|
"status": "bound",
|
||||||
|
"bound_account": "新角色",
|
||||||
|
"bound_account_id": "10001",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/api/redeem/CODE-BOUND-OLD/refresh":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"token": map[string]interface{}{
|
||||||
|
"status": "redeemed",
|
||||||
|
"bound_account": "旧角色",
|
||||||
|
"bound_account_id": "10002",
|
||||||
|
"redeemed_at": "2026-11-07 12:00:00",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewRockService(db, NewRockClient(server.URL))
|
||||||
|
setRockTestBearer(t, svc, "bearer-test")
|
||||||
|
|
||||||
|
old := time.Now().AddDate(0, 0, -10)
|
||||||
|
usingToken := model.RockToken{
|
||||||
|
UpstreamTokenID: "stale-using",
|
||||||
|
RedeemCode: "CODE-USING-OLD",
|
||||||
|
Status: model.RockStatusUsing,
|
||||||
|
UsingAt: &old,
|
||||||
|
}
|
||||||
|
boundToken := model.RockToken{
|
||||||
|
UpstreamTokenID: "stale-bound",
|
||||||
|
RedeemCode: "CODE-BOUND-OLD",
|
||||||
|
Status: model.RockStatusBound,
|
||||||
|
BoundAt: &old,
|
||||||
|
}
|
||||||
|
for _, token := range []*model.RockToken{&usingToken, &boundToken} {
|
||||||
|
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.RefreshAllStaleTokens(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("一键刷新失败: %v", err)
|
||||||
|
}
|
||||||
|
if result.Scanned != 2 || result.Fixed != 2 || result.Remaining != 0 || result.Failed != 0 {
|
||||||
|
t.Fatalf("一键刷新结果不符: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedUsing, savedBound model.RockToken
|
||||||
|
if err := db.First(&savedUsing, usingToken.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取使用超时 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if savedUsing.Status != model.RockStatusBound {
|
||||||
|
t.Fatalf("使用超时 Token 应同步为 bound,实际 %s", savedUsing.Status)
|
||||||
|
}
|
||||||
|
if err := db.First(&savedBound, boundToken.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取绑定超时 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if savedBound.Status != model.RockStatusRedeemed || savedBound.RedeemedAt == nil {
|
||||||
|
t.Fatalf("绑定超时 Token 应同步为 redeemed,实际 %+v", savedBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动刷新与一键刷新同规则:开关开启后也要覆盖使用超时 Token。
|
||||||
|
func TestRefreshStaleTokens_IncludesOverdueWhenEnabled(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": "20001",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewRockService(db, NewRockClient(server.URL))
|
||||||
|
setRockTestBearer(t, svc, "bearer-test")
|
||||||
|
enabled := true
|
||||||
|
if _, err := svc.UpdateConfig(context.Background(), UpdateRockConfigReq{AutoRefreshMismatchEnabled: &enabled}); err != nil {
|
||||||
|
t.Fatalf("开启自动刷新失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := time.Now().AddDate(0, 0, -10)
|
||||||
|
token := model.RockToken{
|
||||||
|
UpstreamTokenID: "auto-stale-using",
|
||||||
|
RedeemCode: "CODE-AUTO-OLD",
|
||||||
|
Status: model.RockStatusUsing,
|
||||||
|
UsingAt: &old,
|
||||||
|
}
|
||||||
|
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.RefreshStaleTokens(context.Background())
|
||||||
|
|
||||||
|
var saved model.RockToken
|
||||||
|
if err := db.First(&saved, token.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取 Token 失败: %v", err)
|
||||||
|
}
|
||||||
|
if saved.Status != model.RockStatusBound {
|
||||||
|
t.Fatalf("自动刷新也应处理使用超时 Token,实际 %s", saved.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 每次巡检/一键刷新最多处理 20 条,避免手动请求超时。
|
||||||
|
rockStaleScanLimit = 20
|
||||||
|
// 同一 Token 的处理冷却:10 分钟内不重复调用上游。
|
||||||
|
rockStaleScanCooldown = 10 * time.Minute
|
||||||
|
// 逐条刷新间隔,避免触发上游限流。
|
||||||
|
rockStaleScanGap = 1500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// errRockStaleScanBusy 表示已有巡检/一键刷新在跑,本次直接跳过。
|
||||||
|
var errRockStaleScanBusy = errors.New("刷新任务正在执行")
|
||||||
|
|
||||||
|
var rockStaleScanRunning atomic.Bool
|
||||||
|
|
||||||
|
// RockStaleRefreshResult 是异常 Token 刷新(巡检/一键)的汇总结果。
|
||||||
|
type RockStaleRefreshResult struct {
|
||||||
|
Scanned int `json:"scanned"` // 本次扫描条数
|
||||||
|
Fixed int `json:"fixed"` // 已同步为其它状态(视为修复)的条数
|
||||||
|
Failed int `json:"failed"` // 调用上游失败的条数
|
||||||
|
Remaining int `json:"remaining"` // 刷新后状态仍未变化的条数
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshStaleTokens 定时巡检入口:受「自动刷新」开关控制,关闭时直接跳过。
|
||||||
|
//
|
||||||
|
// 与手动「一键刷新」使用同一套扫描规则:绑定错误 + 使用超时 + 绑定超时。
|
||||||
|
func (s *RockService) RefreshStaleTokens(ctx context.Context) {
|
||||||
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Rock] 异常 Token 巡检读取配置失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cfg.AutoRefreshMismatchEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.refreshStaleTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errRockStaleScanBusy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Rock] 异常 Token 巡检失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Scanned == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Rock] 异常 Token 巡检完成: 扫描=%d 修复=%d 仍异常=%d 失败=%d",
|
||||||
|
result.Scanned, result.Fixed, result.Remaining, result.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAllStaleTokens 手动「一键刷新」:绑定错误 + 使用超时 + 绑定超时,
|
||||||
|
// 忽略自动开关,同步执行并返回结果。
|
||||||
|
func (s *RockService) RefreshAllStaleTokens(ctx context.Context) (*RockStaleRefreshResult, error) {
|
||||||
|
return s.refreshStaleTokens(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshStaleTokens 扫描异常 Token,逐条刷新上游状态。
|
||||||
|
//
|
||||||
|
// 选取条件:绑定错误,或使用中/已绑定超过阈值(与列表筛选同口径);
|
||||||
|
// 且 updated_at 早于 10 分钟前(同一 Token 冷却),按最久未处理优先,单次最多 20 条。
|
||||||
|
// 刷新失败或状态未变化时,RefreshTokenBinding 会写 updated_at,从而进入下一轮冷却。
|
||||||
|
func (s *RockService) refreshStaleTokens(ctx context.Context) (*RockStaleRefreshResult, error) {
|
||||||
|
if !rockStaleScanRunning.CompareAndSwap(false, true) {
|
||||||
|
return nil, errRockStaleScanBusy
|
||||||
|
}
|
||||||
|
defer rockStaleScanRunning.Store(false)
|
||||||
|
|
||||||
|
cfg, err := s.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rockStaleScanCooldown)
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.RockToken{}).
|
||||||
|
Where("updated_at <= ?", cutoff).
|
||||||
|
Where(
|
||||||
|
"(status = ? OR (status = ? AND using_at IS NOT NULL AND using_at <= ?) OR (status = ? AND bound_at IS NOT NULL AND bound_at <= ?))",
|
||||||
|
model.RockStatusMismatch,
|
||||||
|
model.RockStatusUsing, rockOverdueCutoff(now, warnDays),
|
||||||
|
model.RockStatusBound, rockOverdueCutoff(now, boundWarnDays),
|
||||||
|
)
|
||||||
|
|
||||||
|
var tokens []model.RockToken
|
||||||
|
if err := query.Order("updated_at ASC").Limit(rockStaleScanLimit).Find(&tokens).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RockStaleRefreshResult{Scanned: len(tokens)}
|
||||||
|
for i, token := range tokens {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return result, ctx.Err()
|
||||||
|
case <-time.After(rockStaleScanGap):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == token.Status {
|
||||||
|
result.Remaining++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Fixed++
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -785,7 +785,7 @@ export interface RockTokenRestoreResult {
|
|||||||
skipped_items: RockTokenRestoreSkipItem[];
|
skipped_items: RockTokenRestoreSkipItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RockMismatchRefreshResult {
|
export interface RockStaleRefreshResult {
|
||||||
scanned: number;
|
scanned: number;
|
||||||
fixed: number;
|
fixed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
@@ -867,10 +867,10 @@ export const rockApi = {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
refreshMismatchTokens: () =>
|
refreshStaleTokens: () =>
|
||||||
request
|
request
|
||||||
.post("/platform/rock/token-batches/refresh-mismatch")
|
.post("/platform/rock/token-batches/refresh-stale")
|
||||||
.then((r) => r.data.data as RockMismatchRefreshResult),
|
.then((r) => r.data.data as RockStaleRefreshResult),
|
||||||
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
previewTokenDelete: (params?: { status?: string; search?: string }) =>
|
||||||
request
|
request
|
||||||
.get("/platform/rock/token-batches/preview", { params })
|
.get("/platform/rock/token-batches/preview", { params })
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function PlatformRock() {
|
|||||||
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
||||||
const [autoRefreshMismatchEnabled, setAutoRefreshMismatchEnabled] = useState(false)
|
const [autoRefreshMismatchEnabled, setAutoRefreshMismatchEnabled] = useState(false)
|
||||||
const [togglingAutoRefresh, setTogglingAutoRefresh] = useState(false)
|
const [togglingAutoRefresh, setTogglingAutoRefresh] = useState(false)
|
||||||
const [refreshingMismatch, setRefreshingMismatch] = useState(false)
|
const [refreshingStale, setRefreshingStale] = useState(false)
|
||||||
const [purchasing, setPurchasing] = useState(false)
|
const [purchasing, setPurchasing] = useState(false)
|
||||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||||
@@ -177,13 +177,13 @@ export default function PlatformRock() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一键刷新全部绑定错误的 Token:同步执行,单次最多 20 条
|
// 一键刷新异常 Token:绑定错误 + 使用超时 + 绑定超时,同步执行,单次最多 20 条
|
||||||
const handleRefreshMismatch = async () => {
|
const handleRefreshStale = async () => {
|
||||||
setRefreshingMismatch(true)
|
setRefreshingStale(true)
|
||||||
try {
|
try {
|
||||||
const res = await rockApi.refreshMismatchTokens()
|
const res = await rockApi.refreshStaleTokens()
|
||||||
if (res.scanned === 0) {
|
if (res.scanned === 0) {
|
||||||
message.info('暂无可刷新的绑定错误 Token(10 分钟内已刷新过的会自动跳过)')
|
message.info('暂无可刷新的异常 Token(10 分钟内已刷新过的会自动跳过)')
|
||||||
} else if (res.failed > 0 || res.remaining > 0) {
|
} else if (res.failed > 0 || res.remaining > 0) {
|
||||||
message.warning(res.message)
|
message.warning(res.message)
|
||||||
} else {
|
} else {
|
||||||
@@ -192,13 +192,13 @@ export default function PlatformRock() {
|
|||||||
loadData()
|
loadData()
|
||||||
loadTokens()
|
loadTokens()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error('刷新绑定错误失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
message.error('一键刷新失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshingMismatch(false)
|
setRefreshingStale(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动刷新绑定错误开关:切换即保存,后端每 5 分钟巡检一次
|
// 自动刷新开关:与「一键刷新」同规则(绑定错误 + 使用超时 + 绑定超时),切换即保存
|
||||||
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
|
const handleToggleAutoRefreshMismatch = async (checked: boolean) => {
|
||||||
const previous = autoRefreshMismatchEnabled
|
const previous = autoRefreshMismatchEnabled
|
||||||
setAutoRefreshMismatchEnabled(checked)
|
setAutoRefreshMismatchEnabled(checked)
|
||||||
@@ -208,7 +208,7 @@ export default function PlatformRock() {
|
|||||||
setConfig(updated)
|
setConfig(updated)
|
||||||
setAutoRefreshMismatchEnabled(updated.auto_refresh_mismatch_enabled)
|
setAutoRefreshMismatchEnabled(updated.auto_refresh_mismatch_enabled)
|
||||||
message.success(
|
message.success(
|
||||||
checked ? '自动刷新已开启:每 5 分钟最多刷新 20 条绑定错误 Token' : '自动刷新已关闭',
|
checked ? '自动刷新已开启:每 5 分钟按同样规则刷新最多 20 条异常 Token' : '自动刷新已关闭',
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setAutoRefreshMismatchEnabled(previous)
|
setAutoRefreshMismatchEnabled(previous)
|
||||||
@@ -476,12 +476,12 @@ export default function PlatformRock() {
|
|||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
loadingTokens={loadingTokens}
|
loadingTokens={loadingTokens}
|
||||||
autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
|
autoRefreshMismatchEnabled={autoRefreshMismatchEnabled}
|
||||||
refreshingMismatch={refreshingMismatch}
|
refreshingStale={refreshingStale}
|
||||||
togglingAutoRefresh={togglingAutoRefresh}
|
togglingAutoRefresh={togglingAutoRefresh}
|
||||||
onSearchKeywordChange={setSearchKeyword}
|
onSearchKeywordChange={setSearchKeyword}
|
||||||
onFilterStatusChange={handleFilterStatusChange}
|
onFilterStatusChange={handleFilterStatusChange}
|
||||||
onRefresh={loadTokens}
|
onRefresh={loadTokens}
|
||||||
onRefreshMismatch={handleRefreshMismatch}
|
onRefreshStale={handleRefreshStale}
|
||||||
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
|
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, Button, Card, Input, Radio, Space, Switch, Tooltip, Typography } from 'antd'
|
import { Badge, Button, Card, Input, Radio, Space, Switch, Tooltip, Typography } from 'antd'
|
||||||
import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, QuestionCircleOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import type { RockStats } from '../../api'
|
import type { RockStats } from '../../api'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@@ -11,12 +11,12 @@ export interface RockTokenFilterBarProps {
|
|||||||
totalTokens: number
|
totalTokens: number
|
||||||
loadingTokens: boolean
|
loadingTokens: boolean
|
||||||
autoRefreshMismatchEnabled: boolean
|
autoRefreshMismatchEnabled: boolean
|
||||||
refreshingMismatch: boolean
|
refreshingStale: boolean
|
||||||
togglingAutoRefresh: boolean
|
togglingAutoRefresh: boolean
|
||||||
onSearchKeywordChange: (value: string) => void
|
onSearchKeywordChange: (value: string) => void
|
||||||
onFilterStatusChange: (value: string) => void
|
onFilterStatusChange: (value: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
onRefreshMismatch: () => void
|
onRefreshStale: () => void
|
||||||
onToggleAutoRefreshMismatch: (checked: boolean) => void
|
onToggleAutoRefreshMismatch: (checked: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ export function RockTokenFilterBar({
|
|||||||
totalTokens,
|
totalTokens,
|
||||||
loadingTokens,
|
loadingTokens,
|
||||||
autoRefreshMismatchEnabled,
|
autoRefreshMismatchEnabled,
|
||||||
refreshingMismatch,
|
refreshingStale,
|
||||||
togglingAutoRefresh,
|
togglingAutoRefresh,
|
||||||
onSearchKeywordChange,
|
onSearchKeywordChange,
|
||||||
onFilterStatusChange,
|
onFilterStatusChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshMismatch,
|
onRefreshStale,
|
||||||
onToggleAutoRefreshMismatch,
|
onToggleAutoRefreshMismatch,
|
||||||
}: RockTokenFilterBarProps) {
|
}: RockTokenFilterBarProps) {
|
||||||
return (
|
return (
|
||||||
@@ -109,15 +109,24 @@ export function RockTokenFilterBar({
|
|||||||
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
|
<Button icon={<ReloadOutlined />} onClick={onRefresh} loading={loadingTokens}>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip title="立即刷新全部绑定错误 Token(单次最多 20 条,约 30 秒)">
|
<Space size={4} style={{ whiteSpace: 'nowrap' }}>
|
||||||
<Button
|
<Button icon={<SyncOutlined />} onClick={onRefreshStale} loading={refreshingStale}>
|
||||||
icon={<SyncOutlined />}
|
一键刷新
|
||||||
onClick={onRefreshMismatch}
|
|
||||||
loading={refreshingMismatch}
|
|
||||||
>
|
|
||||||
一键刷新全部绑定错误
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7 }}>
|
||||||
|
同时刷新三类异常 Token:绑定错误、使用超时、绑定超时。
|
||||||
|
<br />
|
||||||
|
单次最多 20 条,逐条间隔 1.5 秒,同一 Token 10 分钟内不重复刷新。
|
||||||
|
<br />
|
||||||
|
「启用自动刷新」每 5 分钟按同样规则自动执行。
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#94a3b8', fontSize: 15, cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
<Space size={6} style={{ whiteSpace: 'nowrap' }}>
|
<Space size={6} style={{ whiteSpace: 'nowrap' }}>
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
Reference in New Issue
Block a user