From ad6d28fd85806ceddf9866075a8ed602d7ed0f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Wed, 30 Sep 2026 00:25:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(media):=20=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E4=BB=A3=E7=90=86=E6=94=B9=E4=B8=BA=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - downloadMedia 拿到响应头即返回 Body,音频不再全量驻留内存,改由 io.Copy 流式写出 - 重试仅覆盖响应阶段;Body 读取中途失败由客户端自行重试 - 请求头设置提取为 setMediaHeaders 复用;data_uri 模式保持全量读取(本就需驻留内存) - 漫画代理因需解密后输出,维持原有全量方式 --- internal/fqapi/audio_proxy.go | 15 +++---- internal/fqapi/media_utils.go | 75 +++++++++++++++++++---------------- internal/handlers/media.go | 11 ++--- 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/internal/fqapi/audio_proxy.go b/internal/fqapi/audio_proxy.go index 018ef1c..9cc6677 100644 --- a/internal/fqapi/audio_proxy.go +++ b/internal/fqapi/audio_proxy.go @@ -4,15 +4,16 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "time" ) // AudioCacheItem 音频缓存项 type AudioCacheItem struct { OriginalURL string `json:"original_url"` // 原始CDN URL - DeviceID string `json:"device_id"` // 关联的设备ID + DeviceID string `json:"device_id"` // 关联的设备ID Headers map[string]string `json:"headers"` // 请求头信息 - CachedAt time.Time `json:"cached_at"` // 缓存时间 + CachedAt time.Time `json:"cached_at"` // 缓存时间 } // GenerateAudioProxyURL 生成音频代理URL @@ -49,8 +50,8 @@ func GenerateAudioProxyURL(originalURL, deviceID string, headers map[string]stri return fmt.Sprintf("/audio/proxy/%s", key) } -// HandleAudioProxy 处理音频代理请求 -func HandleAudioProxy(key string) ([]byte, string, error) { +// HandleAudioProxy 处理音频代理请求(流式返回,调用方负责 Close) +func HandleAudioProxy(key string) (io.ReadCloser, string, error) { var originalURL, deviceID string var headers map[string]string @@ -95,13 +96,13 @@ func HandleAudioProxy(key string) ([]byte, string, error) { headers = make(map[string]string) } - // 下载音频内容,使用关联的设备信息 - data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3) + // 下载音频内容(流式),使用关联的设备信息 + body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 3) if err != nil { return nil, "", fmt.Errorf("下载音频失败: %w", err) } - return data, contentType, nil + return body, contentType, nil } // CleanExpiredAudioCache 清理过期的音频缓存 diff --git a/internal/fqapi/media_utils.go b/internal/fqapi/media_utils.go index 415ed72..562ce02 100644 --- a/internal/fqapi/media_utils.go +++ b/internal/fqapi/media_utils.go @@ -32,15 +32,15 @@ func extractDeviceParam(deviceHeader, paramName string) string { return "" } -// downloadMediaWithRetry 带重试的媒体下载,自动切换设备 -func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) ([]byte, string, error) { +// downloadMediaWithRetry 带重试的流式媒体下载,自动切换设备(仅响应阶段可重试) +func downloadMediaWithRetry(url, deviceID string, headers map[string]string, maxRetries int) (io.ReadCloser, string, error) { var lastErr error currentDeviceID := deviceID for i := 0; i <= maxRetries; i++ { - data, contentType, err := downloadMedia(url, currentDeviceID, headers) - if err == nil && len(data) > 0 { - return data, contentType, nil + body, contentType, err := downloadMedia(url, currentDeviceID, headers) + if err == nil { + return body, contentType, nil } lastErr = err @@ -58,9 +58,10 @@ func downloadMediaWithRetry(url, deviceID string, headers map[string]string, max return nil, "", fmt.Errorf("下载失败(已重试%d次): %w", maxRetries, lastErr) } -// downloadMedia 下载媒体内容 -func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, string, error) { - // 创建专用的媒体下载客户端,超时时间更长 +// downloadMedia 流式下载媒体内容:拿到响应头即返回,Body 由调用方消费 +// 大文件不再整体驻留内存;Body 读取中途的错误无法重试,由客户端自行重试 +func downloadMedia(url, deviceID string, headers map[string]string) (io.ReadCloser, string, error) { + // 创建专用的媒体下载客户端,超时时间更长(Timeout 覆盖整个响应周期含 Body 传输) client := &http.Client{ Timeout: 300 * time.Second, // 5分钟超时,适合大文件 } @@ -70,6 +71,30 @@ func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, str return nil, "", err } + setMediaHeaders(req, deviceID, headers) + + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("下载失败: %v", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() + return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode) + } + + // 获取Content-Type + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + // 根据文件后缀推断 + contentType = getContentTypeFromURL(url) + } + + return resp.Body, contentType, nil +} + +// setMediaHeaders 设置媒体下载请求头(CDN 伪装 + 设备信息 + 自定义头) +func setMediaHeaders(req *http.Request, deviceID string, headers map[string]string) { // 设置CDN请求头,模拟安卓客户端请求 req.Header.Set("User-Agent", "com.dragon.read/7.9.9.32 (Linux; U; Android 13; zh_CN; M2102K1AC; Build/TKQ1.220807.001; Cronet/113.0.5672.62)") req.Header.Set("Accept", "*/*") @@ -89,31 +114,6 @@ func downloadMedia(url, deviceID string, headers map[string]string) ([]byte, str for k, v := range headers { req.Header.Set(k, v) } - - resp, err := client.Do(req) - if err != nil { - return nil, "", fmt.Errorf("下载失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, "", fmt.Errorf("HTTP状态码: %d", resp.StatusCode) - } - - // 读取内容 - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, "", fmt.Errorf("读取内容失败: %w", err) - } - - // 获取Content-Type - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - // 根据文件后缀推断 - contentType = getContentTypeFromURL(url) - } - - return data, contentType, nil } // getContentTypeFromURL 根据URL推断Content-Type @@ -179,11 +179,16 @@ func processMediaURL(originalURL, deviceID string, headers map[string]string, ou // 其他类型返回原始URL return originalURL case "data_uri": - // 直接下载并返回base64 - data, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2) + // 直接下载并返回base64(data_uri 模式本就要求全量驻留内存) + body, contentType, err := downloadMediaWithRetry(originalURL, deviceID, headers, 2) if err != nil { return originalURL } + data, readErr := io.ReadAll(body) + body.Close() + if readErr != nil { + return originalURL + } return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data)) default: // 直接返回原始URL diff --git a/internal/handlers/media.go b/internal/handlers/media.go index c2568c1..6a68990 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fanqie/internal/fqapi" "fmt" + "io" "net/http" "strings" ) @@ -31,9 +32,9 @@ func HandleAudio(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, result) } -// HandleAudioProxy 音频代理处理 +// HandleAudioProxy 音频代理处理(流式转发,音频不全量驻留内存) func HandleAudioProxy(w http.ResponseWriter, r *http.Request) { - incrementStat("total") + incrementStat("total", "audio") // 从URL路径提取key: /audio/proxy/{key} path := r.URL.Path @@ -44,11 +45,12 @@ func HandleAudioProxy(w http.ResponseWriter, r *http.Request) { } // 调用代理处理函数 - data, contentType, err := fqapi.HandleAudioProxy(key) + body, contentType, err := fqapi.HandleAudioProxy(key) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + defer body.Close() // 设置正确的Content-Type if contentType != "" { @@ -59,7 +61,7 @@ func HandleAudioProxy(w http.ResponseWriter, r *http.Request) { // 直接播放,不强制下载 // 浏览器会根据Content-Type决定是播放还是下载 - w.Write(data) + io.Copy(w, body) } // HandleVideo 视频处理 @@ -133,4 +135,3 @@ func HandleManga(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, string(jsonData)) } } -