Files
cmsp/app.go
T
QiuSWandClaude Opus 5 d257752441 feat: 货憨憨登录与认证复用 (#7)
由 Codex (gpt-5.6-sol) 实施,Claude 审核。

internal/huohanhan/auth.go
- 六步登录:取 CID/CST 作 Basic 认证 → getCltConf 取 clientId →
  下载验证码 → OCR 识别 → 提交登录 → app-version/info 在线验证
- OCR 响应递归查找 4~8 位验证码,优先 text/result/data/content/captcha/code
- 错误分支:invalid_verify_code 换图重试(上限可配,默认 3);
  invalid_credentials 与 disabled_credentials 直接报中文错误不重试
- 认证复用改用 SQLite kv 表 + sync.Mutex,不照搬 Python 版的 Redis
- GetValidAuth 按「内存 → SQLite 在线验证 → 重新登录」顺序取认证

internal/huohanhan/client.go
- 统一请求方法,自动带 Authorization 与 cookies
- 认证失败时清状态、重登、最多重试一次;超时、500、普通业务错误不重试

app.go
- 新增 TestHuohanhanLogin,供设置页「测试连接」调用

测试全部使用 httptest 假服务,不需要真实账号即可运行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-02 16:45:02 +08:00

258 lines
8.4 KiB
Go

package main
import (
"context"
"fmt"
"time"
"cmsp/internal/config"
"cmsp/internal/huohanhan"
"cmsp/internal/logx"
"cmsp/internal/store"
"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)
}
// 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
}
// ---------------------------------------------------------------- 路径选择
// 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()
}
// ---------------------------------------------------------------- 运行日志
// 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
}
// ---------------------------------------------------------------- 占位方法
//
// 下面这些是界面上已有按钮、但功能还没实现的方法。
// 先返回明确的「还没实现」,比让按钮点了没反应好——
// 同事至少知道是没做,而不是坏了。
//
// 实现顺序见 Wiki 需求总览:R1 下载数据 → R2/R3 淘宝登录与图搜
// → R4 下载视频 → R5 上传数据。
// DownloadProductData 从货憨憨拉取商品列表到本地(需求 R1)。
func (a *App) DownloadProductData() error {
return fmt.Errorf("「下载数据」还没实现,见工单 R1")
}
// 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")
}
// ListShops 返回当前账号可用的店铺(需求 R1)。
//
// 真正实现时要调用货憨憨的 erp/shop/all,不要在界面里写死店铺列表。
func (a *App) ListShops() ([]Shop, error) {
return nil, fmt.Errorf("店铺列表还没实现,见工单 R1")
}
// Shop 是一个店铺,对应货憨憨 erp/shop/all 的返回。
type Shop struct {
PlatformShopID string `json:"platformShopId"` // 平台店铺 ID
ShopName string `json:"shopName"` // 店铺名
Region string `json:"region"` // 地区,例如 TW
}