Files
MeBox/internal/handler/reader_media_test.go
T
truewhile d820214abc 优化
2026-10-29 22:52:39 +08:00

526 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),根因被彻底掩盖。
//
// 音轨走的是整段落盘缓存(见 TestReaderAudioCacheRejectsTruncatedUpstream):
// 拉不完整就重试,全部失败回 502,绝不会把半截音频交给播放器。
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)
// .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4")
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)")
}
}
// TestReaderAudioCacheServesFromDisk 音轨首次拉取后落盘;后续请求即使带着一张
// 新签的票(query 全变了)也不再回源——这是让播放不受跨境链路抖动影响的关键。
func TestReaderAudioCacheServesFromDisk(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("首次拉取失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("首次拉取应对上游请求 1 次,实际 %d", got)
}
// 同一段音频的另一张票:URL 的 query 不同,缓存键必须一致。
rotated := svc.Reader.ProxyURL(book.ID, srv.URL+"/media?t=ticket-rotated")
code, body = getReaderMedia(t, router, rotated)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("命中缓存失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("命中缓存后不该再回源,实际上游请求 %d 次", got)
}
}
// TestReaderAudioCacheRejectsTruncatedUpstream 音轨拉不完整时重试;全部失败回 502,
// 且不能留下半截缓存文件(否则后续会把截断的音频当完整文件下发)。
func TestReaderAudioCacheRejectsTruncatedUpstream(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")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("上游持续截断应当返回 502,实际 %d body=%.120s", code, body)
}
if strings.Contains(body, "ftyp") {
t.Fatalf("不能把半截音频交给客户端: %q", body)
}
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
t.Fatalf("失败的下载不应留下缓存文件")
}
}
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
mediaHits 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":
s.mu.Lock()
s.mediaHits++
s.mu.Unlock()
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
}
func (s *mediaTicketServer) mediaRequests() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.mediaHits
}
// 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
}