From e71559e2f6571e7f44c2cfb41cf9b46ed656852a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 3 Sep 2026 10:37:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=8C=89=E8=9D=A6?= =?UTF-8?q?=E7=9A=AE=E5=95=86=E5=93=81=20ID=20=E5=88=86=E5=AD=90=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=AD=98=E6=94=BE=EF=BC=8C=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E4=BB=A5=E8=9D=A6=E7=9A=AE=E5=95=86=E5=93=81=20ID=20=E4=B8=BA?= =?UTF-8?q?=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先所有视频平铺在视频目录下,后续别的程序无法按商品区分。改为: <视频目录>/<蝦皮商品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 Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq --- app.go | 28 +++++++++---- internal/downloader/downloader.go | 27 ++++++++++++- internal/downloader/downloader_test.go | 55 +++++++++++++++++++++++--- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/app.go b/app.go index 6adb547..4306061 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go index ff47e83..3734562 100644 --- a/internal/downloader/downloader.go +++ b/internal/downloader/downloader.go @@ -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) +} diff --git a/internal/downloader/downloader_test.go b/internal/downloader/downloader_test.go index 8f125f2..70edb5d 100644 --- a/internal/downloader/downloader_test.go +++ b/internal/downloader/downloader_test.go @@ -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) + } + } +}