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) }