mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
优化
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
// 115 开放平台 HTTP 客户端(移植自 QMediaSync 的 v115open,去掉 resty 依赖,
|
||||
// 使用 net/http + 简单限流重试;只保留只读能力:授权/列目录/详情/下载直链)。
|
||||
// 115 开放平台 HTTP 客户端(集成全局三级令牌桶限流、熔断保护与防 405 重定向策略)。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,21 +20,35 @@ type OpenClient struct {
|
||||
HTTP *http.Client
|
||||
AccessToken string
|
||||
RefreshTokenStr string
|
||||
|
||||
// 全局 QPS 限流(115 开放平台免费额度较低)
|
||||
lastSecond int64
|
||||
reqInSecond int64
|
||||
executor *QueueExecutor
|
||||
}
|
||||
|
||||
var openClientMu sync.Mutex
|
||||
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
|
||||
func default115HTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
// 防止 Go 标准库在遇到 301/302/307 重定向时将 POST 降级为 GET 导致 115 报 405 Method Not Allowed
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("stopped after 5 redirects")
|
||||
}
|
||||
// 如果原请求是 POST,重定向时不允许静默转为 GET(直接终止自动重定向,由业务层处理响应)
|
||||
if len(via) > 0 && via[len(via)-1].Method == http.MethodPost {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewOpenClient 构造客户端。
|
||||
func NewOpenClient(appID, accessToken, refreshToken string) *OpenClient {
|
||||
return &OpenClient{
|
||||
AppID: appID,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
HTTP: default115HTTPClient(),
|
||||
AccessToken: accessToken,
|
||||
RefreshTokenStr: refreshToken,
|
||||
executor: GetGlobalExecutor(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,30 +58,6 @@ func (c *OpenClient) SetAuthToken(accessToken, refreshToken string) {
|
||||
c.RefreshTokenStr = refreshToken
|
||||
}
|
||||
|
||||
// throttle 简单的每秒限流(默认 4 QPS,115 免费应用限额约 5 QPS)。
|
||||
func (c *OpenClient) throttle(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
now := time.Now().Unix()
|
||||
last := atomic.LoadInt64(&c.lastSecond)
|
||||
if last != now {
|
||||
if atomic.CompareAndSwapInt64(&c.lastSecond, last, now) {
|
||||
atomic.StoreInt64(&c.reqInSecond, 0)
|
||||
}
|
||||
}
|
||||
count := atomic.LoadInt64(&c.reqInSecond)
|
||||
if count >= 4 {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
i--
|
||||
continue
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(&c.reqInSecond, count, count+1) {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
// RespState 兼容 115 不同端点返回的 state 类型(proapi 返回布尔、passport 返回数字)。
|
||||
type RespState bool
|
||||
|
||||
@@ -101,38 +89,65 @@ type RespBase struct {
|
||||
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
|
||||
}
|
||||
|
||||
// doJSON 执行 GET 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
|
||||
executor := c.executor
|
||||
if executor == nil {
|
||||
executor = GetGlobalExecutor()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
c.throttle(1)
|
||||
// 1. 获取全局三级令牌桶(QPS/QPM/QPH)令牌,若在熔断状态则阻塞等待冷却
|
||||
if err := executor.Acquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := c.buildRequest(ctx, method, rawURL, form, access)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
time.Sleep(time.Duration(attempt+1) * 1 * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查 HTTP 状态码:特别处理 405/406/429 等限流和异常阻断
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotAcceptable || resp.StatusCode == http.StatusTooManyRequests {
|
||||
// 触发全局熔断冷却,防止短时间连续重试导致 IP 被完全拉黑
|
||||
executor.MarkThrottled()
|
||||
lastErr = fmt.Errorf("115 接口触发频控/安全拦截(HTTP %d):%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if attempt < retries {
|
||||
// 阻塞等待 115 限流冷却解封后自动重试当前请求
|
||||
if waitErr := executor.throttleManager.WaitThrottleRecovery(ctx); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
lastErr = fmt.Errorf("115 接口返回 HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
time.Sleep(time.Duration(attempt+1) * 1 * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
var base RespBase
|
||||
if err := json.Unmarshal(body, &base); err != nil {
|
||||
return nil, fmt.Errorf("115 接口响应解析失败:%w", err)
|
||||
@@ -141,13 +156,29 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
if base.State {
|
||||
return &base, nil
|
||||
}
|
||||
// 业务失败:限流/Token 错误不重试,其余按配置重试
|
||||
if IsThrottleCode(base.Code) || isTokenCode(base.Code) {
|
||||
|
||||
// 业务返回限流错误码(406 / 770004):标记全局熔断,等待冷却后重试
|
||||
if IsThrottleCode(base.Code) {
|
||||
executor.MarkThrottled()
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 访问频率达到上限,已进入冷却")
|
||||
if attempt < retries {
|
||||
// 阻塞等待限流冷却解封后自动重试当前请求
|
||||
if waitErr := executor.throttleManager.WaitThrottleRecovery(ctx); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
return &base, lastErr
|
||||
}
|
||||
|
||||
// Token 失效不重试
|
||||
if isTokenCode(base.Code) {
|
||||
return &base, nil
|
||||
}
|
||||
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
time.Sleep(time.Duration(attempt+1) * 1 * time.Second)
|
||||
continue
|
||||
}
|
||||
return &base, lastErr
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockTransport 用 httptest server 替换 API 基址。
|
||||
@@ -321,3 +322,56 @@ func TestSourceCatalog(t *testing.T) {
|
||||
t.Fatal("relay/thrid-party sources broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP405AndThrottleRecovery(t *testing.T) {
|
||||
tm := NewThrottleManager(100 * time.Millisecond)
|
||||
qe := NewQueueExecutor(10, 200, 12000)
|
||||
qe.throttleManager = tm
|
||||
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte("Method Not Allowed"))
|
||||
})
|
||||
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
c.executor = qe
|
||||
|
||||
_, err := c.GetDownloadURL(context.Background(), "pickTest")
|
||||
if err == nil {
|
||||
t.Fatal("expected 405 error")
|
||||
}
|
||||
if !tm.IsThrottled() {
|
||||
t.Fatal("405 should trigger throttle status")
|
||||
}
|
||||
|
||||
// 验证熔断冷却后能正常恢复
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
if err := tm.WaitThrottleRecovery(ctx); err != nil {
|
||||
t.Fatalf("wait throttle recovery failed: %v", err)
|
||||
}
|
||||
if tm.IsThrottled() {
|
||||
t.Fatal("throttle status should be cleared after duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleCodeHandling(t *testing.T) {
|
||||
tm := NewThrottleManager(100 * time.Millisecond)
|
||||
qe := NewQueueExecutor(10, 200, 12000)
|
||||
qe.throttleManager = tm
|
||||
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"state":false,"code":770004,"message":"访问频率过高"}`))
|
||||
})
|
||||
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
c.executor = qe
|
||||
|
||||
_, _, err := c.GetFsList(context.Background(), "0", 0, 100)
|
||||
if err == nil {
|
||||
t.Fatal("expected throttle error")
|
||||
}
|
||||
if !tm.IsThrottled() {
|
||||
t.Fatal("code 770004 should trigger throttle status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ const (
|
||||
RefreshTokenCheckFailed = 40140120
|
||||
)
|
||||
|
||||
// DefaultUA 请求 UA。
|
||||
const DefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-115-OpenAPI/1.0"
|
||||
// DefaultUA 请求 UA(标准 API 客户端格式,避免伪装浏览器触发 115 WAF 拦截与 405 错误)。
|
||||
const DefaultUA = "MMTL-115-GoClient/1.0"
|
||||
|
||||
// OpenAPIError 保留 115 开放平台返回的原始错误信息。
|
||||
type OpenAPIError struct {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// QueueExecutor 全局 115 请求调度执行器,通过三级令牌桶(QPS / QPM / QPH)平滑所有外发请求。
|
||||
type QueueExecutor struct {
|
||||
sync.RWMutex
|
||||
qpsLimiter *rate.Limiter
|
||||
qpmLimiter *rate.Limiter
|
||||
qphLimiter *rate.Limiter
|
||||
throttleManager *ThrottleManager
|
||||
|
||||
qpsConfig int
|
||||
qpmConfig int
|
||||
qphConfig int
|
||||
}
|
||||
|
||||
var (
|
||||
globalExecutor *QueueExecutor
|
||||
executorOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalExecutor 获取全局队列执行器单例(默认 QPS=2, QPM=120, QPH=6000,保障 115 API 调用安全不超频)。
|
||||
func GetGlobalExecutor() *QueueExecutor {
|
||||
executorOnce.Do(func() {
|
||||
globalExecutor = NewQueueExecutor(2, 120, 6000)
|
||||
})
|
||||
return globalExecutor
|
||||
}
|
||||
|
||||
// NewQueueExecutor 创建新的队列调度执行器。
|
||||
func NewQueueExecutor(qps, qpm, qph int) *QueueExecutor {
|
||||
if qps <= 0 {
|
||||
qps = 3
|
||||
}
|
||||
if qpm <= 0 {
|
||||
qpm = 200
|
||||
}
|
||||
if qph <= 0 {
|
||||
qph = 12000
|
||||
}
|
||||
|
||||
return &QueueExecutor{
|
||||
qpsLimiter: rate.NewLimiter(rate.Limit(qps), qps),
|
||||
qpmLimiter: rate.NewLimiter(rate.Every(time.Minute/time.Duration(qpm)), qpm),
|
||||
qphLimiter: rate.NewLimiter(rate.Every(time.Hour/time.Duration(qph)), qph),
|
||||
throttleManager: GetGlobalThrottleManager(),
|
||||
qpsConfig: qps,
|
||||
qpmConfig: qpm,
|
||||
qphConfig: qph,
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire 统一在发送请求前获取令牌,并等待熔断恢复。
|
||||
func (qe *QueueExecutor) Acquire(ctx context.Context) error {
|
||||
// 1. 如果处于限流状态,先阻塞等待 60s 静默冷却结束
|
||||
if err := qe.throttleManager.WaitThrottleRecovery(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 依次获取小时级、分钟级、秒级令牌
|
||||
if err := qe.qphLimiter.Wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := qe.qpmLimiter.Wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := qe.qpsLimiter.Wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRateLimitConfig 动态调整速率限制。
|
||||
func (qe *QueueExecutor) SetRateLimitConfig(qps, qpm, qph int) {
|
||||
qe.Lock()
|
||||
defer qe.Unlock()
|
||||
|
||||
if qps > 0 {
|
||||
qe.qpsConfig = qps
|
||||
qe.qpsLimiter.SetLimit(rate.Limit(qps))
|
||||
qe.qpsLimiter.SetBurst(qps)
|
||||
}
|
||||
if qpm > 0 {
|
||||
qe.qpmConfig = qpm
|
||||
qe.qpmLimiter.SetLimit(rate.Every(time.Minute / time.Duration(qpm)))
|
||||
qe.qpmLimiter.SetBurst(qpm)
|
||||
}
|
||||
if qph > 0 {
|
||||
qe.qphConfig = qph
|
||||
qe.qphLimiter.SetLimit(rate.Every(time.Hour / time.Duration(qph)))
|
||||
qe.qphLimiter.SetBurst(qph)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkThrottled 触发限流熔断。
|
||||
func (qe *QueueExecutor) MarkThrottled() {
|
||||
qe.throttleManager.MarkThrottled()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ThrottleManager 全局限流/熔断管理器,用于控制 115 API 访问频率与被风控时的全局静默冷却。
|
||||
type ThrottleManager struct {
|
||||
sync.RWMutex
|
||||
isThrottled bool
|
||||
throttleStartTime time.Time
|
||||
throttleNotify chan struct{}
|
||||
throttleDuration time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
globalThrottleManager *ThrottleManager
|
||||
throttleOnce sync.Once
|
||||
)
|
||||
|
||||
// GetGlobalThrottleManager 获取全局限流管理器单例。
|
||||
func GetGlobalThrottleManager() *ThrottleManager {
|
||||
throttleOnce.Do(func() {
|
||||
globalThrottleManager = NewThrottleManager(60 * time.Second)
|
||||
})
|
||||
return globalThrottleManager
|
||||
}
|
||||
|
||||
// NewThrottleManager 创建限流管理器。
|
||||
func NewThrottleManager(duration time.Duration) *ThrottleManager {
|
||||
if duration <= 0 {
|
||||
duration = 60 * time.Second
|
||||
}
|
||||
return &ThrottleManager{
|
||||
isThrottled: false,
|
||||
throttleNotify: make(chan struct{}),
|
||||
throttleDuration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// IsThrottled 检查是否处于限流冷却状态。
|
||||
func (tm *ThrottleManager) IsThrottled() bool {
|
||||
tm.RLock()
|
||||
defer tm.RUnlock()
|
||||
|
||||
if !tm.isThrottled {
|
||||
return false
|
||||
}
|
||||
if time.Since(tm.throttleStartTime) >= tm.throttleDuration {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MarkThrottled 标记为限流状态,并启动恢复计时器。
|
||||
func (tm *ThrottleManager) MarkThrottled() {
|
||||
tm.Lock()
|
||||
defer tm.Unlock()
|
||||
|
||||
if tm.isThrottled && time.Since(tm.throttleStartTime) < tm.throttleDuration {
|
||||
return
|
||||
}
|
||||
|
||||
tm.isThrottled = true
|
||||
tm.throttleStartTime = time.Now()
|
||||
|
||||
go tm.startRecoveryTimer()
|
||||
}
|
||||
|
||||
func (tm *ThrottleManager) startRecoveryTimer() {
|
||||
time.Sleep(tm.throttleDuration)
|
||||
|
||||
tm.Lock()
|
||||
defer tm.Unlock()
|
||||
|
||||
tm.isThrottled = false
|
||||
|
||||
select {
|
||||
case tm.throttleNotify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// WaitThrottleRecovery 如果处于限流状态,挂起等待直到冷却恢复。
|
||||
func (tm *ThrottleManager) WaitThrottleRecovery(ctx context.Context) error {
|
||||
for {
|
||||
if !tm.IsThrottled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
ticker.Stop()
|
||||
if !tm.IsThrottled() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 重置限流状态(主要用于单元测试)。
|
||||
func (tm *ThrottleManager) Reset() {
|
||||
tm.Lock()
|
||||
defer tm.Unlock()
|
||||
tm.isThrottled = false
|
||||
}
|
||||
|
||||
// SetDuration 调整限流冷却时间(用于测试或动态配置)。
|
||||
func (tm *ThrottleManager) SetDuration(d time.Duration) {
|
||||
tm.Lock()
|
||||
defer tm.Unlock()
|
||||
tm.throttleDuration = d
|
||||
}
|
||||
@@ -71,4 +71,4 @@ func ClearDownloadURLCache(pickCode string) {
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
delete(urlCache, pickCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,4 @@ func TestDownloadURLCache(t *testing.T) {
|
||||
if got := GetDownloadURLCache("pick1"); got != "" {
|
||||
t.Fatalf("cleared entry should be a miss, got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user