feat:0666账号密码自动登录刷新Token

This commit is contained in:
2026-11-03 12:13:39 +08:00
parent 4dbec33dbd
commit f69cdc6d8e
12 changed files with 741 additions and 11 deletions
+10 -3
View File
@@ -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()
}
}()
@@ -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 '';
+17
View File
@@ -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())
+5
View File
@@ -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"`
+1
View File
@@ -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)
+112 -3
View File
@@ -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))
}
+264
View File
@@ -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)
}
}
+30 -2
View File
@@ -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
+196
View File
@@ -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
}