解除 #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
1279 lines
43 KiB
Go
1279 lines
43 KiB
Go
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)
|
||
}
|