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

1279 lines
43 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"sync"
"time"
"cmsp/internal/config"
"cmsp/internal/downloader"
"cmsp/internal/huohanhan"
"cmsp/internal/logx"
"cmsp/internal/store"
"cmsp/internal/taobao"
"cmsp/internal/task"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App 是暴露给前端的对象。
//
// 重要约定(会决定将来能不能顺利升级到 Wails v3):
//
// - 只有 main.go 和 app.go 这两个文件允许 import wails 包。
// internal/ 下面的业务代码一律不许依赖 wails,这样将来换框架
// 只用改这两个文件。
//
// - 本文件里的方法只做三件事:校验参数、调用 internal 里的业务代码、
// 把结果转成前端能用的形式。不要在这里写业务逻辑。
//
// - 每个公开方法都会被 Wails 生成成 JavaScript 函数给前端调用,
// 所以方法名和参数类型要稳定,改名等于改接口。
type App struct {
ctx context.Context
cfgPath string
cfg config.Config
db *store.Store
log *logx.Logger
probe downloader.ProbeFunc
videoTaskMu sync.Mutex
videoTask *task.Runner
}
// NewApp 创建应用对象。真正的初始化在 startup 里做。
func NewApp() *App {
return &App{
cfgPath: config.DefaultPath(),
cfg: config.Default(),
log: logx.New(2000),
probe: downloader.Probe,
}
}
// startup 在窗口创建后被 Wails 调用。
//
// 这里做的事要尽量少、尽量不出错:这时候界面已经显示了,
// 抛错的话使用者只会看到一个空白窗口,不知道发生了什么。
// 所以出错时记录到日志,让界面能打开,由使用者去「参数设置」里修。
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 日志写入后推给前端,「运行日志」窗口就能实时刷新。
a.log.SetHandler(func(e logx.Entry) {
runtime.EventsEmit(ctx, "log:entry", e)
})
a.log.Info("cmsp 启动")
cfg, err := config.Load(a.cfgPath)
if err != nil {
// 配置读不出来不算致命:用默认配置继续,让使用者能进设置页修。
a.log.Error("读取配置失败,已使用默认配置:%v", err)
cfg = config.Default()
}
a.cfg = cfg
a.log.Info("配置文件:%s", a.cfgPath)
dbPath := store.DefaultPath()
db, err := store.Open(dbPath)
if err != nil {
// 数据库打不开就没法干活了,但仍然让窗口显示,
// 并把原因写在日志里,比直接闪退好排查。
a.log.Error("打开数据库失败:%v", err)
return
}
a.db = db
a.log.Success("数据库就绪:%s", dbPath)
if count, err := a.db.ResetRunningStatuses(); err != nil {
a.log.Warn("启动时重置残留的运行中状态失败:%v", err)
} else if count > 0 {
a.log.Info("启动时重置了 %d 个残留的运行中状态", count)
}
// 未配置账号时不要刷新。同事第一次安装还没填账号就启动,
// 同步刷新必然失败,一开机就弹「登录失败」,体验很差。
if cfg.Huohanhan.Account != "" && cfg.Huohanhan.Password != "" {
go a.refreshShopsOnStartup()
}
}
// shutdown 在窗口关闭时被 Wails 调用,用来收尾。
func (a *App) shutdown(ctx context.Context) {
if a.db != nil {
if err := a.db.Close(); err != nil {
a.log.Warn("关闭数据库出错:%v", err)
}
}
}
// ---------------------------------------------------------------- 配置
// GetConfig 返回当前配置给「参数设置」页显示。
//
// 注意这里返回的是含密码的完整配置:设置页本来就要显示密码框,
// 前端不会把它写进日志。真正需要脱敏的是日志和工单。
func (a *App) GetConfig() config.Config {
return a.cfg
}
// SaveConfig 校验并保存配置。
//
// 返回的 error 会变成前端的 Promise reject,界面直接把消息弹出来,
// 所以 Validate 里的错误信息必须是能看懂的中文。
func (a *App) SaveConfig(cfg config.Config) error {
if err := config.Save(a.cfgPath, cfg); err != nil {
a.log.Error("保存配置失败:%v", err)
return err
}
a.cfg = cfg
a.log.Success("配置已保存")
return nil
}
// ResetConfig 把配置还原成默认值,但不写文件。
// 使用者还要点「保存设置」才会真正生效,避免手滑丢掉已填的账号。
func (a *App) ResetConfig() config.Config {
return config.Default()
}
// TestHuohanhanLogin 使用当前设置重新登录并在线验证认证状态。
// 设置页用它确认账号、密码、网址和 OCR 服务可以协同工作。
func (a *App) TestHuohanhanLogin() error {
if a.db == nil {
return fmt.Errorf("数据库未就绪,请查看运行日志")
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
manager := huohanhan.NewAuthManager(a.cfg.Huohanhan, a.db, a.log, huohanhan.AuthOptions{})
_, err := manager.ForceLogin(ctx)
return err
}
// OpenTaobaoLogin 打开专属 Chrome 的淘宝登录页。
// 登录、验证码和安全验证全部由使用者在 Chrome 中手动完成。
func (a *App) OpenTaobaoLogin() error {
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
state, err := taobao.EnsureBrowser(ctx, a.cfg.Taobao, "https://login.taobao.com/member/login.jhtml")
if err != nil {
a.log.Error("打开淘宝专属 Chrome 失败:%v", err)
return err
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
a.log.Error("连接淘宝专属 Chrome 失败:%v", err)
return err
}
defer cdp.Close()
if err := cdp.Navigate("https://login.taobao.com/member/login.jhtml", 0); err != nil {
a.log.Error("打开淘宝登录页失败:%v", err)
return err
}
a.log.Info("已打开淘宝登录页,请在专属 Chrome 中手动完成登录或安全验证")
return nil
}
// CheckTaobaoLogin 执行 Cookie 名称与「我的淘宝」页面两层登录检查。
func (a *App) CheckTaobaoLogin() (taobao.LoginStatus, error) {
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
state, err := taobao.EnsureBrowser(ctx, a.cfg.Taobao, "https://www.taobao.com/")
if err != nil {
status := taobao.LoginStatus{Message: err.Error()}
a.emitTaobaoStatus(status)
a.log.Error("准备淘宝专属 Chrome 失败:%v", err)
return status, err
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
status := taobao.LoginStatus{Message: err.Error()}
a.emitTaobaoStatus(status)
a.log.Error("连接淘宝专属 Chrome 失败:%v", err)
return status, err
}
defer cdp.Close()
status, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
status.Message = err.Error()
a.emitTaobaoStatus(status)
a.log.Error("检查淘宝登录状态失败:%v", err)
return status, err
}
a.emitTaobaoStatus(status)
if status.Valid {
if status.Nickname == "" {
a.log.Warn("淘宝登录有效,但页面中未识别到展示昵称;昵称不参与登录判定")
} else {
a.log.Success("淘宝登录检查通过")
}
} else {
a.log.Warn("淘宝登录检查未通过:%s", status.Message)
}
return status, nil
}
// CloseTaobaoBrowser 安全关闭归属明确的专属 Chrome。
func (a *App) CloseTaobaoBrowser() error {
stateFile := config.ResolveDataPath("浏览器运行状态.json")
closed, err := taobao.CloseBrowser(stateFile, a.cfg.Taobao.UserDataDir)
if err != nil {
a.log.Error("关闭淘宝专属 Chrome 失败:%v", err)
return err
}
status := taobao.LoginStatus{Message: "淘宝专属 Chrome 已关闭"}
a.emitTaobaoStatus(status)
if closed {
a.log.Info("淘宝专属 Chrome 已关闭")
} else {
a.log.Info("没有需要关闭的淘宝专属 Chrome")
}
return nil
}
func (a *App) emitTaobaoStatus(status taobao.LoginStatus) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "taobao:status", status)
}
}
// ---------------------------------------------------------------- 路径选择
// PickChromeExe 弹出文件选择框让使用者挑 chrome.exe。
//
// 用文件选择框而不是目录选择框,因为这个配置项指向的是文件本身。
// 使用者点了取消时返回空字符串,前端要判断一下再覆盖原值。
func (a *App) PickChromeExe() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择 chrome.exe",
DefaultDirectory: `C:\Program Files\Google\Chrome\Application`,
Filters: []runtime.FileFilter{
{DisplayName: "Chrome 可执行文件 (chrome.exe)", Pattern: "chrome.exe"},
{DisplayName: "可执行文件 (*.exe)", Pattern: "*.exe"},
},
})
}
// PickDirectory 弹出目录选择框,用于用户数据目录和视频保存目录。
func (a *App) PickDirectory(title string) (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: title,
})
}
// ---------------------------------------------------------------- 商品
// ListProducts 按条件分页查询本地商品。
//
// 注意这里查的是本地 SQLite,不会去请求货憨憨。
// 只有点「下载数据」时才会真正联网。
func (a *App) ListProducts(query store.ProductQuery) (store.ProductPage, error) {
if a.db == nil {
return store.ProductPage{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.ListProducts(query)
}
// CountProducts 返回本地商品总数。
func (a *App) CountProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.CountProducts()
}
// SearchTaobaoByProduct 用一个本地商品的主图执行一次淘宝以图搜。
// 结果只返回前端展示,不写入 SQLite,也不创建批量任务。
func (a *App) SearchTaobaoByProduct(productID string) ([]taobao.SimilarItem, error) {
if a.db == nil {
return nil, fmt.Errorf("数据库未就绪,请查看运行日志")
}
product, found, err := a.db.GetProduct(strings.TrimSpace(productID))
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("找不到商品:%s", productID)
}
if strings.TrimSpace(product.MainImage) == "" {
return nil, fmt.Errorf("商品没有可用于搜索的主图")
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
state, err := taobao.EnsureBrowser(ctx, a.cfg.Taobao, "https://www.taobao.com/")
if err != nil {
a.log.Error("准备淘宝专属 Chrome 失败:%v", err)
return nil, err
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
a.log.Error("连接淘宝专属 Chrome 失败:%v", err)
return nil, err
}
defer cdp.Close()
status, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
status.Message = err.Error()
a.emitTaobaoStatus(status)
a.log.Error("以图搜前检查淘宝登录状态失败:%v", err)
return nil, err
}
a.emitTaobaoStatus(status)
if !status.Valid {
a.log.Warn("淘宝登录检查未通过,已停止以图搜:%s", status.Message)
return nil, fmt.Errorf("淘宝登录无效:%s", status.Message)
}
a.log.Info("开始为商品 %s 搜索淘宝同款", product.ID)
items, err := taobao.SearchByImage(ctx, cdp, product.MainImage)
if err != nil {
a.log.Error("商品 %s 搜同款失败:%v", product.ID, err)
return nil, err
}
a.log.Success("商品 %s 搜同款完成,共 %d 条结果", product.ID, len(items))
return items, nil
}
// FetchResult 是单个商品取视频的结果,供界面刷新数量和目录状态。
type FetchResult struct {
ProductID string `json:"productId"`
SourceItem string `json:"sourceItem"`
VideoCount int `json:"videoCount"`
DownloadedCount int `json:"downloadedCount"`
Directory string `json:"directory"`
}
// VideoSummary 是商品列表显示所需的最小本地视频摘要。
type VideoSummary struct {
DownloadedCount int `json:"downloadedCount"`
Directory string `json:"directory"`
}
// UploadPreview 是二次确认框显示的本地预检汇总;不含任何容量查询结果。
type UploadPreview struct {
Total int `json:"total"`
UploadableCount int `json:"uploadableCount"`
UploadableSize int64 `json:"uploadableSize"`
MissingCount int `json:"missingCount"`
InvalidCount int `json:"invalidCount"`
HasExistingVideo bool `json:"hasExistingVideo"`
}
// FetchVideosForProduct 为一个商品依次搜同款、提取视频并下载到本地。
// 单商品入口与批量任务共用 prepareVideoFetch;这里保持原有串行下载行为。
func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
ctx := a.appContext()
riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold)
result, work, err := a.prepareVideoFetch(ctx, productID, riskGuard)
if err != nil {
return result, err
}
if work.Skipped {
return result, nil
}
downloaded, err := task.RunDownloads(ctx, 1, work)
result.DownloadedCount = downloaded
return result, err
}
// prepareVideoFetch 串行完成一个商品所有会触碰 CDP 的步骤,并把纯 HTTP
// 下载闭包交给调用方。批量 Runner 因此不会让多个协程争抢同一个页面。
func (a *App) prepareVideoFetch(ctx context.Context, productID string, riskGuard *task.EmptyRiskGuard) (FetchResult, task.Work, error) {
productID = strings.TrimSpace(productID)
result := FetchResult{ProductID: productID}
if a.db == nil {
return result, task.Work{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
product, found, err := a.db.GetProduct(productID)
if err != nil {
return result, task.Work{}, err
}
if !found {
return result, task.Work{}, fmt.Errorf("找不到商品:%s", productID)
}
if strings.TrimSpace(product.MainImage) == "" {
err := fmt.Errorf("商品没有可用于搜索的主图")
return result, task.Work{}, a.failVideoFetch(product.ID, err)
}
state, err := taobao.EnsureBrowser(ctx, a.cfg.Taobao, "https://www.taobao.com/")
if err != nil {
a.log.Error("商品 %s 准备淘宝专属 Chrome 失败:%v", product.ID, err)
return result, task.Work{}, a.failVideoFetch(product.ID, err)
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
a.log.Error("商品 %s 连接淘宝专属 Chrome 失败:%v", product.ID, err)
return result, task.Work{}, a.failVideoFetch(product.ID, err)
}
defer cdp.Close()
login, err := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if err != nil {
login.Message = err.Error()
a.emitTaobaoStatus(login)
a.log.Error("商品 %s 取视频前深度检查淘宝登录失败:%v", product.ID, err)
return result, task.Work{}, fmt.Errorf("%w:%v", task.ErrLoginRequired, err)
}
a.emitTaobaoStatus(login)
if !login.Valid {
a.log.Warn("商品 %s 已触发登录停止门:%s", product.ID, login.Message)
return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, login.Message)
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoPending, store.DownloadRunning, "", ""); err != nil {
return result, task.Work{}, err
}
a.log.Info("商品 %s 开始搜索淘宝同款", product.ID)
similar, err := taobao.SearchByImage(ctx, cdp, product.MainImage)
if err != nil {
return result, task.Work{}, a.failVideoFetch(product.ID, fmt.Errorf("搜同款失败:%w", err))
}
limit := min(len(similar), a.cfg.Download.SearchTopN)
a.log.Info("商品 %s 搜到 %d 个同款,将按顺序检查前 %d 个", product.ID, len(similar), limit)
var selected taobao.SimilarItem
var videoURLs []string
var detailErrors []error
for i := 0; i < limit; i++ {
if i > 0 {
wait := randomWait(a.cfg.Download.WaitSecondsMin, a.cfg.Download.WaitSecondsMax)
a.log.Info("商品 %s 等待 %.1f 秒后检查下一个同款", product.ID, wait.Seconds())
time.Sleep(wait)
}
item := similar[i]
a.log.Info("商品 %s 正在检查第 %d/%d 个淘宝同款 %s", product.ID, i+1, limit, item.ItemID)
login, guardErr := taobao.CheckLogin(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second)))
if guardErr != nil || !login.Valid {
message := login.Message
if guardErr != nil {
message = guardErr.Error()
}
return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, message)
}
detail, detailErr := taobao.ExtractDetailVideos(cdp, item.ItemID, time.Duration(a.cfg.Download.DetailWaitSeconds*float64(time.Second)))
if errors.Is(detailErr, taobao.ErrLoginInvalid) {
a.emitTaobaoStatus(taobao.LoginStatus{Message: detailErr.Error()})
a.log.Warn("商品 %s 访问详情页时登录失效,已立即停止", product.ID)
return result, task.Work{}, fmt.Errorf("%w:%v", task.ErrLoginRequired, detailErr)
}
if detailErr != nil {
detailErrors = append(detailErrors, detailErr)
a.log.Warn("商品 %s 的同款 %s 详情读取失败:%v", product.ID, item.ItemID, detailErr)
continue
}
quick, quickErr := taobao.QuickCheckLogin(cdp, detail.Title, detail.Text)
if quickErr != nil || !quick.Valid {
message := quick.Message
if quickErr != nil {
message = quickErr.Error()
}
return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, message)
}
if riskGuard != nil && riskGuard.Record(len(detail.URLs)) {
return result, task.Work{}, fmt.Errorf("%w:%w:%w", task.ErrLoginRequired, task.ErrRiskSuspected, task.RiskSuspectedError{Consecutive: riskGuard.Consecutive()})
}
if len(detail.URLs) == 0 {
a.log.Info("淘宝同款 %s 未提取到可信视频", item.ItemID)
continue
}
selected = item
videoURLs = detail.URLs
break
}
if len(videoURLs) == 0 {
if len(detailErrors) > 0 {
return result, task.Work{}, a.failVideoFetch(product.ID,
fmt.Errorf("未能完成全部同款详情检查,首个错误:%w", detailErrors[0]))
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoNone, store.DownloadPending, "", ""); err != nil {
return result, task.Work{}, err
}
a.log.Info("商品 %s 检查完 %d 个同款,未找到视频", product.ID, limit)
return result, task.Work{Skipped: true}, nil
}
if len(videoURLs) > a.cfg.Download.MaxVideosPerProduct {
videoURLs = videoURLs[:a.cfg.Download.MaxVideosPerProduct]
}
videoDir, err := filepath.Abs(a.cfg.Download.VideoDir)
if err != nil {
return result, task.Work{}, 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, task.Work{}, a.failVideoFetch(product.ID, fmt.Errorf("创建商品视频目录失败:%w", err))
}
result.SourceItem = selected.ItemID
result.VideoCount = len(videoURLs)
result.Directory = productDir
referer := "https://item.taobao.com/item.htm?id=" + url.QueryEscape(selected.ItemID)
downloaderClient := downloader.New()
records := make([]store.Video, len(videoURLs))
now := time.Now().Format("2006-01-02 15:04:05")
downloads := make([]task.DownloadFunc, len(videoURLs))
for i, sourceURL := range videoURLs {
i, sourceURL := i, sourceURL
downloads[i] = func(downloadCtx context.Context) error {
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)
downloaded, downloadErr := downloaderClient.Download(downloadCtx, sourceURL, referer, target, a.cfg.Download.DownloadRetries)
record := store.Video{SourceItem: selected.ItemID, SourceURL: sourceURL,
LocalPath: target, Status: store.VideoStatusFailed, CreatedAt: now}
if downloadErr != nil {
record.LastError = downloadErr.Error()
a.log.Error("商品 %s 视频下载失败(主机 %s,文件 %s):%v",
product.ID, host, name, downloadErr)
} else {
record.Status = store.VideoStatusDownloaded
record.FileSize = downloaded.Size
if downloaded.Skipped {
a.log.Success("商品 %s 视频已存在,跳过下载(文件 %s,%d 字节)",
product.ID, name, downloaded.Size)
} else {
a.log.Success("商品 %s 视频校验完成(文件 %s,时长 %.2f 秒,%d 字节)",
product.ID, name, downloaded.Duration, downloaded.Size)
}
}
records[i] = record
return downloadErr
}
}
work := task.Work{Downloads: downloads}
work.Finalize = func(downloadErrors []error) error {
result.DownloadedCount = 0
var firstDownloadError error
for _, downloadErr := range downloadErrors {
if downloadErr == nil {
result.DownloadedCount++
} else if firstDownloadError == nil {
firstDownloadError = downloadErr
}
}
// 视频记录只在全部下载结束后替换。ReplaceVideos 会按商品全量覆盖,
// 提前清空只会让任务中断、重启或崩溃时留下磁盘有文件而库中无记录的窗口。
if err := a.db.ReplaceVideos(product.ID, records, now); err != nil {
return a.failVideoFetch(product.ID, err)
}
if firstDownloadError != nil {
if err := a.db.UpdateProductStatus(product.ID, store.VideoFound, store.DownloadFailed, "", firstDownloadError.Error()); err != nil {
return err
}
return firstDownloadError
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoFound, store.DownloadDone, "", ""); err != nil {
return err
}
a.log.Success("商品 %s 采用同款 %s,共下载 %d 个视频", product.ID, selected.ItemID, result.DownloadedCount)
return nil
}
return result, work, nil
}
func (a *App) failVideoFetch(productID string, cause error) error {
if err := a.db.UpdateProductStatus(productID, "", store.DownloadFailed, "", cause.Error()); err != nil {
a.log.Error("商品 %s 记录取视频失败状态时出错:%v", productID, err)
}
a.log.Error("商品 %s 取视频失败:%v", productID, cause)
return cause
}
func (a *App) appContext() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func randomWait(minSeconds, maxSeconds float64) time.Duration {
if maxSeconds <= minSeconds {
return time.Duration(minSeconds * float64(time.Second))
}
seconds := minSeconds + rand.Float64()*(maxSeconds-minSeconds)
return time.Duration(seconds * float64(time.Second))
}
func videoURLHost(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Hostname() == "" {
return "未知"
}
return strings.ToLower(parsed.Hostname())
}
// GetVideoSummary 返回列表页需要的已下载视频数量与目录。
func (a *App) GetVideoSummary(productID string) (VideoSummary, error) {
if a.db == nil {
return VideoSummary{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
items, err := a.db.ListVideos(strings.TrimSpace(productID))
if err != nil {
return VideoSummary{}, err
}
summary := VideoSummary{}
for _, item := range items {
if item.Status != store.VideoStatusDownloaded || item.LocalPath == "" {
continue
}
info, statErr := os.Stat(item.LocalPath)
if statErr == nil && !info.IsDir() && info.Size() > 0 {
summary.DownloadedCount++
}
}
// 目录取实际文件所在的目录,而不是拼出来的。
// 这样按蝦皮商品 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
}
// OpenFolder 只允许打开配置的视频目录及其子目录。
func (a *App) OpenFolder(path string) error {
base, err := filepath.Abs(a.cfg.Download.VideoDir)
if err != nil {
return fmt.Errorf("解析视频目录失败:%w", err)
}
target, err := filepath.Abs(strings.TrimSpace(path))
if err != nil {
return fmt.Errorf("解析要打开的目录失败:%w", err)
}
baseResolved, err := filepath.EvalSymlinks(base)
if err != nil {
return fmt.Errorf("视频目录不可访问:%w", err)
}
targetResolved, err := filepath.EvalSymlinks(target)
if err != nil {
return fmt.Errorf("目录不可访问:%w", err)
}
relative, err := filepath.Rel(baseResolved, targetResolved)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("只能打开视频保存目录及其子目录")
}
info, err := os.Stat(targetResolved)
if err != nil {
return fmt.Errorf("目录不可访问:%w", err)
}
if !info.IsDir() {
return fmt.Errorf("只能打开目录")
}
if err := exec.Command("explorer.exe", targetResolved).Start(); err != nil {
return fmt.Errorf("打开视频目录失败:%w", err)
}
return nil
}
// OpenInBrowser 用系统默认浏览器打开一个网址。
//
// 只允许 http/https,避免以后有人把本地文件路径或自定义协议传进来
// 变成一个可以启动任意程序的入口。
func (a *App) OpenInBrowser(rawURL string) error {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return fmt.Errorf("只能打开 http 或 https 地址")
}
runtime.BrowserOpenURL(a.ctx, rawURL)
return nil
}
// SaveImageAs 把一张网络图片下载并另存到使用者选择的位置。
//
// 弹窗里的「另存为」按钮用它。WebView2 的原生右键菜单在个别机器上
// 不给图片选项,这里提供一条不依赖浏览器菜单的路径。
//
// 只接受 http/https,避免变成可以读取本地任意文件的入口。
func (a *App) SaveImageAs(imageURL string) (string, error) {
if !strings.HasPrefix(imageURL, "http://") && !strings.HasPrefix(imageURL, "https://") {
return "", fmt.Errorf("只能保存 http 或 https 图片")
}
// 从地址里猜一个默认文件名,猜不出就用时间戳。
name := path.Base(strings.SplitN(imageURL, "?", 2)[0])
if name == "" || name == "." || name == "/" || !strings.Contains(name, ".") {
name = fmt.Sprintf("商品主图-%s.jpg", time.Now().Format("20060102-150405"))
}
target, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存商品主图",
DefaultFilename: name,
Filters: []runtime.FileFilter{
{DisplayName: "图片 (*.jpg;*.jpeg;*.png;*.webp)", Pattern: "*.jpg;*.jpeg;*.png;*.webp"},
{DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
},
})
if err != nil {
return "", err
}
if target == "" {
// 使用者点了取消,不算错误。
return "", nil
}
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return "", fmt.Errorf("构造图片请求失败:%w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("下载图片失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("下载图片失败:HTTP %d", resp.StatusCode)
}
// 限制 20 MB,避免异常地址把内存吃光。
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return "", fmt.Errorf("读取图片失败:%w", err)
}
if len(data) == 0 {
return "", fmt.Errorf("下载到的图片是空的")
}
if err := os.WriteFile(target, data, 0o600); err != nil {
return "", fmt.Errorf("保存图片失败:%w", err)
}
a.log.Success("商品主图已保存:%s", target)
return target, nil
}
// ---------------------------------------------------------------- 运行日志
// GetLogs 返回当前保留的全部日志,「运行日志」窗口打开时调用一次。
// 之后的新日志靠 log:entry 事件推送,不用轮询。
func (a *App) GetLogs() []logx.Entry {
return a.log.Entries()
}
// ClearLogs 清空日志。
func (a *App) ClearLogs() {
a.log.Clear()
}
// ExportLogs 让使用者选个位置,把日志存成 txt。
func (a *App) ExportLogs() (string, error) {
name := fmt.Sprintf("cmsp-日志-%s.txt", time.Now().Format("20060102-150405"))
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "导出运行日志",
DefaultFilename: name,
Filters: []runtime.FileFilter{
{DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
},
})
if err != nil {
return "", err
}
if path == "" {
// 使用者点了取消,不算错误。
return "", nil
}
if err := writeTextFile(path, a.log.Text()); err != nil {
a.log.Error("导出日志失败:%v", err)
return "", err
}
a.log.Success("日志已导出:%s", path)
return path, nil
}
// ---------------------------------------------------------------- 商品操作
// DownloadProductData 从货憨憨拉取商品列表到本地(需求 R1)。
func (a *App) DownloadProductData(platformShopID string) error {
if a.db == nil {
return fmt.Errorf("数据库未就绪,请查看运行日志")
}
if platformShopID == "" {
return fmt.Errorf("请先选择店铺")
}
client, err := a.newHuohanhanClient()
if err != nil {
return err
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
a.log.Info("开始下载所选店铺的商品数据")
products, diagnoses, err := client.DownloadAllProducts(ctx, platformShopID, func(current, total int) {
a.log.Info("已拉取 %d/%d 页", current, total)
})
if err != nil {
a.log.Error("下载商品数据失败:%v", err)
return err
}
updatedAt := time.Now().Format("2006-01-02 15:04:05")
if err := a.db.UpsertProducts(products, updatedAt); err != nil {
a.log.Error("保存商品数据失败:%v", err)
return err
}
for _, product := range products {
if product.ID == "" {
continue
}
if err := a.db.ReplaceDiagnoses(product.ID, diagnoses[product.ID], updatedAt); err != nil {
a.log.Error("保存商品诊断失败:%v", err)
return err
}
}
a.log.Success("商品数据下载完成,共拉取 %d 条", len(products))
return nil
}
// StartVideoTask 启动选中商品的批量取视频任务。
func (a *App) StartVideoTask(productIDs []string) error {
if a.db == nil {
return fmt.Errorf("数据库未就绪,请查看运行日志")
}
if len(productIDs) == 0 {
return fmt.Errorf("请先勾选要处理的商品")
}
a.videoTaskMu.Lock()
defer a.videoTaskMu.Unlock()
if a.videoTask != nil && a.videoTask.Snapshot().State == task.StateRunning {
return fmt.Errorf("已有任务在运行")
}
riskGuard := task.NewEmptyRiskGuard(a.cfg.Download.RiskEmptyThreshold)
runner := task.NewRunner(task.Options{
Concurrency: a.cfg.Download.Concurrency,
WaitMin: time.Duration(a.cfg.Download.WaitSecondsMin * float64(time.Second)),
WaitMax: time.Duration(a.cfg.Download.WaitSecondsMax * float64(time.Second)),
Load: func(_ context.Context, productID string) (task.Product, error) {
product, found, err := a.db.GetProduct(productID)
if err != nil {
return task.Product{ID: productID}, err
}
if !found {
return task.Product{ID: productID}, fmt.Errorf("找不到商品:%s", productID)
}
return task.Product{
ID: product.ID,
ItemID: product.ItemID,
Name: product.ItemName,
DownloadStatus: product.DownloadStatus,
VideoStatus: product.VideoStatus,
}, nil
},
Prepare: func(ctx context.Context, product task.Product) (task.Work, error) {
_, work, err := a.prepareVideoFetch(ctx, product.ID, riskGuard)
return work, err
},
OnProgress: func(progress task.Progress) {
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "task:progress", progress)
}
},
OnFinished: func(progress task.Progress) {
if a.ctx == nil {
return
}
if progress.State == task.StateLoginRequired {
runtime.EventsEmit(a.ctx, "task:login-required", progress)
return
}
runtime.EventsEmit(a.ctx, "task:finished", progress)
},
})
a.videoTask = runner
return runner.Start(a.appContext(), productIDs)
}
// CountResettableNoneProducts 供设置页在人工确认前显示影响范围。
func (a *App) CountResettableNoneProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.CountResettableNoneProducts()
}
// ResetNoneProducts 仅在未运行取视频任务时执行显式数据订正。
func (a *App) ResetNoneProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
a.videoTaskMu.Lock()
runner := a.videoTask
a.videoTaskMu.Unlock()
if runner != nil && runner.Snapshot().State == task.StateRunning {
return 0, fmt.Errorf("取视频任务运行中,不能重置商品")
}
return a.db.ResetNoneProducts()
}
// StopVideoTask 请求在当前商品已启动的下载全部结束后停止。
func (a *App) StopVideoTask() error {
a.videoTaskMu.Lock()
runner := a.videoTask
a.videoTaskMu.Unlock()
if runner != nil {
runner.Stop()
}
return nil
}
// GetVideoTaskProgress 返回批量任务快照,供前端事件漏收时轮询兜底。
func (a *App) GetVideoTaskProgress() (task.Progress, error) {
a.videoTaskMu.Lock()
runner := a.videoTask
a.videoTaskMu.Unlock()
if runner == nil {
return task.Progress{State: task.StateCompleted}, nil
}
return runner.Snapshot(), nil
}
// DownloadVideos 保留旧 Wails 绑定,行为与新批量入口一致。
func (a *App) DownloadVideos(productIDs []string) error {
return a.StartVideoTask(productIDs)
}
// UploadVideos 把本次勾选的商品逐个串行上传。每个商品的失败只写回自身状态,
// 不会把批量任务当成淘宝登录失效那样全局停止。
func (a *App) UploadVideos(productIDs []string) error {
if a.db == nil {
return fmt.Errorf("数据库未就绪,请查看运行日志")
}
ids := cleanProductIDs(productIDs)
if len(ids) == 0 {
return fmt.Errorf("请先勾选要上传的商品")
}
client, err := a.newHuohanhanClient()
if err != nil {
return err
}
ctx := a.appContext()
for _, productID := range ids {
a.uploadOneVideo(ctx, client, productID, len(ids))
}
return nil
}
func (a *App) uploadOneVideo(ctx context.Context, client *huohanhan.Client, productID string, selectedCount int) {
product, found, err := a.db.GetProduct(productID)
if err != nil {
a.log.Error("商品 %s 读取上传信息失败:%v", productID, err)
return
}
if !found {
a.log.Error("商品 %s 不存在,跳过上传", productID)
return
}
fail := func(cause error) {
if updateErr := a.db.UpdateProductStatus(product.ID, "", "", store.UploadFailed, cause.Error()); updateErr != nil {
a.log.Error("商品 %s 记录上传失败状态时出错:%v", product.ID, updateErr)
}
a.log.Error("商品 %s 上传失败:%v", product.ID, cause)
}
check, err := client.CheckShopProductVideo(ctx, product.ID)
if err != nil {
fail(err)
return
}
if check.Confirmed() && selectedCount > 1 {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadSkippedExisting, ""); err != nil {
a.log.Error("商品 %s 记录已有视频状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 货憨憨已有视频,批量上传跳过", product.ID)
return
}
localPath, info, found, err := a.findUploadVideo(product)
if err != nil {
fail(err)
return
}
if !found {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadMissingVideo, ""); err != nil {
a.log.Error("商品 %s 记录缺少视频状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 子目录没有 mp4 文件,跳过上传", product.ID)
return
}
if invalidReason, err := a.validateUploadVideo(ctx, localPath, info); err != nil {
fail(err)
return
} else if invalidReason != "" {
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadInvalidVideo, invalidReason); err != nil {
a.log.Error("商品 %s 记录视频不合规状态失败:%v", product.ID, err)
return
}
a.log.Info("商品 %s 视频不合规,跳过上传:%s", product.ID, invalidReason)
return
}
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadRunning, ""); err != nil {
a.log.Error("商品 %s 写入上传中状态失败:%v", product.ID, err)
return
}
content, err := os.ReadFile(localPath)
if err != nil {
fail(fmt.Errorf("读取本地视频失败:%w", err))
return
}
a.log.Info("商品 %s 开始上传视频文件 %s(%d 字节)", product.ID, filepath.Base(localPath), len(content))
remoteURL, err := client.UploadVideo(ctx, localPath, content)
if err != nil {
fail(err)
return
}
a.log.Info("商品 %s 素材上传完成,COS 主机:%s", product.ID, videoURLHost(remoteURL))
if err := client.UpdateShopProductVideo(ctx, product.ID, product.PlatformShopID, remoteURL); err != nil {
fail(err)
return
}
check, err = client.CheckShopProductVideo(ctx, product.ID)
if err != nil {
fail(err)
return
}
if reason := strings.TrimSpace(check.FailReason); reason != "" {
fail(fmt.Errorf("货憨憨处理视频失败:%s", reason))
return
}
if !check.Confirmed() {
fail(fmt.Errorf("回读商品视频失败:货憨憨没有记录到刚设置的视频"))
return
}
if err := a.db.UpsertUploadedVideo(product.ID, localPath, info.Size(), remoteURL, time.Now().Format("2006-01-02 15:04:05")); err != nil {
fail(err)
return
}
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadDone, ""); err != nil {
a.log.Error("商品 %s 写入上传完成状态失败:%v", product.ID, err)
return
}
a.log.Success("商品 %s 视频上传并回读确认完成", product.ID)
}
// GetUploadPreview 只扫描本地磁盘并执行本地预检;批量确认框不发远端请求。
// 单商品保留 R5 首版的覆盖警告,因此额外读取一次远端当前状态。
func (a *App) GetUploadPreview(productIDs []string) (UploadPreview, error) {
if a.db == nil {
return UploadPreview{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
ids := cleanProductIDs(productIDs)
if len(ids) == 0 {
return UploadPreview{}, fmt.Errorf("请先勾选要上传的商品")
}
preview := UploadPreview{Total: len(ids)}
var single store.Product
for _, productID := range ids {
product, found, err := a.db.GetProduct(productID)
if err != nil {
return UploadPreview{}, err
}
if !found {
return UploadPreview{}, fmt.Errorf("找不到要上传的商品")
}
if len(ids) == 1 {
single = product
}
localPath, info, exists, err := a.findUploadVideo(product)
if err != nil {
return UploadPreview{}, err
}
if !exists {
preview.MissingCount++
continue
}
invalidReason, err := a.validateUploadVideo(a.appContext(), localPath, info)
if err != nil {
return UploadPreview{}, err
}
if invalidReason != "" {
preview.InvalidCount++
continue
}
preview.UploadableCount++
preview.UploadableSize += info.Size()
}
if len(ids) == 1 {
client, err := a.newHuohanhanClient()
if err != nil {
return UploadPreview{}, err
}
check, err := client.CheckShopProductVideo(a.appContext(), single.ID)
if err != nil {
return UploadPreview{}, err
}
preview.HasExistingVideo = check.Confirmed()
}
return preview, nil
}
func cleanProductIDs(productIDs []string) []string {
seen := make(map[string]struct{}, len(productIDs))
ids := make([]string, 0, len(productIDs))
for _, productID := range productIDs {
productID = strings.TrimSpace(productID)
if productID == "" {
continue
}
if _, exists := seen[productID]; exists {
continue
}
seen[productID] = struct{}{}
ids = append(ids, productID)
}
return ids
}
// findUploadVideo 只以磁盘为事实来源,绝不查询 videos 表。
func (a *App) findUploadVideo(product store.Product) (string, os.FileInfo, bool, error) {
dir := filepath.Join(a.cfg.Download.VideoDir, downloader.SafeDirName(product.ItemID))
return downloader.FindFirstMP4(dir)
}
func (a *App) validateUploadVideo(ctx context.Context, localPath string, info os.FileInfo) (string, error) {
probeFunc := a.probe
if probeFunc == nil {
probeFunc = downloader.Probe
}
probe, err := probeFunc(ctx, localPath)
if err != nil {
return "", fmt.Errorf("本地视频预检失败:%w", err)
}
return downloader.UploadValidationError(probe, info.Size()), nil
}
// GetCachedShops 只读本地店铺缓存,不会联网。
func (a *App) GetCachedShops() ([]store.Shop, error) {
if a.db == nil {
return nil, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.ListShops()
}
// GetShopsUpdatedAt 返回店铺缓存时间;尚未同步时返回空字符串。
func (a *App) GetShopsUpdatedAt() (string, error) {
if a.db == nil {
return "", fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.ShopsUpdatedAt()
}
// RefreshShops 从货憨憨读取最新店铺并全量替换本地缓存。
func (a *App) RefreshShops() ([]store.Shop, error) {
if a.db == nil {
return nil, fmt.Errorf("数据库未就绪,请查看运行日志")
}
client, err := a.newHuohanhanClient()
if err != nil {
return nil, err
}
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
shops, err := client.ListShops(ctx)
if err != nil {
a.log.Error("更新店铺列表失败:%v", err)
return nil, err
}
updatedAt := time.Now().Format("2006-01-02 15:04:05")
cached := make([]store.Shop, 0, len(shops))
for _, shop := range shops {
cached = append(cached, store.Shop{
PlatformShopID: shop.PlatformShopID,
ID: shop.ID,
ShopName: shop.ShopName,
ShopAlias: shop.ShopAlias,
Region: shop.Region,
RegionName: shop.RegionName,
Platform: shop.Platform,
Status: shop.Status,
UpdatedAt: updatedAt,
})
}
if err := a.db.ReplaceShops(cached, updatedAt); err != nil {
a.log.Error("保存店铺缓存失败:%v", err)
return nil, err
}
a.log.Success("店铺列表更新完成,共 %d 个店铺", len(cached))
return cached, nil
}
// ListShops 保留原有前端接口,但现在只读缓存,不再联网。
func (a *App) ListShops() ([]store.Shop, error) {
return a.GetCachedShops()
}
// refreshShopsOnStartup 在后台更新店铺,不能阻塞窗口显示。
// 无论成功还是失败都用事件通知前端,让界面更新列表或说明缓存状态。
func (a *App) refreshShopsOnStartup() {
shops, err := a.RefreshShops()
if err == nil {
runtime.EventsEmit(a.ctx, "shops:refreshed", shops)
return
}
cached, cachedErr := a.GetCachedShops()
if cachedErr != nil {
a.log.Error("读取店铺缓存数量失败:%v", cachedErr)
cached = nil
}
cachedAt, cachedAtErr := a.GetShopsUpdatedAt()
if cachedAtErr != nil {
a.log.Error("读取店铺缓存时间失败:%v", cachedAtErr)
cachedAt = ""
}
runtime.EventsEmit(a.ctx, "shops:refresh-failed", map[string]any{
"reason": err.Error(),
"cachedAt": cachedAt,
"cachedCount": len(cached),
})
}
// newHuohanhanClient 使用当前配置创建业务客户端。
// 每次创建可确保设置页刚保存的账号或网址立即生效。
func (a *App) newHuohanhanClient() (*huohanhan.Client, error) {
manager := huohanhan.NewAuthManager(a.cfg.Huohanhan, a.db, a.log, huohanhan.AuthOptions{})
return huohanhan.NewClient(a.cfg.Huohanhan, manager, a.log, nil)
}