优化
This commit is contained in:
truewhile
2026-08-24 17:00:18 +08:00
parent afae22ffe2
commit 0aff87c69d
39 changed files with 846 additions and 219 deletions
+72 -41
View File
@@ -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")
}
}
+2 -2
View File
@@ -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 {
+106
View File
@@ -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
}
+1 -1
View File
@@ -71,4 +71,4 @@ func ClearDownloadURLCache(pickCode string) {
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
delete(urlCache, pickCode)
}
}
+1 -1
View File
@@ -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)
}
}
}