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

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

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

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

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

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

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

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

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

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

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

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

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

138 lines
4.6 KiB
Go

package store
import (
"fmt"
"os"
"strings"
)
const (
VideoStatusPending = "pending"
VideoStatusDownloaded = "downloaded"
VideoStatusFailed = "failed"
VideoStatusUploaded = "uploaded"
)
// Video 对应 videos 表的一行。
type Video struct {
ID int64 `json:"id"`
ProductID string `json:"productId"`
SourceItem string `json:"sourceItem"`
SourceURL string `json:"sourceUrl"`
LocalPath string `json:"localPath"`
FileSize int64 `json:"fileSize"`
RemoteURL string `json:"remoteUrl"`
Status string `json:"status"`
LastError string `json:"lastError"`
CreatedAt string `json:"createdAt"`
}
// ReplaceVideos 在一个事务内按商品全量替换视频记录。
func (s *Store) ReplaceVideos(productID string, items []Video, now string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("开启视频替换事务失败:%w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM videos WHERE product_id = ?`, productID); err != nil {
return fmt.Errorf("清理商品旧视频失败:%w", err)
}
stmt, err := tx.Prepare(`INSERT INTO videos (
product_id, source_item, source_url, local_path, file_size,
remote_url, status, last_error, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("准备写入视频语句失败:%w", err)
}
defer stmt.Close()
for _, item := range items {
createdAt := item.CreatedAt
if createdAt == "" {
createdAt = now
}
if _, err := stmt.Exec(productID, item.SourceItem, item.SourceURL,
item.LocalPath, item.FileSize, item.RemoteURL, item.Status,
item.LastError, createdAt); err != nil {
return fmt.Errorf("写入商品视频失败:%w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交视频替换事务失败:%w", err)
}
return nil
}
func (s *Store) ListVideos(productID string) ([]Video, error) {
rows, err := s.db.Query(`SELECT id, product_id, source_item, source_url,
local_path, file_size, remote_url, status, last_error, created_at
FROM videos WHERE product_id = ? ORDER BY id`, productID)
if err != nil {
return nil, fmt.Errorf("查询商品视频失败:%w", err)
}
defer rows.Close()
items := make([]Video, 0)
for rows.Next() {
var item Video
if err := rows.Scan(&item.ID, &item.ProductID, &item.SourceItem,
&item.SourceURL, &item.LocalPath, &item.FileSize, &item.RemoteURL,
&item.Status, &item.LastError, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("读取商品视频行失败:%w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历商品视频失败:%w", err)
}
return items, nil
}
// FirstUploadableVideo 返回第一个已下载且本地文件仍存在的记录。
func (s *Store) FirstUploadableVideo(productID string) (Video, bool, error) {
items, err := s.ListVideos(productID)
if err != nil {
return Video{}, false, err
}
for _, item := range items {
if item.Status != VideoStatusDownloaded || strings.TrimSpace(item.LocalPath) == "" {
continue
}
info, err := os.Stat(item.LocalPath)
if err != nil || info.IsDir() {
continue
}
return item, true, nil
}
return Video{}, false, nil
}
// MarkVideoUploaded 写回成功上传后的素材地址和状态。
func (s *Store) MarkVideoUploaded(videoID int64, remoteURL string) error {
if _, err := s.db.Exec(`UPDATE videos SET remote_url = ?, status = ?, last_error = '' WHERE id = ?`, remoteURL, VideoStatusUploaded, videoID); err != nil {
return fmt.Errorf("更新视频上传状态失败:%w", err)
}
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
}