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}