Files
cmsp/internal/store/video.go
T
QiuSWandClaude Opus 5 d1149b629d feat: 视频上传回货憨憨并关联商品,首版限单商品 (#17)
接口契约来自 payloads/huohanhan_batch_save_product_video.har,两步:

  POST product/material/uploadFiles              multipart
       files / isLocalFile=true / fileType=1  →  bean[0] 是 COS 地址
  POST product/batchEdit/batchUpdateShopProductVideo   json
       [{id, platformShopId, videoUrl}]        裸数组,只有三个字段

id 是货憨憨内部记录 ID(products.id),不是蝦皮商品 ID。

不采用 common-product/updateProductItem 那条路:它要回传整份商品共 56 个
字段 26589 字节,而 getDetail 返回的结构无法直接回传,model[].priceInfo
的值就对不上——那是价格数据,照抄会写坏真实店铺且不可逆。
batchUpdateShopProductVideo 只改 videoUrl 一个字段。

这是本项目第一个会往真实店铺写、且删不回来的功能,所以:

- 只有显式点击「上传数据」才触发,不作为任何查询流程的副作用
- 必须二次确认,列出蝦皮 ID、商品名、本地文件名与大小
- 该商品已有视频时额外红字警告会覆盖且无法恢复(接口是覆盖语义,
  videoUrl 传空即删除)
- 首版一次只能一个商品,拦截在 Go 侧,前端只做提示
- 任一步非 SUCCESS 立即停止,不自动重试

关联成功后必须回读 getShopItemInfoPage 确认 records[0].video 非空,
才写 upload_status=done。接口返回 SUCCESS 不等于视频真的挂上了。

容量判断整体延后:payloads 下所有 HAR 都没有 getMaterialSize 的证据,
路径与返回结构均属推测,不凭猜测实现。本次只在确认框放一行静态提示,
待补抓包后另开工单。

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

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

115 lines
3.5 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
}