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,445 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 媒体代理在「上游签名票被拒」时的行为契约。
|
||||
//
|
||||
// 背景(真实故障):聆阅·懒人听书源的音频直链是第三方中转现签的腾讯 CDN 票,
|
||||
// 票被 CDN 判掉时上游回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
|
||||
// 原样透传给播放器,播放器重试的还是同一个 URL,于是 App 日志里连续 4~8 个
|
||||
// ERROR_CODE_IO_BAD_HTTP_STATUS (2004),表现为「这本书怎么都播不了」。
|
||||
//
|
||||
// 现在的契约:
|
||||
// - 能换票 → 用新票重拉,直接返回媒体本体(第一个请求就被治好);
|
||||
// - 换不到 → 502 + 明确文案,而不是把 401 丢给播放器;
|
||||
// - 非音轨地址(漫画图片防盗链等)→ 维持原样透传,不改变既有行为。
|
||||
|
||||
func TestReaderMediaPayloadRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
head string
|
||||
contentType string
|
||||
want bool
|
||||
}{
|
||||
{"HTML 错误页", "<!DOCTYPE html><html><body>403</body></html>", "text/html; charset=utf-8", true},
|
||||
{"JSON 报错", `{"code":403,"message":"expired"}`, "application/json", true},
|
||||
{"数组形态报错", `[{"error":"bad ticket"}]`, "application/json; charset=utf-8", true},
|
||||
{"纯文本报错", "ticket expired", "text/plain", false},
|
||||
{"正常 m4a", "\x00\x00\x00\x20ftypmp42", "audio/mp4", false},
|
||||
{"正常 mp3", "ID3\x04\x00", "audio/mpeg", false},
|
||||
{"正常图片", "\xff\xd8\xff\xe0", "image/jpeg", false},
|
||||
{"播放列表", "#EXTM3U\n#EXTINF:10,", "application/vnd.apple.mpegurl", false},
|
||||
{"空体", "", "text/html", false},
|
||||
{"HTML 类型但二进制体", "\x00\x00\x00\x20ftypmp42", "text/html", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := readerMediaPayloadRejected([]byte(c.head), c.contentType)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: readerMediaPayloadRejected(%q, %q) = %v,期望 %v",
|
||||
c.name, c.head, c.contentType, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaProxyHealsStaleTicket 票被上游拒绝时代理自己换票重拉,
|
||||
// 第一个请求就返回媒体本体(不必等播放器重试)。
|
||||
func TestReaderMediaProxyHealsStaleTicket(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
|
||||
signed := primeTicketBook(t, svc, book.ID)
|
||||
// 让中转再签一张新票:章节缓存里那张就此变成「旧票」。
|
||||
srv.rotate()
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("换票重试后 status=%d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "fake-audio-bytes") {
|
||||
t.Fatalf("响应体不是媒体: %q", body)
|
||||
}
|
||||
if got := srv.contentHits(); got < 2 {
|
||||
t.Fatalf("应当回源重取章节,实际请求 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaProxyFailsClearlyWhenRefreshImpossible 换不到票时给 502 + 明确文案,
|
||||
// 而不是把 401 透传给播放器(那样播放器只会对着必然失败的地址反复重试)。
|
||||
func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
|
||||
signed := primeTicketBook(t, svc, book.ID)
|
||||
srv.rotate()
|
||||
srv.stopIssuing()
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("换票失败应当返回 502,实际 %d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "签名失效") {
|
||||
t.Fatalf("错误文案应说明是音源签名失效: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址,
|
||||
// 上游 4xx 仍按原状态透传(既有行为不变)。
|
||||
func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
|
||||
// 书源域名下的图片地址:不在任何音轨缓存里。
|
||||
imgURL := srv.URL + "/img/forbidden.jpg"
|
||||
signed := svc.Reader.ProxyURL(book.ID, imgURL)
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code == http.StatusBadGateway || code == http.StatusUnauthorized {
|
||||
t.Fatalf("非音轨地址不该走换票分支,实际 %d body=%s", code, body)
|
||||
}
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("图片 403 应原样透传,实际 %d body=%s", code, body)
|
||||
}
|
||||
if got := srv.contentHits(); got != 0 {
|
||||
t.Fatalf("图片失败不该触发回源取章节,实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaProxyRejectsNonMediaPayload 上游用 200 回一段 HTML/JSON 错误页时,
|
||||
// 代理直接判失败,不把网页喂给播放器(否则报「格式不支持」)。
|
||||
func TestReaderMediaProxyRejectsNonMediaPayload(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
|
||||
signed := primeTicketBook(t, svc, book.ID)
|
||||
srv.serveErrorPage()
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("非媒体内容应当返回 502,实际 %d body=%.160s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "非媒体内容") {
|
||||
t.Fatalf("错误文案应说明上游返回了非媒体内容: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 上游抖动:连接被 reset 时的重试与截断可见化 ─────────────────────────
|
||||
|
||||
// TestReaderMediaProbeRetriesConnectionReset 上游在响应体中途 RST 连接时,
|
||||
// 代理要退避重试,而不是第一次读失败就回 502。
|
||||
//
|
||||
// 真实故障:腾讯 CDN 的跨境边缘在并发/长传输时会直接 RST,同一章重试一次
|
||||
// 往往就好。以前这里读 body 失败即判死,把一次可恢复的抖动变成「这本书播不了」。
|
||||
func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
|
||||
var attempts int32
|
||||
const bodyBytes = 200 << 10
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n == 1 {
|
||||
// 第一次:声明长度,写一小段后直接断开连接(模拟 RST/提前 EOF)。
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
|
||||
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42"))
|
||||
w.(http.Flusher).Flush()
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
|
||||
payload := make([]byte, 8<<10)
|
||||
for written := 0; written < bodyBytes; written += len(payload) {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
|
||||
router := mediaRouterForTest(svc)
|
||||
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("连接被 reset 后应当重试成功,实际 status=%d body=%.200s", code, body)
|
||||
}
|
||||
if int32(len(body)) != bodyBytes {
|
||||
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got < 2 {
|
||||
t.Fatalf("应当重试过,实际上游请求 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaAbortsWhenUpstreamTruncates 上游中途断开且重试都失败时,
|
||||
// 代理必须让客户端看到「传输不完整」,不能返回一个长度正确却内容缺失的响应。
|
||||
//
|
||||
// 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的
|
||||
// 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。
|
||||
func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
|
||||
const declared = 400 << 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(declared))
|
||||
// 先写够探测用的前缀,再断开:探测能过,透传阶段才断。
|
||||
payload := make([]byte, 8<<10)
|
||||
for written := 0; written < 128<<10; written += len(payload) {
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
|
||||
router := mediaRouterForTest(svc)
|
||||
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
|
||||
// 这里断言它确实发生了(即「没有静默地把半截文件当成功」)。
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatalf("上游截断时必须中止响应(panic(http.ErrAbortHandler)),实际正常返回了 %d 字节",
|
||||
w.Body.Len())
|
||||
}
|
||||
if recovered != http.ErrAbortHandler {
|
||||
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
|
||||
}
|
||||
}()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Body.Len() >= declared {
|
||||
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
|
||||
}
|
||||
}
|
||||
|
||||
func mediaRouterForTest(svc *service.Container) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// /api/reader/media 挂在公共 api 组(签名鉴权,不需要 JWT),单独注册即可。
|
||||
r.GET("/api/reader/media", readerMediaProxyHandler(svc))
|
||||
return r
|
||||
}
|
||||
|
||||
func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, string) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
|
||||
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
|
||||
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
|
||||
t.Helper()
|
||||
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次取章节失败: %v", err)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
return out.Tracks[0]
|
||||
}
|
||||
|
||||
// ─── 模拟「中转现签 CDN 直链」的上游 ───────────────────────────────────────
|
||||
|
||||
// mediaTicketServer:
|
||||
// - /book/1 详情页
|
||||
// - /content 章节正文,每次请求签一张新票
|
||||
// - /media 媒体本体,只认最新签出的票;旧票回**空的 401**(与腾讯 CDN 一致)
|
||||
// - /img/* 图片资源,固定 403(模拟防盗链)
|
||||
type mediaTicketServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
issued []string
|
||||
hits int
|
||||
noToken bool
|
||||
errorPage bool
|
||||
}
|
||||
|
||||
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
|
||||
t.Helper()
|
||||
s := &mediaTicketServer{}
|
||||
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 := s.newTokenLocked(fmt.Sprintf("ticket-%d", n))
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
|
||||
case r.URL.Path == "/media":
|
||||
if s.errorPageNow() {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
|
||||
return
|
||||
}
|
||||
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 {
|
||||
// 与上游一致:空 body、无 Content-Type。
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
|
||||
case strings.HasPrefix(r.URL.Path, "/img/"):
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// newTokenLocked 签一张新票(调用方需持有或随后获取锁;这里内部加锁)。
|
||||
func (s *mediaTicketServer) newTokenLocked(token string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.issued = append(s.issued, token)
|
||||
return token
|
||||
}
|
||||
|
||||
// rotate 模拟「中转又签了一张新票」:章节缓存里那张就此失效。
|
||||
func (s *mediaTicketServer) rotate() {
|
||||
s.mu.Lock()
|
||||
s.issued = append(s.issued, fmt.Sprintf("rotated-%d", len(s.issued)))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// stopIssuing 让书源此后给不出播放地址(模拟中转本身挂了)。
|
||||
func (s *mediaTicketServer) stopIssuing() {
|
||||
s.mu.Lock()
|
||||
s.noToken = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// serveErrorPage 让媒体端点改用 200 + HTML 错误页(直链失效的另一种形态)。
|
||||
func (s *mediaTicketServer) serveErrorPage() {
|
||||
s.mu.Lock()
|
||||
s.errorPage = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *mediaTicketServer) errorPageNow() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.errorPage
|
||||
}
|
||||
|
||||
func (s *mediaTicketServer) contentHits() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hits
|
||||
}
|
||||
|
||||
// prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。
|
||||
func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
|
||||
t.Helper()
|
||||
svc := newReaderHandlerContainer(t)
|
||||
svc.Cfg.Cache.CacheDir = t.TempDir()
|
||||
// 占位 ticket server:只用来满足清理钩子,不参与请求。
|
||||
srv := newMediaTicketServer(t)
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceName": "上游直连测试",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": %q,
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`, upstream)
|
||||
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
|
||||
t.Fatalf("导入书源失败: %v", err)
|
||||
}
|
||||
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("读不到书源: %v", err)
|
||||
}
|
||||
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
|
||||
SourceID: srcs[0].ID,
|
||||
Origin: upstream,
|
||||
OriginName: "上游直连测试",
|
||||
OriginType: 1,
|
||||
BookURL: upstream + "/book/1",
|
||||
}, "上游直连之书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
return svc, srv, book
|
||||
}
|
||||
|
||||
func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
|
||||
t.Helper()
|
||||
svc := newReaderHandlerContainer(t)
|
||||
// 章节缓存必须落在磁盘上:换票要凭落盘载荷找回「这个地址属于哪一章」。
|
||||
// newReaderHandlerContainer 只给空 cfg,这里补一个临时缓存目录。
|
||||
svc.Cfg.Cache.CacheDir = t.TempDir()
|
||||
srv := newMediaTicketServer(t)
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceName": "票源测试",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": %q,
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`, srv.URL)
|
||||
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
|
||||
t.Fatalf("导入书源失败: %v", err)
|
||||
}
|
||||
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("读不到书源: %v", err)
|
||||
}
|
||||
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
|
||||
SourceID: srcs[0].ID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "票源测试",
|
||||
OriginType: 1,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "票源之书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
return svc, srv, book
|
||||
}
|
||||
@@ -2,11 +2,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -869,12 +874,18 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
|
||||
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
|
||||
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
|
||||
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
|
||||
// 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页);
|
||||
// 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。
|
||||
probeBody := c.Request.Method == http.MethodGet
|
||||
media, err := fetchReaderMedia(c.Request.Context(), svc, book, rawURL, c.GetHeader("Range"), mediaOptions.Headers, probeBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
resp := media.resp
|
||||
defer resp.Body.Close()
|
||||
// 换过票时用新地址:m3u8 里的相对分片要按新地址解析。
|
||||
rawURL = media.url
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
|
||||
@@ -885,6 +896,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
|
||||
return
|
||||
}
|
||||
sniffed = append(append([]byte(nil), media.head...), sniffed...)
|
||||
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
|
||||
if decErr != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
|
||||
@@ -897,20 +909,21 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Data(resp.StatusCode, contentType, decoded)
|
||||
return
|
||||
}
|
||||
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
|
||||
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
|
||||
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
|
||||
// 200 的 GET:media.head 已经读过一小段,先用它判播放列表与错误页。
|
||||
//
|
||||
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
|
||||
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
|
||||
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
|
||||
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
|
||||
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中),
|
||||
// 所以以 #EXTM3U 标记为准。
|
||||
if probeBody && resp.StatusCode == http.StatusOK {
|
||||
head := media.head
|
||||
if reader.BodyIsPlaylist(head, ct) {
|
||||
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
|
||||
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||
return
|
||||
}
|
||||
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
|
||||
// 走到这里说明 head 不是错误页(fetchReaderMedia 已经把错误页换票重试过了),
|
||||
// 原样拼回去继续流式透传。Content-Length 因为已读过一部分会失配,
|
||||
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
|
||||
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
@@ -921,7 +934,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if len(head) > 0 {
|
||||
_, _ = c.Writer.Write(head)
|
||||
}
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
streamReaderMediaBody(c.Writer, resp.Body)
|
||||
return
|
||||
}
|
||||
// 流式透传(含 206 Partial Content,支持音频拖动进度)
|
||||
@@ -931,13 +944,229 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
streamReaderMediaBody(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// streamReaderMediaBody 把上游响应体写到客户端,并在上游中途断开时**主动掐断**连接。
|
||||
//
|
||||
// 这是修复「播放器拿到半首歌」的关键:以前这里 `_, _ = io.Copy(...)` 把错误直接
|
||||
// 丢掉,上游(腾讯 CDN 边缘)在跨境链路上 RST 连接时,客户端收到的是一个正常
|
||||
// 结束、但字节数不足的响应 —— 播放器无从判断,只能报
|
||||
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(看起来像「格式不支持」,实际是被截断)。
|
||||
//
|
||||
// 现在改成:上游没读完就中止这个响应,客户端因此拿到「传输不完整」而不是「正常结束」,
|
||||
// 播放器可以按常规的重试/重连逻辑处理,而不是误判成格式问题。
|
||||
func streamReaderMediaBody(w io.Writer, body io.Reader) {
|
||||
if _, err := io.Copy(w, body); err != nil {
|
||||
// panic(http.ErrAbortHandler) 是 net/http 约定的「异常中止」:不再补写
|
||||
// chunked 结尾,直接关闭连接,客户端据此判定响应不完整。
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
|
||||
const readerImageDecodeMaxBytes = 32 << 20
|
||||
|
||||
// readerMediaProbeBytes 媒体代理嗅探上游响应体用的前缀长度。
|
||||
//
|
||||
// 这个前缀同时用于三件事:判定 HLS 播放列表(#EXTM3U)、判定「其实是错误页」、
|
||||
// 以及把已读出的字节原样拼回响应体继续流式透传。
|
||||
const readerMediaProbeBytes = 64 << 10
|
||||
|
||||
// readerMediaResult 一次媒体拉取的结果。
|
||||
type readerMediaResult struct {
|
||||
resp *http.Response
|
||||
// url 是本次真正请求的媒体地址(换过票时是新票),m3u8 重写要用它做 base。
|
||||
url string
|
||||
// head 是已从响应体读出的前缀(仅 200 的 GET 才有),调用方写响应时要先写它。
|
||||
head []byte
|
||||
// rejected 表示 200 的响应体其实是错误页(HTML/JSON 报错),不能喂给播放器。
|
||||
rejected bool
|
||||
}
|
||||
|
||||
// fetchReaderMedia 拉取媒体,并在「签名票失效」时换票重试一次。
|
||||
//
|
||||
// 听书源的播放地址是第三方中转现签的 CDN 直链(vkey/uid 带时效),票被上游判掉时
|
||||
// 有两种表现,这里都要兜住:
|
||||
//
|
||||
// ① 空的 401:没有 Content-Type、没有 body(腾讯 CDN 对无效 vkey/uid 的固定回答);
|
||||
// ② 200 + HTML/JSON 错误页:中转自己出错时把它的报错页当成媒体回给我们。
|
||||
//
|
||||
// 旧行为是两种都原样透传给播放器:① 变成连续多个 401(播放器重试的是同一个
|
||||
// 必然失败的地址),② 变成 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(19 个
|
||||
// extractor 一个都不认)。两者的根因都是「这张票已经不能用了」,所以统一处理成
|
||||
// 回源换一张新票再拉:
|
||||
//
|
||||
// - 换票成功 → 用新地址重拉,新票同时写回章节缓存,后续请求自然有效;
|
||||
// - 地址不属于可换票的音轨(漫画图片防盗链的 403 等)→ 原样透传上游状态,
|
||||
// 不改变原有行为;
|
||||
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
|
||||
// 而不是让播放器对着必然失败的地址反复重试。
|
||||
func fetchReaderMedia(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
rawURL, rangeHeader string,
|
||||
options map[string]string,
|
||||
probeBody bool,
|
||||
) (*readerMediaResult, error) {
|
||||
// 上游 CDN 在并发/跨境链路上偶发 reset 连接(实测同一章重试即可成功),
|
||||
// 探测阶段读到一半断开不能直接判死,先按退避重试几次。
|
||||
out, refreshable, err := fetchReaderMediaWithRetry(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
|
||||
if err != nil || !refreshable {
|
||||
return out, err
|
||||
}
|
||||
fresh, canRefresh, refreshErr := svc.Reader.RefreshMediaURL(ctx, book, rawURL)
|
||||
if !canRefresh {
|
||||
// 换不动(漫画图片防盗链的 403 等):原样透传上游状态,保持既有行为。
|
||||
// 但有两类失败例外——它们透传给播放器毫无意义(播放器只会一直重试
|
||||
// 同一个必然失败的地址,或者把网页当成音频),改成明确错误让前端提示。
|
||||
switch {
|
||||
case out.rejected:
|
||||
_ = out.resp.Body.Close()
|
||||
return nil, fmt.Errorf("音源返回了非媒体内容(%s),请稍后重试或换源",
|
||||
strings.TrimSpace(out.resp.Header.Get("Content-Type")))
|
||||
case out.resp.StatusCode == http.StatusUnauthorized:
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
|
||||
_ = out.resp.Body.Close()
|
||||
return nil, fmt.Errorf("音源鉴权被拒(上游 401),请稍后重试或换源")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// 换得动就必须先释放这张失败响应的连接,再发新请求。
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
|
||||
_ = out.resp.Body.Close()
|
||||
if refreshErr != nil {
|
||||
return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr)
|
||||
}
|
||||
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retried.rejected {
|
||||
_ = retried.resp.Body.Close()
|
||||
return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
|
||||
}
|
||||
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
|
||||
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode)
|
||||
}
|
||||
return retried, nil
|
||||
}
|
||||
|
||||
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
|
||||
//
|
||||
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
|
||||
// 重试一次往往就好。以前这里读 body 失败就直接回 502,把一次可恢复的抖动
|
||||
// 变成了「这本书播不了」。
|
||||
const readerMediaProbeAttempts = 3
|
||||
|
||||
// readerMediaProbeRetryDelay 探测重试的退避基数。
|
||||
const readerMediaProbeRetryDelay = 300 * time.Millisecond
|
||||
|
||||
// readerMediaProbeError 探测阶段的可重试错误(连接被 reset / 提前 EOF)。
|
||||
type readerMediaProbeError struct{ err error }
|
||||
|
||||
func (e *readerMediaProbeError) Error() string { return e.err.Error() }
|
||||
func (e *readerMediaProbeError) Unwrap() error { return e.err }
|
||||
|
||||
// fetchReaderMediaWithRetry 探测阶段按退避重试,只对「连接中断」类错误重试:
|
||||
// 上游明确回了 4xx 时重试同一张票没有意义(那是换票流程的事)。
|
||||
func fetchReaderMediaWithRetry(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
rawURL, rangeHeader string,
|
||||
options map[string]string,
|
||||
probeBody bool,
|
||||
) (*readerMediaResult, bool, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= readerMediaProbeAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, false, ctx.Err()
|
||||
case <-time.After(readerMediaProbeRetryDelay * time.Duration(attempt-1)):
|
||||
}
|
||||
}
|
||||
out, refreshable, err := fetchReaderMediaOnce(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
|
||||
if err == nil {
|
||||
return out, refreshable, nil
|
||||
}
|
||||
var probeErr *readerMediaProbeError
|
||||
if !errors.As(err, &probeErr) {
|
||||
return nil, false, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, false, lastErr
|
||||
}
|
||||
|
||||
// fetchReaderMediaOnce 发一次媒体请求并做可用性判定。
|
||||
//
|
||||
// 返回的 refreshable 表示「这次失败值得换一张签名票再试」:
|
||||
// 上游 401/403/410,或 200 但响应体其实是错误页(详见 fetchReaderMedia 的说明)。
|
||||
// rejected 表示「200 但内容不是媒体」——调用方据此在无法换票时报明确错误,
|
||||
// 而不是把网页喂给播放器。
|
||||
func fetchReaderMediaOnce(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
rawURL, rangeHeader string,
|
||||
options map[string]string,
|
||||
probeBody bool,
|
||||
) (*readerMediaResult, bool, error) {
|
||||
resp, err := svc.Reader.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, options)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if reader.MediaNeedsRefresh(resp.StatusCode) {
|
||||
return &readerMediaResult{resp: resp, url: rawURL}, true, nil
|
||||
}
|
||||
if !probeBody || resp.StatusCode != http.StatusOK {
|
||||
return &readerMediaResult{resp: resp, url: rawURL}, false, nil
|
||||
}
|
||||
// 先读一小段判断是不是错误页/播放列表;不是就原样拼回去继续流式透传。
|
||||
head, readErr := io.ReadAll(io.LimitReader(resp.Body, readerMediaProbeBytes))
|
||||
if readErr != nil {
|
||||
_ = resp.Body.Close()
|
||||
// 连接被 reset / 提前 EOF 属于上游抖动,交给上层退避重试。
|
||||
return nil, false, &readerMediaProbeError{err: fmt.Errorf("读取媒体响应失败: %w", readErr)}
|
||||
}
|
||||
rejected := readerMediaPayloadRejected(head, resp.Header.Get("Content-Type"))
|
||||
return &readerMediaResult{resp: resp, url: rawURL, head: head, rejected: rejected}, rejected, nil
|
||||
}
|
||||
|
||||
// readerMediaPayloadRejected 判断上游 200 响应体其实是错误页而不是媒体。
|
||||
//
|
||||
// 直链失效的另一个变种:CDN/中转不返回 4xx,而是回一段 HTML 错误页或 JSON
|
||||
// 报错(Content-Type 是 text/html、application/json)。这类字节喂给播放器,
|
||||
// ExoPlayer 的 19 个 extractor 全都不认,报出来的是
|
||||
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED —— 看起来像「格式不支持」,
|
||||
// 实际是「拿到了一张网页」。这里按 Content-Type + 首字符识别出来,直接判失败。
|
||||
func readerMediaPayloadRejected(head []byte, contentType string) bool {
|
||||
if len(head) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(contentType)
|
||||
errish := strings.Contains(lower, "html") ||
|
||||
strings.Contains(lower, "json") ||
|
||||
strings.HasPrefix(lower, "text/")
|
||||
if !errish {
|
||||
return false
|
||||
}
|
||||
trimmed := bytes.TrimSpace(head)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
switch trimmed[0] {
|
||||
case '<', '{', '[':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
|
||||
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
|
||||
func isMediaPlaylistRequest(resp *http.Response) bool {
|
||||
|
||||
+43
-4
@@ -7,6 +7,7 @@ import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"compress/zlib"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -88,10 +89,51 @@ func StripAcceptEncoding(h http.Header) {
|
||||
//
|
||||
// When useProxy is false, the client is created without proxy plumbing so
|
||||
// the request goes out direct, matching the user's checkbox intent.
|
||||
//
|
||||
// 注意 http.Client.Timeout 是「整个请求」的上限,包含读完响应体。书源的
|
||||
// 搜索/目录/正文都是小响应,30 秒足够;但同一个 client 也被用来流转发
|
||||
// 音频/视频(见 NewMediaHTTPClient),那里必须换一个没有整体超时的 client。
|
||||
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 15
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
||||
Transport: newSiteTransport(useProxy),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMediaHTTPClient 拉取并转发媒体流(音频/视频/大图)的客户端。
|
||||
//
|
||||
// 为什么不能用 NewSiteHTTPClient:http.Client.Timeout 会覆盖「读完 body」这个
|
||||
// 阶段。一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,一旦超时,net/http 会
|
||||
// 直接掐断响应体,而调用方的 io.Copy 只是正常返回 —— 表现为**静默截断**
|
||||
// (播放器拿到半首歌,或判「格式不支持」),或者上游慢到 Do 本身报错 → 502。
|
||||
//
|
||||
// 为什么强制 HTTP/1.1:实测腾讯 CDN(vb.stream.tencentmusic.com)的 h2 边缘在
|
||||
// 长连接大文件上不按预期回 WINDOW_UPDATE,Go 的 http2 Transport 初始连接窗口
|
||||
// 是 1MiB,于是下载**稳定卡在 1MiB/2MiB/3MiB 整数倍**处断开;同一 URL 用
|
||||
// HTTP/1.1(wget、curl --http1.1)从头到尾都完整。因此媒体通路固定走 h1.1:
|
||||
// 音视频本来就没有「多路复用省请求」的收益,反而是流控 bug 的受害者。
|
||||
func NewMediaHTTPClient(useProxy bool) *http.Client {
|
||||
tr := newSiteTransport(useProxy)
|
||||
// 等响应头最多 90 秒:跨境 CDN 首包偶发要几十秒,但绝不能无上限挂着。
|
||||
tr.ResponseHeaderTimeout = 90 * time.Second
|
||||
// 媒体是长连接大流量,连接池开大一些,避免并发取图/拖动进度时互相排队。
|
||||
tr.MaxIdleConns = 32
|
||||
tr.MaxIdleConnsPerHost = 16
|
||||
// 关闭 HTTP/2:见上面的流控说明。
|
||||
tr.ForceAttemptHTTP2 = false
|
||||
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
||||
// 不复用连接:实测腾讯 CDN 的边缘在「同一条 keep-alive 连接上连续取多个
|
||||
// 大对象」时会直接 RST 第二个请求的响应体(容器内 wget/curl 每次新建连接
|
||||
// 就完全正常)。音视频请求本来就稀疏,为省一次 TLS 握手去冒截断风险不划算。
|
||||
tr.DisableKeepAlives = true
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
// newSiteTransport 书源请求共用的 Transport(媒体与规则请求配置一致的部分)。
|
||||
func newSiteTransport(useProxy bool) *http.Transport {
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
@@ -104,10 +146,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
return ProxyFromEnvironmentOrSystem(r)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
||||
Transport: tr,
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
|
||||
|
||||
@@ -17,12 +17,24 @@ var gzipExcludedExtensions = []string{
|
||||
|
||||
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
|
||||
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
|
||||
//
|
||||
// 阅读媒体代理(/api/reader/media、/api/reader/local/audio、
|
||||
// /api/reader/audio/transcode)同样必须排除,原因有两条:
|
||||
// - 它们透传上游的 206 Partial Content。gin-contrib/gzip 在压缩时会删掉
|
||||
// Content-Length,却把 Content-Range 原样留下,客户端看到的是「声明
|
||||
// N 字节、实际收到 M 字节的 gzip 流」,自己发 Accept-Encoding 的播放器
|
||||
// (ExoPlayer 不透明处理)直接判 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED。
|
||||
// - 音视频本来就是压缩格式,再 gzip 一遍只烧 CPU(3.5MB 的 m4a 压完还是
|
||||
// 3.5MB 量级),对带宽没有收益。
|
||||
var apiGzipExcludedPrefixes = []string{
|
||||
"/api/stream/",
|
||||
"/api/hls/",
|
||||
"/api/img",
|
||||
"/api/subtitles/",
|
||||
"/api/strm/play/",
|
||||
"/api/reader/media",
|
||||
"/api/reader/local/audio",
|
||||
"/api/reader/audio/transcode",
|
||||
"/api/ws",
|
||||
"/api/events",
|
||||
}
|
||||
|
||||
@@ -70,6 +70,52 @@ func TestGzipAPIExcludesStreamPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipAPIExcludesReaderMedia 阅读媒体代理必须排除在 gzip 之外。
|
||||
//
|
||||
// 它会透传 CDN 的 206 Partial Content。gin-contrib/gzip 压缩时会删掉
|
||||
// Content-Length 却保留 Content-Range,客户端看到「声明 N 字节、实收 M 字节」,
|
||||
// 自己发 Accept-Encoding 的播放器(ExoPlayer)直接判
|
||||
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,表现为「音频格式不支持」。
|
||||
func TestGzipAPIExcludesReaderMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
api.Use(GzipAPI())
|
||||
// 用 206 + Content-Range 模拟真实的音频分段响应。
|
||||
segment := func(c *gin.Context) {
|
||||
c.Header("Content-Range", "bytes 0-99/100")
|
||||
c.Data(http.StatusPartialContent, "audio/mp4", []byte(strings.Repeat("x", 4096)))
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/reader/media",
|
||||
"/reader/local/audio",
|
||||
"/reader/audio/transcode",
|
||||
} {
|
||||
api.GET(path, segment)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/reader/media",
|
||||
"/reader/local/audio",
|
||||
"/reader/audio/transcode",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api"+path+"?b=book&u=x&s=y", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
|
||||
t.Errorf("%s 不该被 gzip(Range 语义会被破坏)", path)
|
||||
}
|
||||
if got := w.Header().Get("Content-Range"); got != "bytes 0-99/100" {
|
||||
t.Errorf("%s 的 Content-Range 被破坏: %q", path, got)
|
||||
}
|
||||
if w.Body.Len() != 4096 {
|
||||
t.Errorf("%s 响应体长度 = %d,期望 4096", path, w.Body.Len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipStaticCompressesAssets(t *testing.T) {
|
||||
router := newGzipTestRouter()
|
||||
|
||||
|
||||
@@ -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