This commit is contained in:
truewhile
2026-10-29 00:09:00 +08:00
parent 416d4c8c3b
commit db516ded61
11 changed files with 1397 additions and 19 deletions
+190
View File
@@ -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("换票失败后旧缓存应已被作废,不应继续下发死票")
}
}
+70
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
}
}
+26 -4
View File
@@ -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)
}
// ─── 书架 ──────────────────────────────────────────────────────────────────