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) + } +}