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) } } // 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 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.RefreshStaleTokens(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 TestRefreshAllStaleTokens_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.RefreshAllStaleTokens(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) } } // 手动一键刷新除了绑定错误,还要覆盖「使用超时」和「绑定超时」两类 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) } } // 巡检遇到上游「Token 不存在」时,应标记为已上报失效并停止重复扫描。 func TestRefreshAllStaleTokens_MarksNotFoundAsAbnormal(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") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"Token 不存在"}`)) })) defer server.Close() svc := NewRockService(db, NewRockClient(server.URL)) setRockTestBearer(t, svc, "bearer-test") token := model.RockToken{ UpstreamTokenID: "scan-deleted", RedeemCode: "CODE-SCAN-DELETED", 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) } result, err := svc.RefreshAllStaleTokens(context.Background()) if err != nil { t.Fatalf("一键刷新失败: %v", err) } if result.Scanned != 1 || result.Fixed != 1 || 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.RockStatusAbnormal { t.Fatalf("应标记为 abnormal,实际 %s", saved.Status) } // 已 abnormal 的 Token 不再进入扫描范围。 second, err := svc.RefreshAllStaleTokens(context.Background()) if err != nil { t.Fatalf("第二次一键刷新失败: %v", err) } if second.Scanned != 0 { t.Fatalf("已标记失效的 Token 不应再次被扫描,实际 %+v", second) } }