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
This commit is contained in:
QiuSW
2026-09-03 17:08:12 +08:00
co-authored by Claude Opus 5
parent 16e4f0faf8
commit c6bafd968e
8 changed files with 425 additions and 92 deletions
+175 -81
View File
@@ -46,8 +46,9 @@ type App struct {
cfgPath string
cfg config.Config
db *store.Store
log *logx.Logger
db *store.Store
log *logx.Logger
probe downloader.ProbeFunc
videoTaskMu sync.Mutex
videoTask *task.Runner
@@ -59,6 +60,7 @@ func NewApp() *App {
cfgPath: config.DefaultPath(),
cfg: config.Default(),
log: logx.New(2000),
probe: downloader.Probe,
}
}
@@ -371,14 +373,14 @@ type VideoSummary struct {
Directory string `json:"directory"`
}
// UploadPreview 是二次确认框显示的单商品上传信息。
// UploadPreview 是二次确认框显示的本地预检汇总;不含任何容量查询结果。
type UploadPreview struct {
ProductID string `json:"productId"`
ItemID string `json:"itemId"`
ItemName string `json:"itemName"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
HasExistingVideo bool `json:"hasExistingVideo"`
Total int `json:"total"`
UploadableCount int `json:"uploadableCount"`
UploadableSize int64 `json:"uploadableSize"`
MissingCount int `json:"missingCount"`
InvalidCount int `json:"invalidCount"`
HasExistingVideo bool `json:"hasExistingVideo"`
}
// FetchVideosForProduct 为一个商品依次搜同款、提取视频并下载到本地。
@@ -968,123 +970,215 @@ func (a *App) DownloadVideos(productIDs []string) error {
return a.StartVideoTask(productIDs)
}
// UploadVideos 把本地视频上传回货憨憨(需求 R5)。
// UploadVideos 把本次勾选的商品逐个串行上传。每个商品的失败只写回自身状态,
// 不会把批量任务当成淘宝登录失效那样全局停止。
func (a *App) UploadVideos(productIDs []string) error {
if len(productIDs) != 1 {
return fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
if a.db == nil {
return fmt.Errorf("数据库未就绪,请查看运行日志")
}
// 第二个参数为 false:这里不查远端当前视频,那只是确认框要显示的信息。
_, product, video, err := a.uploadPreview(productIDs[0], false)
ids := cleanProductIDs(productIDs)
if len(ids) == 0 {
return fmt.Errorf("请先勾选要上传的商品")
}
client, err := a.newHuohanhanClient()
if err != nil {
return err
}
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadRunning, ""); err != nil {
return err
ctx := a.appContext()
for _, productID := range ids {
a.uploadOneVideo(ctx, client, productID, len(ids))
}
return nil
}
fail := func(cause error) error {
func (a *App) uploadOneVideo(ctx context.Context, client *huohanhan.Client, productID string, selectedCount int) {
product, found, err := a.db.GetProduct(productID)
if err != nil {
a.log.Error("商品 %s 读取上传信息失败:%v", productID, err)
return
}
if !found {
a.log.Error("商品 %s 不存在,跳过上传", productID)
return
}
fail := func(cause error) {
if updateErr := a.db.UpdateProductStatus(product.ID, "", "", store.UploadFailed, cause.Error()); updateErr != nil {
a.log.Error("商品 %s 记录上传失败状态时出错:%v", product.ID, updateErr)
}
a.log.Error("商品 %s 上传失败:%v", product.ID, cause)
return cause
}
content, err := os.ReadFile(video.LocalPath)
check, err := client.CheckShopProductVideo(ctx, product.ID)
if err != nil {
return fail(fmt.Errorf("读取本地视频失败:%w", err))
fail(err)
return
}
client, err := a.newHuohanhanClient()
if err != nil {
return fail(err)
if check.Confirmed() && selectedCount > 1 {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadSkippedExisting, ""); err != nil {
a.log.Error("商品 %s 记录已有视频状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 货憨憨已有视频,批量上传跳过", product.ID)
return
}
ctx := a.appContext()
a.log.Info("商品 %s 开始上传视频文件 %s(%d 字节)", product.ID, filepath.Base(video.LocalPath), len(content))
remoteURL, err := client.UploadVideo(ctx, video.LocalPath, content)
localPath, info, found, err := a.findUploadVideo(product)
if err != nil {
return fail(err)
fail(err)
return
}
if !found {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadMissingVideo, ""); err != nil {
a.log.Error("商品 %s 记录缺少视频状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 子目录没有 mp4 文件,跳过上传", product.ID)
return
}
if invalidReason, err := a.validateUploadVideo(ctx, localPath, info); err != nil {
fail(err)
return
} else if invalidReason != "" {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadInvalidVideo, invalidReason); err != nil {
a.log.Error("商品 %s 记录视频不合规状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 视频不合规,跳过上传:%s", product.ID, invalidReason)
return
}
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadRunning, ""); err != nil {
a.log.Error("商品 %s 写入上传中状态失败:%v", product.ID, err)
return
}
content, err := os.ReadFile(localPath)
if err != nil {
fail(fmt.Errorf("读取本地视频失败:%w", err))
return
}
a.log.Info("商品 %s 开始上传视频文件 %s(%d 字节)", product.ID, filepath.Base(localPath), len(content))
remoteURL, err := client.UploadVideo(ctx, localPath, content)
if err != nil {
fail(err)
return
}
a.log.Info("商品 %s 素材上传完成,COS 主机:%s", product.ID, videoURLHost(remoteURL))
if err := client.UpdateShopProductVideo(ctx, product.ID, product.PlatformShopID, remoteURL); err != nil {
return fail(err)
fail(err)
return
}
// 回读确认。注意不能只看 video 字段:货憨憨推送到 Shopee 是异步的,
// 刚保存完 video 必然还是空的,视频这时在 tempVideoUrl 里。
check, err := client.CheckShopProductVideo(ctx, product.ID)
check, err = client.CheckShopProductVideo(ctx, product.ID)
if err != nil {
return fail(err)
fail(err)
return
}
if reason := strings.TrimSpace(check.FailReason); reason != "" {
return fail(fmt.Errorf("货憨憨处理视频失败:%s", reason))
fail(fmt.Errorf("货憨憨处理视频失败:%s", reason))
return
}
if !check.Confirmed() {
return fail(fmt.Errorf("回读商品视频失败:货憨憨没有记录到刚设置的视频"))
fail(fmt.Errorf("回读商品视频失败:货憨憨没有记录到刚设置的视频"))
return
}
if check.LiveOnShopee() {
a.log.Info("商品 %s 的视频已在 Shopee 生效", product.ID)
} else {
a.log.Info("商品 %s 的视频已提交,等待货憨憨同步到 Shopee(稍后可在货憨憨界面查看)", product.ID)
}
if err := a.db.MarkVideoUploaded(video.ID, remoteURL); err != nil {
return fail(err)
if err := a.db.UpsertUploadedVideo(product.ID, localPath, info.Size(), remoteURL, time.Now().Format("2006-01-02 15:04:05")); err != nil {
fail(err)
return
}
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadDone, ""); err != nil {
return fail(err)
a.log.Error("商品 %s 写入上传完成状态失败:%v", product.ID, err)
return
}
a.log.Success("商品 %s 视频上传并回读确认完成", product.ID)
return nil
}
// GetUploadPreview 在显式点击上传后提供二次确认所需信息,只读取本地状态和远端当前视频。
// GetUploadPreview 只扫描本地磁盘并执行本地预检;批量确认框不发远端请求。
// 单商品保留 R5 首版的覆盖警告,因此额外读取一次远端当前状态。
func (a *App) GetUploadPreview(productIDs []string) (UploadPreview, error) {
if len(productIDs) != 1 {
return UploadPreview{}, fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
}
preview, _, _, err := a.uploadPreview(productIDs[0], true)
return preview, err
}
func (a *App) uploadPreview(productID string, readRemote bool) (UploadPreview, store.Product, store.Video, error) {
if a.db == nil {
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("数据库未就绪,请查看运行日志")
return UploadPreview{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
productID = strings.TrimSpace(productID)
if productID == "" {
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
ids := cleanProductIDs(productIDs)
if len(ids) == 0 {
return UploadPreview{}, fmt.Errorf("请先勾选要上传的商品")
}
product, found, err := a.db.GetProduct(productID)
if err != nil {
return UploadPreview{}, store.Product{}, store.Video{}, err
preview := UploadPreview{Total: len(ids)}
var single store.Product
for _, productID := range ids {
product, found, err := a.db.GetProduct(productID)
if err != nil {
return UploadPreview{}, err
}
if !found {
return UploadPreview{}, fmt.Errorf("找不到要上传的商品")
}
if len(ids) == 1 {
single = product
}
localPath, info, exists, err := a.findUploadVideo(product)
if err != nil {
return UploadPreview{}, err
}
if !exists {
preview.MissingCount++
continue
}
invalidReason, err := a.validateUploadVideo(a.appContext(), localPath, info)
if err != nil {
return UploadPreview{}, err
}
if invalidReason != "" {
preview.InvalidCount++
continue
}
preview.UploadableCount++
preview.UploadableSize += info.Size()
}
if !found {
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("找不到要上传的商品")
}
video, found, err := a.db.FirstUploadableVideo(product.ID)
if err != nil {
return UploadPreview{}, store.Product{}, store.Video{}, err
}
if !found {
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("该商品没有可上传的本地视频")
}
info, err := os.Stat(video.LocalPath)
if err != nil {
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("读取本地视频信息失败:%w", err)
}
preview := UploadPreview{ProductID: product.ID, ItemID: product.ItemID, ItemName: product.ItemName, FileName: filepath.Base(video.LocalPath), FileSize: info.Size()}
if readRemote {
if len(ids) == 1 {
client, err := a.newHuohanhanClient()
if err != nil {
return UploadPreview{}, store.Product{}, store.Video{}, err
return UploadPreview{}, err
}
// 用 Confirmed 而不是只看 video:等待同步中的视频同样会被这次上传覆盖,
// 覆盖警告必须把它也算上。
check, err := client.CheckShopProductVideo(a.appContext(), product.ID)
check, err := client.CheckShopProductVideo(a.appContext(), single.ID)
if err != nil {
return UploadPreview{}, store.Product{}, store.Video{}, err
return UploadPreview{}, err
}
preview.HasExistingVideo = check.Confirmed()
}
return preview, product, video, nil
return preview, nil
}
func cleanProductIDs(productIDs []string) []string {
seen := make(map[string]struct{}, len(productIDs))
ids := make([]string, 0, len(productIDs))
for _, productID := range productIDs {
productID = strings.TrimSpace(productID)
if productID == "" {
continue
}
if _, exists := seen[productID]; exists {
continue
}
seen[productID] = struct{}{}
ids = append(ids, productID)
}
return ids
}
// findUploadVideo 只以磁盘为事实来源,绝不查询 videos 表。
func (a *App) findUploadVideo(product store.Product) (string, os.FileInfo, bool, error) {
dir := filepath.Join(a.cfg.Download.VideoDir, downloader.SafeDirName(product.ItemID))
return downloader.FindFirstMP4(dir)
}
func (a *App) validateUploadVideo(ctx context.Context, localPath string, info os.FileInfo) (string, error) {
probeFunc := a.probe
if probeFunc == nil {
probeFunc = downloader.Probe
}
probe, err := probeFunc(ctx, localPath)
if err != nil {
return "", fmt.Errorf("本地视频预检失败:%w", err)
}
return downloader.UploadValidationError(probe, info.Size()), nil
}
// GetCachedShops 只读本地店铺缓存,不会联网。
+30 -5
View File
@@ -56,6 +56,7 @@ const emptyQuery = () => ({
createdTo: '',
itemStatus: 'NORMAL',
videoDiagnosis: 'missing',
uploadStatus: '',
page: 1,
pageSize: 20,
})
@@ -77,6 +78,17 @@ const diagnosisOptions = [
{ label: '有视频', value: 'ok' },
]
const uploadStatusOptions = [
{ label: '全部上传状态', value: '' },
{ label: '待上传', value: 'pending' },
{ label: '上传中', value: 'running' },
{ label: '已上传', value: 'done' },
{ label: '上传失败', value: 'failed' },
{ label: '已有视频', value: 'existing' },
{ label: '缺少视频', value: 'missing' },
{ label: '视频不合规', value: 'invalid' },
]
// ---- 店铺下拉 ----
// 店铺列表必须来自货憨憨 erp/shop/all,不能在前端写死。
const shopOptions = ref([])
@@ -347,6 +359,9 @@ const uploadText = {
running: ['上传中', 'primary'],
done: ['已上传', 'ok'],
failed: ['失败', 'bad'],
existing: ['已有视频', 'muted'],
missing: ['缺少视频', 'warn'],
invalid: ['视频不合规', 'bad'],
}
// Shopee 各站点域名。地区码来自货憨憨的 region 字段。
@@ -526,8 +541,8 @@ function formatFileSize(size) {
}
async function uploadSelectedVideo() {
if (checkedIds.value.length !== 1) {
message.warning('首版一次只能上传一个商品,确认线上结果正常后再开放批量')
if (checkedIds.value.length === 0) {
message.warning('请先勾选要上传的商品')
return
}
try {
@@ -536,9 +551,13 @@ async function uploadSelectedVideo() {
dialog.warning({
title: '确认上传视频',
content: () => h('div', { style: 'white-space: pre-line; line-height: 1.75' }, [
`蝦皮 ID:${preview.itemId}\n`,
`商品名称:${preview.itemName}\n`,
`本地视频:${preview.fileName}(${formatFileSize(preview.fileSize)})\n`,
`将处理 ${preview.total} 个商品\n`,
` 可上传 ${preview.uploadableCount} 个,共 ${formatFileSize(preview.uploadableSize)}\n`,
` 缺少视频 ${preview.missingCount} 个\n`,
` 视频不合规 ${preview.invalidCount} 个(时长或像素不符合货憨憨要求)\n`,
checkedIds.value.length > 1
? ' 已有视频:执行时逐个检查,已有视频的会跳过\n'
: '',
'无法查询货憨憨剩余容量(接口未确认)。图片空间已用约 98.7%,剩余约 26 GB,请自行确认后再继续。',
preview.hasExistingVideo
? h('div', { style: 'color: var(--n-error-color); font-weight: 600; margin-top: 8px' }, '该商品已有视频,上传会覆盖原视频且无法恢复')
@@ -709,6 +728,12 @@ onUnmounted(() => {
:options="diagnosisOptions"
style="width: 130px"
/>
<span class="lb">上传</span>
<n-select
v-model:value="query.uploadStatus"
:options="uploadStatusOptions"
style="width: 130px"
/>
<n-button style="margin-left: 12px" @click="reset">重置</n-button>
<n-button type="primary" @click="search()">搜索</n-button>
</div>
+66 -2
View File
@@ -6,10 +6,12 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -22,6 +24,8 @@ type ProbeResult struct {
Duration float64
Size int64
FormatName string
Width int
Height int
}
type ProbeFunc func(context.Context, string) (ProbeResult, error)
@@ -209,7 +213,8 @@ func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
return ProbeResult{}, fmt.Errorf("未找到 ffprobe,请先安装并加入 PATH")
}
command := exec.CommandContext(ctx, ffprobe,
"-v", "error", "-show_entries", "format=duration,size,format_name", "-of", "json", path)
"-v", "error", "-select_streams", "v:0",
"-show_entries", "format=duration,size,format_name:stream=width,height", "-of", "json", path)
output, err := command.Output()
if err != nil {
return ProbeResult{}, fmt.Errorf("ffprobe 执行失败:%w", err)
@@ -220,6 +225,10 @@ func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
Size string `json:"size"`
FormatName string `json:"format_name"`
} `json:"format"`
Streams []struct {
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &payload); err != nil {
return ProbeResult{}, fmt.Errorf("解析 ffprobe 输出失败:%w", err)
@@ -235,7 +244,62 @@ func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
if strings.TrimSpace(payload.Format.FormatName) == "" {
return ProbeResult{}, fmt.Errorf("ffprobe 未返回 format_name")
}
return ProbeResult{Duration: duration, Size: size, FormatName: payload.Format.FormatName}, nil
if len(payload.Streams) == 0 || payload.Streams[0].Width <= 0 || payload.Streams[0].Height <= 0 {
return ProbeResult{}, fmt.Errorf("ffprobe 未返回有效视频像素")
}
return ProbeResult{Duration: duration, Size: size, FormatName: payload.Format.FormatName,
Width: payload.Streams[0].Width, Height: payload.Streams[0].Height}, nil
}
// Probe 使用 ffprobe 读取本地视频的容器、时长、大小和首个视频流像素。
// 保留这个导出入口,使上传前预检和下载完整性校验复用同一份 ffprobe 行为。
func Probe(ctx context.Context, path string) (ProbeResult, error) { return runFFProbe(ctx, path) }
// FindFirstMP4 在商品视频目录中按文件名升序返回第一个 mp4 文件。
// 目录缺失和没有 mp4 都是正常的“未找到”,调用方据此更新上传状态。
func FindFirstMP4(dir string) (string, os.FileInfo, bool, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return "", nil, false, nil
}
if err != nil {
return "", nil, false, fmt.Errorf("读取视频目录失败:%w", err)
}
paths := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".mp4") {
continue
}
paths = append(paths, filepath.Join(dir, entry.Name()))
}
if len(paths) == 0 {
return "", nil, false, nil
}
sort.Strings(paths)
info, err := os.Stat(paths[0])
if err != nil {
return "", nil, false, fmt.Errorf("读取本地视频信息失败:%w", err)
}
return paths[0], info, true, nil
}
// UploadValidationError 返回货憨憨本地预检的全部违规原因;空字符串表示合规。
// 比较始终使用原始浮点时长,展示时才格式化,不能以四舍五入结果参与判断。
func UploadValidationError(probe ProbeResult, size int64) string {
problems := make([]string, 0, 4)
if size > 30*1024*1024 {
problems = append(problems, fmt.Sprintf("视频大小 %.2f MB,超过 30 MB 要求", float64(size)/(1024*1024)))
}
if probe.Duration < 10.0 || probe.Duration > 60.0 {
problems = append(problems, fmt.Sprintf("视频时长 %.2f 秒,不满足 10—60 秒要求", math.Round(probe.Duration*100)/100))
}
if !strings.Contains(strings.ToLower(probe.FormatName), "mp4") {
problems = append(problems, fmt.Sprintf("视频容器格式 %s,不满足 mp4 要求", probe.FormatName))
}
if probe.Width > 1280 || probe.Height > 1280 {
problems = append(problems, fmt.Sprintf("视频像素 %d×%d,宽高不能超过 1280×1280", probe.Width, probe.Height))
}
return strings.Join(problems, ";")
}
// SafeDirName 把蝦皮商品 ID 清洗成可以直接当目录名的字符串。
+62
View File
@@ -223,3 +223,65 @@ func TestSafeDirName拒绝危险输入(t *testing.T) {
}
}
}
func Test扫描上传视频目录只取升序第一个MP4(t *testing.T) {
dir := t.TempDir()
missingDir := filepath.Join(dir, "不存在")
if _, _, found, err := FindFirstMP4(missingDir); err != nil || found {
t.Fatalf("目录不存在应返回未找到:found=%v err=%v", found, err)
}
emptyDir := filepath.Join(dir, "空目录")
if err := os.Mkdir(emptyDir, 0o755); err != nil {
t.Fatalf("创建空目录失败:%v", err)
}
if _, _, found, err := FindFirstMP4(emptyDir); err != nil || found {
t.Fatalf("空目录应返回未找到:found=%v err=%v", found, err)
}
for name := range map[string]string{"b.mp4": "b", "a.mp4": "a", "忽略.txt": "x", "子目录.mp4": ""} {
path := filepath.Join(dir, name)
if name == "子目录.mp4" {
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("创建同名子目录失败:%v", err)
}
continue
}
if err := os.WriteFile(path, []byte(name), 0o644); err != nil {
t.Fatalf("准备文件失败:%v", err)
}
}
path, info, found, err := FindFirstMP4(dir)
if err != nil || !found || filepath.Base(path) != "a.mp4" || info.Size() != int64(len("a.mp4")) {
t.Fatalf("应忽略非 mp4 并取 a.mp4:path=%q info=%v found=%v err=%v", path, info, found, err)
}
}
func Test上传视频本地预检边界(t *testing.T) {
valid := ProbeResult{Duration: 10.0, FormatName: "mov,mp4,m4a", Width: 1280, Height: 1280}
cases := []struct {
name string
probe ProbeResult
size int64
want bool
text string
}{
{"9.985 秒不合规", ProbeResult{Duration: 9.985, FormatName: "mp4", Width: 1280, Height: 1280}, 1, true, "视频时长 9.99 秒"},
{"10 秒合规", valid, 30 * 1024 * 1024, false, ""},
{"60 秒合规", ProbeResult{Duration: 60.0, FormatName: "mp4", Width: 1280, Height: 1280}, 1, false, ""},
{"60.01 秒不合规", ProbeResult{Duration: 60.01, FormatName: "mp4", Width: 1280, Height: 1280}, 1, true, "60.01"},
{"大小多一字节不合规", valid, 30*1024*1024 + 1, true, "视频大小"},
{"宽超限不合规", ProbeResult{Duration: 10, FormatName: "mp4", Width: 1281, Height: 1280}, 1, true, "1281×1280"},
{"高超限不合规", ProbeResult{Duration: 10, FormatName: "mp4", Width: 1280, Height: 1281}, 1, true, "1280×1281"},
{"非 mp4 容器不合规", ProbeResult{Duration: 10, FormatName: "matroska,webm", Width: 1280, Height: 1280}, 1, true, "matroska,webm"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := UploadValidationError(tc.probe, tc.size)
if (got != "") != tc.want {
t.Fatalf("预检结果不正确:%q", got)
}
if tc.text != "" && !strings.Contains(got, tc.text) {
t.Fatalf("预检错误应含具体数值 %q,实际 %q", tc.text, got)
}
})
}
}
+12 -4
View File
@@ -29,10 +29,13 @@ const (
DownloadFailed = "failed" // 失败
// upload_status:上传回货憨憨的进度
UploadPending = "pending" // 待上传
UploadRunning = "running" // 上传中
UploadDone = "done" // 已上传
UploadFailed = "failed" // 失败
UploadPending = "pending" // 待上传
UploadRunning = "running" // 上传中
UploadDone = "done" // 已上传
UploadFailed = "failed" // 失败
UploadSkippedExisting = "existing" // 货憨憨已有视频,批量时跳过
UploadMissingVideo = "missing" // 子目录里没有 mp4
UploadInvalidVideo = "invalid" // 有文件但不符合货憨憨要求
)
// Product 是一个商品。字段和 products 表一一对应。
@@ -68,6 +71,7 @@ type ProductQuery struct {
CreatedTo string `json:"createdTo"` // 创建时间止
ItemStatus string `json:"itemStatus"` // 商品状态
VideoDiagnosis string `json:"videoDiagnosis"` // 视频诊断
UploadStatus string `json:"uploadStatus"` // 上传状态
Page int `json:"page"` // 页码,从 1 开始
PageSize int `json:"pageSize"` // 每页条数
}
@@ -218,6 +222,10 @@ func buildWhere(q ProductQuery) (string, []any) {
conds = append(conds, "video_diagnosis = ?")
args = append(args, v)
}
if v := strings.TrimSpace(q.UploadStatus); v != "" {
conds = append(conds, "upload_status = ?")
args = append(args, v)
}
if v := strings.TrimSpace(q.CreatedFrom); v != "" {
conds = append(conds, "created_at >= ?")
args = append(args, v)
+19
View File
@@ -157,6 +157,25 @@ func Test按视频诊断筛选商品(t *testing.T) {
}
}
func Test按上传状态筛选商品(t *testing.T) {
s := newTestStore(t)
items := []Product{{ID: "缺少", ItemID: "1"}, {ID: "不合规", ItemID: "2"}, {ID: "已有", ItemID: "3"}}
if err := s.UpsertProducts(items, "2026-09-03 10:00:00"); err != nil {
t.Fatalf("写入商品失败:%v", err)
}
for id, status := range map[string]string{"缺少": UploadMissingVideo, "不合规": UploadInvalidVideo, "已有": UploadSkippedExisting} {
if err := s.UpdateProductStatus(id, "", "", status, ""); err != nil {
t.Fatalf("准备上传状态失败:%v", err)
}
}
for status, id := range map[string]string{UploadMissingVideo: "缺少", UploadInvalidVideo: "不合规", UploadSkippedExisting: "已有"} {
page, err := s.ListProducts(ProductQuery{UploadStatus: status})
if err != nil || page.Total != 1 || page.Items[0].ID != id {
t.Fatalf("按上传状态 %q 筛选不正确:page=%+v err=%v", status, page, err)
}
}
}
func Test重复写入商品不会清空诊断结果(t *testing.T) {
s := newTestStore(t)
product := Product{
+23
View File
@@ -112,3 +112,26 @@ func (s *Store) MarkVideoUploaded(videoID int64, remoteURL string) error {
}
return nil
}
// UpsertUploadedVideo 按商品和本地路径补写手工放入目录的视频上传结果。
// 它绝不能使用 ReplaceVideos:同一商品可能还有其它视频来源记录。
func (s *Store) UpsertUploadedVideo(productID, localPath string, fileSize int64, remoteURL, now string) error {
result, err := s.db.Exec(`UPDATE videos SET file_size = ?, remote_url = ?, status = ?, last_error = ''
WHERE product_id = ? AND local_path = ?`, fileSize, remoteURL, VideoStatusUploaded, productID, localPath)
if err != nil {
return fmt.Errorf("更新已上传视频记录失败:%w", err)
}
changed, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("读取已上传视频更新数量失败:%w", err)
}
if changed > 0 {
return nil
}
if _, err := s.db.Exec(`INSERT INTO videos (
product_id, source_item, source_url, local_path, file_size, remote_url, status, last_error, created_at
) VALUES (?, '', '', ?, ?, ?, ?, '', ?)`, productID, localPath, fileSize, remoteURL, VideoStatusUploaded, now); err != nil {
return fmt.Errorf("补写已上传视频记录失败:%w", err)
}
return nil
}
+38
View File
@@ -61,3 +61,41 @@ func Test取第一个存在的已下载视频并写回上传状态(t *testing.T)
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)
}
}