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:
@@ -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 只读本地店铺缓存,不会联网。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 清洗成可以直接当目录名的字符串。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user