diff --git a/app.go b/app.go
index 6ea7dd3..ccf49b8 100644
--- a/app.go
+++ b/app.go
@@ -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 只读本地店铺缓存,不会联网。
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue
index c590918..7519027 100644
--- a/frontend/src/views/ProductListView.vue
+++ b/frontend/src/views/ProductListView.vue
@@ -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"
/>
+ 上传
+
重置
搜索
diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go
index f6244b4..279ba3a 100644
--- a/internal/downloader/downloader.go
+++ b/internal/downloader/downloader.go
@@ -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 清洗成可以直接当目录名的字符串。
diff --git a/internal/downloader/downloader_test.go b/internal/downloader/downloader_test.go
index df21ec5..6e680aa 100644
--- a/internal/downloader/downloader_test.go
+++ b/internal/downloader/downloader_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/store/product.go b/internal/store/product.go
index 3ad9a3b..a862a84 100644
--- a/internal/store/product.go
+++ b/internal/store/product.go
@@ -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)
diff --git a/internal/store/product_test.go b/internal/store/product_test.go
index a85893d..27f8b66 100644
--- a/internal/store/product_test.go
+++ b/internal/store/product_test.go
@@ -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{
diff --git a/internal/store/video.go b/internal/store/video.go
index 8d89f70..1ce31bd 100644
--- a/internal/store/video.go
+++ b/internal/store/video.go
@@ -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
+}
diff --git a/internal/store/video_test.go b/internal/store/video_test.go
index b8f2a2f..d73a776 100644
--- a/internal/store/video_test.go
+++ b/internal/store/video_test.go
@@ -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)
+ }
+}