feat: 双层并发 + 全局限流 + 整轮扫尾 + 画廊目录清单

原实现只能「多画廊并行,每个画廊串行下」,单张大画廊下得很慢。
按用户多年使用习惯(5 画廊 × 每画廊 8 张)改造为双层并发。

- 双层并发:画廊并发(默认5) × 页并发(默认8) = 最多 40 张同时下载,
  两个值都在设置面板可调(1-20)。页级 worker 池 + mutex 保护计数。
- 全局限流:对齐参考实现的 nextRequestTime 算法,限所有请求
  (含 /s/ 解析与图片本体),默认 0 秒关闭。
- 整轮扫尾:跑完后自动重扫失败页 1 轮(同样走完整 4 次退避重试),
  仍失败则停下并标记 partial,换线路后手动点开始即可续传。
- 画廊目录清单 .ehdl.json:记录 gid/token/总页数/已完成页,
  即使 state.json 丢失也能算出「应该多少张、还差多少张」。

顺带修复:
- Start() 双重加锁导致死锁(引擎完全无法启动)
- clampInt 越界时返回默认值而非边界值(设 30 会静默变 8)
- 直链正则写死 hath.network,CDN 换域名会全军覆没 -> 加图片 URL 兜底
- ReconcileTask 会误报「有改动」

测试从 37 增至 62,其中并发/限流/扫尾用 httptest 模拟 EH 实测:
- 页并发峰值 = 8、画廊并发峰值 = 5(完全生效)
- 12 次请求限流 80ms 实测 966ms(期望 880ms)
- 断点续传 4 张已存在时只发 5 次请求
This commit is contained in:
2026-10-09 00:11:38 +08:00
parent beb1dac980
commit 0447875b6d
17 changed files with 1981 additions and 130 deletions
+507
View File
@@ -0,0 +1,507 @@
package core
// 双层并发 + 扫尾 的集成测试:用 httptest 模拟 EH 服务器,
// 记录每个时刻有多少请求在飞,验证「页并发」真的生效。
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"eh-downloader-wails/internal/eh"
)
// mockEH 一个可控的 EH 模拟器
type mockEH struct {
srv *httptest.Server
mu sync.Mutex
// 记录并发峰值
inFlight int32
peakFlight int32
totalReqs int64
// 每页失败次数(第一次失败 -> 成功,模拟可重试错误)
failOnce map[int]int32
// 哪些页彻底失败
alwaysFail map[int]bool
// 慢响应(ms),用于观察并发
delayMs int32
}
func newMockEH(totalPages int) *mockEH {
m := &mockEH{
failOnce: map[int]int32{},
alwaysFail: map[int]bool{},
delayMs: 5,
}
mux := http.NewServeMux()
// 画廊页(一次性给全,避免分页逻辑干扰测试)
mux.HandleFunc("/g/", func(w http.ResponseWriter, r *http.Request) {
m.track(func() {
var sb strings.Builder
sb.WriteString(`<html><head><title>[Test] Gallery - E-Hentai Galleries</title>`)
sb.WriteString(`<meta name="description" content="Free Hentai Doujinshi Gallery: [Test] Gallery: x" />`)
sb.WriteString(`</head><body><div id="taglist"><table>`)
sb.WriteString(`<tr><td class="tc">artist:</td><td><div class="gtl"><a href="/tag/artist:x">X</a></div></td></tr>`)
sb.WriteString(`</table></div>`)
for i := 1; i <= totalPages; i++ {
sb.WriteString(fmt.Sprintf(
`<a href="/s/k%03d/999-%d"><div title="Page %d: %03d.png"></div></a>`,
i, i, i, i))
}
sb.WriteString(fmt.Sprintf(`Showing 1 - %d of %d images</body></html>`, totalPages, totalPages))
w.Write([]byte(sb.String()))
})
})
// /s/ 解析页 —— 返回的图片 URL 指向 mock 服务器自己
mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
m.track(func() {
// /s/k001/999-1 -> page 1
page := 1
if idx := strings.LastIndex(r.URL.Path, "-"); idx > 0 {
page, _ = strconv.Atoi(r.URL.Path[idx+1:])
}
// 图片 URL 用 mock 服务器自己的地址,否则请求到不了本地。
// (真实的 hath URL 是随机子域+随机端口,协议逻辑在 eh.go 里测),
// 这里只验证「解析出的 URL 被拿去下载」这条链路。
base := m.URL()
w.Write([]byte(fmt.Sprintf(
`<html><img src="%s/img/%03d.png?xres=800"/></html>`, base, page)))
})
})
// 图片本体
mux.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) {
m.track(func() {
page := 0
name := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
fmt.Sscanf(name, "%03d.png", &page)
m.mu.Lock()
always := m.alwaysFail[page]
n := m.failOnce[page]
if !always && n > 0 {
m.failOnce[page] = n - 1
}
m.mu.Unlock()
if always {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if n > 0 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Write([]byte("image-data-" + name))
})
})
m.srv = httptest.NewServer(mux)
return m
}
// track 统计并发峰值
func (m *mockEH) track(fn func()) {
cur := atomic.AddInt32(&m.inFlight, 1)
atomic.AddInt64(&m.totalReqs, 1)
for {
old := atomic.LoadInt32(&m.peakFlight)
if cur <= old || atomic.CompareAndSwapInt32(&m.peakFlight, old, cur) {
break
}
}
time.Sleep(time.Duration(m.delayMs) * time.Millisecond)
defer atomic.AddInt32(&m.inFlight, -1)
fn()
}
func (m *mockEH) URL() string { return m.srv.URL }
func (m *mockEH) Close() { m.srv.Close() }
func (m *mockEH) peak() int32 { return atomic.LoadInt32(&m.peakFlight) }
// 把 hath URL 指向 mock 服务器
func (m *mockEH) installBase() func() {
old := eh.EHBase
eh.EHBase = m.srv.URL
return func() { eh.EHBase = old }
}
func newEngineFor(t *testing.T, root string, g, p int) (*Engine, *Store) {
t.Helper()
s := NewStore(filepath.Join(t.TempDir(), "state.json"))
s.UpdateSettings(map[string]interface{}{
"downloadRoot": root,
"dirTemplate": "{gid}",
"galleryConcurrency": g,
"pageConcurrency": p,
"proxy": "",
"proxyMode": "off",
"timeout": 10000,
})
e := NewEngine(s, &noopSink{}, s.Settings())
return e, s
}
func addOne(t *testing.T, s *Store, gid, token string) {
t.Helper()
s.AddTasks([]eh.GalleryRef{{GID: gid, Token: token}})
}
// 页并发真的生效:单画廊 8 并发时,服务器侧应看到并发 > 1
func TestPageConcurrencyWorks(t *testing.T) {
const total = 20
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 8)
addOne(t, s, "999", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
e.Start(ctx)
task := s.FindTask("999", "tok")
if task.Status != "done" {
t.Fatalf("应全部下完,实际状态 %s (done=%d failed=%d)", task.Status, task.Done, task.Failed)
}
if task.Done != total {
t.Errorf("应下 %d 张,实际 %d", total, task.Done)
}
// 页并发 8 => 峰值并发应该 > 1
if m.peak() < 2 {
t.Errorf("页并发 8 时服务器侧并发峰值只有 %d,说明页级并发没生效", m.peak())
}
t.Logf("页并发峰值 = %d(期望接近 8)", m.peak())
// 文件都要在
for i := 1; i <= total; i++ {
name := filepath.Join(root, "999", fmt.Sprintf("%03d.png", i))
if st, err := os.Stat(name); err != nil || st.Size() == 0 {
t.Errorf("第 %d 张图没下好", i)
}
}
// 清单也要写
mf, err := ReadManifest(filepath.Join(root, "999"))
if err != nil || mf == nil {
t.Fatalf("画廊清单没写: %v", err)
}
if mf.TotalPages != total || len(mf.DonePages) != total {
t.Errorf("清单内容不对: total=%d done=%d", mf.TotalPages, len(mf.DonePages))
}
}
// 页并发=1 时应退化成串行
func TestPageConcurrencyOneIsSerial(t *testing.T) {
const total = 8
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 1)
addOne(t, s, "888", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
e.Start(ctx)
if s.FindTask("888", "tok").Status != "done" {
t.Error("应全部下完")
}
if m.peak() > 1 {
t.Errorf("页并发 1 时不该有并发请求,峰值 %d", m.peak())
}
}
// 重试:首轮失败(503)后应重试成功,不该进扫尾失败
func TestRetryThenSucceed(t *testing.T) {
const total = 4
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
// 每页都先失败一次
m.mu.Lock()
for i := 1; i <= total; i++ {
m.failOnce[i] = 1
}
m.mu.Unlock()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 4)
addOne(t, s, "777", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
e.Start(ctx)
task := s.FindTask("777", "tok")
if task.Status != "done" {
t.Errorf("重试后应全部成功,实际 %s (failed=%d)", task.Status, task.Failed)
}
if task.Failed != 0 {
t.Errorf("不该有失败,实际 %d", task.Failed)
}
}
// 整轮扫尾:首轮必定失败(4 次重试全挂),扫尾再试仍失败 -> 标记 partial
// 但如果扫尾时恢复成功,应该是 done
func TestSweepRetrySucceeds(t *testing.T) {
const total = 3
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
// 每页失败次数 = 首轮重试次数(4) + 1,确保首轮 4 次全挂,
// 扫尾时能成功
m.mu.Lock()
for i := 1; i <= total; i++ {
m.failOnce[i] = 5
}
m.mu.Unlock()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 3)
addOne(t, s, "666", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
e.Start(ctx)
task := s.FindTask("666", "tok")
if task.Status != "done" {
t.Errorf("扫尾后应全部成功,实际 %s (done=%d failed=%d)", task.Status, task.Done, task.Failed)
}
if task.Failed != 0 {
t.Errorf("扫尾后不该有失败,实际 %d", task.Failed)
}
for i := 1; i <= total; i++ {
name := filepath.Join(root, "666", fmt.Sprintf("%03d.png", i))
if st, err := os.Stat(name); err != nil || st.Size() == 0 {
t.Errorf("第 %d 张没下好", i)
}
}
}
// 彻底失败(一直 503)-> partial,且扫尾也救不回来
func TestPermanentFailureMarksPartial(t *testing.T) {
const total = 2
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
m.mu.Lock()
for i := 1; i <= total; i++ {
m.alwaysFail[i] = true
}
m.mu.Unlock()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 2)
addOne(t, s, "555", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
e.Start(ctx)
task := s.FindTask("555", "tok")
if task.Status != "partial" {
t.Errorf("全失败应标记 partial,实际 %s", task.Status)
}
if task.Failed != total {
t.Errorf("失败数应为 %d,实际 %d", total, task.Failed)
}
if len(task.FailedList) != total {
t.Errorf("失败清单应有 %d 条,实际 %d", total, len(task.FailedList))
}
}
// 断点续传:已有文件不重下
func TestResumeWithExistingFiles(t *testing.T) {
const total = 6
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
dir := filepath.Join(root, "444")
os.MkdirAll(dir, 0o755)
// 预先放好 4 张
for i := 1; i <= 4; i++ {
os.WriteFile(filepath.Join(dir, fmt.Sprintf("%03d.png", i)), []byte("image-data"), 0o644)
}
e, s := newEngineFor(t, root, 1, 4)
addOne(t, s, "444", "tok")
before := m.totalReqs
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
e.Start(ctx)
task := s.FindTask("444", "tok")
if task.Status != "done" {
t.Errorf("应下完,实际 %s (done=%d)", task.Status, task.Done)
}
if task.Done != total {
t.Errorf("完成数应为 %d,实际 %d", total, task.Done)
}
// 只该请求剩下的 2 页(每页 2 个请求 = 4 次)
used := m.totalReqs - before
if used > 8 {
t.Errorf("已有 4 张却发了 %d 次请求 —— 断点续传没生效", used)
}
t.Logf("续传只用了 %d 次请求(4 张已存在,应只用 4 次)", used)
}
// 多个画廊并行:画廊并发生效
func TestGalleryConcurrencyWorks(t *testing.T) {
const (
galleries = 5
pages = 4
)
m := newMockEH(pages)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
e, s := newEngineFor(t, root, galleries, 1)
for i := 1; i <= galleries; i++ {
gid := strconv.Itoa(300 + i)
addOne(t, s, gid, "tok")
}
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
e.Start(ctx)
for i := 1; i <= galleries; i++ {
gid := strconv.Itoa(300 + i)
task := s.FindTask(gid, "tok")
if task == nil || task.Status != "done" {
t.Errorf("画廊 %s 没下完: %v", gid, task)
}
}
// 画廊并发 5 => 峰值并发应该 > 1
if m.peak() < 2 {
t.Errorf("画廊并发 5 时峰值只有 %d,并发没生效", m.peak())
}
t.Logf("画廊并发峰值 = %d", m.peak())
}
// 全局限流在引擎里生效
func TestEngineRespectsRateLimit(t *testing.T) {
const total = 6
m := newMockEH(total)
defer m.Close()
restore := m.installBase()
defer restore()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 4)
e.SetRequestInterval(80) // 80ms
addOne(t, s, "333", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
start := time.Now()
e.Start(ctx)
elapsed := time.Since(start)
task := s.FindTask("333", "tok")
if task.Status != "done" {
t.Fatalf("应下完,实际 %s", task.Status)
}
// 6 页 = 6 次 /s/ + 6 次图片 = 12 次请求,至少 11 个间隔 = 880ms
wantMin := 11 * 80 * time.Millisecond
if elapsed < wantMin {
t.Errorf("限流 80ms 时 %d 次请求应至少耗时 %v,实际 %v", 12, wantMin, elapsed)
}
t.Logf("12 次请求限流 80ms,实际耗时 %v", elapsed)
}
// 取消:页级并发下点停止要能真的中断
func TestCancelWithPageConcurrency(t *testing.T) {
const total = 30
m := newMockEH(total)
defer m.Close()
m.delayMs = 50 // 放慢点,方便中途取消
restore := m.installBase()
defer restore()
root := t.TempDir()
e, s := newEngineFor(t, root, 1, 8)
addOne(t, s, "222", "tok")
e.Enqueue(s.Tasks())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { e.Start(ctx); close(done) }()
time.Sleep(300 * time.Millisecond)
e.Stop() // 相当于点「停止」
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("取消后应该在 5 秒内返回")
}
task := s.FindTask("222", "tok")
if task.Status != "queued" {
t.Errorf("取消后任务应退回 queued 等待续传,实际 %s", task.Status)
}
// 已下的文件还在(断点续传的基础)
dir := filepath.Join(root, "222")
files, _ := os.ReadDir(dir)
if len(files) > 0 {
t.Logf("取消前已下 %d 个文件,续传时会跳过", len(files))
}
}