feat: 视频按蝦皮商品 ID 分子目录存放,文件名以蝦皮商品 ID 为前缀

原先所有视频平铺在视频目录下,后续别的程序无法按商品区分。改为:

  <视频目录>/<蝦皮商品ID>/<蝦皮商品ID>_<序号>.mp4
  例:运行数据\视频\40583431295\40583431295_1.mp4

- 子目录名和文件名前缀都用蝦皮商品 ID(itemId),不是货憨憨的内部
  记录 ID,因为后续程序对接的是 Shopee 侧的标识
- 文件名同时带前缀,是为了文件被单独挪走或和别的商品混在一起时
  仍能认出归属
- 视频来自哪个淘宝同款不再体现在文件名里,该信息记录在
  videos.source_item,需要回溯来源时查库

安全处理:目录名直接来自货憨憨返回的商品 ID,正常是纯数字但不能假定。
新增 SafeDirName 只保留字母数字与减号下划线;并加固 safeID,
让 ".." 这类全部字符被替换后的输入回落为 unknown,而不是生成
"__" 这种无意义目录名。有测试覆盖路径穿越、Windows 非法字符等输入。

GetVideoSummary 的目录改为取实际文件所在目录而非拼接,
这样分目录之前下载的旧文件(平铺在根目录)点「目录」也能正确打开。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
QiuSW
2026-09-03 10:37:27 +08:00
co-authored by Claude Opus 5
parent 46378db86b
commit e71559e2f6
3 changed files with 96 additions and 14 deletions
+21 -7
View File
@@ -476,17 +476,24 @@ func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
if err != nil {
return result, a.failVideoFetch(product.ID, fmt.Errorf("解析视频目录失败:%w", err))
}
// 每个蝦皮商品一个子目录,方便后续别的程序按商品 ID 找视频。
// 目录名用蝦皮商品 ID(itemId),不是货憨憨的内部记录 ID。
productDir := filepath.Join(videoDir, downloader.SafeDirName(product.ItemID))
if err := os.MkdirAll(productDir, 0o755); err != nil {
return result, a.failVideoFetch(product.ID, fmt.Errorf("创建商品视频目录失败:%w", err))
}
result.SourceItem = selected.ItemID
result.VideoCount = len(videoURLs)
result.Directory = videoDir
result.Directory = productDir
referer := "https://item.taobao.com/item.htm?id=" + url.QueryEscape(selected.ItemID)
downloaderClient := downloader.New()
records := make([]store.Video, 0, len(videoURLs))
now := time.Now().Format("2006-01-02 15:04:05")
var firstDownloadError error
for i, sourceURL := range videoURLs {
name := downloader.Filename(product.ID, selected.ItemID, i+1)
target := filepath.Join(videoDir, name)
name := downloader.Filename(product.ItemID, i+1)
target := filepath.Join(productDir, name)
host := videoURLHost(sourceURL)
a.log.Info("商品 %s 正在下载视频 %d/%d(主机 %s,文件 %s)",
product.ID, i+1, len(videoURLs), host, name)
@@ -578,10 +585,17 @@ func (a *App) GetVideoSummary(productID string) (VideoSummary, error) {
summary.DownloadedCount++
}
}
if summary.DownloadedCount > 0 {
summary.Directory, err = filepath.Abs(a.cfg.Download.VideoDir)
if err != nil {
return VideoSummary{}, fmt.Errorf("解析视频目录失败:%w", err)
// 目录取实际文件所在的目录,而不是拼出来的。
// 这样按蝦皮商品 ID 分目录之前下载的旧文件(平铺在根目录)也能正确打开。
if summary.DownloadedCount > 0 && summary.Directory == "" {
for _, item := range items {
if item.Status != store.VideoStatusDownloaded || item.LocalPath == "" {
continue
}
if info, statErr := os.Stat(item.LocalPath); statErr == nil && !info.IsDir() {
summary.Directory = filepath.Dir(item.LocalPath)
break
}
}
}
return summary, nil
+25 -2
View File
@@ -57,11 +57,20 @@ func NewWithOptions(client *http.Client, probe ProbeFunc) *Downloader {
}
// Filename 生成 Windows 可用且不会逃出目标目录的视频文件名。
func Filename(productID, sourceItem string, index int) string {
//
// 形如 40583431295_1.mp4:前缀是蝦皮商品 ID(和所在子目录同名),
// 后面是该商品的视频序号,从 1 开始。
//
// 文件被单独挪走或和别的商品混在一起时,靠前缀仍能认出属于哪个商品,
// 后续别的程序也好按前缀匹配。
//
// 视频来自哪个淘宝同款不体现在文件名里,那个记录在 videos.source_item,
// 需要回溯来源时查库。
func Filename(shopeeItemID string, index int) string {
if index < 1 {
index = 1
}
return fmt.Sprintf("淘宝-%s-%s-%d.mp4", safeID(productID), safeID(sourceItem), index)
return fmt.Sprintf("%s_%d.mp4", safeID(shopeeItemID), index)
}
func safeID(value string) string {
@@ -77,6 +86,11 @@ func safeID(value string) string {
if value == "" {
return "unknown"
}
// 全是占位下划线说明原值里没有任何可用字符(比如 ".." 或 "///"),
// 这种目录名毫无意义,回落到 unknown 更好排查。
if strings.Trim(value, "_") == "" {
return "unknown"
}
return value
}
@@ -188,3 +202,12 @@ func runFFProbe(ctx context.Context, path string) (ProbeResult, error) {
}
return ProbeResult{Duration: duration, Size: size, FormatName: payload.Format.FormatName}, nil
}
// SafeDirName 把蝦皮商品 ID 清洗成可以直接当目录名的字符串。
//
// 商品 ID 正常是纯数字,但不能假定:一旦货憨憨返回带路径分隔符或
// .. 的值,直接拼进路径就能写到目录之外。这里统一只保留字母数字
// 和减号下划线。
func SafeDirName(value string) string {
return safeID(value)
}
+50 -5
View File
@@ -13,12 +13,28 @@ import (
)
func Test文件名特殊字符不会生成非法路径(t *testing.T) {
got := Filename(`商品:/\\*?"<>|..`, `同款/A:B`, 2)
if strings.ContainsAny(got, `:/\\*?"<>|`) {
t.Fatalf("文件名仍包含 Windows 非法字符:%s", got)
got := Filename(`商品:/\*?"<>|..`, 2)
if strings.ContainsAny(got, `:/\*?"<>|`) {
t.Fatalf("文件名不得含 Windows 非法字符:%q", got)
}
if filepath.Base(got) != got || !strings.HasSuffix(got, ".mp4") {
t.Fatalf("文件名不应包含目录且必须以 mp4 结尾:%s", got)
if strings.Contains(got, "..") {
t.Fatalf("文件名不得含 ..,否则可能逃出目标目录:%q", got)
}
if !strings.HasSuffix(got, "_2.mp4") {
t.Fatalf("文件名应以 _序号.mp4 结尾,实际 %q", got)
}
}
// 文件名前缀必须是蝦皮商品 ID,后续别的程序靠它区分商品。
func Test文件名以蝦皮商品ID为前缀(t *testing.T) {
if got := Filename("40583431295", 1); got != "40583431295_1.mp4" {
t.Fatalf("应当是 40583431295_1.mp4,实际 %q", got)
}
if got := Filename("40583431295", 0); got != "40583431295_1.mp4" {
t.Fatalf("序号小于 1 时应回落为 1,实际 %q", got)
}
if got := Filename("40583431295", 3); got != "40583431295_3.mp4" {
t.Fatalf("应当是 40583431295_3.mp4,实际 %q", got)
}
}
@@ -99,3 +115,32 @@ func Test下载校验成功后改名为正式文件(t *testing.T) {
t.Fatalf("成功后不应留下 .part:%v", err)
}
}
// 目录名直接来自货憨憨返回的蝦皮商品 ID。正常是纯数字,
// 但不能假定:一旦返回带路径分隔符或 .. 的值,拼进路径就能写到
// 视频目录之外。这个测试守住清洗逻辑。
func TestSafeDirName拒绝危险输入(t *testing.T) {
cases := []struct {
in string
want string
}{
{"40583431295", "40583431295"},
{"", "unknown"},
{" ", "unknown"},
{"..", "unknown"},
{"../../windows", "______windows"},
{`a\b`, "a_b"},
{"a/b", "a_b"},
{"a:b", "a_b"},
{"CON", "CON"},
}
for _, c := range cases {
got := SafeDirName(c.in)
if got != c.want {
t.Fatalf("SafeDirName(%q) 应当是 %q,实际 %q", c.in, c.want, got)
}
if strings.ContainsAny(got, `/\:`) || got == ".." {
t.Fatalf("清洗后仍含危险字符:%q", got)
}
}
}