Files
cmsp/internal/task/task_test.go
T
QiuSWandClaude Opus 5 6cc334ab92 fix: 取视频链路与 Python 参考实现对齐,识别风控降级 (#15)
淘宝会在登录仍然有效的情况下停止下发视频资源:不跳登录页、不出验证码,
详情页里就是没有 mp4 地址。原实现识别不了,把它当成「这个同款没视频」,
继续跑满 20 个同款加重风控,最后写 video_status='none',
而断点规则会永久跳过 none,导致账号恢复后这批商品也不再重试。

与参考实现对齐:

- 详情页等待 4 秒改为 8 秒,提为 detail_wait_seconds
- 每个同款详情页前做一次 my_itaobao 深度守卫,在两次详情页之间插入
  正常页面访问;守卫等待提为 guard_wait_seconds
- 详情页后补一次 Cookie 级快速守卫,复用 judgeLogin
- 淘宝首页导航等待 3 秒改为 6 秒。签名请求在该页面上下文里 fetch,
  页面没加载完就发可能带不上 Cookie
- 下载失败重试,5xx 与连接错误重试 download_retries 次,
  4xx 和 ffprobe 校验失败不重试
- 每次下载尝试各有独立的 180 秒超时。此前超时套在整个重试循环外面,
  大视频首次跑到一半失败后剩余预算不足,重试等于不生效

新增风控降级识别:连续 risk_empty_threshold 个同款打开成功但取不到视频时,
判为疑似风控,中断整批任务并保持商品 pending,绝不写入 none。
停止原因通过 stopReason 枚举传给前端,不依赖中文文本分支。

数据订正提供显式按钮,不写进 migrations。刻意不做日期过滤:
synced_at 记录的是商品数据何时从货憨憨拉取,与 video_status 何时被写成
none 无关,每次「下载数据」都会把它刷成当天。某商品是否需要重做的长期
答案来自货憨憨每次全量拉取覆盖的 video_diagnosis,不来自本地时间戳。

淘宝与 Chrome 相关逻辑由假实现覆盖,真机验证尚未进行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 14:29:03 +08:00

298 lines
8.7 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 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
runner := newTestRunner(Options{Prepare: func(_ context.Context, product Product) (Work, error) {
processed = append(processed, product.ID)
if product.ID == "2" {
return Work{}, errors.Join(ErrLoginRequired, ErrRiskSuspected, RiskSuspectedError{Consecutive: 8})
}
return Work{}, nil
}})
if err := runner.Start(context.Background(), []string{"1", "2", "3"}); err != nil {
t.Fatalf("启动任务失败:%v", err)
}
progress := waitForState(t, runner, StateLoginRequired)
if !reflect.DeepEqual(processed, []string{"1", "2"}) {
t.Fatalf("疑似风控后仍处理后续商品:%v", processed)
}
if progress.StopReason != StopReasonRiskSuspected {
t.Fatalf("停止原因应为疑似风控,实际:%+v", progress)
}
if progress.RiskEmptyCount != 8 {
t.Fatalf("连续空结果数应传给前端,实际:%+v", progress)
}
}
func Test空结果中途发现视频会清零(t *testing.T) {
guard := NewEmptyRiskGuard(3)
if guard.Record(0) || guard.Record(0) {
t.Fatal("阈值前不应触发")
}
if guard.Record(1) || guard.Consecutive() != 0 {
t.Fatalf("发现视频应清零,实际连续数 %d", guard.Consecutive())
}
if guard.Record(0) || guard.Record(0) || !guard.Record(0) {
t.Fatal("清零后应重新累计到阈值")
}
}
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{}
}