package main import ( "context" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "strings" "sync" "time" "cmsp/internal/config" "cmsp/internal/downloader" "cmsp/internal/erpgo" "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 productSyncMu sync.Mutex uploadMu sync.Mutex } // 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) } // 未配置 erpgo 时不发起店铺刷新。 if cfg.ERPGo.BaseURL != "" && cfg.ERPGo.APIKey != "" { 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() } // 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, make(taobao.DetailCache), &taobao.LoginGuard{}) 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, cache taobao.DetailCache, loginGuard *taobao.LoginGuard) (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 product.DownloadStatus == store.DownloadDone { summary, err := a.GetVideoSummary(product.ID) if err != nil { return result, task.Work{}, err } if summary.DownloadedCount > 0 { result.DownloadedCount, result.Directory = summary.DownloadedCount, summary.Directory return result, task.Work{Skipped: true}, nil } } 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() return a.prepareVideoFetchOnPage(ctx, product, cdp, func() ([]taobao.SimilarItem, error) { return taobao.SearchByImage(ctx, cdp, product.MainImage) }, riskGuard, cache, loginGuard) } // prepareVideoFetchOnPage 将导航和状态收口放在同一流程中,便于离线验证停止门。 func (a *App) prepareVideoFetchOnPage(ctx context.Context, product store.Product, cdp taobao.PageSession, search func() ([]taobao.SimilarItem, error), riskGuard *task.EmptyRiskGuard, cache taobao.DetailCache, loginGuard *taobao.LoginGuard) (FetchResult, task.Work, error) { result := FetchResult{ProductID: product.ID} if err := ctx.Err(); err != nil { return result, task.Work{}, err } login, err := loginGuard.Check(cdp, time.Duration(a.cfg.Download.GuardWaitSeconds*float64(time.Second))) if err := ctx.Err(); err != nil { return result, task.Work{}, err } 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) if login.StopReason == task.StopReasonRiskBlocked { return result, task.Work{}, task.ErrRiskBlocked } return result, task.Work{}, fmt.Errorf("%w:%s", task.ErrLoginRequired, login.Message) } a.log.Info("商品 %s 开始搜索淘宝同款", product.ID) if err := ctx.Err(); err != nil { return result, task.Work{}, err } similar, err := search() if err != nil { if stopped := a.videoAccessStop(err); stopped != nil { return result, task.Work{}, stopped } if err := ctx.Err(); err != nil { return result, task.Work{}, err } return result, task.Work{}, a.failVideoFetch(product.ID, fmt.Errorf("搜同款失败:%w", err)) } selected, videoURLs, err := taobao.SelectVideo(ctx, cdp, similar, taobao.CandidateOptions{ Limit: a.cfg.Download.SearchTopN, WaitMin: time.Duration(a.cfg.Download.WaitSecondsMin * float64(time.Second)), WaitMax: time.Duration(a.cfg.Download.WaitSecondsMax * float64(time.Second)), DetailWait: time.Duration(a.cfg.Download.DetailWaitSeconds * float64(time.Second)), OnDetail: func(count int) error { if riskGuard != nil && riskGuard.Record(count) { return fmt.Errorf("%w:%w:%w", task.ErrLoginRequired, task.ErrRiskSuspected, task.RiskSuspectedError{Consecutive: riskGuard.Consecutive()}) } return nil }, }, cache) if err != nil { if stopped := a.videoAccessStop(err); stopped != nil { a.log.Warn("商品 %s 已触发淘宝全局停止门", product.ID) return result, task.Work{}, stopped } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return result, task.Work{}, err } return result, task.Work{}, a.failVideoFetch(product.ID, err) } if err := ctx.Err(); err != nil { return result, task.Work{}, err } if len(videoURLs) == 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, min(len(similar), a.cfg.Download.SearchTopN)) 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)) } if err := ctx.Err(); err != nil { return result, task.Work{}, err } if err := a.db.UpdateProductStatus(product.ID, store.VideoPending, store.DownloadRunning, "", ""); err != nil { return result, task.Work{}, err } result.SourceItem = selected.ItemID result.VideoCount = len(videoURLs) result.Directory = productDir referer := selected.URL 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 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 通过 erpgo 拉取商品列表到本地(需求 R1)。 func (a *App) DownloadProductData(platformShopID string) (erpgo.SyncResult, error) { if a.db == nil { return erpgo.SyncResult{}, erpgo.BridgeError(fmt.Errorf("数据库未就绪")) } if !a.productSyncMu.TryLock() { return erpgo.SyncResult{}, &erpgo.Error{Code: "SYNC_IN_PROGRESS", Message: "已有商品同步正在进行,请等待完成"} } defer a.productSyncMu.Unlock() client, err := erpgo.NewClient(a.cfg.ERPGo, nil) if err != nil { return erpgo.SyncResult{}, err } ctx := a.ctx if ctx == nil { ctx = context.Background() } a.log.Info("开始同步商品数据") result, err := client.SyncProductData(ctx, a.db, platformShopID, func(p erpgo.SyncProgress) { if p.Current == 0 { a.log.Info("正在同步第 %d/%d 个店铺", p.ShopIndex, p.TotalShops) } else { a.log.Info("第 %d/%d 个店铺已拉取 %d/%d 页", p.ShopIndex, p.TotalShops, p.Current, p.Pages) } }) if err != nil { a.log.Error("下载商品数据失败:%s", erpgo.LogSummary(err)) return result, err } for _, failed := range result.Failures { a.log.Warn("第 %d 个店铺同步失败:%s", failed.ShopIndex, erpgo.LogSummary(failed.Error)) } if result.StopError != nil { a.log.Warn("商品同步已停止:%s", erpgo.LogSummary(result.StopError)) } if result.FailedShops > 0 || result.StopError != nil { a.log.Warn("商品同步结束:成功 %d,失败 %d,未处理 %d 个店铺,共保存 %d 条商品", result.SucceededShops, result.FailedShops, result.SkippedShops, result.ProductCount) } else { a.log.Success("商品数据同步完成:%d 个店铺,共保存 %d 条商品", result.SucceededShops, result.ProductCount) } return result, 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) detailCache := make(taobao.DetailCache) loginGuard := &taobao.LoginGuard{} 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, detailCache, loginGuard) return work, err }, OnProgress: func(progress task.Progress) { if a.ctx != nil { runtime.EventsEmit(a.ctx, "task:progress", progress) } }, OnFinished: func(progress task.Progress) { clear(detailCache) 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) } // 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 := erpgo.NewClient(a.cfg.ERPGo, nil) if err != nil { return UploadPreview{}, err } check, err := client.GetCurrentVideo(a.appContext(), single.ItemID) 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 通过 erpgo 读取最新店铺并全量替换本地缓存。 func (a *App) RefreshShops() ([]store.Shop, error) { if a.db == nil { return nil, erpgo.BridgeError(fmt.Errorf("数据库未就绪")) } client, err := erpgo.NewClient(a.cfg.ERPGo, nil) 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("更新店铺列表失败:%s", erpgo.LogSummary(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("保存店铺缓存失败:%s", erpgo.LogSummary(err)) return nil, erpgo.BridgeError(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), }) } // videoAccessStop 把淘宝包的认证/访问异常映射为任务全局停止门。 func (a *App) videoAccessStop(err error) error { if errors.Is(err, taobao.ErrAccessBlocked) { a.emitTaobaoStatus(taobao.LoginStatus{StopReason: task.StopReasonRiskBlocked, Message: "淘宝访问受限,已停止自动访问,请在专属 Chrome 中手动处理"}) return errors.Join(task.ErrRiskBlocked, err) } if errors.Is(err, taobao.ErrLoginInvalid) { a.emitTaobaoStatus(taobao.LoginStatus{StopReason: "login_required", Message: "淘宝登录失效,已停止自动访问,请在专属 Chrome 中手动登录"}) return errors.Join(task.ErrLoginRequired, err) } if errors.Is(err, task.ErrLoginRequired) || errors.Is(err, task.ErrRiskBlocked) { return err } return nil }