package handler import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "os" "strconv" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service" "github.com/truewhile/MeBox/internal/service/reader" ) // 音轨「区间直通」的行为契约。 // // 背景(真实故障):播放器(media3)取一条 m4a 会开两次请求——先整段打开嗅探 // 容器,再带 Range 从目标位置重开。旧实现只允许「无 Range + 无分片」的请求 // 边下边发,于是带 Range 的那次(真正的起播请求)必须把整章下完再按区间下发。 // 线上日志:每章固定 200(探针,被播放器掐断)+ 206(整章下完才吐第一个字节), // 42 章里「媒体已装载 → 实际起播」平均 4.79 秒,最坏 35 秒。 // // 现在的契约: // - 带 Range 的冷缓存请求 → 上游一到字节就往下发,不等整章; // - 分片已有的前缀从本地发,上游只续下剩余字节; // - 整章落满时把分片提升为正式缓存,之后走本地; // - 上游不认 Range 时也要给出正确的 206(跳过前缀),且不写带洞的分片; // - 拿不到写锁时降级成纯透传,绝不排队等前一个下载。 // rangeUpstream 支持 Range 的上游:/content 给出音轨地址,/media 回音频本体。 type rangeUpstream struct { *httptest.Server payload []byte honorRange bool // firstChunk > 0 且 release 非 nil 时:先写这么多字节再等放行, // 用来证明「客户端不必等整章下完」。 firstChunk int release chan struct{} mu sync.Mutex ranges []string hits int } func newRangeUpstream(t *testing.T, payload []byte, honorRange bool) *rangeUpstream { t.Helper() u := &rangeUpstream{payload: payload, honorRange: honorRange} u.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": w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media"}}`, u.URL))) case r.URL.Path == "/media": u.serveMedia(w, r) default: http.NotFound(w, r) } })) t.Cleanup(u.Close) return u } func (u *rangeUpstream) serveMedia(w http.ResponseWriter, r *http.Request) { u.mu.Lock() u.hits++ u.ranges = append(u.ranges, r.Header.Get("Range")) u.mu.Unlock() total := len(u.payload) start, end := 0, total-1 partial := false if u.honorRange { if s, e, ok := parseReaderAudioRange(r.Header.Get("Range")); ok { start = int(s) if e >= 0 && int(e) < end { end = int(e) } partial = true } } w.Header().Set("Content-Type", "audio/mp4") w.Header().Set("Accept-Ranges", "bytes") if start >= total { w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) return } body := u.payload[start : end+1] w.Header().Set("Content-Length", strconv.Itoa(len(body))) if partial { w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total)) w.WriteHeader(http.StatusPartialContent) } else { w.WriteHeader(http.StatusOK) } if u.release != nil && u.firstChunk > 0 && len(body) > u.firstChunk { _, _ = w.Write(body[:u.firstChunk]) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } select { case <-u.release: case <-r.Context().Done(): return } body = body[u.firstChunk:] } _, _ = w.Write(body) } func (u *rangeUpstream) rangeHeaders() []string { u.mu.Lock() defer u.mu.Unlock() return append([]string(nil), u.ranges...) } func (u *rangeUpstream) requestCount() int { u.mu.Lock() defer u.mu.Unlock() return u.hits } // prepareRangeBook 建一本「书源直连上游」的音频书,并取回该音轨的签名代理路径。 func prepareRangeBook(t *testing.T, upstream string) (*service.Container, *model.ReaderBook, string) { t.Helper() svc := newReaderHandlerContainer(t) svc.Cfg.Cache.CacheDir = t.TempDir() 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) } if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{ {Index: 0, Title: "第一章", URL: upstream + "/content"}, }); err != nil { t.Fatalf("保存目录失败: %v", err) } out, err := svc.Reader.GetContentForBook(t.Context(), "u1", book.ID, 0, reader.ContentOptions{}) if err != nil { t.Fatalf("取章节失败: %v", err) } if len(out.Tracks) != 1 { t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks)) } return svc, book, out.Tracks[0] } func audioPayload(n int) []byte { payload := make([]byte, n) for i := range payload { payload[i] = byte(i % 251) } return payload } // waitCached 等待分片被提升为正式缓存(提升发生在响应收尾,与客户端读完成之间有微小竞态)。 func waitCached(t *testing.T, svc *service.Container, bookID, mediaURL string) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if svc.Reader.HasCachedRemoteAudio(bookID, mediaURL) { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("整章下完后应提升为正式缓存") } // TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter 冷缓存 + Range: // 上游只吐了第一段,客户端就必须已经拿到字节。 func TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter(t *testing.T) { payload := audioPayload(512 << 10) upstream := newRangeUpstream(t, payload, true) upstream.firstChunk = 64 << 10 upstream.release = make(chan struct{}) svc, book, track := prepareRangeBook(t, upstream.URL) mediaURL := upstream.URL + "/media" srv := httptest.NewServer(mediaRouterForTest(svc)) defer srv.Close() req, err := http.NewRequest(http.MethodGet, srv.URL+track, nil) if err != nil { t.Fatalf("构造请求失败: %v", err) } req.Header.Set("Range", "bytes=0-") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("请求失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusPartialContent { t.Fatalf("状态码 = %d,期望 206", resp.StatusCode) } if got := resp.Header.Get("Content-Range"); got != fmt.Sprintf("bytes 0-%d/%d", len(payload)-1, len(payload)) { t.Fatalf("Content-Range = %q", got) } if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) { t.Fatalf("Content-Length = %q,期望 %d", got, len(payload)) } // 上游此时还被 release 卡着:能在超时内读到第一段就说明没有等整章。 head := make([]byte, upstream.firstChunk) readDone := make(chan error, 1) go func() { _, readErr := io.ReadFull(resp.Body, head) readDone <- readErr }() select { case readErr := <-readDone: if readErr != nil { t.Fatalf("读首段失败: %v", readErr) } case <-time.After(3 * time.Second): t.Fatalf("上游还没给完就卡住了:客户端没能在整章下完前拿到第一个字节") } if !bytes.Equal(head, payload[:upstream.firstChunk]) { t.Fatalf("首段字节不一致") } close(upstream.release) rest, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("读剩余字节失败: %v", err) } if got := append(head, rest...); !bytes.Equal(got, payload) { t.Fatalf("整体字节不一致: got %d bytes", len(got)) } if got := upstream.rangeHeaders(); len(got) != 1 || got[0] != "bytes=0-" { t.Fatalf("上游收到的 Range = %v,期望且仅一次 bytes=0-", got) } waitCached(t, svc, book.ID, mediaURL) } // TestReaderAudioRangeResumesFromPartialFragment 分片里已有前缀时,前缀从本地发, // 上游只续下剩余字节;整章落满后提升为正式缓存。 func TestReaderAudioRangeResumesFromPartialFragment(t *testing.T) { payload := audioPayload(256 << 10) upstream := newRangeUpstream(t, payload, true) svc, book, track := prepareRangeBook(t, upstream.URL) mediaURL := upstream.URL + "/media" // 模拟「播放器嗅探完就断开」留下的分片:前 96KB 已经在盘上。 const partLen = 96 << 10 cachePath := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL) if cachePath == "" { t.Fatalf("缓存路径为空") } if mkErr := os.MkdirAll(cachePath[:strings.LastIndex(cachePath, string(os.PathSeparator))], 0o750); mkErr != nil { t.Fatalf("建缓存目录失败: %v", mkErr) } if writeErr := os.WriteFile(cachePath+".part", payload[:partLen], 0o640); writeErr != nil { t.Fatalf("写分片失败: %v", writeErr) } code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-") if code != http.StatusPartialContent { t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body) } if !bytes.Equal([]byte(body), payload) { t.Fatalf("拼接后的字节不一致: got %d bytes want %d", len(body), len(payload)) } headers := upstream.rangeHeaders() if len(headers) != 1 || headers[0] != fmt.Sprintf("bytes=%d-", partLen) { t.Fatalf("上游收到的 Range = %v,期望从分片长度续下 bytes=%d-", headers, partLen) } waitCached(t, svc, book.ID, mediaURL) // 提升之后同一首歌不再回源。 before := upstream.requestCount() code, body = getReaderMedia(t, mediaRouterForTest(svc), track) if code != http.StatusOK || !bytes.Equal([]byte(body), payload) { t.Fatalf("命中缓存失败 status=%d len=%d", code, len(body)) } if after := upstream.requestCount(); after != before { t.Fatalf("命中缓存后不应再回源,多了 %d 次", after-before) } } // TestReaderAudioRangeHandlesUpstreamThatIgnoresRange 上游不认 Range(回 200 整段)时, // 仍要按客户端要的起点给出正确的 206,并且不写带洞的分片。 func TestReaderAudioRangeHandlesUpstreamThatIgnoresRange(t *testing.T) { payload := audioPayload(300 << 10) upstream := newRangeUpstream(t, payload, false) svc, book, track := prepareRangeBook(t, upstream.URL) mediaURL := upstream.URL + "/media" const start = 1000 code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, fmt.Sprintf("bytes=%d-", start)) if code != http.StatusPartialContent { t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body) } if !bytes.Equal([]byte(body), payload[start:]) { t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload)-start) } // 起点不在分片内 → 不能落盘(否则分片开头会是空洞)。 if svc.Reader.HasPartialRemoteAudio(book.ID, mediaURL) { t.Fatalf("起点靠后的区间请求不应生成分片") } if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) { t.Fatalf("只下了一段不应生成完整缓存") } } // TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy 拿不到写锁时不排队: // 仍然立刻把区间发出去,只是这一轮不落盘。 func TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy(t *testing.T) { payload := audioPayload(128 << 10) upstream := newRangeUpstream(t, payload, true) svc, book, track := prepareRangeBook(t, upstream.URL) mediaURL := upstream.URL + "/media" cacheKey := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL) if cacheKey == "" { t.Fatalf("缓存路径为空") } unlock := readerAudioStreamLocks.Lock(cacheKey) defer unlock() done := make(chan struct{}) var code int var body string go func() { code, body = getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-") close(done) }() select { case <-done: case <-time.After(3 * time.Second): t.Fatalf("写锁被占用时不该让请求排队等待") } if code != http.StatusPartialContent { t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body) } if !bytes.Equal([]byte(body), payload) { t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload)) } if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) { t.Fatalf("降级透传不应写缓存") } } func TestParseReaderAudioRangeCases(t *testing.T) { cases := []struct { in string start int64 end int64 want bool }{ {"bytes=0-", 0, -1, true}, {"bytes=100-199", 100, 199, true}, {"Bytes=1024-", 1024, -1, true}, {" bytes=5-5 ", 5, 5, true}, {"bytes=-500", 0, 0, false}, {"bytes=0-99,200-299", 0, 0, false}, {"items=0-", 0, 0, false}, {"bytes=abc-", 0, 0, false}, {"bytes=200-100", 0, 0, false}, {"", 0, 0, false}, } for _, c := range cases { start, end, ok := parseReaderAudioRange(c.in) if ok != c.want || start != c.start || end != c.end { t.Errorf("parseReaderAudioRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)", c.in, start, end, ok, c.start, c.end, c.want) } } } // getReaderMediaWithRange 发一次带 Range 头的媒体代理请求。 func getReaderMediaWithRange(t *testing.T, router *gin.Engine, signedPath, rangeHeader string) (int, string) { t.Helper() w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, signedPath, nil) req.Header.Set("Range", rangeHeader) router.ServeHTTP(w, req) return w.Code, w.Body.String() }