由 Codex (gpt-5.6-sol) 实施,Claude 审核。MVP2 最后一块。 internal/task/task.go - 串行阶段(Prepare)一次一个商品,全程持有同一个 CDP: 登录深度检查 → 图搜 → 逐个同款提取视频地址。 并发只作用于下载阶段(纯 HTTP + ffprobe,不碰 CDP)。 这样既避免多协程踩同一个浏览器页面,也不抬高淘宝风控概率。 - 登录失效走独立的 ErrLoginRequired,触发后立即终止整批, 不再启动后续商品,不自动重试也不自动重新登录 - 其它错误属于单商品失败,记录后继续处理下一个 - 断点复用商品自身的 download_status,不另建断点文件或断点表, 因此不会出现断点与数据库不一致 app.go 抽出单商品核心供两处复用,未复制逻辑。 审核补测(跑完删除,未入库),五项全部通过: - 登录失效发生在第 3 个商品时,后续 D/E/F 一个都没被处理 - Concurrency 设为 4 时 Prepare 峰值仍为 1,串行确实生效 - 下载阶段峰值恰为 2,并发上限真的起作用且确实并行 - 连续 5 个普通失败仍跑完全部,状态为 completed - 停止后可以重新启动,不会被永久锁死 硬性验收项全部通过:前端无 window.go 等绕过生成绑定的写法 (前三个工单连续出现,本次未再出现);internal 未 import wails; 无新增依赖。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
261 lines
7.3 KiB
Go
261 lines
7.3 KiB
Go
package task
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"reflect"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func Test正常跑完N个商品(t *testing.T) {
|
||
var mu sync.Mutex
|
||
var doneValues []int
|
||
runner := newTestRunner(Options{
|
||
OnProgress: func(progress Progress) {
|
||
mu.Lock()
|
||
doneValues = append(doneValues, progress.Done)
|
||
mu.Unlock()
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
if progress.Total != 3 || progress.Done != 3 {
|
||
t.Fatalf("完成进度错误:%+v", progress)
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(doneValues, []int{1, 2, 3}) {
|
||
t.Fatalf("进度没有从 0 依次推进到 N:%v", doneValues)
|
||
}
|
||
}
|
||
|
||
func Test中途停止后不再启动剩余商品(t *testing.T) {
|
||
started := make(chan string, 3)
|
||
release := make(chan struct{})
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
started <- product.ID
|
||
if product.ID == "1" {
|
||
<-release
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
if got := <-started; got != "1" {
|
||
t.Fatalf("首个商品错误:%s", got)
|
||
}
|
||
runner.Stop()
|
||
close(release)
|
||
progress := waitForState(t, runner, StateStopped)
|
||
if progress.Done != 1 {
|
||
t.Fatalf("当前商品应处理完再停止:%+v", progress)
|
||
}
|
||
select {
|
||
case id := <-started:
|
||
t.Fatalf("停止后仍启动了商品:%s", id)
|
||
default:
|
||
}
|
||
}
|
||
|
||
func Test登录失效立即触发全局停止门(t *testing.T) {
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
if product.ID == "2" {
|
||
return Work{}, errors.Join(ErrLoginRequired, errors.New("深度检查不通过"))
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateLoginRequired)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"1", "2"}) {
|
||
t.Fatalf("登录失效后仍处理了后续商品:%v", processed)
|
||
}
|
||
if progress.Done != 1 || progress.Failed != 0 {
|
||
t.Fatalf("登录失效商品不能记为单商品失败:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test单商品失败后继续处理后续商品(t *testing.T) {
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
if product.ID == "2" {
|
||
return Work{}, errors.New("图搜失败")
|
||
}
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"1", "2", "3"}) {
|
||
t.Fatalf("单商品失败后没有继续:%v", processed)
|
||
}
|
||
if progress.Failed != 1 || progress.Done != 3 {
|
||
t.Fatalf("失败计数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test已有任务运行时拒绝再次启动(t *testing.T) {
|
||
release := make(chan struct{})
|
||
started := make(chan struct{})
|
||
runner := newTestRunner(Options{
|
||
Prepare: func(context.Context, Product) (Work, error) {
|
||
close(started)
|
||
<-release
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
|
||
t.Fatalf("首次启动失败:%v", err)
|
||
}
|
||
<-started
|
||
if err := runner.Start(context.Background(), []string{"2"}); err == nil || err.Error() != "已有任务在运行" {
|
||
t.Fatalf("重复启动应返回指定中文错误,实际:%v", err)
|
||
}
|
||
close(release)
|
||
waitForState(t, runner, StateCompleted)
|
||
}
|
||
|
||
func Test已完成和无同款商品不进入队列(t *testing.T) {
|
||
statuses := map[string]Product{
|
||
"done": {ID: "done", DownloadStatus: "done"},
|
||
"none": {ID: "none", VideoStatus: "none"},
|
||
"todo": {ID: "todo"},
|
||
}
|
||
var processed []string
|
||
var mu sync.Mutex
|
||
runner := newTestRunner(Options{
|
||
Load: func(_ context.Context, id string) (Product, error) { return statuses[id], nil },
|
||
Prepare: func(_ context.Context, product Product) (Work, error) {
|
||
mu.Lock()
|
||
processed = append(processed, product.ID)
|
||
mu.Unlock()
|
||
return Work{}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"done", "none", "todo"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if !reflect.DeepEqual(processed, []string{"todo"}) {
|
||
t.Fatalf("断点跳过逻辑错误:%v", processed)
|
||
}
|
||
if progress.Total != 1 || progress.Done != 1 {
|
||
t.Fatalf("跳过后的总数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test下载并发数不超过配置值(t *testing.T) {
|
||
var active int32
|
||
var maximum int32
|
||
jobs := make([]DownloadFunc, 8)
|
||
for index := range jobs {
|
||
jobs[index] = func(context.Context) error {
|
||
current := atomic.AddInt32(&active, 1)
|
||
for {
|
||
old := atomic.LoadInt32(&maximum)
|
||
if current <= old || atomic.CompareAndSwapInt32(&maximum, old, current) {
|
||
break
|
||
}
|
||
}
|
||
time.Sleep(15 * time.Millisecond)
|
||
atomic.AddInt32(&active, -1)
|
||
return nil
|
||
}
|
||
}
|
||
runner := newTestRunner(Options{
|
||
Concurrency: 2,
|
||
Prepare: func(context.Context, Product) (Work, error) {
|
||
return Work{Downloads: jobs}, nil
|
||
},
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
progress := waitForState(t, runner, StateCompleted)
|
||
if maximum != 2 {
|
||
t.Fatalf("下载同时运行数应为 2,实际为 %d", maximum)
|
||
}
|
||
if progress.Downloaded != len(jobs) {
|
||
t.Fatalf("下载计数错误:%+v", progress)
|
||
}
|
||
}
|
||
|
||
func Test每处理完一个商品调用一次进度回调(t *testing.T) {
|
||
var calls int32
|
||
runner := newTestRunner(Options{
|
||
OnProgress: func(Progress) { atomic.AddInt32(&calls, 1) },
|
||
})
|
||
if err := runner.Start(context.Background(), []string{"1", "2", "3", "4"}); err != nil {
|
||
t.Fatalf("启动任务失败:%v", err)
|
||
}
|
||
waitForState(t, runner, StateCompleted)
|
||
if calls != 4 {
|
||
t.Fatalf("进度回调次数应等于处理商品数,实际为 %d", calls)
|
||
}
|
||
}
|
||
|
||
func newTestRunner(override Options) *Runner {
|
||
options := Options{
|
||
Concurrency: 1,
|
||
Load: func(_ context.Context, id string) (Product, error) {
|
||
return Product{ID: id, Name: "商品" + id}, nil
|
||
},
|
||
Prepare: func(context.Context, Product) (Work, error) { return Work{}, nil },
|
||
}
|
||
if override.Load != nil {
|
||
options.Load = override.Load
|
||
}
|
||
if override.Prepare != nil {
|
||
options.Prepare = override.Prepare
|
||
}
|
||
if override.Concurrency > 0 {
|
||
options.Concurrency = override.Concurrency
|
||
}
|
||
options.OnProgress = override.OnProgress
|
||
options.OnFinished = override.OnFinished
|
||
return NewRunner(options)
|
||
}
|
||
|
||
func waitForState(t *testing.T, runner *Runner, state string) Progress {
|
||
t.Helper()
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
progress := runner.Snapshot()
|
||
if progress.State == state {
|
||
return progress
|
||
}
|
||
time.Sleep(time.Millisecond)
|
||
}
|
||
t.Fatalf("等待任务状态 %s 超时,当前:%+v", state, runner.Snapshot())
|
||
return Progress{}
|
||
}
|