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 ( - ⚠️ 使用中超时 {days.toFixed(1)} 天 + ⚠️ 使用中超时 {displayDays.toFixed(1)} 天 ) } - if (days !== null && days >= usingWarnDays) { + if (usingOverdue) { return ( - ● 扫码使用中 · {days.toFixed(1)} 天 + ● 扫码使用中 · {displayDays.toFixed(1)} 天 ) } @@ -180,14 +189,16 @@ export function buildTokenColumns({ ) } case 'bound': { - const boundDays = elapsedDaysSince(record.bound_at) - if (boundDays !== null && boundDays >= boundWarnDays) { + const boundDays = serverElapsedDays(record.bound_days, record.bound_at) + const boundOverdue = record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays) + if (boundOverdue) { + const displayDays = boundDays === null ? 0 : floorDays(boundDays) return ( - ⏱ 绑定超时 {boundDays.toFixed(1)} 天 + ⏱ 绑定超时 {displayDays.toFixed(1)} 天 ) } @@ -233,14 +244,17 @@ export function buildTokenColumns({ title: '流转时间', 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 usingDays = record.status === 'using' ? serverElapsedDays(record.using_days, record.using_at) : null + const boundDays = record.status === 'bound' ? serverElapsedDays(record.bound_days, record.bound_at) : null + const usingOverdue = record.using_overdue ?? (usingDays !== null && usingDays >= usingWarnDays) + const usingDanger = record.using_danger ?? (usingDays !== null && usingDays >= usingDangerDays) + const boundOverdue = record.bound_overdue ?? (boundDays !== null && boundDays >= boundWarnDays) const usingColor = usingDays === null ? '#64748b' - : usingDays >= usingDangerDays + : usingDanger ? '#dc2626' - : usingDays >= usingWarnDays + : usingOverdue ? '#d97706' : '#0891b2' return ( @@ -253,7 +267,7 @@ export function buildTokenColumns({
⏳ 使用中: {formatDateTime(record.using_at)} - {usingDays !== null ? `(${usingDays.toFixed(1)} 天)` : ''} + {usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
)} {record.mismatch_at && ( @@ -269,10 +283,10 @@ export function buildTokenColumns({ )} {!record.redeemed_at && record.bound_at && ( -
= boundWarnDays ? '#ea580c' : '#0284c7' }}> +
🔗 绑定: {formatDateTime(record.bound_at)} - {boundDays !== null ? `(${boundDays.toFixed(1)} 天)` : ''} + {boundDays !== null ? `(${floorDays(boundDays).toFixed(1)} 天)` : ''}
)} {!record.bound_at && !record.mismatch_at && record.claimed_at && (