由 Codex (gpt-5.6-sol) 实施,Claude 审核。MVP2 第一块。 internal/taobao/chrome.go - EnsureBrowser 按四项条件判断能否复用已有实例:PID 存在、进程命令行 属于本程序专属 profile、CDP 端口可访问、存在 page 类型目标 - 启动前遍历整个端口段,发现已有 Chrome 但状态文件丢失时报错而不是 再起一个,避免同一 profile 出现两个实例损坏登录数据 - CloseBrowser 必须先校验 PID 归属才允许 taskkill,防止误杀使用者 自己的 Chrome internal/taobao/cdp.go - 基于 gorilla/websocket 的最小 CDP 客户端(该依赖原本就在模块图里, 由 Wails 引入,此处仅从 indirect 提为直接依赖) - CookieNames 的结构体只声明 Name 和 Domain,Cookie 的值根本不会 进入内存,比"取到但不使用"更彻底 internal/taobao/login.go - 两层判定:四个必需 Cookie 齐全,且我的淘宝页面标题与正文不含 六个阻断词 - 昵称只作展示,不参与判定。这是对参考 Python 实现的一处有意偏离: 参考实现要求解析出昵称才算有效,但该解析依赖淘宝页面文案, 淘宝一改版就会永远解析不到,导致登录永远判为无效、全局停止门 永久触发、工具彻底不可用。放宽的代价可控:误判为已登录时后续 MTOP 请求会失败,那条链路本就有 token 失效处理。 app.go 只在点击按钮时启动 Chrome,startup 不自动启动。 前端:设置页两个按钮接真实方法并显示检查结果;侧栏淘宝状态灯 监听 taobao:status 事件。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
483 lines
15 KiB
Go
483 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"cmsp/internal/config"
|
|
"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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 运行日志
|
|
|
|
// 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)
|
|
}
|