Files
QiuSWandClaude Opus 5 c6bafd968e feat: 批量上传视频,以磁盘为准并按货憨憨规则本地预检 (#19)
解除 #17 的单商品限制,改为勾选多个一次上传,逐个串行处理。

选定文件的方式彻底改了:不再查 videos 表,改为扫描
<video_dir>/<蝦皮ID>/*.mp4 按文件名升序取第一个。原因是使用者可能手工把
别处来的视频放进目录,那类文件在 videos 表里没有记录;而且库与磁盘的漂移
无法根绝(手动挪文件、换 video_dir、外部程序写入),以磁盘为事实来源更稳。

上传前用 ffprobe 按货憨憨的要求本地预检,不合规的跳过而不是白传:

  大小  <= 30 MB
  时长  10.0 <= duration <= 60.0,严格比较不取整
  格式  容器为 mp4
  像素  宽 <= 1280 且高 <= 1280

时长必须严格判定:本地已有的 49365131605_1.mp4 是 9.985011 秒,
差 0.015 秒卡在门槛下,任何取整都会把它误判为合规。
ProbeResult 相应增加 Width / Height。

每个商品四条分支,任一分支结束后继续下一个:

  远端已有视频且勾选 > 1  -> existing,跳过,不发任何写请求
  子目录没有 mp4          -> missing,跳过
  有文件但不合规          -> invalid,last_error 写明违反哪条及具体数值
  合规                    -> 上传,done / failed

覆盖策略:batchUpdateShopProductVideo 是覆盖语义且不可恢复,所以批量永不
覆盖远端已有视频,单个(勾选 == 1)保持 #17 的红字警告加二次确认后可覆盖。
判断依据是实时读取的远端状态,不用本地 video_diagnosis——后者只在
「下载数据」时刷新,可能已经过期。

这三个新状态只是上一次尝试的结果记录,不构成断点:批量只处理当次勾选的
商品,不会因为状态是 missing/invalid/existing/failed 就把商品排除在外,
每次点上传都重新扫盘、重新预检、重新读远端。这是为了避免重蹈
video_status='none' 被断点永久跳过的覆辙(见 #15)。

上传成功后按 product_id + local_path 写入或更新 videos 记录,
source_item 留空(磁盘还原不出来自哪个淘宝同款),不使用 ReplaceVideos。

按负责人明确决定,不做容量门禁,仅保留确认框中的静态提示。

测试全部用 httptest 假服务器,未向真实货憨憨发出任何请求。
真机验证尚未进行。

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

102 lines
4.1 KiB
Go

package store
import (
"os"
"path/filepath"
"testing"
)
func TestReplaceVideos按商品全量替换(t *testing.T) {
s := newTestStore(t)
first := []Video{
{SourceItem: "a", SourceURL: "https://example.invalid/a.mp4", Status: VideoStatusPending},
{SourceItem: "b", SourceURL: "https://example.invalid/b.mp4", Status: VideoStatusDownloaded},
{SourceItem: "c", SourceURL: "https://example.invalid/c.mp4", Status: VideoStatusFailed},
}
if err := s.ReplaceVideos("product-1", first, "2026-09-03 10:00:00"); err != nil {
t.Fatalf("首次写入 3 条视频失败:%v", err)
}
second := []Video{{SourceItem: "d", SourceURL: "https://example.invalid/d.mp4", Status: VideoStatusDownloaded}}
if err := s.ReplaceVideos("product-1", second, "2026-09-03 11:00:00"); err != nil {
t.Fatalf("用 1 条视频替换失败:%v", err)
}
got, err := s.ListVideos("product-1")
if err != nil {
t.Fatalf("读取替换后视频失败:%v", err)
}
if len(got) != 1 || got[0].SourceItem != "d" {
t.Fatalf("全量替换后应只剩 d,实际:%+v", got)
}
if got[0].ProductID != "product-1" || got[0].CreatedAt != "2026-09-03 11:00:00" {
t.Fatalf("商品 ID 或创建时间没有按参数写入:%+v", got[0])
}
}
func Test取第一个存在的已下载视频并写回上传状态(t *testing.T) {
s := newTestStore(t)
dir := t.TempDir()
missing := filepath.Join(dir, "missing.mp4")
available := filepath.Join(dir, "available.mp4")
if err := os.WriteFile(available, []byte("fake-mp4"), 0o600); err != nil {
t.Fatalf("准备本地视频失败:%v", err)
}
if err := s.ReplaceVideos("商品-1", []Video{
{LocalPath: missing, Status: VideoStatusDownloaded},
{LocalPath: available, Status: VideoStatusDownloaded},
}, "2026-09-03 10:00:00"); err != nil {
t.Fatalf("写入视频失败:%v", err)
}
video, found, err := s.FirstUploadableVideo("商品-1")
if err != nil || !found || video.LocalPath != available {
t.Fatalf("应跳过不存在文件并取第一个可上传视频,video=%+v found=%v err=%v", video, found, err)
}
if err := s.MarkVideoUploaded(video.ID, "https://cos.example.invalid/video.mp4"); err != nil {
t.Fatalf("写回上传状态失败:%v", err)
}
items, err := s.ListVideos("商品-1")
if err != nil {
t.Fatalf("读取视频失败:%v", err)
}
if items[1].Status != VideoStatusUploaded || items[1].RemoteURL != "https://cos.example.invalid/video.mp4" {
t.Fatalf("上传状态或远端地址没有写回:%+v", items[1])
}
}
func Test按本地路径补写上传记录不删除其它视频(t *testing.T) {
s := newTestStore(t)
if err := s.ReplaceVideos("商品-1", []Video{
{LocalPath: "D:/videos/其它.mp4", SourceItem: "淘宝-1", Status: VideoStatusDownloaded},
{LocalPath: "D:/videos/目标.mp4", SourceItem: "淘宝-2", Status: VideoStatusDownloaded},
}, "2026-09-03 10:00:00"); err != nil {
t.Fatalf("准备已有视频记录失败:%v", err)
}
if err := s.UpsertUploadedVideo("商品-1", "D:/videos/目标.mp4", 123, "https://cos.example.invalid/target.mp4", "2026-09-03 11:00:00"); err != nil {
t.Fatalf("更新同路径上传记录失败:%v", err)
}
if err := s.UpsertUploadedVideo("商品-1", "D:/videos/新增.mp4", 456, "https://cos.example.invalid/new.mp4", "2026-09-03 11:00:00"); err != nil {
t.Fatalf("新增上传记录失败:%v", err)
}
items, err := s.ListVideos("商品-1")
if err != nil {
t.Fatalf("读取视频记录失败:%v", err)
}
if len(items) != 3 {
t.Fatalf("补写不能删除其它视频或重复同路径,实际 %d 条:%+v", len(items), items)
}
var target, added Video
for _, item := range items {
switch item.LocalPath {
case "D:/videos/目标.mp4":
target = item
case "D:/videos/新增.mp4":
added = item
}
}
if target.Status != VideoStatusUploaded || target.FileSize != 123 || target.RemoteURL == "" || target.SourceItem != "淘宝-2" {
t.Fatalf("同路径记录未正确更新:%+v", target)
}
if added.Status != VideoStatusUploaded || added.FileSize != 456 || added.RemoteURL == "" || added.SourceItem != "" {
t.Fatalf("新增记录未按磁盘来源写入:%+v", added)
}
}