From 227d4b93ad97aec1f2a80e37070f3dfd5b84d69c Mon Sep 17 00:00:00 2001
From: zhaoxing <2558691827@qq.com>
Date: Tue, 10 Nov 2026 17:25:59 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20Rock=20Token=20=E8=B6=85=E6=97=B6?=
=?UTF-8?q?=E6=8C=89=E6=BB=A1=202/3=20=E5=A4=A9=E5=88=A4=E5=AE=9A=E5=B9=B6?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=98=BE=E7=A4=BA=E5=8F=A3=E5=BE=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/model/rock.go | 7 ++
backend/internal/service/rock_overdue_test.go | 74 +++++++++++++++++++
backend/internal/service/rock_stock.go | 6 +-
backend/internal/service/rock_support.go | 9 +++
backend/internal/service/rock_token_delete.go | 33 +++++++++
.../internal/service/rock_token_restore.go | 6 +-
frontend/src/api/index.ts | 6 ++
frontend/src/pages/PlatformRock.tsx | 22 +++++-
.../src/pages/platformRock/tokenColumns.tsx | 50 ++++++++-----
9 files changed, 190 insertions(+), 23 deletions(-)
diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go
index a92a774..0d8b4e3 100644
--- a/backend/internal/model/rock.go
+++ b/backend/internal/model/rock.go
@@ -108,6 +108,13 @@ type RockToken struct {
DeletedAtText string `gorm:"-" json:"deleted_at,omitempty"` // 回收站展示用,仅管理端响应返回
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+
+ // 以下为列表派生字段,不落库:由后端用与筛选/统计完全相同的 now 计算。
+ BoundOverdue bool `gorm:"-" json:"bound_overdue"`
+ UsingOverdue bool `gorm:"-" json:"using_overdue"`
+ UsingDanger bool `gorm:"-" json:"using_danger"`
+ BoundDays float64 `gorm:"-" json:"bound_days"`
+ UsingDays float64 `gorm:"-" json:"using_days"`
}
func (RockToken) TableName() string {
diff --git a/backend/internal/service/rock_overdue_test.go b/backend/internal/service/rock_overdue_test.go
index 84ffa95..0357cae 100644
--- a/backend/internal/service/rock_overdue_test.go
+++ b/backend/internal/service/rock_overdue_test.go
@@ -98,6 +98,80 @@ func TestListTokens_OverdueFilters(t *testing.T) {
}
}
+// TestListTokens_OverdueBoundaryInclusive 锁定「满 2 天 / 满 3 天」的含边界口径:
+// 刚好达到或超过阈值必须进入筛选并带超时标记,差 1 小时则不能进入。
+func TestListTokens_OverdueBoundaryInclusive(t *testing.T) {
+ db := newRockTestDB(t)
+ svc := NewRockService(db, NewRockClient("https://example.com"))
+ ctx := context.Background()
+
+ now := time.Now()
+ exactBoundAt := now.AddDate(0, 0, -2).Add(-time.Second) // 2 天零 1 秒
+ almostBoundAt := now.AddDate(0, 0, -2).Add(time.Hour) // 1 天 23 小时
+ exactUsingAt := now.AddDate(0, 0, -3).Add(-time.Second) // 3 天零 1 秒
+ almostUsingAt := now.AddDate(0, 0, -3).Add(time.Hour) // 2 天 23 小时
+
+ tokens := []model.RockToken{
+ {UpstreamTokenID: "bound-exact", RedeemCode: "BOUND-1", Status: model.RockStatusBound, BoundAt: &exactBoundAt},
+ {UpstreamTokenID: "bound-almost", RedeemCode: "BOUND-2", Status: model.RockStatusBound, BoundAt: &almostBoundAt},
+ {UpstreamTokenID: "using-exact", RedeemCode: "USING-1", Status: model.RockStatusUsing, UsingAt: &exactUsingAt},
+ {UpstreamTokenID: "using-almost", RedeemCode: "USING-2", Status: model.RockStatusUsing, UsingAt: &almostUsingAt},
+ }
+ for i := range tokens {
+ if err := db.Create(&tokens[i]).Error; err != nil {
+ t.Fatalf("创建 Token 失败: %v", err)
+ }
+ }
+
+ boundList, boundTotal, err := svc.ListTokens(ctx, 1, 20, rockTokenStatusBoundOverdue, "")
+ if err != nil {
+ t.Fatalf("查询绑定超时失败: %v", err)
+ }
+ if boundTotal != 1 || len(boundList) != 1 || boundList[0].ID != tokens[0].ID {
+ t.Fatalf("绑定超时边界错误: total=%d list=%+v", boundTotal, boundList)
+ }
+ if !boundList[0].BoundOverdue || boundList[0].BoundDays < 2 {
+ t.Fatalf("绑定超时标记/天数错误: overdue=%v days=%.4f", boundList[0].BoundOverdue, boundList[0].BoundDays)
+ }
+
+ usingList, usingTotal, err := svc.ListTokens(ctx, 1, 20, rockTokenStatusUsingOverdue, "")
+ if err != nil {
+ t.Fatalf("查询使用超时失败: %v", err)
+ }
+ if usingTotal != 1 || len(usingList) != 1 || usingList[0].ID != tokens[2].ID {
+ t.Fatalf("使用超时边界错误: total=%d list=%+v", usingTotal, usingList)
+ }
+ if !usingList[0].UsingOverdue || usingList[0].UsingDays < 3 {
+ t.Fatalf("使用超时标记/天数错误: overdue=%v days=%.4f", usingList[0].UsingOverdue, usingList[0].UsingDays)
+ }
+
+ all, _, err := svc.ListTokens(ctx, 1, 20, "", "")
+ if err != nil {
+ t.Fatalf("查询全部 Token 失败: %v", err)
+ }
+ byID := make(map[uint]model.RockToken, len(all))
+ for _, token := range all {
+ byID[token.ID] = token
+ }
+ if byID[tokens[1].ID].BoundOverdue {
+ t.Fatal("差 1 小时满 2 天的 Token 不应标记绑定超时")
+ }
+ if byID[tokens[3].ID].UsingOverdue {
+ t.Fatal("差 1 小时满 3 天的 Token 不应标记使用超时")
+ }
+ if !byID[tokens[0].ID].BoundOverdue || !byID[tokens[2].ID].UsingOverdue {
+ t.Fatal("刚好达到阈值的 Token 必须标记超时")
+ }
+
+ stats, err := svc.GetStats(ctx)
+ if err != nil {
+ t.Fatalf("获取统计失败: %v", err)
+ }
+ if stats.BoundOverdueStock != 1 || stats.UsingOverdueStock != 1 {
+ t.Fatalf("超时统计不符: bound=%d using=%d", stats.BoundOverdueStock, stats.UsingOverdueStock)
+ }
+}
+
func TestRefreshStaleTokens_SkipsWhenAutoRefreshDisabled(t *testing.T) {
db := newRockTestDB(t)
var calls int32
diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go
index 129183a..27a8c48 100644
--- a/backend/internal/service/rock_stock.go
+++ b/backend/internal/service/rock_stock.go
@@ -572,8 +572,9 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
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)
+ warnDays, dangerDays, boundWarnDays := rockOverdueThresholds(cfg)
+ now := time.Now()
+ db := applyRockTokenListFilters(s.db.WithContext(ctx).Model(&model.RockToken{}), status, search, now, warnDays, boundWarnDays)
var total int64
if err := db.Count(&total).Error; err != nil {
@@ -589,6 +590,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
if err != nil {
return nil, 0, err
}
+ applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.prepareRockTokenList(ctx, tokens)
return tokens, total, nil
diff --git a/backend/internal/service/rock_support.go b/backend/internal/service/rock_support.go
index ee3c26d..0f5ef7c 100644
--- a/backend/internal/service/rock_support.go
+++ b/backend/internal/service/rock_support.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
+ "time"
"affiliate_dash/internal/model"
)
@@ -27,6 +28,13 @@ func (s *RockService) ListSupportBoundTokens(ctx context.Context, page, pageSize
pageSize = 100
}
+ cfg, err := s.GetConfig(ctx)
+ if err != nil {
+ return nil, 0, err
+ }
+ warnDays, dangerDays, boundWarnDays := rockOverdueThresholds(cfg)
+ now := time.Now()
+
db := s.db.WithContext(ctx).Model(&model.RockToken{}).Where("status = ?", model.RockStatusBound)
term := "%" + search + "%"
db = db.Where(
@@ -45,6 +53,7 @@ func (s *RockService) ListSupportBoundTokens(ctx context.Context, page, pageSize
Find(&tokens).Error; err != nil {
return nil, 0, err
}
+ applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
s.prepareRockTokenList(ctx, tokens)
return tokens, total, nil
}
diff --git a/backend/internal/service/rock_token_delete.go b/backend/internal/service/rock_token_delete.go
index 2601bf9..06ae571 100644
--- a/backend/internal/service/rock_token_delete.go
+++ b/backend/internal/service/rock_token_delete.go
@@ -121,6 +121,39 @@ func rockOverdueCutoff(now time.Time, days int) time.Time {
return now.AddDate(0, 0, -days)
}
+// applyRockTokenOverdueFlags 用与筛选、统计完全相同的 now 和阈值,
+// 为列表补充超时标记与实际天数,避免前端用本地时间算出「显示 2.0 天但未超时」。
+func applyRockTokenOverdueFlags(tokens []model.RockToken, now time.Time, warnDays, dangerDays, boundWarnDays int) {
+ usingCutoff := rockOverdueCutoff(now, warnDays)
+ dangerCutoff := rockOverdueCutoff(now, dangerDays)
+ boundCutoff := rockOverdueCutoff(now, boundWarnDays)
+ for i := range tokens {
+ switch tokens[i].Status {
+ case model.RockStatusUsing:
+ if tokens[i].UsingAt == nil {
+ continue
+ }
+ days := now.Sub(*tokens[i].UsingAt).Hours() / 24
+ if days < 0 {
+ days = 0
+ }
+ tokens[i].UsingDays = days
+ tokens[i].UsingOverdue = !tokens[i].UsingAt.After(usingCutoff)
+ tokens[i].UsingDanger = !tokens[i].UsingAt.After(dangerCutoff)
+ case model.RockStatusBound:
+ if tokens[i].BoundAt == nil {
+ continue
+ }
+ days := now.Sub(*tokens[i].BoundAt).Hours() / 24
+ if days < 0 {
+ days = 0
+ }
+ tokens[i].BoundDays = days
+ tokens[i].BoundOverdue = !tokens[i].BoundAt.After(boundCutoff)
+ }
+ }
+}
+
// applyRockTokenListFilters 与 Token 列表使用同一套筛选条件,避免“看到多少、删多少”不一致。
// 列表、删除预览、全部删除、回收站共用;now 由调用方传入以便同一请求内阈值一致。
func applyRockTokenListFilters(db *gorm.DB, status, search string, now time.Time, warnDays, boundWarnDays int) *gorm.DB {
diff --git a/backend/internal/service/rock_token_restore.go b/backend/internal/service/rock_token_restore.go
index 8831e33..d6bc387 100644
--- a/backend/internal/service/rock_token_restore.go
+++ b/backend/internal/service/rock_token_restore.go
@@ -55,10 +55,11 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
if err != nil {
return nil, 0, err
}
- warnDays, _, boundWarnDays := rockOverdueThresholds(cfg)
+ warnDays, dangerDays, boundWarnDays := rockOverdueThresholds(cfg)
+ now := time.Now()
db := applyRockTokenListFilters(
s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}).Where("deleted_at IS NOT NULL"),
- status, search, time.Now(), warnDays, boundWarnDays,
+ status, search, now, warnDays, boundWarnDays,
)
var total int64
@@ -79,6 +80,7 @@ func (s *RockService) ListDeletedRockTokens(ctx context.Context, page, pageSize
tokens[i].DeletedAtText = tokens[i].DeletedAt.Time.Format(time.RFC3339)
}
}
+ applyRockTokenOverdueFlags(tokens, now, warnDays, dangerDays, boundWarnDays)
return tokens, total, nil
}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index de4db55..3647cbc 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -675,6 +675,12 @@ export interface RockTokenItem {
created_at: string;
updated_at?: string;
deleted_at?: string | null;
+ // 后端按与筛选/统计一致的阈值计算,前端标签直接使用,避免本地时间造成边界不一致。
+ bound_overdue?: boolean;
+ using_overdue?: boolean;
+ using_danger?: boolean;
+ bound_days?: number;
+ using_days?: number;
}
export interface RockPurchaseResult {
diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx
index 5b8563f..62f8f89 100644
--- a/frontend/src/pages/PlatformRock.tsx
+++ b/frontend/src/pages/PlatformRock.tsx
@@ -103,6 +103,12 @@ export default function PlatformRock() {
}
}, [form])
+ // 只刷新统计,不触碰配置表单,供定时刷新和列表刷新共用。
+ const loadStats = useCallback(async () => {
+ const st = await rockApi.getStats()
+ setStats(st)
+ }, [])
+
const loadTokens = useCallback(async () => {
setLoadingTokens(true)
try {
@@ -129,6 +135,20 @@ export default function PlatformRock() {
loadTokens()
}, [loadTokens])
+ // 定时刷新列表与统计,保证跨过 2 天/3 天阈值后标签和徽标自动更新。
+ const refreshTokensAndStats = useCallback(() => {
+ void loadStats().catch(() => {})
+ void loadTokens()
+ }, [loadStats, loadTokens])
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ if (document.visibilityState !== 'visible') return
+ refreshTokensAndStats()
+ }, 60000)
+ return () => window.clearInterval(timer)
+ }, [refreshTokensAndStats])
+
useEffect(() => {
setSelectedTokenIds([])
setSelectedTokens([])
@@ -480,7 +500,7 @@ export default function PlatformRock() {
togglingAutoRefresh={togglingAutoRefresh}
onSearchKeywordChange={setSearchKeyword}
onFilterStatusChange={handleFilterStatusChange}
- onRefresh={loadTokens}
+ onRefresh={refreshTokensAndStats}
onRefreshStale={handleRefreshStale}
onToggleAutoRefreshMismatch={handleToggleAutoRefreshMismatch}
/>
diff --git a/frontend/src/pages/platformRock/tokenColumns.tsx b/frontend/src/pages/platformRock/tokenColumns.tsx
index 9449d52..5f5a454 100644
--- a/frontend/src/pages/platformRock/tokenColumns.tsx
+++ b/frontend/src/pages/platformRock/tokenColumns.tsx
@@ -29,9 +29,15 @@ function elapsedDaysSince(value?: string): number | null {
return diff > 0 ? diff / MS_PER_DAY : 0
}
-// 使用中超时起点:using_at。
-function usingElapsedDays(usingAt?: string): number | null {
- return elapsedDaysSince(usingAt)
+// 展示用天数:向下取整到 1 位,避免 1.99 天被四舍五入成 2.0 天而误以为已超时。
+function floorDays(days: number): number {
+ return Math.floor(days * 10) / 10
+}
+
+// 优先使用后端返回的服务端天数,旧接口没有该字段时回退到本地计算。
+function serverElapsedDays(serverDays: number | undefined, at?: string): number | null {
+ if (typeof serverDays === 'number') return serverDays
+ return elapsedDaysSince(at)
}
// Token 主信息:编号/兑换码 与 包ID/提取码 上下两行展示,
@@ -155,21 +161,24 @@ export function buildTokenColumns({
)
case 'using': {
- const days = usingElapsedDays(record.using_at)
- if (days !== null && days >= usingDangerDays) {
+ const days = serverElapsedDays(record.using_days, record.using_at)
+ const usingOverdue = record.using_overdue ?? (days !== null && days >= usingWarnDays)
+ const usingDanger = record.using_danger ?? (days !== null && days >= usingDangerDays)
+ const displayDays = days === null ? 0 : floorDays(days)
+ if (usingDanger) {
return (