diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 259ac85..c8ce5b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -124,7 +124,7 @@ func main() { // 这里传的只是初始/兜底地址,真正的上游地址以 rock_configs.base_url 为准, // 因此紧接着要同步一次,否则后台改过的地址重启后会失效。 rockClient := service.NewRockClient(model.RockDefaultBaseURL) - rockSvc := service.NewRockService(db, rockClient) + rockSvc := service.NewRockService(db, rockClient, codec) if err := rockSvc.ApplyConfigToClient(context.Background()); err != nil { applog.Warnf("读取 Rock 平台配置失败,暂用默认上游地址 %s: %v", model.RockDefaultBaseURL, err) } @@ -168,12 +168,19 @@ func main() { time.Duration(cfg.FulfillmentTimeoutScanIntervalSeconds)*time.Second, ) - // 定时巡检 Rock 库存低水位与自动补货(每5分钟) + // 启动后立即检查一次 Token,之后每 5 分钟巡检 Token 有效性 + 库存低水位。 go func() { + run := func() { + if err := rockSvc.EnsureTokenValid(context.Background()); err != nil { + log.Printf("[Rock] Token 检查失败: %v", err) + } + rockSvc.CheckStockAndAutoPurchase(context.Background()) + } + run() ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { - rockSvc.CheckStockAndAutoPurchase(context.Background()) + run() } }() diff --git a/backend/internal/database/migrations/022_rock_login_credentials.sql b/backend/internal/database/migrations/022_rock_login_credentials.sql new file mode 100644 index 0000000..dd95c12 --- /dev/null +++ b/backend/internal/database/migrations/022_rock_login_credentials.sql @@ -0,0 +1,5 @@ +-- 0666.pro 账号密码登录:Bearer Token 失效时自动重新登录 +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS login_username VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS login_password_ciphertext TEXT NOT NULL DEFAULT ''; +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_token_refresh_at TIMESTAMPTZ; +ALTER TABLE rock_configs ADD COLUMN IF NOT EXISTS last_token_error TEXT NOT NULL DEFAULT ''; diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go index d4b0741..c62b313 100644 --- a/backend/internal/handler/rock.go +++ b/backend/internal/handler/rock.go @@ -68,6 +68,23 @@ func (h *RockHandler) TestConnection(c *gin.Context) { response.OK(c, me) } +type testLoginReq struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// TestLogin 使用账号密码试登录上游,不保存 token +func (h *RockHandler) TestLogin(c *gin.Context) { + var req testLoginReq + _ = c.ShouldBindJSON(&req) + result, err := h.rockSvc.TestLogin(c.Request.Context(), req.Username, req.Password) + 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/model/rock.go b/backend/internal/model/rock.go index 069a031..79ece91 100644 --- a/backend/internal/model/rock.go +++ b/backend/internal/model/rock.go @@ -40,6 +40,11 @@ type RockConfig struct { AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关 BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"` + LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"` + LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"` + LoginPasswordSet bool `gorm:"-" json:"login_password_set"` + LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"` + LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"` Username string `gorm:"size:128;not null;default:''" json:"username"` Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"` MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 8a6e0fc..684a1c6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -286,6 +286,7 @@ func Setup(h *Handlers) *gin.Engine { platform.GET("/platform/rock/config", h.Rock.GetConfig) 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.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_client.go b/backend/internal/service/rock_client.go index b2c159a..89718c7 100644 --- a/backend/internal/service/rock_client.go +++ b/backend/internal/service/rock_client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -66,6 +67,12 @@ type RockClient struct { httpClient *http.Client } +// ErrRockUnauthorized 表示上游拒绝了当前 Bearer Token(401)。 +var ErrRockUnauthorized = errors.New("上游 Bearer Token 已失效或未授权") + +// 上游登录接口会校验浏览器态请求头,Go 默认 UA 会被判为异常请求。 +const rockBrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36" + func NewRockClient(baseURL string) *RockClient { c := &RockClient{ httpClient: &http.Client{ @@ -413,7 +420,7 @@ func (c *RockClient) ListPackages(ctx context.Context, bearerToken string, page, return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err) } if resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("上游 packages 列表接口返回错误码 %d: %s", resp.StatusCode, string(body)) @@ -515,6 +522,105 @@ func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*R return &res, nil } +// RockLoginResponse 是 POST /api/auth/login 的返回结构。 +type RockLoginResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Error string `json:"error,omitempty"` + Token string `json:"token"` + Role string `json:"role"` + Username string `json:"username"` + User struct { + Username string `json:"username"` + Role string `json:"role"` + } `json:"user"` +} + +func (r *RockLoginResponse) ResolveUsername() string { + if r == nil { + return "" + } + if value := strings.TrimSpace(r.Username); value != "" { + return value + } + return strings.TrimSpace(r.User.Username) +} + +func (r *RockLoginResponse) ResolveRole() string { + if r == nil { + return "" + } + if value := strings.TrimSpace(r.Role); value != "" { + return value + } + return strings.TrimSpace(r.User.Role) +} + +// Login 使用账号密码登录上游,返回新的 Bearer Token。 +// 上游登录接口会校验浏览器态请求头(Origin/Referer/Sec-Fetch),这里显式补齐; +// 请求体包含密码,不能交给 traceRockRequest 记录。 +func (c *RockClient) Login(ctx context.Context, username, password string) (*RockLoginResponse, error) { + reqURL := fmt.Sprintf("%s/api/auth/login", c.BaseURL()) + payload, err := json.Marshal(map[string]interface{}{ + "username": strings.TrimSpace(username), + "password": password, + "publicShape": true, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + base := c.BaseURL() + req.Header.Set("accept", "*/*") + req.Header.Set("accept-language", "zh-CN,zh;q=0.9") + req.Header.Set("content-type", "application/json") + req.Header.Set("origin", base) + req.Header.Set("referer", base+"/") + req.Header.Set("priority", "u=1, i") + req.Header.Set("sec-ch-ua", `"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"Windows"`) + req.Header.Set("sec-fetch-dest", "empty") + req.Header.Set("sec-fetch-mode", "cors") + req.Header.Set("sec-fetch-site", "same-origin") + req.Header.Set("user-agent", rockBrowserUserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 0666 登录接口失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取登录响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("上游登录接口返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + var res RockLoginResponse + if err := json.Unmarshal(body, &res); err != nil { + return nil, fmt.Errorf("解析登录响应失败: %w", err) + } + if res.Code != 0 { + message := strings.TrimSpace(res.Message) + if message == "" { + message = strings.TrimSpace(res.Error) + } + if message == "" { + message = "上游登录失败" + } + return nil, errors.New(message) + } + if strings.TrimSpace(res.Token) == "" { + return nil, errors.New("上游登录成功但未返回 token") + } + return &res, nil +} + // GetMe 获取商户个人信息及积分 func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) { startedAt := time.Now() @@ -539,7 +645,7 @@ func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResp traceRockRequest(ctx, "me", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err) if resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("上游返回错误状态码 %d: %s", resp.StatusCode, string(body)) @@ -618,7 +724,7 @@ func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*Roc } traceRockRequest(ctx, "dashboard", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err) if resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)") + return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("上游 dashboard 接口返回错误码 %d: %s", resp.StatusCode, string(body)) @@ -668,6 +774,9 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color } traceRockRequest(ctx, "claim", http.MethodPost, reqURL, jsonBytes, resp.StatusCode, body, time.Since(startedAt), err) + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized) + } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("上游 claim 接口返回错误码 %d: %s", resp.StatusCode, string(body)) } diff --git a/backend/internal/service/rock_login_test.go b/backend/internal/service/rock_login_test.go new file mode 100644 index 0000000..8756831 --- /dev/null +++ b/backend/internal/service/rock_login_test.go @@ -0,0 +1,264 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "affiliate_dash/internal/model" +) + +func TestEnsureTokenValid_RefreshesExpiredToken(t *testing.T) { + db := newRockTestDB(t) + codec, err := NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("创建 codec 失败: %v", err) + } + + var loginCalls int32 + var badBody 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) + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + if body["publicShape"] != true || body["username"] != "tester" { + atomic.AddInt32(&badBody, 1) + } + _, _ = w.Write([]byte(`{"code":0,"token":"fresh-token","user":{"username":"tester","role":"user"}}`)) + case "/api/me": + if r.Header.Get("Authorization") == "Bearer fresh-token" { + _, _ = w.Write([]byte(`{"user":{"username":"tester","points":"123.5"}}`)) + return + } + w.WriteHeader(http.StatusUnauthorized) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL), codec) + ctx := context.Background() + cfg, err := svc.GetConfig(ctx) + if err != nil { + t.Fatalf("初始化配置失败: %v", err) + } + ciphertext, err := codec.Encrypt("secret-pass") + if err != nil { + t.Fatalf("加密密码失败: %v", err) + } + if err := db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ + "bearer_token": "expired-token", + "login_username": "tester", + "login_password_ciphertext": ciphertext, + }).Error; err != nil { + t.Fatalf("写入测试配置失败: %v", err) + } + + if err := svc.EnsureTokenValid(ctx); err != nil { + t.Fatalf("Token 自动刷新失败: %v", err) + } + if atomic.LoadInt32(&badBody) != 0 { + t.Fatal("登录请求体缺少 publicShape 或 username") + } + if calls := atomic.LoadInt32(&loginCalls); calls != 1 { + t.Fatalf("应只登录一次,实际 %d", calls) + } + + updated, err := svc.GetConfig(ctx) + if err != nil { + t.Fatalf("读取配置失败: %v", err) + } + if updated.BearerToken != "fresh-token" { + t.Fatalf("token 未刷新: %q", updated.BearerToken) + } + if updated.LastTokenRefreshAt == nil { + t.Fatal("last_token_refresh_at 未记录") + } + if updated.LastTokenError != "" { + t.Fatalf("刷新成功后不应保留错误: %q", updated.LastTokenError) + } + if updated.Points != 123.5 { + t.Fatalf("积分未同步: %v", updated.Points) + } +} + +func TestEnsureTokenValid_LoginFailureRecordsError(t *testing.T) { + db := newRockTestDB(t) + codec, _ := NewSecretCodec("test-master-key") + 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": + _, _ = w.Write([]byte(`{"code":1,"message":"用户名或密码错误"}`)) + default: + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer server.Close() + + svc := NewRockService(db, NewRockClient(server.URL), codec) + ctx := context.Background() + cfg, _ := svc.GetConfig(ctx) + ciphertext, _ := codec.Encrypt("wrong-pass") + _ = db.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(map[string]interface{}{ + "bearer_token": "expired-token", + "login_username": "tester", + "login_password_ciphertext": ciphertext, + }).Error + + err := svc.EnsureTokenValid(ctx) + if err == nil || !strings.Contains(err.Error(), "用户名或密码错误") { + t.Fatalf("应返回登录失败原因,实际 %v", err) + } + updated, _ := svc.GetConfig(ctx) + if !strings.Contains(updated.LastTokenError, "用户名或密码错误") { + t.Fatalf("last_token_error 未记录: %q", updated.LastTokenError) + } + if updated.BearerToken != "expired-token" { + t.Fatalf("登录失败不应覆盖 token: %q", updated.BearerToken) + } +} + +func TestEnsureTokenValid_NoCredentialsSkipsSilently(t *testing.T) { + db := newRockTestDB(t) + svc := NewRockService(db, NewRockClient("https://example.com")) + if err := svc.EnsureTokenValid(context.Background()); err != nil { + t.Fatalf("未配置 token 与账号密码时应静默返回,实际 %v", err) + } +} + +func TestUpdateConfig_EncryptsPasswordAndHidesIt(t *testing.T) { + db := newRockTestDB(t) + codec, _ := NewSecretCodec("test-master-key") + svc := NewRockService(db, NewRockClient("https://example.com"), codec) + ctx := context.Background() + + username := "tester" + password := "p@ss-word" + updated, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{ + LoginUsername: &username, + LoginPassword: &password, + }) + if err != nil { + t.Fatalf("保存账号密码失败: %v", err) + } + if updated.LoginPasswordCiphertext == "" || updated.LoginPasswordCiphertext == password { + t.Fatalf("密码未加密落库: %q", updated.LoginPasswordCiphertext) + } + if !updated.LoginPasswordSet { + t.Fatal("login_password_set 应为 true") + } + plain, err := codec.Decrypt(updated.LoginPasswordCiphertext) + if err != nil || plain != password { + t.Fatalf("密码解密不一致: %q err=%v", plain, err) + } + raw, _ := json.Marshal(updated) + if strings.Contains(string(raw), password) || strings.Contains(string(raw), updated.LoginPasswordCiphertext) { + t.Fatalf("配置响应泄露了密码: %s", string(raw)) + } + if !strings.Contains(string(raw), `"login_password_set":true`) { + t.Fatalf("配置响应缺少 login_password_set: %s", string(raw)) + } + + // 不传密码时应保持已保存的密文不变。 + same, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{}) + if err != nil { + t.Fatalf("再次保存失败: %v", err) + } + if same.LoginPasswordCiphertext != updated.LoginPasswordCiphertext { + t.Fatal("未传密码时不应改动已保存密文") + } + + empty := "" + cleared, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{LoginPassword: &empty}) + if err != nil { + t.Fatalf("清空密码失败: %v", err) + } + if cleared.LoginPasswordCiphertext != "" || cleared.LoginPasswordSet { + t.Fatal("空密码应清空已保存密文") + } +} + +func TestTestLogin_ReturnsUserInfoWithoutSavingToken(t *testing.T) { + db := newRockTestDB(t) + codec, _ := NewSecretCodec("test-master-key") + 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": + _, _ = w.Write([]byte(`{"code":0,"token":"test-token","role":"user","username":"tester"}`)) + case "/api/me": + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"user":{"username":"tester","points":88.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{}{ + "login_username": "tester", + "login_password_ciphertext": ciphertext, + }).Error + + result, err := svc.TestLogin(ctx, "", "") + if err != nil { + t.Fatalf("测试登录失败: %v", err) + } + if result.Username != "tester" || result.Role != "user" || result.Points != 88.5 { + t.Fatalf("返回信息不符: %+v", result) + } + after, _ := svc.GetConfig(ctx) + if after.BearerToken != "" { + t.Fatalf("测试登录不应保存 token: %q", after.BearerToken) + } +} + +func TestEnsureTokenValid_RefreshesWhenBearerEmpty(t *testing.T) { + db := newRockTestDB(t) + codec, _ := NewSecretCodec("test-master-key") + 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": + _, _ = w.Write([]byte(`{"code":0,"token":"first-token","user":{"username":"tester","role":"user"}}`)) + case "/api/me": + _, _ = w.Write([]byte(`{"user":{"username":"tester","points":10}}`)) + 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{}{ + "login_username": "tester", + "login_password_ciphertext": ciphertext, + }).Error + + if err := svc.EnsureTokenValid(ctx); err != nil { + t.Fatalf("空 token 时应自动登录: %v", err) + } + after, _ := svc.GetConfig(ctx) + if after.BearerToken != "first-token" { + t.Fatalf("token 未写入: %q", after.BearerToken) + } +} diff --git a/backend/internal/service/rock_service.go b/backend/internal/service/rock_service.go index e9706c6..219fa5e 100644 --- a/backend/internal/service/rock_service.go +++ b/backend/internal/service/rock_service.go @@ -17,17 +17,23 @@ import ( type RockService struct { db *gorm.DB client *RockClient + codec *SecretCodec mu sync.Mutex + tokenMu sync.Mutex refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem lastSummary *PurchaseSummary } -func NewRockService(db *gorm.DB, client *RockClient) *RockService { - return &RockService{ +func NewRockService(db *gorm.DB, client *RockClient, codecs ...*SecretCodec) *RockService { + svc := &RockService{ db: db, client: client, } + if len(codecs) > 0 { + svc.codec = codecs[0] + } + return svc } // RockStatsResponse 看板与库存统计返回 @@ -80,6 +86,8 @@ type UpdateRockConfigReq struct { AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"` AutoPurchaseCount *int `json:"auto_purchase_count"` PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"` + LoginUsername *string `json:"login_username"` + LoginPassword *string `json:"login_password"` } // GetConfig 获取当前配置,若无则初始化 @@ -104,11 +112,13 @@ func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) if createErr := s.db.WithContext(ctx).Create(&cfg).Error; createErr != nil { return nil, createErr } + cfg.LoginPasswordSet = strings.TrimSpace(cfg.LoginPasswordCiphertext) != "" return &cfg, nil } if err != nil { return nil, err } + cfg.LoginPasswordSet = strings.TrimSpace(cfg.LoginPasswordCiphertext) != "" return &cfg, nil } @@ -159,6 +169,24 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) if req.PurchaseCooldownMinutes != nil { updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes } + if req.LoginUsername != nil { + updates["login_username"] = strings.TrimSpace(*req.LoginUsername) + } + if req.LoginPassword != nil { + password := *req.LoginPassword + if password == "" { + updates["login_password_ciphertext"] = "" + } else { + if s.codec == nil { + return nil, &RockConfigValidationError{Message: "未配置数据加密密钥,无法保存登录密码"} + } + ciphertext, encryptErr := s.codec.Encrypt(password) + if encryptErr != nil { + return nil, &RockConfigValidationError{Message: "加密登录密码失败: " + encryptErr.Error()} + } + updates["login_password_ciphertext"] = ciphertext + } + } // 使用中超时预警阈值:橙色必须小于红色,否则前端无法区分两级。 warnDays := cfg.UsingWarnDays diff --git a/backend/internal/service/rock_token_auth.go b/backend/internal/service/rock_token_auth.go new file mode 100644 index 0000000..a81ca5f --- /dev/null +++ b/backend/internal/service/rock_token_auth.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "affiliate_dash/internal/model" +) + +// RockLoginTestResult 是「测试登录」返回给管理端的结果,不含 token。 +type RockLoginTestResult struct { + Username string `json:"username"` + Role string `json:"role"` + Points float64 `json:"points"` +} + +func (s *RockService) hasLoginCredentials(cfg *model.RockConfig) bool { + if cfg == nil { + return false + } + return strings.TrimSpace(cfg.LoginUsername) != "" && strings.TrimSpace(cfg.LoginPasswordCiphertext) != "" +} + +func (s *RockService) decryptLoginPassword(cfg *model.RockConfig) (string, error) { + if cfg == nil { + return "", errors.New("Rock 配置不存在") + } + if strings.TrimSpace(cfg.LoginPasswordCiphertext) == "" { + return "", errors.New("尚未配置上游登录密码") + } + if s.codec == nil { + return "", errors.New("未配置数据加密密钥,无法解密登录密码") + } + password, err := s.codec.Decrypt(cfg.LoginPasswordCiphertext) + if err != nil { + return "", fmt.Errorf("解密登录密码失败: %w", err) + } + if strings.TrimSpace(password) == "" { + return "", errors.New("登录密码为空,请重新配置") + } + return password, nil +} + +// EnsureTokenValid 检查当前 Bearer Token;失效时用已保存的账号密码自动重新登录。 +// 未配置 token 且未配置账号密码时静默返回,避免全新环境每 5 分钟刷错误。 +func (s *RockService) EnsureTokenValid(ctx context.Context) error { + cfg, err := s.GetConfig(ctx) + if err != nil { + return err + } + + token := strings.TrimSpace(cfg.BearerToken) + if token == "" { + if !s.hasLoginCredentials(cfg) { + return nil + } + return s.loginAndSaveToken(ctx, cfg) + } + + me, err := s.client.GetMe(ctx, token) + if err == nil { + s.markTokenHealthy(ctx, cfg.ID, me) + return nil + } + if !errors.Is(err, ErrRockUnauthorized) { + return err + } + if !s.hasLoginCredentials(cfg) { + message := err.Error() + ";请配置上游登录账号密码或手动更新 Bearer Token" + s.recordTokenError(ctx, cfg.ID, message) + return errors.New(message) + } + return s.loginAndSaveToken(ctx, cfg) +} + +// loginAndSaveToken 登录上游并把新 token 落库;并发调用时只允许一次真实登录。 +func (s *RockService) loginAndSaveToken(ctx context.Context, cfg *model.RockConfig) error { + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + + // 双重检查:拿到锁时可能已经有别的 goroutine 刷新过了。 + latest, err := s.GetConfig(ctx) + if err != nil { + return err + } + if latest.LastTokenRefreshAt != nil && time.Since(*latest.LastTokenRefreshAt) < 30*time.Second { + return nil + } + + password, err := s.decryptLoginPassword(latest) + if err != nil { + s.recordTokenError(ctx, latest.ID, err.Error()) + return err + } + resp, err := s.client.Login(ctx, latest.LoginUsername, password) + if err != nil { + message := "上游登录失败: " + err.Error() + s.recordTokenError(ctx, latest.ID, message) + return errors.New(message) + } + + now := time.Now() + updates := map[string]interface{}{ + "bearer_token": resp.Token, + "last_token_refresh_at": now, + "last_token_error": "", + "updated_at": now, + } + if username := resp.ResolveUsername(); username != "" { + updates["username"] = username + } + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", latest.ID). + Updates(updates).Error; err != nil { + return err + } + + // 新 token 立即拉一次用户信息,顺带刷新积分与心跳。 + if me, meErr := s.client.GetMe(ctx, resp.Token); meErr == nil && me != nil { + s.markTokenHealthy(ctx, latest.ID, me) + } + log.Printf("[RockService] 自动登录刷新 Token 成功: user=%s", resp.ResolveUsername()) + return nil +} + +func (s *RockService) markTokenHealthy(ctx context.Context, cfgID uint, me *RockMeResponse) { + updates := map[string]interface{}{ + "last_heartbeat_at": time.Now(), + "last_token_error": "", + } + if me != nil { + if username := strings.TrimSpace(me.User.Username); username != "" { + updates["username"] = username + } + updates["points"] = me.User.Points.Float64() + } + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Updates(updates).Error; err != nil { + log.Printf("[RockService] 写入 token 健康状态失败: %v", err) + } +} + +func (s *RockService) recordTokenError(ctx context.Context, cfgID uint, message string) { + message = truncatePurchaseError(message) + log.Printf("[RockService][WARN] Token 维护失败: %s", message) + if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). + Where("id = ?", cfgID). + Updates(map[string]interface{}{ + "last_token_error": message, + "updated_at": time.Now(), + }).Error; err != nil { + log.Printf("[RockService] 写入 token 错误失败: %v", err) + } +} + +// TestLogin 用当前(或传入的)账号密码试登录,不保存 token,供配置页验证。 +func (s *RockService) TestLogin(ctx context.Context, username, password string) (*RockLoginTestResult, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + username = strings.TrimSpace(username) + if username == "" { + username = cfg.LoginUsername + } + if username == "" { + return nil, errors.New("请先填写上游登录账号") + } + if password == "" { + password, err = s.decryptLoginPassword(cfg) + if err != nil { + return nil, errors.New("请先填写登录密码(或先在配置中保存密码)") + } + } + + resp, err := s.client.Login(ctx, username, password) + if err != nil { + return nil, err + } + result := &RockLoginTestResult{ + Username: resp.ResolveUsername(), + Role: resp.ResolveRole(), + } + if me, meErr := s.client.GetMe(ctx, resp.Token); meErr == nil && me != nil { + result.Points = me.User.Points.Float64() + if result.Username == "" { + result.Username = me.User.Username + } + } + return result, nil +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 73e7b97..7503c0f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -580,6 +580,10 @@ export interface RockConfig { auto_purchase_enabled: boolean; auto_purchase_count: number; purchase_cooldown_minutes: number; + login_username?: string; + login_password_set?: boolean; + last_token_refresh_at?: string; + last_token_error?: string; last_purchased_at?: string; last_heartbeat_at?: string; } @@ -786,6 +790,10 @@ export const rockApi = { request .post("/platform/rock/test-conn", { bearer_token }) .then((r) => r.data.data), + testLogin: (data: { username?: string; password?: string }) => + request + .post("/platform/rock/login-test", data) + .then((r) => r.data.data as { username: string; role: string; points: number }), 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 43e900b..c09094d 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -31,6 +31,7 @@ export default function PlatformRock() { const [upstreamStock, setUpstreamStock] = useState(null) const [loadingStats, setLoadingStats] = useState(false) const [testingConn, setTestingConn] = useState(false) + const [testingLogin, setTestingLogin] = useState(false) const [savingConfig, setSavingConfig] = useState(false) const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false) const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false) @@ -81,6 +82,7 @@ export default function PlatformRock() { auto_delivery_enabled: cfg.auto_delivery_enabled, base_url: cfg.base_url, bearer_token: cfg.bearer_token, + login_username: cfg.login_username, min_points_warning: cfg.min_points_warning, token_warning_stock: cfg.token_warning_stock, using_warn_days: cfg.using_warn_days ?? 3, @@ -188,10 +190,29 @@ export default function PlatformRock() { } } + // 测试上游账号密码登录(只验证,不保存 token) + const handleTestLogin = async () => { + const username = form.getFieldValue('login_username') as string | undefined + const password = form.getFieldValue('login_password') as string | undefined + setTestingLogin(true) + try { + const res = await rockApi.testLogin({ username, password }) + message.success(`登录成功:${res.username}(${res.role}),积分 ${res.points}`) + } catch (e) { + message.error('测试登录失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setTestingLogin(false) + } + } + // 保存策略配置 const handleSaveConfig = async () => { try { const values = await form.validateFields() + // 密码留空表示不修改,避免每次保存都清空已保存的密文 + if (!values.login_password) { + delete values.login_password + } setSavingConfig(true) const updated = await rockApi.updateConfig(values) setConfig(updated) @@ -369,6 +390,11 @@ export default function PlatformRock() { autoPurchaseEnabled={autoPurchaseEnabled} autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0} nextAutoPurchaseAt={stats?.next_auto_purchase_at} + loginPasswordSet={config?.login_password_set} + lastTokenRefreshAt={config?.last_token_refresh_at} + lastTokenError={config?.last_token_error} + testingLogin={testingLogin} + onTestLogin={handleTestLogin} togglingAutoPurchase={togglingAutoPurchase} testingConn={testingConn} savingConfig={savingConfig} diff --git a/frontend/src/pages/platformRock/RockStrategyCard.tsx b/frontend/src/pages/platformRock/RockStrategyCard.tsx index 38db32b..34b91ff 100644 --- a/frontend/src/pages/platformRock/RockStrategyCard.tsx +++ b/frontend/src/pages/platformRock/RockStrategyCard.tsx @@ -21,6 +21,7 @@ import { SafetyCertificateOutlined, ThunderboltOutlined, } from '@ant-design/icons' +import { formatDateTime } from '../../utils/time' const { Text } = Typography @@ -30,6 +31,11 @@ export interface RockStrategyCardProps { autoPurchaseEnabled: boolean autoPurchaseCoolingSeconds?: number nextAutoPurchaseAt?: string + loginPasswordSet?: boolean + lastTokenRefreshAt?: string + lastTokenError?: string + testingLogin: boolean + onTestLogin: () => void togglingAutoPurchase: boolean testingConn: boolean savingConfig: boolean @@ -45,6 +51,11 @@ export function RockStrategyCard({ autoPurchaseEnabled, autoPurchaseCoolingSeconds, nextAutoPurchaseAt, + loginPasswordSet, + lastTokenRefreshAt, + lastTokenError, + testingLogin, + onTestLogin, togglingAutoPurchase, testingConn, savingConfig, @@ -170,7 +181,7 @@ export function RockStrategyCard({ label={ 上游商户 Bearer Token (yd_token) - + @@ -196,6 +207,59 @@ export function RockStrategyCard({ /> + + + + + + + + + + + + + + + + {lastTokenError ? ( + + Token 刷新失败:{lastTokenError} + + ) : lastTokenRefreshAt ? ( + + Token 上次刷新:{formatDateTime(lastTokenRefreshAt)} + + ) : ( + + 尚未自动刷新 Token + + )} + +
- 💡 极简提取指引:登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '} + 💡 Token 兜底:自动登录失败时,可登录 0666.pro 网页按 F12 打开控制台,执行{' '} localStorage.getItem('yd_token') {' '} - 即可一键复制。 + 手动粘贴到上方。