解除 #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
138 lines
4.6 KiB
Go
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
|
|
}
|