feat:0666账号密码自动登录刷新Token
This commit is contained in:
@@ -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 '';
|
||||
@@ -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())
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user