Files
cmsp/app.go
T
QiuSWandClaude Opus 5 46378db86b feat: 商品主图可点击查看原图,支持右键另存为
- 表格主图列的缩略图可点击,弹出原图弹窗,鼠标指针改为 zoom-in
- main.go 打开 EnableDefaultContextMenu,这样在弹窗图片上右键才有
  「图片另存为」。代价是整个界面右键都会出现浏览器菜单,其中包含
  「检查」,同事可能误点开发者工具;权衡后仍然打开,因为另存图片是
  实际需要,误点开发者工具关掉即可,没有破坏性。同时移除 main.go 中
  已不再成立的「关掉右键检查」注释。
- 弹窗另配一个「另存为」按钮:WebView2 的原生右键菜单在个别机器上
  不给图片选项,这条路径不依赖浏览器菜单
- app.go 新增 SaveImageAs:只接受 http/https,避免变成可读取本地
  任意文件的入口;30 秒超时、20 MB 上限、空响应判失败
- 原图限制在 70vh 内并可滚动,避免大图撑出屏幕

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 10:24:18 +08:00

889 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"cmsp/internal/config"
"cmsp/internal/downloader"
"cmsp/internal/huohanhan"
"cmsp/internal/logx"
"cmsp/internal/store"
"cmsp/internal/taobao"
"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
}
// 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 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)
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)
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"`
}
// FetchVideosForProduct 为一个商品依次搜同款、提取视频并下载到本地。
// 本方法始终串行执行,不创建批量任务、队列或并发下载。
func (a *App) FetchVideosForProduct(productID string) (FetchResult, error) {
productID = strings.TrimSpace(productID)
result := FetchResult{ProductID: productID}
if a.db == nil {
return result, fmt.Errorf("数据库未就绪,请查看运行日志")
}
product, found, err := a.db.GetProduct(productID)
if err != nil {
return result, err
}
if !found {
return result, fmt.Errorf("找不到商品:%s", productID)
}
if strings.TrimSpace(product.MainImage) == "" {
return result, 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("商品 %s 准备淘宝专属 Chrome 失败:%v", product.ID, err)
return result, err
}
cdp, err := taobao.Connect(state.Port)
if err != nil {
a.log.Error("商品 %s 连接淘宝专属 Chrome 失败:%v", product.ID, err)
return result, err
}
defer cdp.Close()
login, err := taobao.CheckLogin(cdp)
if err != nil {
login.Message = err.Error()
a.emitTaobaoStatus(login)
a.log.Error("商品 %s 取视频前深度检查淘宝登录失败:%v", product.ID, err)
return result, err
}
a.emitTaobaoStatus(login)
if !login.Valid {
err := fmt.Errorf("淘宝登录无效:%s", login.Message)
a.log.Warn("商品 %s 已触发登录停止门:%s", product.ID, login.Message)
return result, err
}
// 登录通过后才清理旧记录,避免一次登录失效把已下载记录抹掉。
if err := a.db.ReplaceVideos(product.ID, nil, time.Now().Format("2006-01-02 15:04:05")); err != nil {
return result, err
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoPending, store.DownloadRunning, "", ""); err != nil {
return result, err
}
a.log.Info("商品 %s 开始搜索淘宝同款", product.ID)
similar, err := taobao.SearchByImage(ctx, cdp, product.MainImage)
if err != nil {
return result, 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)
urls, detailErr := taobao.ExtractDetailVideos(cdp, item.ItemID, 4*time.Second)
if errors.Is(detailErr, taobao.ErrLoginInvalid) {
a.emitTaobaoStatus(taobao.LoginStatus{Message: detailErr.Error()})
a.log.Warn("商品 %s 访问详情页时登录失效,已立即停止", product.ID)
return result, a.failVideoFetch(product.ID, detailErr)
}
if detailErr != nil {
detailErrors = append(detailErrors, detailErr)
a.log.Warn("商品 %s 的同款 %s 详情读取失败:%v", product.ID, item.ItemID, detailErr)
continue
}
if len(urls) == 0 {
a.log.Info("淘宝同款 %s 未提取到可信视频", item.ItemID)
continue
}
selected = item
videoURLs = urls
break
}
if len(videoURLs) == 0 {
if len(detailErrors) > 0 {
return result, a.failVideoFetch(product.ID,
fmt.Errorf("未能完成全部同款详情检查,首个错误:%w", detailErrors[0]))
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoNone, store.DownloadPending, "", ""); err != nil {
return result, err
}
a.log.Info("商品 %s 检查完 %d 个同款,未找到视频", product.ID, limit)
return result, 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, a.failVideoFetch(product.ID, fmt.Errorf("解析视频目录失败:%w", err))
}
result.SourceItem = selected.ItemID
result.VideoCount = len(videoURLs)
result.Directory = videoDir
referer := "https://item.taobao.com/item.htm?id=" + url.QueryEscape(selected.ItemID)
downloaderClient := downloader.New()
records := make([]store.Video, 0, len(videoURLs))
now := time.Now().Format("2006-01-02 15:04:05")
var firstDownloadError error
for i, sourceURL := range videoURLs {
name := downloader.Filename(product.ID, selected.ItemID, i+1)
target := filepath.Join(videoDir, name)
host := videoURLHost(sourceURL)
a.log.Info("商品 %s 正在下载视频 %d/%d(主机 %s,文件 %s)",
product.ID, i+1, len(videoURLs), host, name)
downloaded, downloadErr := downloaderClient.Download(ctx, sourceURL, referer, target)
record := store.Video{
SourceItem: selected.ItemID,
SourceURL: sourceURL,
LocalPath: target,
Status: store.VideoStatusFailed,
CreatedAt: now,
}
if downloadErr != nil {
record.LastError = downloadErr.Error()
if firstDownloadError == nil {
firstDownloadError = downloadErr
}
a.log.Error("商品 %s 视频下载失败(主机 %s,文件 %s):%v",
product.ID, host, name, downloadErr)
} else {
record.Status = store.VideoStatusDownloaded
record.FileSize = downloaded.Size
result.DownloadedCount++
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 = append(records, record)
}
if err := a.db.ReplaceVideos(product.ID, records, now); err != nil {
return result, a.failVideoFetch(product.ID, err)
}
if firstDownloadError != nil {
if err := a.db.UpdateProductStatus(product.ID, store.VideoFound, store.DownloadFailed, "", firstDownloadError.Error()); err != nil {
return result, err
}
return result, firstDownloadError
}
if err := a.db.UpdateProductStatus(product.ID, store.VideoFound, store.DownloadDone, "", ""); err != nil {
return result, err
}
a.log.Success("商品 %s 采用同款 %s,共下载 %d 个视频", product.ID, selected.ItemID, result.DownloadedCount)
return result, 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 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++
}
}
if summary.DownloadedCount > 0 {
summary.Directory, err = filepath.Abs(a.cfg.Download.VideoDir)
if err != nil {
return VideoSummary{}, fmt.Errorf("解析视频目录失败:%w", err)
}
}
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
}
// DownloadVideos 对选中的商品搜同款并下载视频(需求 R3、R4)。
func (a *App) DownloadVideos(productIDs []string) error {
return fmt.Errorf("「下载视频」还没实现,见工单 R3 和 R4")
}
// UploadVideos 把本地视频上传回货憨憨(需求 R5)。
func (a *App) UploadVideos(productIDs []string) error {
return fmt.Errorf("「上传数据」还没实现,见工单 R5")
}
// 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)
}