真机使用时发现 55066525387 的子目录里有视频文件,界面上「目录」却不可点。 查明是取视频跑到一半程序被重启,videos 表里该商品的记录已被删除、 新记录还没写回,磁盘有文件而库里无记录。 缺陷一:prepareVideoFetch 在开始下载前先做了一次 ReplaceVideos(product.ID, nil, ...)。而 work.Finalize 里本来就有一次 ReplaceVideos(product.ID, records, now),ReplaceVideos 的语义就是按 product_id 全量替换。前面那次并不能少做任何事,唯一效果是把「文件已下载 但记录已被删」的窗口从零扩大到整个下载过程,任务停止、程序重启和崩溃 都会落进去。删掉它。 原注释说明作者考虑过这个风险,但只防住了登录失效一种情况。 行为变化是期望的:取视频在下载阶段之前失败时,旧的视频记录会保留而不是 被清空。那些文件确实还在磁盘上,「这次没找到视频」由 products.video_status 表达,不该靠删 videos 记录来表达。 缺陷二:进程退出后 download_status / upload_status 仍停留在 running, 但没有任何任务在跑,是个不会自愈的假状态。启动时在同一事务内把这两个字段 的 running 重置为 pending,只动 running,不碰 video_status 和 videos 表。 重置失败只记 Warn,不中断启动。 按负责人要求未对 cmsp.db 做任何手工订正。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
1185 lines
41 KiB
Go
1185 lines
41 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
|
||
|
||
videoTaskMu sync.Mutex
|
||
videoTask *task.Runner
|
||
}
|
||
|
||
// NewApp 创建应用对象。真正的初始化在 startup 里做。
|
||
func NewApp() *App {
|
||
return &App{
|
||
cfgPath: config.DefaultPath(),
|
||
cfg: config.Default(),
|
||
log: logx.New(2000),
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
ProductID string `json:"productId"`
|
||
ItemID string `json:"itemId"`
|
||
ItemName string `json:"itemName"`
|
||
FileName string `json:"fileName"`
|
||
FileSize int64 `json:"fileSize"`
|
||
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 把本地视频上传回货憨憨(需求 R5)。
|
||
func (a *App) UploadVideos(productIDs []string) error {
|
||
if len(productIDs) != 1 {
|
||
return fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
|
||
}
|
||
// 第二个参数为 false:这里不查远端当前视频,那只是确认框要显示的信息。
|
||
_, product, video, err := a.uploadPreview(productIDs[0], false)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadRunning, ""); err != nil {
|
||
return err
|
||
}
|
||
|
||
fail := func(cause error) 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)
|
||
return cause
|
||
}
|
||
|
||
content, err := os.ReadFile(video.LocalPath)
|
||
if err != nil {
|
||
return fail(fmt.Errorf("读取本地视频失败:%w", err))
|
||
}
|
||
client, err := a.newHuohanhanClient()
|
||
if err != nil {
|
||
return fail(err)
|
||
}
|
||
ctx := a.appContext()
|
||
a.log.Info("商品 %s 开始上传视频文件 %s(%d 字节)", product.ID, filepath.Base(video.LocalPath), len(content))
|
||
remoteURL, err := client.UploadVideo(ctx, video.LocalPath, content)
|
||
if err != nil {
|
||
return fail(err)
|
||
}
|
||
a.log.Info("商品 %s 素材上传完成,COS 主机:%s", product.ID, videoURLHost(remoteURL))
|
||
if err := client.UpdateShopProductVideo(ctx, product.ID, product.PlatformShopID, remoteURL); err != nil {
|
||
return fail(err)
|
||
}
|
||
// 回读确认。注意不能只看 video 字段:货憨憨推送到 Shopee 是异步的,
|
||
// 刚保存完 video 必然还是空的,视频这时在 tempVideoUrl 里。
|
||
check, err := client.CheckShopProductVideo(ctx, product.ID)
|
||
if err != nil {
|
||
return fail(err)
|
||
}
|
||
if reason := strings.TrimSpace(check.FailReason); reason != "" {
|
||
return fail(fmt.Errorf("货憨憨处理视频失败:%s", reason))
|
||
}
|
||
if !check.Confirmed() {
|
||
return fail(fmt.Errorf("回读商品视频失败:货憨憨没有记录到刚设置的视频"))
|
||
}
|
||
if check.LiveOnShopee() {
|
||
a.log.Info("商品 %s 的视频已在 Shopee 生效", product.ID)
|
||
} else {
|
||
a.log.Info("商品 %s 的视频已提交,等待货憨憨同步到 Shopee(稍后可在货憨憨界面查看)", product.ID)
|
||
}
|
||
if err := a.db.MarkVideoUploaded(video.ID, remoteURL); err != nil {
|
||
return fail(err)
|
||
}
|
||
if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadDone, ""); err != nil {
|
||
return fail(err)
|
||
}
|
||
a.log.Success("商品 %s 视频上传并回读确认完成", product.ID)
|
||
return nil
|
||
}
|
||
|
||
// GetUploadPreview 在显式点击上传后提供二次确认所需信息,只读取本地状态和远端当前视频。
|
||
func (a *App) GetUploadPreview(productIDs []string) (UploadPreview, error) {
|
||
if len(productIDs) != 1 {
|
||
return UploadPreview{}, fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
|
||
}
|
||
preview, _, _, err := a.uploadPreview(productIDs[0], true)
|
||
return preview, err
|
||
}
|
||
|
||
func (a *App) uploadPreview(productID string, readRemote bool) (UploadPreview, store.Product, store.Video, error) {
|
||
if a.db == nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("数据库未就绪,请查看运行日志")
|
||
}
|
||
productID = strings.TrimSpace(productID)
|
||
if productID == "" {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("首版一次只能上传一个商品,确认线上结果正常后再开放批量")
|
||
}
|
||
product, found, err := a.db.GetProduct(productID)
|
||
if err != nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, err
|
||
}
|
||
if !found {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("找不到要上传的商品")
|
||
}
|
||
video, found, err := a.db.FirstUploadableVideo(product.ID)
|
||
if err != nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, err
|
||
}
|
||
if !found {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("该商品没有可上传的本地视频")
|
||
}
|
||
info, err := os.Stat(video.LocalPath)
|
||
if err != nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, fmt.Errorf("读取本地视频信息失败:%w", err)
|
||
}
|
||
preview := UploadPreview{ProductID: product.ID, ItemID: product.ItemID, ItemName: product.ItemName, FileName: filepath.Base(video.LocalPath), FileSize: info.Size()}
|
||
if readRemote {
|
||
client, err := a.newHuohanhanClient()
|
||
if err != nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, err
|
||
}
|
||
// 用 Confirmed 而不是只看 video:等待同步中的视频同样会被这次上传覆盖,
|
||
// 覆盖警告必须把它也算上。
|
||
check, err := client.CheckShopProductVideo(a.appContext(), product.ID)
|
||
if err != nil {
|
||
return UploadPreview{}, store.Product{}, store.Video{}, err
|
||
}
|
||
preview.HasExistingVideo = check.Confirmed()
|
||
}
|
||
return preview, product, video, 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)
|
||
}
|