diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index c62b313..9f5d001 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -85,6 +85,16 @@ func (h *RockHandler) TestLogin(c *gin.Context) { response.OK(c, result) } +// RefreshToken 强制登录上游并保存新的 Bearer Token +func (h *RockHandler) RefreshToken(c *gin.Context) { + result, err := h.rockSvc.RefreshTokenNow(c.Request.Context()) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, result) +} + // GetStats 获取概览数据与库存统计 func (h *RockHandler) GetStats(c *gin.Context) { stats, err := h.rockSvc.GetStats(c.Request.Context()) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 684a1c6..387436f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -287,6 +287,7 @@ func Setup(h *Handlers) *gin.Engine { platform.PUT("/platform/rock/config", h.Rock.UpdateConfig) platform.POST("/platform/rock/test-conn", h.Rock.TestConnection) platform.POST("/platform/rock/login-test", h.Rock.TestLogin) + platform.POST("/platform/rock/token/refresh", h.Rock.RefreshToken) platform.GET("/platform/rock/stats", h.Rock.GetStats) platform.POST("/platform/rock/dashboard/refresh", h.Rock.RefreshDashboard) platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens) diff --git a/backend/internal/service/rock_login_test.go b/backend/internal/service/rock_login_test.go index 8756831..cac8c58 100644 --- a/backend/internal/service/rock_login_test.go +++ b/backend/internal/service/rock_login_test.go @@ -262,3 +262,67 @@ func TestEnsureTokenValid_RefreshesWhenBearerEmpty(t *testing.T) { t.Fatalf("token 未写入: %q", after.BearerToken) } } + +func TestRefreshTokenNow_SavesNewToken(t *testing.T) { + db := newRockTestDB(t) + codec, _ := NewSecretCodec("test-master-key") + var loginCalls int32 + 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/auth/login": + atomic.AddInt32(&loginCalls, 1) + _, _ = w.Write([]byte(`{"code":0,"token":"manual-fresh-token","user":{"username":"tester","role":"user"}}`)) + case "/api/me": + if r.Header.Get("Authorization") != "Bearer manual-fresh-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"user":{"username":"tester","points":66.5}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL), codec) + ctx := context.Background() + cfg, _ := svc.GetConfig(ctx) + ciphertext, _ := codec.Encrypt("secret-pass") + _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ + "bearer_token": "still-valid-token", + "login_username": "tester", + "login_password_ciphertext": ciphertext, + "last_token_refresh_at": nil, + }).Error + + result, err := svc.RefreshTokenNow(ctx) + if err != nil { + t.Fatalf("手动刷新 Token 失败: %v", err) + } + if result.Username != "tester" || result.Role != "user" || result.Points != 66.5 { + t.Fatalf("返回信息不符: %+v", result) + } + if result.RefreshedAt == "" { + t.Fatal("refreshed_at 未返回") + } + if calls := atomic.LoadInt32(&loginCalls); calls != 1 { + t.Fatalf("应强制登录一次,实际 %d", calls) + } + after, _ := svc.GetConfig(ctx) + if after.BearerToken != "manual-fresh-token" { + t.Fatalf("token 未更新: %q", after.BearerToken) + } + if after.LastTokenRefreshAt == nil { + t.Fatal("last_token_refresh_at 未更新") + } +} + +func TestRefreshTokenNow_RequiresCredentials(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + if _, err := svc.RefreshTokenNow(context.Background()); err == nil || + !strings.Contains(err.Error(), "登录账号") { + t.Fatalf("未配置账号密码时应提示,实际 %v", err) + } +} diff --git a/backend/internal/service/rock_token_auth.go b/backend/internal/service/rock_token_auth.go index a81ca5f..0ceaa26 100644 --- a/backend/internal/service/rock_token_auth.go +++ b/backend/internal/service/rock_token_auth.go @@ -11,11 +11,12 @@ import ( "affiliate_dash/internal/model" ) -// RockLoginTestResult 是「测试登录」返回给管理端的结果,不含 token。 +// RockLoginTestResult 是「测试登录 / 刷新 Token」返回给管理端的结果,不含 token。 type RockLoginTestResult struct { - Username string `json:"username"` - Role string `json:"role"` - Points float64 `json:"points"` + Username string `json:"username"` + Role string `json:"role"` + Points float64 `json:"points"` + RefreshedAt string `json:"refreshed_at,omitempty"` } func (s *RockService) hasLoginCredentials(cfg *model.RockConfig) bool { @@ -90,17 +91,54 @@ func (s *RockService) loginAndSaveToken(ctx context.Context, cfg *model.RockConf if latest.LastTokenRefreshAt != nil && time.Since(*latest.LastTokenRefreshAt) < 30*time.Second { return nil } + _, _, err = s.refreshTokenWithLogin(ctx, latest) + return err +} - password, err := s.decryptLoginPassword(latest) +// RefreshTokenNow 手动强制登录刷新 Token,不检查当前 token 是否有效。 +func (s *RockService) RefreshTokenNow(ctx context.Context) (*RockLoginTestResult, error) { + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + + cfg, err := s.GetConfig(ctx) if err != nil { - s.recordTokenError(ctx, latest.ID, err.Error()) - return err + return nil, err } - resp, err := s.client.Login(ctx, latest.LoginUsername, password) + if !s.hasLoginCredentials(cfg) { + return nil, errors.New("请先在配置中保存上游登录账号与密码") + } + + resp, me, err := s.refreshTokenWithLogin(ctx, cfg) + if err != nil { + return nil, err + } + result := &RockLoginTestResult{ + Username: resp.ResolveUsername(), + Role: resp.ResolveRole(), + RefreshedAt: time.Now().Format("2006-01-02 15:04:05"), + } + if me != nil { + result.Points = me.User.Points.Float64() + if result.Username == "" { + result.Username = me.User.Username + } + } + log.Printf("[RockService] 手动刷新 Token 成功: user=%s", result.Username) + return result, nil +} + +// refreshTokenWithLogin 登录上游、保存新 token 并刷新用户快照;调用方需持有 tokenMu。 +func (s *RockService) refreshTokenWithLogin(ctx context.Context, cfg *model.RockConfig) (*RockLoginResponse, *RockMeResponse, error) { + password, err := s.decryptLoginPassword(cfg) + if err != nil { + s.recordTokenError(ctx, cfg.ID, err.Error()) + return nil, nil, err + } + resp, err := s.client.Login(ctx, cfg.LoginUsername, password) if err != nil { message := "上游登录失败: " + err.Error() - s.recordTokenError(ctx, latest.ID, message) - return errors.New(message) + s.recordTokenError(ctx, cfg.ID, message) + return nil, nil, errors.New(message) } now := time.Now() @@ -114,17 +152,18 @@ func (s *RockService) loginAndSaveToken(ctx context.Context, cfg *model.RockConf updates["username"] = username } if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). - Where("id = ?", latest.ID). + Where("id = ?", cfg.ID). Updates(updates).Error; err != nil { - return err + return nil, nil, err } // 新 token 立即拉一次用户信息,顺带刷新积分与心跳。 - if me, meErr := s.client.GetMe(ctx, resp.Token); meErr == nil && me != nil { - s.markTokenHealthy(ctx, latest.ID, me) + var me *RockMeResponse + if latest, meErr := s.client.GetMe(ctx, resp.Token); meErr == nil && latest != nil { + me = latest + s.markTokenHealthy(ctx, cfg.ID, me) } - log.Printf("[RockService] 自动登录刷新 Token 成功: user=%s", resp.ResolveUsername()) - return nil + return resp, me, nil } func (s *RockService) markTokenHealthy(ctx context.Context, cfgID uint, me *RockMeResponse) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7503c0f..0bf024b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -794,6 +794,18 @@ export const rockApi = { request .post("/platform/rock/login-test", data) .then((r) => r.data.data as { username: string; role: string; points: number }), + refreshRockToken: () => + request + .post("/platform/rock/token/refresh") + .then( + (r) => + r.data.data as { + username: string; + role: string; + points: number; + refreshed_at: string; + }, + ), getStats: () => request.get("/platform/rock/stats").then((r) => r.data.data as RockStats), refreshDashboard: () => diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index c09094d..895d506 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -32,6 +32,7 @@ export default function PlatformRock() { const [loadingStats, setLoadingStats] = useState(false) const [testingConn, setTestingConn] = useState(false) const [testingLogin, setTestingLogin] = useState(false) + const [refreshingToken, setRefreshingToken] = useState(false) const [savingConfig, setSavingConfig] = useState(false) const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false) const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false) @@ -205,6 +206,20 @@ export default function PlatformRock() { } } + // 强制登录上游并保存新的 Token + const handleRefreshToken = async () => { + setRefreshingToken(true) + try { + const res = await rockApi.refreshRockToken() + message.success(`Token 已刷新:${res.username}(${res.role}),积分 ${res.points}`) + await loadData() + } catch (e) { + message.error('刷新 Token 失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setRefreshingToken(false) + } + } + // 保存策略配置 const handleSaveConfig = async () => { try { @@ -395,6 +410,8 @@ export default function PlatformRock() { lastTokenError={config?.last_token_error} testingLogin={testingLogin} onTestLogin={handleTestLogin} + refreshingToken={refreshingToken} + onRefreshToken={handleRefreshToken} togglingAutoPurchase={togglingAutoPurchase} testingConn={testingConn} savingConfig={savingConfig} diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx index 34b91ff..fda9357 100644 --- a/frontend/src/pages/platformRock/RockStrategyCard.tsx +++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx @@ -36,6 +36,8 @@ export interface RockStrategyCardProps { lastTokenError?: string testingLogin: boolean onTestLogin: () => void + refreshingToken: boolean + onRefreshToken: () => void togglingAutoPurchase: boolean testingConn: boolean savingConfig: boolean @@ -56,6 +58,8 @@ export function RockStrategyCard({ lastTokenError, testingLogin, onTestLogin, + refreshingToken, + onRefreshToken, togglingAutoPurchase, testingConn, savingConfig, @@ -245,6 +249,9 @@ export function RockStrategyCard({ + {lastTokenError ? ( Token 刷新失败:{lastTokenError}