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 错误页", "403", "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(`

票源之书

`)) 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(`ticket expired`)) 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 }