feat:0666账号密码自动登录刷新Token
This commit is contained in:
@@ -124,7 +124,7 @@ func main() {
|
|||||||
// 这里传的只是初始/兜底地址,真正的上游地址以 rock_configs.base_url 为准,
|
// 这里传的只是初始/兜底地址,真正的上游地址以 rock_configs.base_url 为准,
|
||||||
// 因此紧接着要同步一次,否则后台改过的地址重启后会失效。
|
// 因此紧接着要同步一次,否则后台改过的地址重启后会失效。
|
||||||
rockClient := service.NewRockClient(model.RockDefaultBaseURL)
|
rockClient := service.NewRockClient(model.RockDefaultBaseURL)
|
||||||
rockSvc := service.NewRockService(db, rockClient)
|
rockSvc := service.NewRockService(db, rockClient, codec)
|
||||||
if err := rockSvc.ApplyConfigToClient(context.Background()); err != nil {
|
if err := rockSvc.ApplyConfigToClient(context.Background()); err != nil {
|
||||||
applog.Warnf("读取 Rock 平台配置失败,暂用默认上游地址 %s: %v", model.RockDefaultBaseURL, err)
|
applog.Warnf("读取 Rock 平台配置失败,暂用默认上游地址 %s: %v", model.RockDefaultBaseURL, err)
|
||||||
}
|
}
|
||||||
@@ -168,12 +168,19 @@ func main() {
|
|||||||
time.Duration(cfg.FulfillmentTimeoutScanIntervalSeconds)*time.Second,
|
time.Duration(cfg.FulfillmentTimeoutScanIntervalSeconds)*time.Second,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 定时巡检 Rock 库存低水位与自动补货(每5分钟)
|
// 启动后立即检查一次 Token,之后每 5 分钟巡检 Token 有效性 + 库存低水位。
|
||||||
go func() {
|
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)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
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)
|
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 获取概览数据与库存统计
|
// GetStats 获取概览数据与库存统计
|
||||||
func (h *RockHandler) GetStats(c *gin.Context) {
|
func (h *RockHandler) GetStats(c *gin.Context) {
|
||||||
stats, err := h.rockSvc.GetStats(c.Request.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"` // 全局自动发货总开关
|
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
|
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"`
|
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"`
|
Username string `gorm:"size:128;not null;default:''" json:"username"`
|
||||||
Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
|
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"`
|
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.GET("/platform/rock/config", h.Rock.GetConfig)
|
||||||
platform.PUT("/platform/rock/config", h.Rock.UpdateConfig)
|
platform.PUT("/platform/rock/config", h.Rock.UpdateConfig)
|
||||||
platform.POST("/platform/rock/test-conn", h.Rock.TestConnection)
|
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.GET("/platform/rock/stats", h.Rock.GetStats)
|
||||||
platform.POST("/platform/rock/dashboard/refresh", h.Rock.RefreshDashboard)
|
platform.POST("/platform/rock/dashboard/refresh", h.Rock.RefreshDashboard)
|
||||||
platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens)
|
platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -66,6 +67,12 @@ type RockClient struct {
|
|||||||
httpClient *http.Client
|
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 {
|
func NewRockClient(baseURL string) *RockClient {
|
||||||
c := &RockClient{
|
c := &RockClient{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
@@ -413,7 +420,7 @@ func (c *RockClient) ListPackages(ctx context.Context, bearerToken string, page,
|
|||||||
return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err)
|
return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("上游 packages 列表接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
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
|
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 获取商户个人信息及积分
|
// GetMe 获取商户个人信息及积分
|
||||||
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
|
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
|
||||||
startedAt := time.Now()
|
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)
|
traceRockRequest(ctx, "me", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("上游返回错误状态码 %d: %s", resp.StatusCode, string(body))
|
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)
|
traceRockRequest(ctx, "dashboard", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
return nil, fmt.Errorf("%w (401)", ErrRockUnauthorized)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("上游 dashboard 接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
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)
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("上游 claim 接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
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 {
|
type RockService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
client *RockClient
|
client *RockClient
|
||||||
|
codec *SecretCodec
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
tokenMu sync.Mutex
|
||||||
refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem
|
refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem
|
||||||
bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem
|
bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem
|
||||||
lastSummary *PurchaseSummary
|
lastSummary *PurchaseSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRockService(db *gorm.DB, client *RockClient) *RockService {
|
func NewRockService(db *gorm.DB, client *RockClient, codecs ...*SecretCodec) *RockService {
|
||||||
return &RockService{
|
svc := &RockService{
|
||||||
db: db,
|
db: db,
|
||||||
client: client,
|
client: client,
|
||||||
}
|
}
|
||||||
|
if len(codecs) > 0 {
|
||||||
|
svc.codec = codecs[0]
|
||||||
|
}
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// RockStatsResponse 看板与库存统计返回
|
// RockStatsResponse 看板与库存统计返回
|
||||||
@@ -80,6 +86,8 @@ type UpdateRockConfigReq struct {
|
|||||||
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
||||||
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
||||||
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
||||||
|
LoginUsername *string `json:"login_username"`
|
||||||
|
LoginPassword *string `json:"login_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig 获取当前配置,若无则初始化
|
// 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 {
|
if createErr := s.db.WithContext(ctx).Create(&cfg).Error; createErr != nil {
|
||||||
return nil, createErr
|
return nil, createErr
|
||||||
}
|
}
|
||||||
|
cfg.LoginPasswordSet = strings.TrimSpace(cfg.LoginPasswordCiphertext) != ""
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.LoginPasswordSet = strings.TrimSpace(cfg.LoginPasswordCiphertext) != ""
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +169,24 @@ func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq)
|
|||||||
if req.PurchaseCooldownMinutes != nil {
|
if req.PurchaseCooldownMinutes != nil {
|
||||||
updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes
|
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
|
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
|
||||||
|
}
|
||||||
@@ -580,6 +580,10 @@ export interface RockConfig {
|
|||||||
auto_purchase_enabled: boolean;
|
auto_purchase_enabled: boolean;
|
||||||
auto_purchase_count: number;
|
auto_purchase_count: number;
|
||||||
purchase_cooldown_minutes: 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_purchased_at?: string;
|
||||||
last_heartbeat_at?: string;
|
last_heartbeat_at?: string;
|
||||||
}
|
}
|
||||||
@@ -786,6 +790,10 @@ export const rockApi = {
|
|||||||
request
|
request
|
||||||
.post("/platform/rock/test-conn", { bearer_token })
|
.post("/platform/rock/test-conn", { bearer_token })
|
||||||
.then((r) => r.data.data),
|
.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: () =>
|
getStats: () =>
|
||||||
request.get("/platform/rock/stats").then((r) => r.data.data as RockStats),
|
request.get("/platform/rock/stats").then((r) => r.data.data as RockStats),
|
||||||
refreshDashboard: () =>
|
refreshDashboard: () =>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function PlatformRock() {
|
|||||||
const [upstreamStock, setUpstreamStock] = useState<number | null>(null)
|
const [upstreamStock, setUpstreamStock] = useState<number | null>(null)
|
||||||
const [loadingStats, setLoadingStats] = useState(false)
|
const [loadingStats, setLoadingStats] = useState(false)
|
||||||
const [testingConn, setTestingConn] = useState(false)
|
const [testingConn, setTestingConn] = useState(false)
|
||||||
|
const [testingLogin, setTestingLogin] = useState(false)
|
||||||
const [savingConfig, setSavingConfig] = useState(false)
|
const [savingConfig, setSavingConfig] = useState(false)
|
||||||
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
|
const [autoPurchaseEnabled, setAutoPurchaseEnabled] = useState(false)
|
||||||
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
const [togglingAutoPurchase, setTogglingAutoPurchase] = useState(false)
|
||||||
@@ -81,6 +82,7 @@ export default function PlatformRock() {
|
|||||||
auto_delivery_enabled: cfg.auto_delivery_enabled,
|
auto_delivery_enabled: cfg.auto_delivery_enabled,
|
||||||
base_url: cfg.base_url,
|
base_url: cfg.base_url,
|
||||||
bearer_token: cfg.bearer_token,
|
bearer_token: cfg.bearer_token,
|
||||||
|
login_username: cfg.login_username,
|
||||||
min_points_warning: cfg.min_points_warning,
|
min_points_warning: cfg.min_points_warning,
|
||||||
token_warning_stock: cfg.token_warning_stock,
|
token_warning_stock: cfg.token_warning_stock,
|
||||||
using_warn_days: cfg.using_warn_days ?? 3,
|
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 () => {
|
const handleSaveConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
|
// 密码留空表示不修改,避免每次保存都清空已保存的密文
|
||||||
|
if (!values.login_password) {
|
||||||
|
delete values.login_password
|
||||||
|
}
|
||||||
setSavingConfig(true)
|
setSavingConfig(true)
|
||||||
const updated = await rockApi.updateConfig(values)
|
const updated = await rockApi.updateConfig(values)
|
||||||
setConfig(updated)
|
setConfig(updated)
|
||||||
@@ -369,6 +390,11 @@ export default function PlatformRock() {
|
|||||||
autoPurchaseEnabled={autoPurchaseEnabled}
|
autoPurchaseEnabled={autoPurchaseEnabled}
|
||||||
autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0}
|
autoPurchaseCoolingSeconds={stats?.auto_purchase_cooldown_seconds ?? 0}
|
||||||
nextAutoPurchaseAt={stats?.next_auto_purchase_at}
|
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}
|
togglingAutoPurchase={togglingAutoPurchase}
|
||||||
testingConn={testingConn}
|
testingConn={testingConn}
|
||||||
savingConfig={savingConfig}
|
savingConfig={savingConfig}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { formatDateTime } from '../../utils/time'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ export interface RockStrategyCardProps {
|
|||||||
autoPurchaseEnabled: boolean
|
autoPurchaseEnabled: boolean
|
||||||
autoPurchaseCoolingSeconds?: number
|
autoPurchaseCoolingSeconds?: number
|
||||||
nextAutoPurchaseAt?: string
|
nextAutoPurchaseAt?: string
|
||||||
|
loginPasswordSet?: boolean
|
||||||
|
lastTokenRefreshAt?: string
|
||||||
|
lastTokenError?: string
|
||||||
|
testingLogin: boolean
|
||||||
|
onTestLogin: () => void
|
||||||
togglingAutoPurchase: boolean
|
togglingAutoPurchase: boolean
|
||||||
testingConn: boolean
|
testingConn: boolean
|
||||||
savingConfig: boolean
|
savingConfig: boolean
|
||||||
@@ -45,6 +51,11 @@ export function RockStrategyCard({
|
|||||||
autoPurchaseEnabled,
|
autoPurchaseEnabled,
|
||||||
autoPurchaseCoolingSeconds,
|
autoPurchaseCoolingSeconds,
|
||||||
nextAutoPurchaseAt,
|
nextAutoPurchaseAt,
|
||||||
|
loginPasswordSet,
|
||||||
|
lastTokenRefreshAt,
|
||||||
|
lastTokenError,
|
||||||
|
testingLogin,
|
||||||
|
onTestLogin,
|
||||||
togglingAutoPurchase,
|
togglingAutoPurchase,
|
||||||
testingConn,
|
testingConn,
|
||||||
savingConfig,
|
savingConfig,
|
||||||
@@ -170,7 +181,7 @@ export function RockStrategyCard({
|
|||||||
label={
|
label={
|
||||||
<Space>
|
<Space>
|
||||||
<span>上游商户 Bearer Token (yd_token)</span>
|
<span>上游商户 Bearer Token (yd_token)</span>
|
||||||
<Tooltip title="0666.pro 采用无密码架构,直接从网页 localStorage 提取 64 位十六进制 Token 即可">
|
<Tooltip title="Token 失效时自动使用下方账号密码登录刷新;手动粘贴的 Token 会被自动登录覆盖">
|
||||||
<ExclamationCircleOutlined style={{ color: '#94a3b8' }} />
|
<ExclamationCircleOutlined style={{ color: '#94a3b8' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -196,6 +207,59 @@ export function RockStrategyCard({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="login_username"
|
||||||
|
label="上游登录账号"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="0666.pro 登录账号"
|
||||||
|
autoComplete="off"
|
||||||
|
name="upstream_login_username"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
name="login_password"
|
||||||
|
label="上游登录密码"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
extra={
|
||||||
|
loginPasswordSet
|
||||||
|
? '已保存密码,留空表示不修改'
|
||||||
|
: '用于 Token 失效后自动登录'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder={loginPasswordSet ? '已保存,留空不修改' : '请输入密码'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
name="upstream_login_password"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
|
<Button size="small" onClick={onTestLogin} loading={testingLogin}>
|
||||||
|
测试登录
|
||||||
|
</Button>
|
||||||
|
{lastTokenError ? (
|
||||||
|
<Text type="danger" style={{ fontSize: 12 }}>
|
||||||
|
Token 刷新失败:{lastTokenError}
|
||||||
|
</Text>
|
||||||
|
) : lastTokenRefreshAt ? (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Token 上次刷新:{formatDateTime(lastTokenRefreshAt)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
尚未自动刷新 Token
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#eff6ff',
|
background: '#eff6ff',
|
||||||
@@ -206,11 +270,11 @@ export function RockStrategyCard({
|
|||||||
color: '#1e40af',
|
color: '#1e40af',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
💡 <strong>极简提取指引:</strong>登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '}
|
💡 <strong>Token 兜底:</strong>自动登录失败时,可登录 0666.pro 网页按 F12 打开控制台,执行{' '}
|
||||||
<Text code copyable={{ text: "localStorage.getItem('yd_token')" }}>
|
<Text code copyable={{ text: "localStorage.getItem('yd_token')" }}>
|
||||||
localStorage.getItem('yd_token')
|
localStorage.getItem('yd_token')
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
即可一键复制。
|
手动粘贴到上方。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
Reference in New Issue
Block a user