mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件处理「音频直链的签名票过期」这一类上游故障。
|
||||
//
|
||||
// 背景:听书源(聆阅·懒人听书这类)的播放地址是第三方中转现签的 CDN 直链,
|
||||
// 形如 https://vb.stream.tencentmusic.com/long/xxx.m4a?...&vkey=...&ttl=86400000。
|
||||
// 票的有效性不只取决于时间:中转背后是多个账号 token 轮换,某个 uid/vkey 组合
|
||||
// 会被 CDN 判掉,此时上游返回**空的 401**(无 Content-Type、无 body),而同一章
|
||||
// 隔几百毫秒重新签一张票就又能取到。
|
||||
//
|
||||
// 旧行为:媒体代理把这个 401 原样透传给播放器,播放器重试的又是同一个 URL,
|
||||
// 于是 App 日志里出现连续 4~8 个 401;同时音频清单还有 30 分钟缓存,这 30 分钟
|
||||
// 内 /content 一直下发同一张死票,表现为「这本书怎么都播不了,换本书才行」。
|
||||
//
|
||||
// 现在的行为:代理遇到 401/403/410 时先把该章的缓存作废、重新走一次书源取正文,
|
||||
// 拿到新票再拉一次;新票同时写回缓存,后续请求自然就是有效的。
|
||||
|
||||
// mediaRefreshTTL 新票的短缓存:播放器一次失败会立刻用同一 URL 重试多次,
|
||||
// 这里让并发的重试共用一次回源,避免把书源打成限流。
|
||||
const mediaRefreshTTL = 2 * time.Minute
|
||||
|
||||
// mediaRefreshEntry 一次成功换票的结果。
|
||||
type mediaRefreshEntry struct {
|
||||
url string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// mediaRefreshState 保护「旧直链 → 新直链」的短缓存。
|
||||
type mediaRefreshState struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]mediaRefreshEntry
|
||||
}
|
||||
|
||||
// lookupMediaRefresh 查换票短缓存(过期条目顺手清掉)。
|
||||
func (s *ReaderService) lookupMediaRefresh(staleURL string) (string, bool) {
|
||||
s.mediaRefresh.mu.Lock()
|
||||
defer s.mediaRefresh.mu.Unlock()
|
||||
entry, ok := s.mediaRefresh.entries[staleURL]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(entry.exp) {
|
||||
delete(s.mediaRefresh.entries, staleURL)
|
||||
return "", false
|
||||
}
|
||||
return entry.url, true
|
||||
}
|
||||
|
||||
// rememberMediaRefresh 记住换票结果,并顺手清理过期条目(表很小,遍历即可)。
|
||||
func (s *ReaderService) rememberMediaRefresh(staleURL, freshURL string) {
|
||||
s.mediaRefresh.mu.Lock()
|
||||
defer s.mediaRefresh.mu.Unlock()
|
||||
if s.mediaRefresh.entries == nil {
|
||||
s.mediaRefresh.entries = map[string]mediaRefreshEntry{}
|
||||
}
|
||||
now := time.Now()
|
||||
for k, v := range s.mediaRefresh.entries {
|
||||
if now.After(v.exp) {
|
||||
delete(s.mediaRefresh.entries, k)
|
||||
}
|
||||
}
|
||||
s.mediaRefresh.entries[staleURL] = mediaRefreshEntry{url: freshURL, exp: now.Add(mediaRefreshTTL)}
|
||||
}
|
||||
|
||||
// RefreshMediaURL 在直链签名失效后重新换一张票。
|
||||
//
|
||||
// staleURL 是书源侧产出的原始媒体地址(媒体代理已还原、未加签名)。返回三个值:
|
||||
//
|
||||
// refreshable=false 这个地址不是「可换票的音轨」(漫画图片、封面、或并非来自
|
||||
// 章节缓存的地址),调用方应原样透传上游状态,保持旧行为;
|
||||
// refreshable=true 且 err!=nil 确实是音轨但换票失败,调用方应给出明确错误,
|
||||
// 别让播放器对着必然失败的地址反复重试;
|
||||
// refreshable=true 且 err==nil 拿到了新票。
|
||||
//
|
||||
// 换票流程:在书架书籍的章节缓存里找到它所属的那一章 → 作废该章缓存(否则
|
||||
// /content 会继续下发同一张死票)→ 重新走书源取正文 → 按同一个音轨序号取新地址。
|
||||
func (s *ReaderService) RefreshMediaURL(ctx context.Context, book *model.ReaderBook, staleURL string) (string, bool, error) {
|
||||
if s == nil || s.repo == nil || book == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
staleURL = strings.TrimSpace(staleURL)
|
||||
if staleURL == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if fresh, ok := s.lookupMediaRefresh(staleURL); ok {
|
||||
return fresh, true, nil
|
||||
}
|
||||
sourceID := s.contentSourceID(ctx, nil, book)
|
||||
if sourceID == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
bookKey := contentBookKey(sourceID, book.BookURL)
|
||||
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
payload, ok := s.readContentPayload(&row)
|
||||
if !ok || payload.ContentType != "audio" {
|
||||
continue
|
||||
}
|
||||
trackIndex := indexOfString(payload.Tracks, staleURL)
|
||||
if trackIndex < 0 {
|
||||
continue
|
||||
}
|
||||
ch, ok := s.chapterForCacheRow(ctx, book, row)
|
||||
if !ok {
|
||||
return "", true, fmt.Errorf("章节已不在目录中")
|
||||
}
|
||||
// 先作废:即使后面重抓失败,也不会继续把死票发给播放器。
|
||||
s.dropContentCacheRow(ctx, &row)
|
||||
out, fetchErr := s.fetchChapterContentShared(ctx, book, ch)
|
||||
if fetchErr != nil {
|
||||
return "", true, fmt.Errorf("重新取章节失败: %w", fetchErr)
|
||||
}
|
||||
if out == nil || trackIndex >= len(out.Tracks) {
|
||||
return "", true, fmt.Errorf("新内容没有对应的音轨")
|
||||
}
|
||||
fresh := strings.TrimSpace(out.Tracks[trackIndex])
|
||||
if strings.HasPrefix(fresh, "/api/") {
|
||||
// fetchChapterContentShared 返回缓存原样地址;兜底解析一次代理形态。
|
||||
return "", true, fmt.Errorf("书源返回了代理地址")
|
||||
}
|
||||
if fresh == "" || fresh == staleURL {
|
||||
return "", true, fmt.Errorf("书源返回了相同地址")
|
||||
}
|
||||
s.rememberMediaRefresh(staleURL, fresh)
|
||||
return fresh, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// readContentPayload 读取一条章节缓存的落盘载荷。
|
||||
func (s *ReaderService) readContentPayload(row *model.ReaderContentCache) (cachedChapterContent, bool) {
|
||||
var payload cachedChapterContent
|
||||
path := s.contentPathFromRow(row)
|
||||
if path == "" {
|
||||
return payload, false
|
||||
}
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil {
|
||||
return payload, false
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return payload, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// chapterForCacheRow 按缓存行的章节键找回目录里的章节(remap 后键仍一致)。
|
||||
func (s *ReaderService) chapterForCacheRow(ctx context.Context, book *model.ReaderBook, row model.ReaderContentCache) (model.ReaderChapter, bool) {
|
||||
chapters, err := s.repo.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
return model.ReaderChapter{}, false
|
||||
}
|
||||
for _, ch := range chapters {
|
||||
if contentChapterKey(book, ch) == row.ChapterKey {
|
||||
return ch, true
|
||||
}
|
||||
}
|
||||
// 退一步按序号取:目录里同一章可能因为标题变化换了身份键。
|
||||
if row.ChapterIndex >= 0 && row.ChapterIndex < len(chapters) && !chapters[row.ChapterIndex].IsVolume {
|
||||
return chapters[row.ChapterIndex], true
|
||||
}
|
||||
return model.ReaderChapter{}, false
|
||||
}
|
||||
|
||||
// indexOfString 返回目标字符串在列表中的下标,找不到返回 -1。
|
||||
func indexOfString(list []string, target string) int {
|
||||
target = strings.TrimSpace(target)
|
||||
for i, item := range list {
|
||||
if strings.TrimSpace(item) == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件:「音频直链签名票被 CDN 拒掉」后的换票回归测试。
|
||||
//
|
||||
// 背景:听书源(聆阅·懒人听书)的播放地址是第三方中转现签的 CDN 直链,票会被
|
||||
// 上游判掉,此时 CDN 回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
|
||||
// 原样透传给播放器,播放器重试的又是同一个 URL,于是连续 4~8 个 401,
|
||||
// App 日志表现为 ERROR_CODE_IO_BAD_HTTP_STATUS (2004)「怎么都播不了」。
|
||||
//
|
||||
// 现在的约定(RefreshMediaURL):
|
||||
// - 能定位到归属章节 → 换票(先作废缓存,再重新走书源取正文);
|
||||
// - 地址不是音轨(漫画图片、封面等)→ refreshable=false,调用方原样透传;
|
||||
// - 是音轨但换不到 → refreshable=true + err,调用方给出明确错误不再让播放器重试。
|
||||
|
||||
// ticketServer 模拟「中转现签 CDN 直链」的上游:
|
||||
// - /book/1 详情页(含目录链接)
|
||||
// - /toc 目录
|
||||
// - /content 章节正文,每次请求签一张新票
|
||||
// - /media 媒体本体,只认最新签出的那张票,其余回空的 401
|
||||
type ticketServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
issued []string
|
||||
hits int
|
||||
noToken bool // true 时正文里不带票(模拟书源不再给新地址)
|
||||
}
|
||||
|
||||
func newTicketServer() *ticketServer {
|
||||
s := &ticketServer{}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1"):
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
|
||||
case r.URL.Path == "/content":
|
||||
s.mu.Lock()
|
||||
s.hits++
|
||||
n := s.hits
|
||||
noToken := s.noToken
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if noToken {
|
||||
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
|
||||
return
|
||||
}
|
||||
token := fmt.Sprintf("ticket-%d", n)
|
||||
s.mu.Lock()
|
||||
s.issued = append(s.issued, token)
|
||||
s.mu.Unlock()
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
|
||||
case r.URL.Path == "/media":
|
||||
// 只认最近签出的那张票;旧票回空的 401(与腾讯 CDN 行为一致)。
|
||||
token := r.URL.Query().Get("t")
|
||||
s.mu.Lock()
|
||||
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
|
||||
s.mu.Unlock()
|
||||
if !valid {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ticketServer) contentHits() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hits
|
||||
}
|
||||
|
||||
// ticketSourceJSON 一个最小听书源:正文规则取 JSON 里的播放直链。
|
||||
func ticketSourceJSON(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
return `{
|
||||
"bookSourceName": "票源测试",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + base + `",
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`
|
||||
}
|
||||
|
||||
// prepareTicketBook 建好「听书源 + 书架书籍 + 一章目录」,并开独立缓存目录。
|
||||
func prepareTicketBook(t *testing.T) (*ReaderService, *ticketServer, *model.ReaderBook) {
|
||||
t.Helper()
|
||||
srv := newTicketServer()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, ticketSourceJSON(t, srv.URL))
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "票源测试",
|
||||
OriginType: 1,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "票源之书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
return svc, srv, book
|
||||
}
|
||||
|
||||
// TestRefreshMediaURLIssuesNewTicket 票被拒后换票:拿到新地址、缓存被刷新。
|
||||
func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 首读:拿到第一张票并落缓存
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d", len(out.Tracks))
|
||||
}
|
||||
stale, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("还原直链失败: %v", err)
|
||||
}
|
||||
|
||||
fresh, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
|
||||
if err != nil {
|
||||
t.Fatalf("换票失败: %v", err)
|
||||
}
|
||||
if !refreshable {
|
||||
t.Fatal("音轨地址应当可换票")
|
||||
}
|
||||
if fresh == stale {
|
||||
t.Fatalf("换票后地址没变: %q", fresh)
|
||||
}
|
||||
if got := srv.contentHits(); got != 2 {
|
||||
t.Fatalf("换票应回源重取一次章节,实际请求 %d 次", got)
|
||||
}
|
||||
|
||||
// 新票必须能取到媒体本体(旧票是空的 401)
|
||||
resp, err := http.Get(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("新票请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("新票状态码 = %d,期望 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 缓存要被刷新成新票:再次读章节不应再回源
|
||||
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("换票后读章节失败: %v", err)
|
||||
}
|
||||
refreshed, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out2.Tracks[0], "u"), trackQueryValue(t, out2.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("还原刷新后的直链失败: %v", err)
|
||||
}
|
||||
if refreshed != fresh {
|
||||
t.Fatalf("章节缓存应刷新为新票 %q,实际 %q", fresh, refreshed)
|
||||
}
|
||||
if got := srv.contentHits(); got != 2 {
|
||||
t.Fatalf("缓存已刷新,不应再回源,实际请求 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshMediaURLMemoryReusesTicket 同一张旧票的多次换票只回源一次:
|
||||
// 播放器一次失败会立刻用同一 URL 重试多次,重复回源会把书源打成限流。
|
||||
func TestRefreshMediaURLMemoryReusesTicket(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
|
||||
first, _, err := svc.RefreshMediaURL(ctx, book, stale)
|
||||
if err != nil {
|
||||
t.Fatalf("首次换票失败: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
again, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
|
||||
if err != nil || !refreshable {
|
||||
t.Fatalf("第 %d 次换票失败: refreshable=%v err=%v", i+2, refreshable, err)
|
||||
}
|
||||
if again != first {
|
||||
t.Fatalf("同一旧票应复用同一个新票:%q vs %q", again, first)
|
||||
}
|
||||
}
|
||||
if got := srv.contentHits(); got != 2 {
|
||||
t.Fatalf("重复换票只该回源一次,实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshMediaURLSkipsNonTrackURL 漫画图片/防盗链图片这类地址不属于音轨,
|
||||
// 调用方应原样透传上游状态(不能被换票逻辑吃掉响应体)。
|
||||
func TestRefreshMediaURLSkipsNonTrackURL(t *testing.T) {
|
||||
svc, _, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0); err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
_, refreshable, err := svc.RefreshMediaURL(ctx, book, "https://img.example.com/cover.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("非音轨地址不该报错: %v", err)
|
||||
}
|
||||
if refreshable {
|
||||
t.Fatal("非音轨地址不该被判为可换票")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshMediaURLErrorsWhenSourceRepeatsTicket 书源重新签出同一张票时,
|
||||
// 说明中转本身没换票,应当明确失败而不是让播放器继续对着死票重试。
|
||||
func TestRefreshMediaURLErrorsWhenSourceRepeatsTicket(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
|
||||
// 让书源此后只回空地址:换票必然失败。
|
||||
srv.mu.Lock()
|
||||
srv.noToken = true
|
||||
srv.mu.Unlock()
|
||||
|
||||
_, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
|
||||
if !refreshable {
|
||||
t.Fatal("音轨地址应当被判为可换票")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("书源给不出新票时应当报错")
|
||||
}
|
||||
// 作废过的缓存不该把死票留在库里
|
||||
if _, ok := svc.loadCachedContent(ctx, nil, nil, book, model.ReaderChapter{
|
||||
Index: 0, Title: "第一章", URL: srv.URL + "/content",
|
||||
}, "audio"); ok {
|
||||
t.Fatal("换票失败后旧缓存应已被作废,不应继续下发死票")
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -132,3 +134,71 @@ func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
|
||||
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaHTTPClientHasNoOverallTimeout 媒体客户端不能用「整体超时」。
|
||||
//
|
||||
// 这是本次故障的第二个根因:http.Client.Timeout 覆盖「读完 body」这一阶段。
|
||||
// 一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,超时后 net/http 直接掐断
|
||||
// 响应体,而调用方的 io.Copy 只是正常返回——表现为**静默截断**(播放器拿到
|
||||
// 半首歌,判「格式不支持」)或 502。所以媒体客户端必须没有整体超时。
|
||||
func TestMediaHTTPClientHasNoOverallTimeout(t *testing.T) {
|
||||
client := helper.NewMediaHTTPClient(false)
|
||||
if client.Timeout != 0 {
|
||||
t.Fatalf("媒体 client 的整体 Timeout = %v,必须为 0(大文件下载会被静默截断)", client.Timeout)
|
||||
}
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("Transport 类型 = %T", client.Transport)
|
||||
}
|
||||
if tr.ResponseHeaderTimeout <= 0 {
|
||||
t.Fatal("媒体 client 应设置 ResponseHeaderTimeout,避免上游不给首字节时无限挂起")
|
||||
}
|
||||
// 必须走 HTTP/1.1:腾讯 CDN 的 h2 边缘在长连接大文件上不回 WINDOW_UPDATE,
|
||||
// Go 的 h2 初始连接窗口是 1MiB,下载会稳定断在 1MiB/2MiB/3MiB 整数倍处。
|
||||
if tr.ForceAttemptHTTP2 {
|
||||
t.Error("媒体 client 必须关闭 HTTP/2(1MiB 窗口截断)")
|
||||
}
|
||||
if tr.TLSNextProto == nil || len(tr.TLSNextProto) != 0 {
|
||||
t.Error("媒体 client 需要清空 TLSNextProto,才能确保不用 h2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaStreamsLargeBodyWithoutTruncation 大响应体必须完整送达。
|
||||
//
|
||||
// 用「慢速分块发送」模拟跨境链路上的大音频:总耗时超过规则请求的 30 秒超时,
|
||||
// 但媒体通路必须把每一个字节都交出来,绝不静默截断。
|
||||
func TestFetchMediaStreamsLargeBodyWithoutTruncation(t *testing.T) {
|
||||
const totalBytes = 24 << 20 // 24MB
|
||||
const chunk = 512 << 10
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(totalBytes))
|
||||
payload := make([]byte, chunk)
|
||||
for off := 0; off < totalBytes; off += chunk {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := svc.FetchMedia(t.Context(), book, srv.URL+"/big.m4a", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
got, readErr := io.Copy(io.Discard, resp.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("读取响应体失败: %v", readErr)
|
||||
}
|
||||
if got != totalBytes {
|
||||
t.Fatalf("交付字节 = %d,期望 %d(少了 %d 字节 → 播放器会判格式不支持)",
|
||||
got, totalBytes, int64(totalBytes)-got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ const (
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
//
|
||||
// 注意 401 不在这里:书源的规则请求返回 401 通常是「登录态失效」,反复重试
|
||||
// 只会白等退避时间。媒体直链另有语义,见 mediaRetryableStatus。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
@@ -95,6 +98,32 @@ func retryableStatus(code int) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// mediaRetryableStatus 媒体拉取专用的可重试判定。
|
||||
//
|
||||
// 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于
|
||||
// 上游抖动,同一张票稍后重试可能就恢复;而 401 是「签名票被拒」,重试同一张票
|
||||
// 一定还是 401,必须由调用方回源换票(见 MediaNeedsRefresh)。
|
||||
// 所以这里只补 410,不把 401 加进来。
|
||||
func mediaRetryableStatus(code int) bool {
|
||||
if retryableStatus(code) {
|
||||
return true
|
||||
}
|
||||
return code == 410
|
||||
}
|
||||
|
||||
// MediaNeedsRefresh 判断媒体上游状态是否值得「换一张签名票再试」。
|
||||
//
|
||||
// 401 是 CDN 对无效 vkey/uid 的固定回答(空 body、无 Content-Type),410 是
|
||||
// 对象已下线,403 常见于票与资源不匹配。这三类都值得回到书源重新签票。
|
||||
func MediaNeedsRefresh(code int) bool {
|
||||
switch code {
|
||||
case 401, 403, 410:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
|
||||
@@ -71,3 +71,36 @@ func TestRetryableStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaRetryableStatus 媒体直链的 410(对象已下线)与 403/429 一样值得原地重试,
|
||||
// 但 401「签名票被拒」原地重试一定是同样的结果,只能靠回源换票。
|
||||
func TestMediaRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 410, 429, 500, 503} {
|
||||
if !mediaRetryableStatus(code) {
|
||||
t.Errorf("媒体状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 206, 301, 400, 401, 404} {
|
||||
if mediaRetryableStatus(code) {
|
||||
t.Errorf("媒体状态码 %d 不该原地重试", code)
|
||||
}
|
||||
}
|
||||
// 规则请求的判定不能被放宽:登录态失效的 401 反复重试只会白等。
|
||||
if retryableStatus(401) || retryableStatus(410) {
|
||||
t.Error("retryableStatus 不应包含 401/410")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaNeedsRefresh 只有「票被拒」这一类状态才值得回源换票。
|
||||
func TestMediaNeedsRefresh(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 410} {
|
||||
if !MediaNeedsRefresh(code) {
|
||||
t.Errorf("状态码 %d 应当换票重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 206, 304, 400, 404, 429, 500} {
|
||||
if MediaNeedsRefresh(code) {
|
||||
t.Errorf("状态码 %d 不该换票", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@ type ReaderService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.ReaderRepository
|
||||
http *http.Client
|
||||
crypto *helper.SecretCipher
|
||||
// mediaHTTP 用于转发媒体流(音频/视频/漫画大图)。
|
||||
//
|
||||
// 必须与 http 分开:http 设了 30 秒的「整体」超时(含读完 body),而一个
|
||||
// 4MB 的音频在跨境链路上很容易超过它,超时后 net/http 直接掐断 body,
|
||||
// 调用方的 io.Copy 却只是正常返回 —— 表现为静默截断(播放器拿到半首歌)
|
||||
// 或 502。见 helper.NewMediaHTTPClient 的说明。
|
||||
mediaHTTP *http.Client
|
||||
crypto *helper.SecretCipher
|
||||
|
||||
// browserMu / browserPending 保护「待用户完成的页面」表
|
||||
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
|
||||
@@ -71,6 +78,10 @@ type ReaderService struct {
|
||||
// 阅读页与批量预取会并发请求同一章(见 fetchChapterContentShared)。
|
||||
contentFlightsMu sync.Mutex
|
||||
contentFlights map[string]*contentFlight
|
||||
|
||||
// mediaRefresh 记录「已失效直链 → 重新签到的新直链」的短缓存:
|
||||
// CDN 判掉某张签名票后,播放器的多次重试共用一次回源(见 media_refresh.go)。
|
||||
mediaRefresh mediaRefreshState
|
||||
}
|
||||
|
||||
// NewReaderService 创建服务。
|
||||
@@ -83,12 +94,21 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: client,
|
||||
mediaHTTP: helper.NewMediaHTTPClient(true),
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
limiter: newSourceRateLimiter(),
|
||||
coverDecodeOrigins: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// mediaClient 返回转发媒体流用的客户端(未注入时退回规则请求的 client)。
|
||||
func (s *ReaderService) mediaClient() *http.Client {
|
||||
if s != nil && s.mediaHTTP != nil {
|
||||
return s.mediaHTTP
|
||||
}
|
||||
return s.http
|
||||
}
|
||||
|
||||
// markSourceCoverDecode 记录某书源是否声明了 coverDecodeJs(导入/更新书源时调用)。
|
||||
func (s *ReaderService) markSourceCoverDecode(sourceURL string, enabled bool) {
|
||||
if s == nil || strings.TrimSpace(sourceURL) == "" {
|
||||
@@ -1942,12 +1962,14 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.http.Do(httpReq)
|
||||
// 媒体用无整体超时的 client:4MB 音频在跨境链路上常超过 30 秒,
|
||||
// 用带整体超时的 client 会在读完 body 前被掐断(静默截断)。
|
||||
resp, err := s.mediaClient().Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
|
||||
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
|
||||
return resp, nil
|
||||
}
|
||||
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
||||
@@ -2511,7 +2533,7 @@ func (s *ReaderService) fetchSourceResource(ctx context.Context, sourceURL, rawU
|
||||
httpReq.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
return s.http.Do(httpReq)
|
||||
return s.mediaClient().Do(httpReq)
|
||||
}
|
||||
|
||||
// ─── 书架 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user