diff --git a/go.mod b/go.mod index 6f0d98d..60b9272 100644 --- a/go.mod +++ b/go.mod @@ -27,11 +27,13 @@ require ( github.com/swaggo/swag v1.16.6 github.com/unrolled/secure v1.17.0 golang.org/x/crypto v0.54.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.2 gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlserver v1.6.4 gorm.io/gorm v1.31.2 + modernc.org/sqlite v1.37.0 ) require ( @@ -143,7 +145,6 @@ require ( modernc.org/libc v1.62.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.9.1 // indirect - modernc.org/sqlite v1.37.0 // indirect ) //replace ( diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7df5ae3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,472 @@ +// Package config 负责读写和校验程序的参数设置。 +// +// 配置文件是程序目录下的 config.yaml,用 YAML 而不是 JSON, +// 是因为 YAML 能写注释——很多坑(比如密码必须加引号)只有写在 +// 字段旁边,同事才不会踩。 +// +// 设计约定(改代码前请先读): +// +// - config.yaml 含密码,已在 .gitignore 里,绝不能提交进 Git。 +// 进 Git 的是 config.example.yaml,那份不含真实凭据。 +// +// - 淘宝没有账号密码字段,将来也不要加。淘宝登录必须由使用者 +// 在专属 Chrome 里手动完成,程序只保存浏览器路径,不碰凭据。 +// 这是 AGENTS.md 里的红线。 +// +// - 保存配置时用 Render() 按固定模板重新渲染,不要用 yaml.Marshal, +// 否则注释会被全部冲掉。 +// +// - 新增一个字段要改四处:结构体、Default()、Validate()、 +// Render() 里的模板。漏掉任何一处,单元测试会失败。 +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config 是程序的全部参数设置,对应界面上的「参数设置」页。 +// +// yaml tag 就是配置文件里的键名。改名会导致老配置读不出来, +// 所以除非有充分理由,不要改已有字段的 tag。 +type Config struct { + Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"` + Taobao TaobaoConfig `yaml:"taobao" json:"taobao"` + Download DownloadConfig `yaml:"download" json:"download"` +} + +// HuohanhanConfig 是货憨憨 ERP 的连接信息。 +type HuohanhanConfig struct { + // BaseURL 是货憨憨网站地址,不带结尾的斜杠。 + BaseURL string `yaml:"base_url" json:"baseUrl"` + // Account 是登录账号。 + Account string `yaml:"account" json:"account"` + // Password 是登录密码。属于凭据,禁止写入日志、工单和 Wiki。 + Password string `yaml:"password" json:"password"` + // OCRURL 是识别登录验证码的外部服务地址。 + OCRURL string `yaml:"ocr_url" json:"ocrUrl"` +} + +// TaobaoConfig 是淘宝专属浏览器的设置。 +// +// 这里故意没有账号和密码字段:淘宝登录由使用者手动完成, +// 登录态保存在 Chrome 用户数据目录里,程序不读取也不保存。 +type TaobaoConfig struct { + // ChromePath 是 chrome.exe 的完整路径,注意是文件不是目录。 + ChromePath string `yaml:"chrome_path" json:"chromePath"` + // UserDataDir 是专属 Chrome 的用户数据目录,淘宝登录态存在这里。 + UserDataDir string `yaml:"user_data_dir" json:"userDataDir"` + // DebugPortStart / DebugPortEnd 是分配调试端口的范围。 + DebugPortStart int `yaml:"debug_port_start" json:"debugPortStart"` + DebugPortEnd int `yaml:"debug_port_end" json:"debugPortEnd"` +} + +// DownloadConfig 是下载与任务参数。 +type DownloadConfig struct { + // VideoDir 是下载的视频保存目录。 + VideoDir string `yaml:"video_dir" json:"videoDir"` + // MaxVideosPerProduct 是每个商品最多下载几个视频。 + MaxVideosPerProduct int `yaml:"max_videos_per_product" json:"maxVideosPerProduct"` + // SearchTopN 是图搜结果里取前几个同款去找视频。 + SearchTopN int `yaml:"search_top_n" json:"searchTopN"` + // Concurrency 是同时下载几个视频。 + // 调大会提高淘宝风控概率,没有实测结论前不要超过 3。 + Concurrency int `yaml:"concurrency" json:"concurrency"` + // WaitSecondsMin / WaitSecondsMax 是处理完一个商品后随机等待的秒数区间。 + WaitSecondsMin float64 `yaml:"wait_seconds_min" json:"waitSecondsMin"` + WaitSecondsMax float64 `yaml:"wait_seconds_max" json:"waitSecondsMax"` + DetailWaitSeconds float64 `yaml:"detail_wait_seconds" json:"detailWaitSeconds"` + GuardWaitSeconds float64 `yaml:"guard_wait_seconds" json:"guardWaitSeconds"` + RiskEmptyThreshold int `yaml:"risk_empty_threshold" json:"riskEmptyThreshold"` + DownloadRetries int `yaml:"download_retries" json:"downloadRetries"` +} + +// Default 返回一份可以直接使用的默认配置。 +// +// 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。 +func Default() Config { + return Config{ + Huohanhan: HuohanhanConfig{ + BaseURL: "https://www.huohanhan.com", + Account: "", + Password: "", + OCRURL: "https://ocr.ilapage.cn/ocr", + }, + Taobao: TaobaoConfig{ + ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`, + UserDataDir: DefaultChromeUserDataDir(), + DebugPortStart: 19666, + DebugPortEnd: 19765, + }, + Download: DownloadConfig{ + VideoDir: DefaultVideoDir(), + MaxVideosPerProduct: 3, + SearchTopN: 20, + Concurrency: 2, + WaitSecondsMin: 2, + WaitSecondsMax: 4, + DetailWaitSeconds: 8, + GuardWaitSeconds: 3, + RiskEmptyThreshold: 8, + DownloadRetries: 3, + }, + } +} + +// DefaultChromeUserDataDir 返回专属 Chrome 用户数据目录的默认位置。 +// +// 这个路径沿用迁移前 Python 版本的目录,改动它会导致同事需要重新 +// 扫码登录淘宝,所以不要随手改。 +func DefaultChromeUserDataDir() string { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".", "淘宝浏览器", "默认账号") + } + base = home + } + return filepath.Join(base, "电商视频自动下载工具", "淘宝浏览器", "默认账号") +} + +// DataRoot 返回程序存放数据的根目录。 +// +// 规则很简单:**配置文件在哪,数据就在哪**。 +// +// 这样两种用法都对: +// - 打包后双击 exe:config.yaml 在 exe 旁边,数据也在 exe 旁边 +// - 开发模式:config.yaml 在项目根目录(wails dev 的工作目录), +// 数据也落在项目根目录 +// +// 不能直接用 exe 所在目录:wails dev 跑的是 buildin\cmsp-dev.exe, +// 数据会落进 buildin,而 `wails build -clean` 会清空那个目录, +// 已经下载好的视频会被一起删掉。 +func DataRoot() string { + return filepath.Dir(DefaultPath()) +} + +// DefaultVideoDir 返回视频默认保存目录:数据根目录下的「运行数据/视频」。 +func DefaultVideoDir() string { + return filepath.Join(DataRoot(), "运行数据", "视频") +} + +// Validate 检查配置是否可用。返回的错误信息会直接显示给使用者, +// 所以要写成一句能看懂的中文,并说明允许范围。 +// +// 这里不校验账号密码对不对,那要等真正登录时才知道。 +// 这里只保证「格式上能用」。 +func (c Config) Validate() error { + h := c.Huohanhan + if strings.TrimSpace(h.BaseURL) == "" { + return fmt.Errorf("货憨憨网址不能为空") + } + if !strings.HasPrefix(h.BaseURL, "http://") && !strings.HasPrefix(h.BaseURL, "https://") { + return fmt.Errorf("货憨憨网址必须以 http:// 或 https:// 开头") + } + if strings.TrimSpace(h.OCRURL) == "" { + return fmt.Errorf("OCR 识别服务地址不能为空") + } + + t := c.Taobao + if strings.TrimSpace(t.ChromePath) == "" { + return fmt.Errorf("Chrome 可执行文件路径不能为空") + } + if strings.TrimSpace(t.UserDataDir) == "" { + return fmt.Errorf("Chrome 用户数据目录不能为空") + } + if err := checkPort(t.DebugPortStart, "调试端口起始"); err != nil { + return err + } + if err := checkPort(t.DebugPortEnd, "调试端口结束"); err != nil { + return err + } + if t.DebugPortEnd < t.DebugPortStart { + return fmt.Errorf("调试端口结束不能小于起始端口") + } + + d := c.Download + if strings.TrimSpace(d.VideoDir) == "" { + return fmt.Errorf("视频保存目录不能为空") + } + if err := checkIntRange(d.MaxVideosPerProduct, 1, 10, "每个商品最多下载视频数"); err != nil { + return err + } + if err := checkIntRange(d.SearchTopN, 1, 60, "图搜取前 N 个同款"); err != nil { + return err + } + if err := checkIntRange(d.Concurrency, 1, 8, "下载并发数"); err != nil { + return err + } + if d.WaitSecondsMin < 0 || d.WaitSecondsMin > 60 { + return fmt.Errorf("商品间最短等待秒数不合法,允许范围 0—60") + } + if d.WaitSecondsMax < 0 || d.WaitSecondsMax > 120 { + return fmt.Errorf("商品间最长等待秒数不合法,允许范围 0—120") + } + if d.WaitSecondsMax < d.WaitSecondsMin { + return fmt.Errorf("商品间最长等待秒数不能小于最短等待秒数") + } + if d.DetailWaitSeconds < 3 || d.DetailWaitSeconds > 30 { + return fmt.Errorf("详情页加载等待秒数不合法,允许范围 3—30") + } + if d.GuardWaitSeconds < 1 || d.GuardWaitSeconds > 15 { + return fmt.Errorf("登录守卫等待秒数不合法,允许范围 1—15") + } + if err := checkIntRange(d.RiskEmptyThreshold, 3, 50, "疑似风控连续空结果阈值"); err != nil { + return err + } + if err := checkIntRange(d.DownloadRetries, 0, 5, "下载重试次数"); err != nil { + return err + } + return nil +} + +func checkPort(port int, name string) error { + if port < 1024 || port > 65535 { + return fmt.Errorf("%s不合法,允许范围 1024—65535", name) + } + return nil +} + +func checkIntRange(value, min, max int, name string) error { + if value < min || value > max { + return fmt.Errorf("%s不合法,允许范围 %d—%d", name, min, max) + } + return nil +} + +// Desensitized 返回一份把密码换成固定占位符的副本。 +// +// 任何要写日志、写工单,或者传给不需要密码的地方的场景,都用这个方法, +// 不要直接传 Config。 +func (c Config) Desensitized() Config { + copied := c + if copied.Huohanhan.Password != "" { + copied.Huohanhan.Password = "******" + } + return copied +} + +// Load 从 path 读取配置。 +// +// 文件不存在时返回默认配置而不是错误——第一次启动本来就没有配置文件, +// 这时应该让程序正常打开、显示默认值,由使用者去填。 +func Load(path string) (Config, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Default(), nil + } + if err != nil { + return Config{}, fmt.Errorf("读取配置文件失败:%w", err) + } + + // 先铺上默认值再解析,这样老配置文件缺少新字段时, + // 新字段会保留默认值而不是变成零值。 + cfg := Default() + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return Config{}, fmt.Errorf("配置文件不是有效的 YAML:%w", err) + } + return cfg, nil +} + +// Save 校验并写入配置。 +// +// 先校验再写,避免把一份用不了的配置存进去。 +// 写入的是 Render() 渲染的带注释版本,不是 yaml.Marshal 的裸数据。 +func Save(path string, cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("创建配置目录失败:%w", err) + } + // 权限 0600:只有当前用户能读。文件里有密码。 + if err := os.WriteFile(path, []byte(cfg.Render()), 0o600); err != nil { + return fmt.Errorf("写入配置文件失败:%w", err) + } + return nil +} + +// Render 按固定模板把配置渲染成带注释的 YAML。 +// +// 为什么不用 yaml.Marshal:那样会把注释全部丢掉,同事下次打开 +// config.yaml 就只剩一堆键值对,不知道每项什么意思、有什么坑。 +// +// 新增字段时记得在这里的模板中也加上,并补一句说明。 +func (c Config) Render() string { + return fmt.Sprintf(`# cmsp 本机配置 +# +# 本文件由「参数设置」页保存时自动重写,注释会保留。 +# 也可以直接用记事本改,改完重启程序生效。 +# +# [必须] 本文件含密码,已在 .gitignore 里,绝不能提交进 Git, +# 也不要打包发给别人、截图或粘贴到工单和日志里。 +# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成 +# 整数,读取时直接报错,前导 0 也会丢。 + +huohanhan: + # 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。 + base_url: %s + + # 登录账号。 + account: %s + + # [必须] 登录密码,加双引号。 + password: %s + + # 登录验证码的自动识别服务。 + # [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。 + ocr_url: %s + +taobao: + # [必须] 这里没有淘宝账号和密码,将来也不要加。 + # 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成, + # 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。 + # 程序不会代填密码,也不会绕过验证码或滑块。 + + # chrome.exe 的完整路径。注意是文件不是目录。 + chrome_path: %s + + # 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。 + # [注意] 改了这个路径就要重新扫码登录一次,不要随手改。 + # 这个目录不要提交、打包或共享。 + user_data_dir: %s + + # 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。 + debug_port_start: %d + debug_port_end: %d + +download: + # 下载的视频保存到哪个目录。 + video_dir: %s + + # 每个商品最多下载几个视频,允许 1—10。 + max_videos_per_product: %d + + # 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。 + search_top_n: %d + + # 同时下载几个视频,允许 1—8。 + # [注意] 调大会提高淘宝风控概率,没有实测结论前不要超过 3。 + concurrency: %d + + # 处理完一个商品后随机等待的秒数区间,用来降低被风控的概率。 + # 改小了跑得快但更容易被拦,不建议低于 2 秒。 + wait_seconds_min: %s + wait_seconds_max: %s + + # 淘宝详情页视频为异步加载;调小会漏视频,不建议低于 8 秒。 + detail_wait_seconds: %s + + # 每个同款详情页前访问「我的淘宝」的深度登录守卫等待秒数。 + guard_wait_seconds: %s + + # 连续多少个正常打开却没有视频的详情页时,判为疑似风控并停止任务。 + risk_empty_threshold: %d + + # 网络层下载失败后的重试次数;HTTP 4xx 和 ffprobe 校验失败不会重试。 + download_retries: %d +`, + yamlString(c.Huohanhan.BaseURL), + yamlString(c.Huohanhan.Account), + quoted(c.Huohanhan.Password), + yamlString(c.Huohanhan.OCRURL), + quoted(c.Taobao.ChromePath), + quoted(c.Taobao.UserDataDir), + c.Taobao.DebugPortStart, + c.Taobao.DebugPortEnd, + quoted(c.Download.VideoDir), + c.Download.MaxVideosPerProduct, + c.Download.SearchTopN, + c.Download.Concurrency, + trimFloat(c.Download.WaitSecondsMin), + trimFloat(c.Download.WaitSecondsMax), + trimFloat(c.Download.DetailWaitSeconds), + trimFloat(c.Download.GuardWaitSeconds), + c.Download.RiskEmptyThreshold, + c.Download.DownloadRetries, + ) +} + +// quoted 把值渲染成带双引号的 YAML 字符串。 +// +// Windows 路径里有反斜杠和空格,密码可能是纯数字或含特殊字符, +// 这些都必须加引号,否则 YAML 解析会出错或类型不对。 +func quoted(v string) string { + // YAML 双引号字符串里,反斜杠和双引号要转义。 + escaped := strings.ReplaceAll(v, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return `"` + escaped + `"` +} + +// yamlString 渲染普通字符串。空值写成一对空引号,避免出现裸的冒号后什么都没有。 +func yamlString(v string) string { + if strings.TrimSpace(v) == "" { + return `""` + } + // 含特殊字符时一律加引号,省得判断哪些安全。 + if strings.ContainsAny(v, `:#{}[],&*?|<>=!%@\"' `) { + return quoted(v) + } + return v +} + +// trimFloat 把 2.0 渲染成 2,把 2.5 保留成 2.5,让配置文件更好看。 +func trimFloat(v float64) string { + s := fmt.Sprintf("%.2f", v) + s = strings.TrimRight(s, "0") + return strings.TrimSuffix(s, ".") +} + +// DefaultPath 返回配置文件的默认位置。 +// +// 放在程序目录而不是系统目录,是为了让同事能直接看到和备份它。 +// +// 查找顺序(这个顺序是为了同时照顾两种用法,改之前先读完): +// +// 1. exe 旁边已有 config.yaml → 用它。这是同事双击 exe 的正常情况。 +// 2. 当前工作目录已有 config.yaml → 用它。这是开发模式的情况: +// `wails dev` 跑的是 build\bin\cmsp-dev.exe,按 exe 目录算会把配置 +// 写到 build\bin\ 里,开发的人在项目根目录怎么找都找不到, +// 还以为保存没生效。 +// 3. 两个都没有 → 新建在 exe 旁边。 +// +// 换句话说:已经存在的配置优先,谁都不存在时才按 exe 目录建。 +func DefaultPath() string { + return resolvePath("config.yaml") +} + +// resolvePath 按「exe 目录 → 工作目录 → exe 目录(兜底)」找一个文件。 +// 配置文件和数据库都用这套规则,行为保持一致。 +func resolvePath(name string) string { + exeDir := "" + if exe, err := os.Executable(); err == nil { + exeDir = filepath.Dir(exe) + if candidate := filepath.Join(exeDir, name); fileExists(candidate) { + return candidate + } + } + if wd, err := os.Getwd(); err == nil { + if candidate := filepath.Join(wd, name); fileExists(candidate) { + return candidate + } + } + if exeDir != "" { + return filepath.Join(exeDir, name) + } + return name +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// ResolveDataPath 供其它包复用同一套查找规则,例如数据库文件。 +func ResolveDataPath(name string) string { + return resolvePath(name) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ae23297 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,328 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 默认配置必须是合法的,否则第一次启动程序就会报错。 +// 新增字段忘了写默认值或校验规则时,这个测试会失败。 +func TestDefaultConfigIsValid(t *testing.T) { + if err := Default().Validate(); err != nil { + t.Fatalf("默认配置应当合法,却报错:%v", err) + } +} + +func TestValidateRejectsBadValues(t *testing.T) { + // 每个用例只改一个字段,这样失败时能一眼看出是哪条规则出问题。 + cases := []struct { + name string + modify func(*Config) + expect string + }{ + {"网址为空", func(c *Config) { c.Huohanhan.BaseURL = "" }, "网址不能为空"}, + {"网址缺协议", func(c *Config) { c.Huohanhan.BaseURL = "www.huohanhan.com" }, "http://"}, + {"OCR 地址为空", func(c *Config) { c.Huohanhan.OCRURL = "" }, "OCR"}, + {"Chrome 路径为空", func(c *Config) { c.Taobao.ChromePath = "" }, "Chrome 可执行文件"}, + {"用户数据目录为空", func(c *Config) { c.Taobao.UserDataDir = "" }, "用户数据目录"}, + {"端口过小", func(c *Config) { c.Taobao.DebugPortStart = 80 }, "1024"}, + {"端口区间颠倒", func(c *Config) { c.Taobao.DebugPortEnd = c.Taobao.DebugPortStart - 1 }, "不能小于起始端口"}, + {"视频目录为空", func(c *Config) { c.Download.VideoDir = "" }, "视频保存目录"}, + {"每商品视频数为 0", func(c *Config) { c.Download.MaxVideosPerProduct = 0 }, "1—10"}, + {"图搜取数过大", func(c *Config) { c.Download.SearchTopN = 61 }, "1—60"}, + {"并发数过大", func(c *Config) { c.Download.Concurrency = 9 }, "1—8"}, + {"等待区间颠倒", func(c *Config) { c.Download.WaitSecondsMax = 1; c.Download.WaitSecondsMin = 5 }, "不能小于最短"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := Default() + tc.modify(&cfg) + err := cfg.Validate() + if err == nil { + t.Fatalf("期望校验失败,却通过了") + } + if !strings.Contains(err.Error(), tc.expect) { + t.Fatalf("错误信息应包含 %q,实际是 %q", tc.expect, err.Error()) + } + }) + } +} + +// 配置文件不存在时必须返回默认配置,而不是报错。 +// 第一次启动程序就是这个场景。 +func TestLoadMissingFileReturnsDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "not-exist.yaml") + cfg, err := Load(path) + if err != nil { + t.Fatalf("文件不存在时不应报错,却报了:%v", err) + } + if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart { + t.Fatalf("应当返回默认配置") + } +} + +func TestSaveThenLoadKeepsValues(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + + saved := Default() + saved.Huohanhan.Account = "13500000000" // 虚构测试数据,非真实账号 + saved.Huohanhan.Password = `测试"密码\含转义` // 故意含引号和反斜杠 + saved.Download.MaxVideosPerProduct = 5 + saved.Download.WaitSecondsMin = 1.5 + + if err := Save(path, saved); err != nil { + t.Fatalf("保存失败:%v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("读取失败:%v", err) + } + if loaded.Huohanhan.Account != saved.Huohanhan.Account { + t.Fatalf("账号应当读回 %q,实际 %q", saved.Huohanhan.Account, loaded.Huohanhan.Account) + } + if loaded.Huohanhan.Password != saved.Huohanhan.Password { + t.Fatalf("含引号和反斜杠的密码应当原样读回,期望 %q,实际 %q", + saved.Huohanhan.Password, loaded.Huohanhan.Password) + } + if loaded.Download.MaxVideosPerProduct != 5 { + t.Fatalf("每商品视频数应当读回 5,实际 %d", loaded.Download.MaxVideosPerProduct) + } + if loaded.Download.WaitSecondsMin != 1.5 { + t.Fatalf("小数应当读回 1.5,实际 %v", loaded.Download.WaitSecondsMin) + } +} + +// Windows 路径全是反斜杠,必须能原样存取。 +func TestSaveThenLoadKeepsWindowsPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + + saved := Default() + saved.Taobao.ChromePath = `C:\Program Files\Google\Chrome\Application\chrome.exe` + saved.Taobao.UserDataDir = `C:\Users\某人\AppData\Local\电商视频自动下载工具\淘宝浏览器\默认账号` + + if err := Save(path, saved); err != nil { + t.Fatalf("保存失败:%v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("读取失败:%v", err) + } + if loaded.Taobao.ChromePath != saved.Taobao.ChromePath { + t.Fatalf("Chrome 路径应当原样读回\n期望 %q\n实际 %q", + saved.Taobao.ChromePath, loaded.Taobao.ChromePath) + } + if loaded.Taobao.UserDataDir != saved.Taobao.UserDataDir { + t.Fatalf("用户数据目录应当原样读回\n期望 %q\n实际 %q", + saved.Taobao.UserDataDir, loaded.Taobao.UserDataDir) + } +} + +// 纯数字密码不加引号会被 YAML 当成整数导致读取失败,这是踩过的坑。 +func TestSaveThenLoadKeepsNumericPassword(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + + saved := Default() + saved.Huohanhan.Password = "0123456789" // 虚构测试数据,注意前导 0 + + if err := Save(path, saved); err != nil { + t.Fatalf("保存失败:%v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("纯数字密码应当能正常读取,却报错:%v", err) + } + if loaded.Huohanhan.Password != "0123456789" { + t.Fatalf("前导 0 应当保留,期望 0123456789,实际 %q", loaded.Huohanhan.Password) + } +} + +// 保存后注释必须还在,否则同事下次打开就只剩键值对。 +func TestSaveKeepsComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := Save(path, Default()); err != nil { + t.Fatalf("保存失败:%v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取文件失败:%v", err) + } + text := string(raw) + + for _, must := range []string{ + "# cmsp 本机配置", + "绝不能提交进 Git", + "密码要用双引号包起来", + "这里没有淘宝账号和密码", + "不会代填密码", + "提高淘宝风控概率", + } { + if !strings.Contains(text, must) { + t.Fatalf("保存后应当保留注释 %q,实际内容:\n%s", must, text) + } + } +} + +// 老配置文件缺少新字段时,新字段要保留默认值,不能变成 0 或空字符串。 +func TestLoadFillsMissingFieldsWithDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "old.yaml") + old := "huohanhan:\n account: \"13500000000\"\ndownload:\n max_videos_per_product: 7\n" + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatalf("准备测试文件失败:%v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("读取失败:%v", err) + } + if cfg.Download.MaxVideosPerProduct != 7 { + t.Fatalf("已有字段应当读回 7,实际 %d", cfg.Download.MaxVideosPerProduct) + } + if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart { + t.Fatalf("缺失字段应当保留默认值 %d,实际 %d", + Default().Taobao.DebugPortStart, cfg.Taobao.DebugPortStart) + } + if cfg.Huohanhan.BaseURL != Default().Huohanhan.BaseURL { + t.Fatalf("缺失的网址应当保留默认值") + } + if cfg.Download.DetailWaitSeconds != 8 || cfg.Download.GuardWaitSeconds != 3 || cfg.Download.RiskEmptyThreshold != 8 || cfg.Download.DownloadRetries != 3 { + t.Fatalf("旧配置缺失新字段时应使用默认值,实际:%+v", cfg.Download) + } +} + +func Test详情页等待秒数边界校验(t *testing.T) { + for _, seconds := range []float64{2, 31} { + cfg := Default() + cfg.Download.DetailWaitSeconds = seconds + if err := cfg.Validate(); err == nil { + t.Fatalf("详情页等待秒数 %v 应被拒绝", seconds) + } + } +} + +// Save 必须拒绝不合法的配置,避免把用不了的配置写进文件。 +func TestSaveRejectsInvalidConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + bad := Default() + bad.Download.Concurrency = 99 + + if err := Save(path, bad); err == nil { + t.Fatalf("应当拒绝不合法的配置") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("校验失败时不应写出文件") + } +} + +// 密码绝不能原样出现在脱敏后的配置里。 +func TestDesensitizedHidesPassword(t *testing.T) { + cfg := Default() + cfg.Huohanhan.Password = "这是一个不该出现在日志里的密码" + + safe := cfg.Desensitized() + if strings.Contains(safe.Huohanhan.Password, "不该出现") { + t.Fatalf("脱敏后仍能看到密码:%q", safe.Huohanhan.Password) + } + if cfg.Huohanhan.Password == safe.Huohanhan.Password { + t.Fatalf("Desensitized 不应修改原配置") + } +} + +// 淘宝配置里绝不能出现账号密码字段。加了就是违反 AGENTS.md 的红线, +// 这个测试就是为了挡住那种改动。 +func TestTaobaoConfigHasNoCredentialFields(t *testing.T) { + rendered := Default().Render() + for _, forbidden := range []string{ + "taobao_password", "taobao_account", + "\n password:", // taobao 段里不该有 password + "\n username:", + } { + // huohanhan 段里的 password 缩进也是两格,所以要精确定位 taobao 段。 + taobaoSection := rendered[strings.Index(rendered, "taobao:"):strings.Index(rendered, "download:")] + if strings.Contains(taobaoSection, forbidden) { + t.Fatalf("taobao 配置段不允许出现 %q,淘宝登录必须由使用者手动完成", forbidden) + } + } +} + +// 配置文件的查找顺序必须同时照顾两种用法: +// 打包后 exe 旁边、开发模式下项目根目录。 +func TestResolvePath优先使用已存在的文件(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(existing, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil { + t.Fatalf("准备测试文件失败:%v", err) + } + + // 把工作目录切到那个临时目录,模拟开发模式: + // exe 在 build\bin 里没有配置,但工作目录有。 + old, err := os.Getwd() + if err != nil { + t.Fatalf("读取工作目录失败:%v", err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("切换工作目录失败:%v", err) + } + + got := DefaultPath() + if filepath.Base(got) != "config.yaml" { + t.Fatalf("应当返回 config.yaml,实际 %q", got) + } + // 关键:不能返回 exe 目录下那个不存在的路径, + // 而应当命中工作目录里已经存在的这一份。 + if !fileExists(got) { + t.Fatalf("应当返回已存在的配置文件,实际 %q 不存在", got) + } +} + +func TestFileExists目录不算文件(t *testing.T) { + dir := t.TempDir() + if fileExists(dir) { + t.Fatalf("目录不应被当成文件") + } + if fileExists(filepath.Join(dir, "不存在.yaml")) { + t.Fatalf("不存在的路径不应返回 true") + } +} + +// 数据根目录必须跟随配置文件所在位置,不能按 exe 目录算。 +// +// 曾经真实踩过:wails dev 跑的是 build\bin\cmsp-dev.exe, +// 按 exe 目录算会把视频下载进 build\bin,而 wails build -clean +// 会清空那个目录,已下载的视频被一起删掉。 +func TestDataRoot跟随配置文件位置(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("huohanhan:\n account: \"x\"\n"), 0o600); err != nil { + t.Fatalf("准备配置文件失败:%v", err) + } + + old, err := os.Getwd() + if err != nil { + t.Fatalf("读取工作目录失败:%v", err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("切换工作目录失败:%v", err) + } + + root := DataRoot() + if root != filepath.Dir(DefaultPath()) { + t.Fatalf("数据根目录应当是配置文件所在目录,实际 %q", root) + } + + videoDir := DefaultVideoDir() + if !strings.HasPrefix(videoDir, root) { + t.Fatalf("视频目录应当在数据根目录之下\n根目录 %q\n视频目录 %q", root, videoDir) + } + if !strings.HasSuffix(videoDir, filepath.Join("运行数据", "视频")) { + t.Fatalf("视频目录应当以 运行数据/视频 结尾,实际 %q", videoDir) + } + // 关键:不能落进 build\bin + if strings.Contains(videoDir, filepath.Join("build", "bin")) { + t.Fatalf("视频目录不得落在 build\bin 下,那里会被 wails build -clean 清空:%q", videoDir) + } +} diff --git a/internal/huohanhan/auth.go b/internal/huohanhan/auth.go new file mode 100644 index 0000000..241b0ff --- /dev/null +++ b/internal/huohanhan/auth.go @@ -0,0 +1,656 @@ +// Package huohanhan 提供货憨憨 ERP 的登录和统一 HTTP 客户端。 +// +// 设计约定(改代码前请先读): +// +// - 账号和密码只从 config 传入,token 与 cookies 只保存在 SQLite kv 表; +// 不要增加 JSON 状态文件,也不要把任何凭据写进日志。 +// - 登录验证码可以更换后重试;账号密码错误、账号禁用和其它登录错误 +// 都不能重试,避免无意义请求触发服务端风控。 +// - AuthManager 只用进程内 mutex 串行登录。本项目是单机 GUI,不能把 +// Python 参考实现里的 Redis 和分布式锁搬进来。 +package huohanhan + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/textproto" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "go-admin/internal/config" + "go-admin/internal/logx" + "go-admin/internal/store" +) + +const ( + authStateKey = "huohanhan.auth" + defaultRequestTimeout = 30 * time.Second + defaultCaptchaAttempts = 3 + defaultAuthExpirySkew = 5 * time.Minute + maximumResponseBodyBytes = 8 << 20 +) + +var ( + cidPattern = regexp.MustCompile(`["']?CID["']?\s*:\s*["']([^"']+)["']`) + cstPattern = regexp.MustCompile(`["']?CST["']?\s*:\s*["']([^"']+)["']`) + captchaPattern = regexp.MustCompile(`^[A-Za-z0-9]{4,8}$`) +) + +// AuthOptions 是登录流程中需要调整的运行参数。 +// +// 零值会使用安全默认值。HTTPClient 和 Now 主要供离线测试注入; +// MaxCaptchaAttempts 让上层配置接入后无需修改登录逻辑。 +type AuthOptions struct { + HTTPClient *http.Client + RequestTimeout time.Duration + MaxCaptchaAttempts int + ExpirySkew time.Duration + Now func() time.Time +} + +// AuthState 是一次登录后需要复用的完整认证状态。 +// +// 该结构会序列化到 SQLite,字段可能含敏感内容,禁止整体写入日志。 +type AuthState struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + LoginTime int64 `json:"login_time"` + Cookies map[string]string `json:"cookies"` +} + +// AuthorizationValue 返回业务请求使用的 Authorization 值。 +func (s AuthState) AuthorizationValue() string { + tokenType := strings.TrimSpace(s.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + return tokenType + " " + s.AccessToken +} + +// Expired 判断 token 是否已经过期或进入安全提前量。 +func (s AuthState) Expired(now time.Time, skew time.Duration) bool { + if s.ExpiresIn <= 0 || s.LoginTime <= 0 { + return true + } + expiresAt := time.UnixMilli(s.LoginTime).Add(time.Duration(s.ExpiresIn) * time.Second) + return !now.Add(skew).Before(expiresAt) +} + +// AuthManager 串行管理内存与 SQLite 中的认证状态。 +type AuthManager struct { + cfg config.HuohanhanConfig + db *store.Store + log *logx.Logger + httpClient *http.Client + attempts int + expirySkew time.Duration + now func() time.Time + + mu sync.Mutex + current *AuthState +} + +// NewAuthManager 创建认证管理器。opts 的零值会补成默认配置。 +func NewAuthManager(cfg config.HuohanhanConfig, db *store.Store, logger *logx.Logger, opts AuthOptions) *AuthManager { + timeout := opts.RequestTimeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: timeout} + } + attempts := opts.MaxCaptchaAttempts + if attempts <= 0 { + attempts = defaultCaptchaAttempts + } + skew := opts.ExpirySkew + if skew <= 0 { + skew = defaultAuthExpirySkew + } + now := opts.Now + if now == nil { + now = time.Now + } + if logger == nil { + logger = logx.New(1000) + } + return &AuthManager{ + cfg: cfg, + db: db, + log: logger, + httpClient: client, + attempts: attempts, + expirySkew: skew, + now: now, + } +} + +// GetValidAuth 按“内存、SQLite、重新登录”的顺序取得有效认证。 +// +// SQLite 中的状态在进程首次使用时必须在线验证,不能只相信本地时间。 +func (m *AuthManager) GetValidAuth(ctx context.Context) (AuthState, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current != nil && !m.current.Expired(m.now(), m.expirySkew) { + return cloneAuthState(*m.current), nil + } + + stored, found, err := m.load() + if err != nil { + return AuthState{}, err + } + if found { + valid, validateErr := m.validate(ctx, stored) + if validateErr != nil { + return AuthState{}, validateErr + } + if valid { + m.current = &stored + m.log.Info("已复用本机保存的货憨憨认证状态") + return cloneAuthState(stored), nil + } + if err := m.clearLocked(); err != nil { + return AuthState{}, err + } + } + + return m.loginAndSaveLocked(ctx) +} + +// ForceLogin 忽略旧状态并重新登录,供测试连接和认证失败恢复使用。 +func (m *AuthManager) ForceLogin(ctx context.Context) (AuthState, error) { + m.mu.Lock() + defer m.mu.Unlock() + // 显式测试连接或服务端已拒绝认证时,不能让失败的重新登录继续留下旧 token。 + if err := m.clearLocked(); err != nil { + return AuthState{}, err + } + return m.loginAndSaveLocked(ctx) +} + +// Invalidate 清除内存与 SQLite 中已被服务端拒绝的认证状态。 +func (m *AuthManager) Invalidate() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.clearLocked() +} + +func (m *AuthManager) loginAndSaveLocked(ctx context.Context) (AuthState, error) { + state, err := m.login(ctx) + if err != nil { + return AuthState{}, err + } + if err := m.save(state); err != nil { + return AuthState{}, err + } + m.current = &state + m.log.Success("货憨憨登录成功") + return cloneAuthState(state), nil +} + +func (m *AuthManager) login(ctx context.Context) (AuthState, error) { + if m.db == nil { + return AuthState{}, fmt.Errorf("本地数据库未就绪") + } + baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/")) + if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { + return AuthState{}, fmt.Errorf("货憨憨网址不正确") + } + if strings.TrimSpace(m.cfg.Account) == "" || m.cfg.Password == "" { + return AuthState{}, fmt.Errorf("请先填写货憨憨账号和密码") + } + if strings.TrimSpace(m.cfg.OCRURL) == "" { + return AuthState{}, fmt.Errorf("OCR 识别服务地址不能为空") + } + + jar, err := cookiejar.New(nil) + if err != nil { + return AuthState{}, fmt.Errorf("创建登录会话失败:%w", err) + } + session := *m.httpClient + session.Jar = jar + + cid, cst, clientID, err := m.loadLoginParameters(ctx, &session, baseURL) + if err != nil { + return AuthState{}, err + } + + for attempt := 1; attempt <= m.attempts; attempt++ { + m.log.Info("正在识别货憨憨登录验证码,第 %d/%d 次", attempt, m.attempts) + captchaKey, image, err := m.downloadCaptcha(ctx, &session, baseURL) + if err != nil { + return AuthState{}, err + } + captchaCode, err := m.recognizeCaptcha(ctx, image) + if err != nil { + return AuthState{}, err + } + + state, code, message, err := m.submitLogin( + ctx, &session, baseURL, cid, cst, clientID, captchaCode, captchaKey, + ) + if err != nil { + return AuthState{}, err + } + if state.AccessToken != "" { + valid, validateErr := m.validateWithClient(ctx, &session, baseURL, state) + if validateErr != nil { + return AuthState{}, validateErr + } + if !valid { + return AuthState{}, fmt.Errorf("登录成功,但服务端未通过认证校验") + } + return state, nil + } + + switch code { + case "invalid_verify_code": + m.log.Warn("验证码不正确,准备更换验证码") + continue + case "invalid_credentials": + return AuthState{}, fmt.Errorf("账号或密码错误") + case "disabled_credentials": + return AuthState{}, fmt.Errorf("账号已被禁用") + default: + if strings.TrimSpace(code) == "" { + code = "未知错误" + } + if strings.TrimSpace(message) == "" { + message = "登录失败" + } + return AuthState{}, fmt.Errorf("登录失败:%s,%s", code, message) + } + } + return AuthState{}, fmt.Errorf("连续多次验证码识别失败,请稍后重试") +} + +func (m *AuthManager) loadLoginParameters(ctx context.Context, client *http.Client, baseURL *url.URL) (string, string, string, error) { + page, err := m.do(ctx, client, http.MethodGet, baseURL.String()+"/login", nil, "", "", "") + if err != nil { + return "", "", "", fmt.Errorf("读取货憨憨登录页失败:%w", err) + } + cid := findRuntimeValue(page, cidPattern) + if cid == "" { + return "", "", "", fmt.Errorf("登录页中没有找到 CID,网页可能已经改版") + } + cst := findRuntimeValue(page, cstPattern) + if cst == "" { + return "", "", "", fmt.Errorf("登录页中没有找到 CST,网页可能已经改版") + } + + form := url.Values{"domain": {baseURL.Hostname()}} + body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "butler/client/getCltConf"), + []byte(form.Encode()), "application/x-www-form-urlencoded", "", "") + if err != nil { + return "", "", "", fmt.Errorf("读取货憨憨网站配置失败:%w", err) + } + var payload struct { + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", "", "", fmt.Errorf("货憨憨网站配置返回的不是有效 JSON") + } + clientID := rawString(payload.ID) + if clientID == "" { + return "", "", "", fmt.Errorf("货憨憨网站配置中没有 clientId") + } + return cid, cst, clientID, nil +} + +func (m *AuthManager) downloadCaptcha(ctx context.Context, client *http.Client, baseURL *url.URL) (string, []byte, error) { + key, err := newUUID() + if err != nil { + return "", nil, fmt.Errorf("生成验证码标识失败:%w", err) + } + endpoint := apiURL(baseURL, "butler/vrify/kaptcha") + "?" + url.Values{"kaptchaKey": {key}}.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", nil, fmt.Errorf("创建验证码请求失败:%w", err) + } + applyCommonHeaders(req) + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("下载验证码失败:%w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, fmt.Errorf("下载验证码失败:HTTP %d", resp.StatusCode) + } + if !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "image") { + return "", nil, fmt.Errorf("验证码接口没有返回图片") + } + image, err := readBody(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("读取验证码图片失败:%w", err) + } + return key, image, nil +} + +func (m *AuthManager) recognizeCaptcha(ctx context.Context, image []byte) (string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="file"; filename="captcha.jpg"`) + header.Set("Content-Type", "image/jpeg") + part, err := writer.CreatePart(header) + if err != nil { + return "", fmt.Errorf("准备验证码图片失败:%w", err) + } + if _, err := part.Write(image); err != nil { + return "", fmt.Errorf("准备验证码图片失败:%w", err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("准备验证码图片失败:%w", err) + } + + payload, err := m.do(ctx, m.httpClient, http.MethodPost, m.cfg.OCRURL, + body.Bytes(), writer.FormDataContentType(), "", "") + if err != nil { + return "", fmt.Errorf("OCR 识别失败:%w", err) + } + var decoded any + if json.Unmarshal(payload, &decoded) != nil { + decoded = string(payload) + } + code := findCaptchaText(decoded) + if code == "" { + return "", fmt.Errorf("OCR 返回成功,但没有找到 4~8 位验证码") + } + return code, nil +} + +func (m *AuthManager) submitLogin(ctx context.Context, client *http.Client, baseURL *url.URL, cid, cst, clientID, captchaCode, captchaKey string) (AuthState, string, string, error) { + form := url.Values{ + "username": {m.cfg.Account}, + "password": {m.cfg.Password}, + "clientId": {clientID}, + "kaptchaCode": {captchaCode}, + "kaptchaKey": {captchaKey}, + } + body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "login"), + []byte(form.Encode()), "application/x-www-form-urlencoded", cid, cst) + if err != nil { + return AuthState{}, "", "", fmt.Errorf("提交货憨憨登录失败:%w", err) + } + var payload map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return AuthState{}, "", "", fmt.Errorf("货憨憨登录接口返回的不是有效 JSON") + } + token := stringValue(payload["access_token"]) + if token == "" { + return AuthState{}, stringValue(payload["code"]), stringValue(payload["message"]), nil + } + + state := AuthState{ + AccessToken: token, + TokenType: stringValue(payload["token_type"]), + ExpiresIn: int64Value(payload["expires_in"]), + LoginTime: int64Value(payload["login_time"]), + Cookies: make(map[string]string), + } + if state.TokenType == "" { + state.TokenType = "Bearer" + } + if state.LoginTime <= 0 { + state.LoginTime = m.now().UnixMilli() + } + for _, cookie := range client.Jar.Cookies(baseURL) { + state.Cookies[cookie.Name] = cookie.Value + } + return state, "", "", nil +} + +func (m *AuthManager) validate(ctx context.Context, state AuthState) (bool, error) { + baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/")) + if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { + return false, fmt.Errorf("货憨憨网址不正确") + } + return m.validateWithClient(ctx, m.httpClient, baseURL, state) +} + +func (m *AuthManager) validateWithClient(ctx context.Context, client *http.Client, baseURL *url.URL, state AuthState) (bool, error) { + form := url.Values{"appCode": {"HHH"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(baseURL, "butler/app-version/info"), strings.NewReader(form.Encode())) + if err != nil { + return false, fmt.Errorf("创建认证校验请求失败:%w", err) + } + applyCommonHeaders(req) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", state.AuthorizationValue()) + for name, value := range state.Cookies { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("校验货憨憨认证失败:%w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return false, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false, fmt.Errorf("校验货憨憨认证失败:HTTP %d", resp.StatusCode) + } + body, err := readBody(resp.Body) + if err != nil { + return false, fmt.Errorf("读取认证校验结果失败:%w", err) + } + var payload struct { + AppCode string `json:"appCode"` + } + if json.Unmarshal(body, &payload) != nil { + return false, nil + } + return payload.AppCode == "HHH", nil +} + +func (m *AuthManager) do(ctx context.Context, client *http.Client, method, endpoint string, body []byte, contentType, basicUser, basicPassword string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + applyCommonHeaders(req) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if basicUser != "" || basicPassword != "" { + req.SetBasicAuth(basicUser, basicPassword) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return readBody(resp.Body) +} + +func (m *AuthManager) save(state AuthState) error { + raw, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("编码货憨憨认证状态失败:%w", err) + } + if err := m.db.SetKV(authStateKey, string(raw), m.now().Format(time.RFC3339)); err != nil { + return fmt.Errorf("保存货憨憨认证状态失败:%w", err) + } + return nil +} + +func (m *AuthManager) load() (AuthState, bool, error) { + if m.db == nil { + return AuthState{}, false, fmt.Errorf("本地数据库未就绪") + } + raw, found, err := m.db.GetKV(authStateKey) + if err != nil { + return AuthState{}, false, fmt.Errorf("读取货憨憨认证状态失败:%w", err) + } + if !found || strings.TrimSpace(raw) == "" { + return AuthState{}, false, nil + } + var state AuthState + if err := json.Unmarshal([]byte(raw), &state); err != nil || state.AccessToken == "" { + m.log.Warn("本机保存的货憨憨认证状态无法读取,将重新登录") + return AuthState{}, false, nil + } + if state.Cookies == nil { + state.Cookies = make(map[string]string) + } + return state, true, nil +} + +func (m *AuthManager) clearLocked() error { + m.current = nil + if m.db == nil { + return fmt.Errorf("本地数据库未就绪") + } + if err := m.db.SetKV(authStateKey, "", m.now().Format(time.RFC3339)); err != nil { + return fmt.Errorf("清除货憨憨认证状态失败:%w", err) + } + return nil +} + +func cloneAuthState(state AuthState) AuthState { + cloned := state + cloned.Cookies = make(map[string]string, len(state.Cookies)) + for name, value := range state.Cookies { + cloned.Cookies[name] = value + } + return cloned +} + +func findRuntimeValue(page []byte, pattern *regexp.Regexp) string { + match := pattern.FindSubmatch(page) + if len(match) != 2 { + return "" + } + return string(match[1]) +} + +func findCaptchaText(value any) string { + switch typed := value.(type) { + case string: + cleaned := strings.Join(strings.Fields(typed), "") + if captchaPattern.MatchString(cleaned) { + return cleaned + } + case map[string]any: + for _, key := range []string{"text", "result", "data", "content", "captcha", "code"} { + if child, ok := typed[key]; ok { + if result := findCaptchaText(child); result != "" { + return result + } + } + } + for key, child := range typed { + if isPreferredCaptchaKey(key) { + continue + } + if result := findCaptchaText(child); result != "" { + return result + } + } + case []any: + for _, child := range typed { + if result := findCaptchaText(child); result != "" { + return result + } + } + } + return "" +} + +func isPreferredCaptchaKey(key string) bool { + for _, preferred := range []string{"text", "result", "data", "content", "captcha", "code"} { + if key == preferred { + return true + } + } + return false +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return "" + } +} + +func int64Value(value any) int64 { + switch typed := value.(type) { + case json.Number: + result, _ := typed.Int64() + return result + case float64: + return int64(typed) + case string: + result, _ := strconv.ParseInt(typed, 10, 64) + return result + default: + return 0 + } +} + +func rawString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var number json.Number + if json.Unmarshal(raw, &number) == nil { + return number.String() + } + return "" +} + +func apiURL(baseURL *url.URL, path string) string { + return strings.TrimRight(baseURL.String(), "/") + "/api/" + strings.TrimLeft(path, "/") +} + +func applyCommonHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152") +} + +func readBody(reader io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(reader, maximumResponseBodyBytes)) +} + +func newUUID() (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + raw[6] = (raw[6] & 0x0f) | 0x40 + raw[8] = (raw[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(raw[:]) + return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil +} + diff --git a/internal/huohanhan/auth_test.go b/internal/huohanhan/auth_test.go new file mode 100644 index 0000000..8da46cc --- /dev/null +++ b/internal/huohanhan/auth_test.go @@ -0,0 +1,341 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "go-admin/internal/config" + "go-admin/internal/logx" + "go-admin/internal/store" +) + +const ( + testAccount = "13500000000" + testPassword = "test-password" + testToken = "test-token" + testCookie = "test-cookie" +) + +type fakeLoginBackend struct { + t *testing.T + mu sync.Mutex + loginCodes []string + loginCount int + captchaCount int + validateCount int + captchaKeys []string + issuedToken string + businessHandler http.HandlerFunc + server *httptest.Server +} + +func newFakeLoginBackend(t *testing.T, loginCodes ...string) *fakeLoginBackend { + t.Helper() + backend := &fakeLoginBackend{t: t, loginCodes: loginCodes, issuedToken: testToken} + backend.server = httptest.NewServer(http.HandlerFunc(backend.serveHTTP)) + t.Cleanup(backend.server.Close) + return backend +} + +func (b *fakeLoginBackend) serveHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + http.SetCookie(w, &http.Cookie{Name: "login-session", Value: testCookie, Path: "/"}) + _, _ = io.WriteString(w, `window.config={"CID":"test-cid", 'CST': 'test-cst'}`) + case "/api/butler/client/getCltConf": + if err := r.ParseForm(); err != nil { + b.t.Errorf("解析网站配置表单失败:%v", err) + } + serverURL, _ := url.Parse(b.server.URL) + if got := r.Form.Get("domain"); got != serverURL.Hostname() { + b.t.Errorf("domain 期望 %q,实际 %q", serverURL.Hostname(), got) + } + writeJSON(w, map[string]any{"id": 12345}) + case "/api/butler/vrify/kaptcha": + b.mu.Lock() + b.captchaCount++ + b.captchaKeys = append(b.captchaKeys, r.URL.Query().Get("kaptchaKey")) + b.mu.Unlock() + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("fake-image")) + case "/api/login": + b.handleLogin(w, r) + case "/api/butler/app-version/info": + b.mu.Lock() + b.validateCount++ + b.mu.Unlock() + if err := r.ParseForm(); err != nil { + b.t.Errorf("解析认证校验表单失败:%v", err) + } + if r.Form.Get("appCode") != "HHH" { + b.t.Errorf("认证校验 appCode 期望 HHH,实际 %q", r.Form.Get("appCode")) + } + if r.Header.Get("Authorization") != "Bearer "+b.issuedToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + if cookie, err := r.Cookie("auth-session"); err != nil || cookie.Value != testCookie { + b.t.Errorf("认证校验应携带登录 cookie,实际 cookie=%v err=%v", cookie, err) + } + writeJSON(w, map[string]any{"appCode": "HHH"}) + default: + if b.businessHandler != nil { + b.businessHandler(w, r) + return + } + http.NotFound(w, r) + } +} + +func (b *fakeLoginBackend) handleLogin(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if !ok || user != "test-cid" || password != "test-cst" { + b.t.Errorf("登录请求 Basic 认证不正确") + } + if err := r.ParseForm(); err != nil { + b.t.Errorf("解析登录表单失败:%v", err) + } + if r.Form.Get("username") != testAccount || r.Form.Get("password") != testPassword { + b.t.Errorf("登录表单账号或密码不正确") + } + if r.Form.Get("clientId") != "12345" || r.Form.Get("kaptchaCode") != "A1b2" { + b.t.Errorf("登录表单 clientId 或验证码不正确:%v", r.Form) + } + if r.Form.Get("kaptchaKey") == "" { + b.t.Errorf("登录表单缺少 kaptchaKey") + } + + b.mu.Lock() + index := b.loginCount + b.loginCount++ + code := "" + if index < len(b.loginCodes) { + code = b.loginCodes[index] + } + b.mu.Unlock() + + if code != "" { + writeJSON(w, map[string]any{"code": code, "message": "fake login error"}) + return + } + http.SetCookie(w, &http.Cookie{Name: "auth-session", Value: testCookie, Path: "/"}) + writeJSON(w, map[string]any{ + "access_token": b.issuedToken, + "token_type": "Bearer", + "expires_in": 3600, + "login_time": time.Now().UnixMilli(), + }) +} + +func newFakeOCRServer(t *testing.T, calls *int) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + (*calls)++ + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("OCR multipart 解析失败:%v", err) + http.Error(w, "bad multipart", http.StatusBadRequest) + return + } + file, header, err := r.FormFile("file") + if err != nil { + t.Errorf("OCR 请求缺少 file 字段:%v", err) + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer file.Close() + if header.Filename != "captcha.jpg" { + t.Errorf("OCR 文件名期望 captcha.jpg,实际 %q", header.Filename) + } + if got := header.Header.Get("Content-Type"); got != "image/jpeg" { + t.Errorf("OCR 文件类型期望 image/jpeg,实际 %q", got) + } + image, _ := io.ReadAll(file) + if string(image) != "fake-image" { + t.Errorf("OCR 图片内容不正确,实际 %q", image) + } + writeJSON(w, map[string]any{"data": map[string]any{"text": " A1 b2 "}}) + })) + t.Cleanup(server.Close) + return server +} + +func newTestAuthManager(t *testing.T, backend *fakeLoginBackend, database *store.Store, logger *logx.Logger, attempts int) *AuthManager { + t.Helper() + ocrCalls := 0 + ocr := newFakeOCRServer(t, &ocrCalls) + cfg := config.HuohanhanConfig{ + BaseURL: backend.server.URL, + Account: testAccount, + Password: testPassword, + OCRURL: ocr.URL, + } + return NewAuthManager(cfg, database, logger, AuthOptions{ + HTTPClient: backend.server.Client(), + MaxCaptchaAttempts: attempts, + }) +} + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + database, err := store.Open(":memory:") + if err != nil { + t.Fatalf("打开测试数据库失败:%v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func Test登录成功并保存认证状态(t *testing.T) { + backend := newFakeLoginBackend(t) + database := newTestStore(t) + logger := logx.New(100) + manager := newTestAuthManager(t, backend, database, logger, 3) + + state, err := manager.ForceLogin(context.Background()) + if err != nil { + t.Fatalf("登录应当成功,实际错误:%v", err) + } + if state.AccessToken != testToken { + t.Fatalf("token 期望 %q,实际 %q", testToken, state.AccessToken) + } + raw, found, err := database.GetKV(authStateKey) + if err != nil || !found || raw == "" { + t.Fatalf("认证状态应写入 SQLite,found=%v err=%v", found, err) + } + if backend.loginCount != 1 || backend.validateCount != 1 { + t.Fatalf("登录和在线校验都应各执行 1 次,实际登录 %d 次、校验 %d 次", backend.loginCount, backend.validateCount) + } +} + +func Test验证码错误后更换图片重试成功(t *testing.T) { + backend := newFakeLoginBackend(t, "invalid_verify_code") + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("第二张验证码应登录成功,实际错误:%v", err) + } + if backend.loginCount != 2 || backend.captchaCount != 2 { + t.Fatalf("应下载并提交 2 张验证码,实际下载 %d 次、提交 %d 次", backend.captchaCount, backend.loginCount) + } + if len(backend.captchaKeys) != 2 || backend.captchaKeys[0] == backend.captchaKeys[1] { + t.Fatalf("重试必须更换 kaptchaKey,实际 %v", backend.captchaKeys) + } +} + +func Test账号错误和禁用都不重试(t *testing.T) { + cases := []struct { + name string + code string + want string + }{ + {"账号密码错误", "invalid_credentials", "账号或密码错误"}, + {"账号已禁用", "disabled_credentials", "账号已被禁用"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backend := newFakeLoginBackend(t, tc.code) + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + _, err := manager.ForceLogin(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("期望错误包含 %q,实际 %v", tc.want, err) + } + if backend.loginCount != 1 || backend.captchaCount != 1 { + t.Fatalf("不可重试的错误应只请求 1 次,实际登录 %d 次、验证码 %d 次", backend.loginCount, backend.captchaCount) + } + }) + } +} + +func Test验证码错误达到上限后停止(t *testing.T) { + backend := newFakeLoginBackend(t, "invalid_verify_code", "invalid_verify_code", "invalid_verify_code") + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 2) + + _, err := manager.ForceLogin(context.Background()) + if err == nil || !strings.Contains(err.Error(), "连续多次验证码识别失败") { + t.Fatalf("达到上限应返回可读错误,实际 %v", err) + } + if backend.loginCount != 2 { + t.Fatalf("上限为 2 时应只提交 2 次,实际 %d 次", backend.loginCount) + } +} + +func Test其它登录错误包含Code和Message且不重试(t *testing.T) { + backend := newFakeLoginBackend(t, "server_rejected") + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + + _, err := manager.ForceLogin(context.Background()) + if err == nil || !strings.Contains(err.Error(), "server_rejected") || !strings.Contains(err.Error(), "fake login error") { + t.Fatalf("其它错误应包含 code 和 message,实际 %v", err) + } + if backend.loginCount != 1 { + t.Fatalf("其它登录错误不应重试,实际登录 %d 次", backend.loginCount) + } +} + +func TestSQLite中的Token重启后复用且内存不重复校验(t *testing.T) { + backend := newFakeLoginBackend(t) + database := newTestStore(t) + logger := logx.New(100) + first := newTestAuthManager(t, backend, database, logger, 3) + if _, err := first.ForceLogin(context.Background()); err != nil { + t.Fatalf("首次登录失败:%v", err) + } + + second := newTestAuthManager(t, backend, database, logger, 3) + if _, err := second.GetValidAuth(context.Background()); err != nil { + t.Fatalf("重启后读取认证状态失败:%v", err) + } + validatedAfterLoad := backend.validateCount + if _, err := second.GetValidAuth(context.Background()); err != nil { + t.Fatalf("内存复用认证状态失败:%v", err) + } + if backend.loginCount != 1 { + t.Fatalf("第二个管理器不应重新登录,实际登录 %d 次", backend.loginCount) + } + if backend.validateCount != validatedAfterLoad { + t.Fatalf("未过期内存状态不应再次在线校验,校验次数从 %d 变成 %d", validatedAfterLoad, backend.validateCount) + } +} + +func Test日志不出现密码Token和Cookie(t *testing.T) { + backend := newFakeLoginBackend(t) + logger := logx.New(100) + manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("登录失败:%v", err) + } + + logs := logger.Text() + for _, secret := range []string{testPassword, testToken, testCookie} { + if strings.Contains(logs, secret) { + t.Fatalf("日志中不应出现敏感测试值 %q,实际日志:%s", secret, logs) + } + } +} + +func TestOCR递归优先读取常见字段(t *testing.T) { + payload := map[string]any{ + "unrelated": "ZZZZ", + "result": map[string]any{"content": " A1 b2 "}, + } + if got := findCaptchaText(payload); got != "A1b2" { + t.Fatalf("应优先从 result/content 读取 A1b2,实际 %q", got) + } +} + +func writeJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + panic(fmt.Sprintf("写入假服务响应失败:%v", err)) + } +} + diff --git a/internal/huohanhan/client.go b/internal/huohanhan/client.go new file mode 100644 index 0000000..71d3228 --- /dev/null +++ b/internal/huohanhan/client.go @@ -0,0 +1,133 @@ +package huohanhan + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go-admin/internal/config" + "go-admin/internal/logx" +) + +var authFailureCodes = map[string]bool{ + "authentication_required": true, + "invalid_token": true, + "invalid_token_expired": true, +} + +// Client 是货憨憨业务接口的统一 HTTP 客户端。 +// +// Request 会自动添加 Authorization 和登录 cookies。只有服务端明确表示 +// 认证失效时才重新登录并重放一次;超时、HTTP 500 和普通业务错误不会重试。 +// 请求体使用 []byte,是为了让认证失败后的唯一一次重放不依赖可回卷的 Reader。 +type Client struct { + baseURL *url.URL + auth *AuthManager + httpClient *http.Client + log *logx.Logger +} + +// NewClient 创建业务请求客户端。 +func NewClient(cfg config.HuohanhanConfig, auth *AuthManager, logger *logx.Logger, httpClient *http.Client) (*Client, error) { + baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")) + if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { + return nil, fmt.Errorf("货憨憨网址不正确") + } + if auth == nil { + return nil, fmt.Errorf("货憨憨认证管理器不能为空") + } + if httpClient == nil { + httpClient = auth.httpClient + } + if logger == nil { + logger = logx.New(1000) + } + return &Client{baseURL: baseURL, auth: auth, httpClient: httpClient, log: logger}, nil +} + +// Request 请求一个相对于 /api/ 的货憨憨接口。 +// +// 返回的 response 由调用方关闭。非 2xx 状态会返回中文错误;认证失败 +// 的 response 在内部关闭后重试,不会泄漏给调用方。 +func (c *Client) Request(ctx context.Context, method, path string, body []byte, contentType string) (*http.Response, error) { + for attempt := 0; attempt < 2; attempt++ { + state, err := c.auth.GetValidAuth(ctx) + if err != nil { + return nil, err + } + resp, payload, err := c.do(ctx, method, path, body, contentType, state) + if err != nil { + return nil, err + } + + if isAuthFailure(resp.StatusCode, payload) { + resp.Body.Close() + if attempt == 1 { + return nil, fmt.Errorf("重新登录后认证仍然失效") + } + c.log.Warn("货憨憨认证已失效,正在重新登录后重试一次") + if err := c.auth.Invalidate(); err != nil { + return nil, err + } + if _, err := c.auth.ForceLogin(ctx); err != nil { + return nil, err + } + continue + } + + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(payload)) + resp.ContentLength = int64(len(payload)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() + return nil, fmt.Errorf("货憨憨接口请求失败:HTTP %d", resp.StatusCode) + } + return resp, nil + } + return nil, fmt.Errorf("货憨憨接口请求失败") +} + +func (c *Client) do(ctx context.Context, method, path string, body []byte, contentType string, state AuthState) (*http.Response, []byte, error) { + req, err := http.NewRequestWithContext(ctx, method, apiURL(c.baseURL, path), bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("创建货憨憨接口请求失败:%w", err) + } + applyCommonHeaders(req) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Authorization", state.AuthorizationValue()) + for name, value := range state.Cookies { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("请求货憨憨接口失败:%w", err) + } + payload, err := readBody(resp.Body) + if err != nil { + resp.Body.Close() + return nil, nil, fmt.Errorf("读取货憨憨接口响应失败:%w", err) + } + return resp, payload, nil +} + +func isAuthFailure(statusCode int, body []byte) bool { + if statusCode == http.StatusUnauthorized { + return true + } + var payload struct { + Code string `json:"code"` + } + if json.Unmarshal(body, &payload) != nil { + return false + } + return authFailureCodes[payload.Code] +} + diff --git a/internal/huohanhan/client_test.go b/internal/huohanhan/client_test.go new file mode 100644 index 0000000..ebb0640 --- /dev/null +++ b/internal/huohanhan/client_test.go @@ -0,0 +1,169 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "go-admin/internal/logx" +) + +func Test业务请求401后自动重登并只重试一次(t *testing.T) { + backend := newFakeLoginBackend(t) + logger := logx.New(100) + manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("准备初始认证失败:%v", err) + } + + businessCalls := 0 + backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { + businessCalls++ + if r.URL.Path != "/api/product/list" { + t.Errorf("业务路径期望 /api/product/list,实际 %s", r.URL.Path) + } + assertBusinessAuth(t, r) + if businessCalls == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + writeJSON(w, map[string]any{"ok": true}) + } + + client, err := NewClient(manager.cfg, manager, logger, backend.server.Client()) + if err != nil { + t.Fatalf("创建客户端失败:%v", err) + } + response, err := client.Request(context.Background(), http.MethodPost, "product/list", []byte("page=1"), "application/x-www-form-urlencoded") + if err != nil { + t.Fatalf("401 后重登重试应成功,实际错误:%v", err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if !strings.Contains(string(body), `"ok":true`) { + t.Fatalf("重试响应内容不正确:%s", body) + } + if businessCalls != 2 { + t.Fatalf("业务请求应执行 2 次,实际 %d 次", businessCalls) + } + if backend.loginCount != 2 { + t.Fatalf("初始登录加失效重登应共 2 次,实际 %d 次", backend.loginCount) + } +} + +func Test业务请求连续401不会无限重试(t *testing.T) { + backend := newFakeLoginBackend(t) + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("准备初始认证失败:%v", err) + } + + businessCalls := 0 + backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { + businessCalls++ + w.WriteHeader(http.StatusUnauthorized) + } + client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) + if err != nil { + t.Fatalf("创建客户端失败:%v", err) + } + _, err = client.Request(context.Background(), http.MethodGet, "always-unauthorized", nil, "") + if err == nil || !strings.Contains(err.Error(), "重新登录后认证仍然失效") { + t.Fatalf("第二次 401 应停止并返回可读错误,实际 %v", err) + } + if businessCalls != 2 { + t.Fatalf("同一请求最多执行 2 次,实际 %d 次", businessCalls) + } + if backend.loginCount != 2 { + t.Fatalf("只应额外重登 1 次,实际总登录 %d 次", backend.loginCount) + } +} + +func Test认证失败业务码触发一次重登(t *testing.T) { + backend := newFakeLoginBackend(t) + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("准备初始认证失败:%v", err) + } + + businessCalls := 0 + backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { + businessCalls++ + if businessCalls == 1 { + writeJSON(w, map[string]any{"code": "invalid_token_expired"}) + return + } + writeJSON(w, map[string]any{"data": "ok"}) + } + client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) + if err != nil { + t.Fatalf("创建客户端失败:%v", err) + } + response, err := client.Request(context.Background(), http.MethodGet, "auth-code", nil, "") + if err != nil { + t.Fatalf("认证失败业务码后应重试成功,实际错误:%v", err) + } + response.Body.Close() + if businessCalls != 2 || backend.loginCount != 2 { + t.Fatalf("应请求 2 次且总登录 2 次,实际请求 %d 次、登录 %d 次", businessCalls, backend.loginCount) + } +} + +func TestHTTP500和普通业务错误不重试(t *testing.T) { + cases := []struct { + name string + statusCode int + body any + wantError bool + }{ + {"HTTP 500", http.StatusInternalServerError, map[string]any{"message": "fake failure"}, true}, + {"普通业务错误", http.StatusOK, map[string]any{"code": "product_not_found"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backend := newFakeLoginBackend(t) + manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("准备初始认证失败:%v", err) + } + calls := 0 + backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(tc.statusCode) + _ = json.NewEncoder(w).Encode(tc.body) + } + client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) + if err != nil { + t.Fatalf("创建客户端失败:%v", err) + } + response, requestErr := client.Request(context.Background(), http.MethodGet, "ordinary-error", nil, "") + if tc.wantError && requestErr == nil { + t.Fatalf("期望返回错误,实际成功") + } + if !tc.wantError && requestErr != nil { + t.Fatalf("普通业务响应应交给调用方处理,实际错误:%v", requestErr) + } + if response != nil { + response.Body.Close() + } + if calls != 1 { + t.Fatalf("非认证错误不能重试,实际请求 %d 次", calls) + } + }) + } +} + +func assertBusinessAuth(t *testing.T, r *http.Request) { + t.Helper() + if got := r.Header.Get("Authorization"); got != "Bearer "+testToken { + t.Errorf("Authorization 不正确,实际 %q", got) + } + cookie, err := r.Cookie("auth-session") + if err != nil || cookie.Value != testCookie { + t.Errorf("业务请求应携带登录 cookie,实际 cookie=%v err=%v", cookie, err) + } +} + diff --git a/internal/huohanhan/product.go b/internal/huohanhan/product.go new file mode 100644 index 0000000..36304fc --- /dev/null +++ b/internal/huohanhan/product.go @@ -0,0 +1,277 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "go-admin/internal/store" +) + +const ( + // 每页条数。实测该接口 size=500 也能返回,但 200 是速度与响应体积的 + // 平衡点:某个 4304 商品的店铺,size=20 要 216 页约 108 秒, + // size=200 只要 22 页约 18 秒。改大之前先实测,不要凭感觉调。 + defaultProductPageSize = 200 + // 分页上限,防止 pages 字段异常导致死循环。 + // 按每页 200 条算,上限对应 4 万个商品,远超实际店铺规模。 + maximumProductPages = 200 +) + +// ProductPageParams 是商品单页查询所需的可变参数。 +// 其它筛选字段由 GetProductPage 按真实网页请求补为空值。 +type ProductPageParams struct { + Size int + Current int + PlatformShopID string +} + +// ProductRecord 是货憨憨商品响应中需要保存的字段白名单。 +type ProductRecord struct { + ID string `json:"id"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + MainImage string `json:"mainImage"` + ShopName string `json:"shopName"` + PlatformShopID string `json:"platformShopId"` + Currency string `json:"currency"` + MinSkuPrice float64 `json:"minSkuPrice"` + ItemStatus string `json:"itemStatus"` + CreateTime string `json:"createTime"` + DiagnosisInfo *DiagnosisInfo `json:"diagnosisInfo"` +} + +// DiagnosisInfo 是货憨憨返回的商品质量诊断对象。 +// 指针字段能保留 JSON null,便于按已确认的两态规则明确处理边界。 +type DiagnosisInfo struct { + ItemID string `json:"itemId"` + QualityLevel string `json:"qualityLevel"` + Diagnoses []DiagnosisGroup `json:"diagnoses"` +} + +// DiagnosisGroup 是按商品字段分组的诊断结果。 +type DiagnosisGroup struct { + Field string `json:"field"` + DiagnosisResults []DiagnosisResult `json:"diagnosisResults"` +} + +// DiagnosisResult 是一条具体的诊断类型和处理建议。 +type DiagnosisResult struct { + Type string `json:"type"` + Solution string `json:"solution"` +} + +// ProductPage 对应货憨憨商品接口返回的裸分页对象。 +type ProductPage struct { + Records []ProductRecord `json:"records"` + Total int `json:"total"` + Size int `json:"size"` + Current int `json:"current"` + Pages int `json:"pages"` +} + +// UnmarshalJSON 兼容分页数字既可能是 JSON 数字、也可能是字符串的响应。 +func (p *ProductPage) UnmarshalJSON(data []byte) error { + var raw struct { + Records []ProductRecord `json:"records"` + Total json.RawMessage `json:"total"` + Size json.RawMessage `json:"size"` + Current json.RawMessage `json:"current"` + Pages json.RawMessage `json:"pages"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + fields := []struct { + name string + raw json.RawMessage + dest *int + }{ + {"total", raw.Total, &p.Total}, + {"size", raw.Size, &p.Size}, + {"current", raw.Current, &p.Current}, + {"pages", raw.Pages, &p.Pages}, + } + for _, field := range fields { + value, err := parsePageInteger(field.raw) + if err != nil { + return fmt.Errorf("分页字段 %s 格式不正确:%w", field.name, err) + } + *field.dest = value + } + p.Records = raw.Records + return nil +} + +func parsePageInteger(raw json.RawMessage) (int, error) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return 0, nil + } + if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' { + var decoded string + if err := json.Unmarshal(raw, &decoded); err != nil { + return 0, err + } + text = decoded + } + return strconv.Atoi(text) +} + +// GetProductPage 按真实网页使用的 form 编码读取一页在售商品。 +func (c *Client) GetProductPage(ctx context.Context, params ProductPageParams) (ProductPage, error) { + if params.Size <= 0 { + params.Size = defaultProductPageSize + } + if params.Current <= 0 { + params.Current = 1 + } + + form := url.Values{ + "size": {strconv.Itoa(params.Size)}, + "current": {strconv.Itoa(params.Current)}, + "descs": {""}, + "ascs": {""}, + "itemStatus": {"NORMAL"}, + "marked": {""}, + "region": {""}, + "platform": {"0"}, + "platformShopId": {strings.TrimSpace(params.PlatformShopID)}, + "itemName": {""}, + "itemIds": {""}, + "itemSkus": {""}, + "modelSku": {""}, + "categoryId": {""}, + "hasSizeChart": {""}, + "sourceId": {""}, + "sourcePlatformCode": {""}, + "isPreOrder": {""}, + "nextDayArrive": {""}, + "createTimeStart": {""}, + "createTimeEnd": {""}, + "minSkuPrice": {""}, + "maxSkuPrice": {""}, + "minSale": {""}, + "maxSale": {""}, + "minViews": {""}, + "maxViews": {""}, + "minLikes": {""}, + "maxLikes": {""}, + "minCommentCount": {""}, + "maxCommentCount": {""}, + "minRatingStar": {""}, + "maxRatingStar": {""}, + "sortField": {"updateTime"}, + "sortType": {"desc"}, + "groupIds": {""}, + } + response, err := c.Request( + ctx, + http.MethodPost, + "product/shop/getPage", + []byte(form.Encode()), + "application/x-www-form-urlencoded;charset=UTF-8", + ) + if err != nil { + return ProductPage{}, fmt.Errorf("读取商品第 %d 页失败:%w", params.Current, err) + } + defer response.Body.Close() + + var page ProductPage + if err := json.NewDecoder(response.Body).Decode(&page); err != nil { + return ProductPage{}, fmt.Errorf("商品分页返回的不是有效 JSON:%w", err) + } + return page, nil +} + +// DownloadAllProducts 逐页下载一个店铺的全部在售商品。 +// +// 最多请求 200 页。服务端分页异常时返回已取得的数据并写警告日志, +// 避免桌面程序陷入无法结束的循环。 +func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress func(current, total int)) ([]store.Product, map[string][]store.Diagnosis, error) { + platformShopID = strings.TrimSpace(platformShopID) + if platformShopID == "" { + return nil, nil, fmt.Errorf("请先选择店铺") + } + + products := make([]store.Product, 0) + diagnoses := make(map[string][]store.Diagnosis) + lastCurrent := 0 + lastPages := 0 + for requestedPage := 1; requestedPage <= maximumProductPages; requestedPage++ { + page, err := c.GetProductPage(ctx, ProductPageParams{ + Size: defaultProductPageSize, + Current: requestedPage, + PlatformShopID: platformShopID, + }) + if err != nil { + return nil, nil, err + } + + for _, record := range page.Records { + product, productDiagnoses := convertProductRecord(record) + products = append(products, product) + // 即使没有明细也保留这个键,落库时才能清掉该商品的旧诊断。 + diagnoses[record.ID] = productDiagnoses + } + + lastCurrent = page.Current + lastPages = page.Pages + if onProgress != nil { + onProgress(page.Current, page.Pages) + } + if page.Current >= page.Pages { + return products, diagnoses, nil + } + } + + if lastCurrent < lastPages { + c.log.Warn("商品分页超过 %d 页上限,已停止拉取;服务端报告进度 %d/%d 页", maximumProductPages, lastCurrent, lastPages) + } + return products, diagnoses, nil +} + +// convertProductRecord 把一条货憨憨记录转换为本地商品和全部诊断明细。 +func convertProductRecord(record ProductRecord) (store.Product, []store.Diagnosis) { + videoDiagnosis := store.VideoDiagnosisOK + qualityLevel := "" + diagnoses := make([]store.Diagnosis, 0) + + if record.DiagnosisInfo != nil { + qualityLevel = record.DiagnosisInfo.QualityLevel + for _, group := range record.DiagnosisInfo.Diagnoses { + for _, result := range group.DiagnosisResults { + diagnoses = append(diagnoses, store.Diagnosis{ + ProductID: record.ID, + Field: group.Field, + Type: result.Type, + Solution: result.Solution, + }) + if result.Type == "缺少视频" { + videoDiagnosis = store.VideoDiagnosisMissing + } + } + } + } + + return store.Product{ + ID: record.ID, + ItemID: record.ItemID, + ItemName: record.ItemName, + MainImage: record.MainImage, + ShopName: record.ShopName, + PlatformShopID: record.PlatformShopID, + Currency: record.Currency, + MinSkuPrice: record.MinSkuPrice, + ItemStatus: record.ItemStatus, + CreatedAt: record.CreateTime, + VideoDiagnosis: videoDiagnosis, + QualityLevel: qualityLevel, + }, diagnoses +} + diff --git a/internal/huohanhan/product_test.go b/internal/huohanhan/product_test.go new file mode 100644 index 0000000..ea096b7 --- /dev/null +++ b/internal/huohanhan/product_test.go @@ -0,0 +1,207 @@ +package huohanhan + +import ( + "context" + "net/http" + "strconv" + "strings" + "testing" + + "go-admin/internal/logx" +) + +func Test商品分页兼容字符串数字并发送完整表单(t *testing.T) { + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/product/shop/getPage" { + t.Errorf("商品接口路径不正确:%s", r.URL.Path) + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + t.Errorf("商品请求必须使用 form 编码,实际 %q", r.Header.Get("Content-Type")) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("解析商品请求表单失败:%v", err) + } + if r.Form.Get("platformShopId") != "1664202094" || r.Form.Get("current") != "2" || r.Form.Get("size") != "20" { + t.Errorf("商品分页参数不正确:%v", r.Form) + } + if r.Form.Get("platform") != "0" || r.Form.Get("itemStatus") != "NORMAL" || r.Form.Get("sortField") != "updateTime" || r.Form.Get("sortType") != "desc" { + t.Errorf("商品固定筛选参数不正确:%v", r.Form) + } + for _, name := range []string{"descs", "ascs", "marked", "region", "itemName", "itemIds", "itemSkus", "modelSku", "categoryId", "hasSizeChart", "sourceId", "sourcePlatformCode", "isPreOrder", "nextDayArrive", "createTimeStart", "createTimeEnd", "minSkuPrice", "maxSkuPrice", "minSale", "maxSale", "minViews", "maxViews", "minLikes", "maxLikes", "minCommentCount", "maxCommentCount", "minRatingStar", "maxRatingStar", "groupIds"} { + if _, exists := r.Form[name]; !exists { + t.Errorf("商品请求缺少空表单字段 %s", name) + } + } + writeJSON(w, map[string]any{ + "records": []any{}, "total": "1256", "size": "20", + "current": "2", "pages": "63", + }) + }) + + page, err := client.GetProductPage(context.Background(), ProductPageParams{ + Size: 20, Current: 2, PlatformShopID: "1664202094", + }) + if err != nil { + t.Fatalf("读取商品分页失败:%v", err) + } + if page.Total != 1256 || page.Size != 20 || page.Current != 2 || page.Pages != 63 { + t.Fatalf("字符串分页数字解析不正确:%+v", page) + } +} + +func Test商品下载拉完三页并正确转换主键(t *testing.T) { + calls := 0 + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("解析商品表单失败:%v", err) + } + current, _ := strconv.Atoi(r.Form.Get("current")) + calls++ + record := map[string]any{ + "id": "hhh-" + strconv.Itoa(current), + "itemId": "shopee-" + strconv.Itoa(current), + "itemName": "商品", "mainImage": "https://example.invalid/image.jpg", + "shopName": "测试店铺", "platformShopId": "1664202094", + "currency": "TWD", "minSkuPrice": 88.5, + "itemStatus": "NORMAL", "createTime": "2026-08-31 01:54:08", + } + if current == 1 { + record["diagnosisInfo"] = map[string]any{ + "itemId": "shopee-1", "qualityLevel": "1", + "diagnoses": []map[string]any{{ + "field": "ALL", + "diagnosisResults": []map[string]any{ + {"type": "缺少视频", "solution": "上传相应的视频"}, + {"type": "缺少品牌信息", "solution": "填写品牌信息"}, + }, + }}, + } + } + writeJSON(w, map[string]any{ + "records": []map[string]any{record}, + "total": 3, "size": 20, "current": current, "pages": 3, + }) + }) + + var progress []int + products, diagnoses, err := client.DownloadAllProducts(context.Background(), "1664202094", func(current, total int) { + if total != 3 { + t.Errorf("总页数应为 3,实际 %d", total) + } + progress = append(progress, current) + }) + if err != nil { + t.Fatalf("下载全部商品失败:%v", err) + } + if calls != 3 || len(progress) != 3 || len(products) != 3 { + t.Fatalf("应完整拉取 3 页,实际请求 %d 次、进度 %v、商品 %d 条", calls, progress, len(products)) + } + first := products[0] + if first.ID != "hhh-1" || first.ItemID != "shopee-1" { + t.Fatalf("id 和 itemId 映射错误:ID=%q ItemID=%q", first.ID, first.ItemID) + } + if first.CreatedAt != "2026-08-31 01:54:08" || first.MinSkuPrice != 88.5 { + t.Fatalf("商品字段转换不完整:%+v", first) + } + if first.VideoDiagnosis != "missing" || first.QualityLevel != "1" { + t.Fatalf("JSON 中的诊断摘要转换不正确:%+v", first) + } + if len(diagnoses[first.ID]) != 2 || diagnoses[first.ID][1].Type != "缺少品牌信息" { + t.Fatalf("JSON 中的全部诊断明细应当返回:%+v", diagnoses[first.ID]) + } +} + +func Test商品分页超过二百页时警告并停止(t *testing.T) { + calls := 0 + logger := logx.New(500) + client := newBusinessTestClient(t, logger, func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("解析商品表单失败:%v", err) + } + current, _ := strconv.Atoi(r.Form.Get("current")) + calls++ + writeJSON(w, map[string]any{ + "records": []any{}, "total": 99999, "size": 20, + "current": current, "pages": 99999, + }) + }) + + products, _, err := client.DownloadAllProducts(context.Background(), "1664202094", nil) + if err != nil { + t.Fatalf("达到分页保护上限不应报错:%v", err) + } + if calls != maximumProductPages { + t.Fatalf("最多应请求 %d 页,实际 %d 页", maximumProductPages, calls) + } + if len(products) != 0 { + t.Fatalf("假服务未返回商品,实际得到 %d 条", len(products)) + } + if !strings.Contains(logger.Text(), "超过 200 页上限") { + t.Fatalf("达到上限必须写警告日志,实际日志:%s", logger.Text()) + } +} + +func Test诊断为空时按负责人决定归入有视频(t *testing.T) { + product, diagnoses := convertProductRecord(ProductRecord{ID: "商品-1"}) + + if product.VideoDiagnosis != "ok" { + t.Fatalf("diagnosisInfo 为 null 时应当归入 ok,实际 %q", product.VideoDiagnosis) + } + if len(diagnoses) != 0 { + t.Fatalf("diagnosisInfo 为 null 时不应生成诊断明细,实际 %d 条", len(diagnoses)) + } +} + +func Test含缺少视频时标记为缺少并保存全部诊断(t *testing.T) { + record := ProductRecord{ + ID: "商品-2", + DiagnosisInfo: &DiagnosisInfo{ + QualityLevel: "1", + Diagnoses: []DiagnosisGroup{{ + Field: "ALL", + DiagnosisResults: []DiagnosisResult{ + {Type: "缺少视频", Solution: "上传相应的视频"}, + {Type: "缺少品牌信息", Solution: "填写品牌信息"}, + }, + }}, + }, + } + + product, diagnoses := convertProductRecord(record) + if product.VideoDiagnosis != "missing" { + t.Fatalf("含缺少视频时应当标记 missing,实际 %q", product.VideoDiagnosis) + } + if product.QualityLevel != "1" { + t.Fatalf("质量等级应当完整转换,实际 %q", product.QualityLevel) + } + if len(diagnoses) != 2 { + t.Fatalf("全部诊断类型都应保留,期望 2 条,实际 %d 条", len(diagnoses)) + } + if diagnoses[1].Type != "缺少品牌信息" || diagnoses[1].ProductID != record.ID { + t.Fatalf("非视频诊断或商品关联丢失:%+v", diagnoses[1]) + } +} + +func Test有诊断但不含缺少视频时归入有视频(t *testing.T) { + record := ProductRecord{ + ID: "商品-3", + DiagnosisInfo: &DiagnosisInfo{ + QualityLevel: "2", + Diagnoses: []DiagnosisGroup{{ + Field: "ALL", + DiagnosisResults: []DiagnosisResult{{ + Type: "缺少尺寸表", Solution: "上传尺寸表", + }}, + }}, + }, + } + + product, diagnoses := convertProductRecord(record) + if product.VideoDiagnosis != "ok" { + t.Fatalf("未报缺少视频时应当归入 ok,实际 %q", product.VideoDiagnosis) + } + if len(diagnoses) != 1 || diagnoses[0].Type != "缺少尺寸表" { + t.Fatalf("其它诊断仍应完整保留:%+v", diagnoses) + } +} + diff --git a/internal/huohanhan/shop.go b/internal/huohanhan/shop.go new file mode 100644 index 0000000..9d628a6 --- /dev/null +++ b/internal/huohanhan/shop.go @@ -0,0 +1,60 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" +) + +// Shop 是界面选择商品来源时需要的店铺信息。 +// +// 货憨憨响应还包含 OAuth token 和手机号等敏感字段。这里刻意只声明 +// 界面需要的白名单字段,避免凭据进入内存模型、日志、SQLite 或前端。 +type Shop struct { + ID string `json:"id"` + ShopName string `json:"shopName"` + ShopAlias string `json:"shopAlias"` + Region string `json:"region"` + RegionName string `json:"regionName"` + Platform string `json:"platform"` + PlatformShopID string `json:"platformShopId"` + Status string `json:"status"` +} + +// ListShops 读取当前账号的 Shopee 店铺,并按店铺名排序。 +func (c *Client) ListShops(ctx context.Context) ([]Shop, error) { + form := url.Values{"userId": {""}} + response, err := c.Request( + ctx, + http.MethodPost, + "erp/shop/all", + []byte(form.Encode()), + "application/x-www-form-urlencoded", + ) + if err != nil { + return nil, fmt.Errorf("读取店铺列表失败:%w", err) + } + defer response.Body.Close() + + var payload []Shop + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("店铺列表返回的不是有效 JSON:%w", err) + } + + shops := make([]Shop, 0, len(payload)) + for _, shop := range payload { + if shop.Platform != "0" { + continue + } + shop.ShopName = strings.TrimSpace(shop.ShopName) + shops = append(shops, shop) + } + sort.Slice(shops, func(i, j int) bool { + return shops[i].ShopName < shops[j].ShopName + }) + return shops, nil +} diff --git a/internal/huohanhan/shop_test.go b/internal/huohanhan/shop_test.go new file mode 100644 index 0000000..d7c260a --- /dev/null +++ b/internal/huohanhan/shop_test.go @@ -0,0 +1,96 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + "go-admin/internal/logx" +) + +func newBusinessTestClient(t *testing.T, logger *logx.Logger, handler http.HandlerFunc) *Client { + t.Helper() + backend := newFakeLoginBackend(t) + manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) + if _, err := manager.ForceLogin(context.Background()); err != nil { + t.Fatalf("准备测试认证失败:%v", err) + } + backend.businessHandler = handler + client, err := NewClient(manager.cfg, manager, logger, backend.server.Client()) + if err != nil { + t.Fatalf("创建测试业务客户端失败:%v", err) + } + return client +} + +func Test店铺列表解析裸数组并过滤排序(t *testing.T) { + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/erp/shop/all" { + t.Errorf("店铺接口路径不正确:%s", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("解析店铺请求表单失败:%v", err) + } + if _, exists := r.Form["userId"]; !exists || r.Form.Get("userId") != "" { + t.Errorf("店铺请求必须包含空 userId,实际表单:%v", r.Form) + } + if _, exists := r.Form["type"]; exists { + t.Errorf("店铺请求不应包含 Python 版的 type 参数") + } + writeJSON(w, []map[string]any{ + { + "id": "shop-2", "shopName": "B店铺 ", "shopAlias": "乙", + "region": "TW", "regionName": "台湾", "platform": "0", + "platformShopId": "200", "status": "NORMAL", + "accessToken": "fake-oauth-access", "refreshToken": "fake-oauth-refresh", + "createUser": "13000000000", + }, + { + "id": "other", "shopName": "其它平台", "platform": "1", + "platformShopId": "999", "status": "NORMAL", + }, + { + "id": "shop-1", "shopName": "A店铺", "shopAlias": "甲", + "region": "TW", "regionName": "台湾", "platform": "0", + "platformShopId": "100", "status": "NORMAL", + }, + }) + }) + + shops, err := client.ListShops(context.Background()) + if err != nil { + t.Fatalf("读取店铺失败:%v", err) + } + if len(shops) != 2 { + t.Fatalf("应只保留 2 个 Shopee 店铺,实际 %d 个", len(shops)) + } + if shops[0].ShopName != "A店铺" || shops[1].ShopName != "B店铺" { + t.Fatalf("店铺应去掉尾部空格并按名称排序,实际:%v", shops) + } +} + +func Test店铺结构不包含凭据和手机号字段(t *testing.T) { + typ := reflect.TypeOf(Shop{}) + for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Name == forbidden || strings.Split(field.Tag.Get("json"), ",")[0] == forbidden { + t.Fatalf("Shop 不得声明敏感字段 %s", forbidden) + } + } + } + + encoded, err := json.Marshal(Shop{ID: "fake-shop", ShopName: "测试店铺"}) + if err != nil { + t.Fatalf("序列化店铺失败:%v", err) + } + for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("店铺 JSON 不得包含敏感字段 %s:%s", forbidden, encoded) + } + } +} + diff --git a/internal/huohanhan/upload.go b/internal/huohanhan/upload.go new file mode 100644 index 0000000..a125ffb --- /dev/null +++ b/internal/huohanhan/upload.go @@ -0,0 +1,169 @@ +package huohanhan + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "path/filepath" + "strings" +) + +// UploadVideo 上传一份本地 MP4 素材,并返回货憨憨保存后的 COS 地址。 +// 素材上传与商品关联是两个独立接口;此方法绝不附带商品信息。 +func (c *Client) UploadVideo(ctx context.Context, localPath string, content []byte) (string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + fileHeader := make(textproto.MIMEHeader) + fileHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files"; filename=%q`, filepath.Base(localPath))) + fileHeader.Set("Content-Type", "video/mp4") + part, err := writer.CreatePart(fileHeader) + if err != nil { + return "", fmt.Errorf("构造视频上传表单失败:%w", err) + } + if _, err := part.Write(content); err != nil { + return "", fmt.Errorf("写入视频上传表单失败:%w", err) + } + if err := writer.WriteField("isLocalFile", "true"); err != nil { + return "", fmt.Errorf("写入本地文件标记失败:%w", err) + } + if err := writer.WriteField("fileType", "1"); err != nil { + return "", fmt.Errorf("写入文件类型失败:%w", err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("完成视频上传表单失败:%w", err) + } + + response, err := c.Request(ctx, http.MethodPost, "product/material/uploadFiles", body.Bytes(), writer.FormDataContentType()) + if err != nil { + return "", fmt.Errorf("上传视频失败:%w", err) + } + defer response.Body.Close() + + var payload successResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("上传视频返回的不是有效 JSON:%w", err) + } + if err := payload.check("上传视频"); err != nil { + return "", err + } + if len(payload.Bean) == 0 || strings.TrimSpace(payload.Bean[0]) == "" { + return "", fmt.Errorf("上传视频失败:服务端未返回视频地址") + } + return strings.TrimSpace(payload.Bean[0]), nil +} + +// UpdateShopProductVideo 用上传后的地址覆盖关联到一个商品的视频。 +func (c *Client) UpdateShopProductVideo(ctx context.Context, id, platformShopID, videoURL string) error { + body, err := json.Marshal([]productVideoUpdate{{ + ID: id, PlatformShopID: platformShopID, VideoURL: videoURL, + }}) + if err != nil { + return fmt.Errorf("构造视频关联请求失败:%w", err) + } + response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/batchUpdateShopProductVideo", body, "application/json;charset=UTF-8") + if err != nil { + return fmt.Errorf("关联商品视频失败:%w", err) + } + defer response.Body.Close() + var payload successResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return fmt.Errorf("关联商品视频返回的不是有效 JSON:%w", err) + } + return payload.check("关联商品视频") +} + +// VideoCheck 是回读商品视频字段的结果。 +// +// 这几个字段的含义不一样,别混用: +// +// Video 已经在 Shopee 上生效的视频。货憨憨推送成功后才有值,是异步的。 +// TempVideoURL 刚设置进去、还没同步到 Shopee 的视频地址。 +// UploadIDStr 货憨憨/Shopee 侧的媒体 ID,和 TempVideoURL 同时出现。 +// FailReason 货憨憨推送失败的原因,非空就是真失败。 +type VideoCheck struct { + Video []json.RawMessage + TempVideoURL string + UploadIDStr string + FailReason string +} + +// Confirmed 表示视频已经设置成功。 +// +// 注意不能只看 Video:保存成功后 Shopee 侧的同步是异步的, +// 刚设置完 Video 必然还是空的,此时视频在 TempVideoURL 里。 +// payloads/huohanhan_save_product_info.har 第 3 个请求就是一次保存成功后 +// 立刻发起的 getDetail,那里 video=[] 而 tempVideoUrl 有值。 +// 只认 Video 会把成功的上传误判成失败。 +func (v VideoCheck) Confirmed() bool { + return len(v.Video) > 0 || strings.TrimSpace(v.TempVideoURL) != "" || + strings.TrimSpace(v.UploadIDStr) != "" +} + +// LiveOnShopee 表示视频已经同步到 Shopee 并生效,比 Confirmed 更强。 +func (v VideoCheck) LiveOnShopee() bool { return len(v.Video) > 0 } + +// CheckShopProductVideo 回读货憨憨的商品视频字段。 +func (c *Client) CheckShopProductVideo(ctx context.Context, id string) (VideoCheck, error) { + form := url.Values{ + "size": {"1"}, "current": {"1"}, "descs": {""}, "ascs": {""}, "ids": {id}, + "fields": {"video,videoUploadIdStr,videoFailReason,tempVideoUrl"}, + } + response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/getShopItemInfoPage", []byte(form.Encode()), "application/x-www-form-urlencoded;charset=UTF-8") + if err != nil { + return VideoCheck{}, fmt.Errorf("回读商品视频失败:%w", err) + } + defer response.Body.Close() + // 这个接口没有 bean 外层,直接就是 {"records":[...]},和其它接口不一样。 + var payload struct { + Records []struct { + Video []json.RawMessage `json:"video"` + TempVideoURL string `json:"tempVideoUrl"` + VideoUploadIDStr string `json:"videoUploadIdStr"` + VideoFailReason string `json:"videoFailReason"` + } `json:"records"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return VideoCheck{}, fmt.Errorf("回读商品视频返回的不是有效 JSON:%w", err) + } + if len(payload.Records) == 0 { + return VideoCheck{}, fmt.Errorf("回读商品视频失败:服务端未返回商品记录") + } + record := payload.Records[0] + return VideoCheck{ + Video: record.Video, TempVideoURL: record.TempVideoURL, + UploadIDStr: record.VideoUploadIDStr, FailReason: record.VideoFailReason, + }, nil +} + +type successResponse struct { + Type string `json:"type"` + Message string `json:"message"` + Code string `json:"code"` + Bean []string `json:"bean"` +} + +func (p successResponse) check(action string) error { + if p.Type == "SUCCESS" { + return nil + } + detail := strings.TrimSpace(p.Message) + if detail == "" { + detail = "服务端未说明原因" + } + if code := strings.TrimSpace(p.Code); code != "" { + return fmt.Errorf("%s失败:%s(错误码 %s)", action, detail, code) + } + return fmt.Errorf("%s失败:%s", action, detail) +} + +// JSON 字段必须恰好是这三个。不要在这里增加商品其它字段,接口是覆盖语义。 +type productVideoUpdate struct { + ID string `json:"id"` + PlatformShopID string `json:"platformShopId"` + VideoURL string `json:"videoUrl"` +} diff --git a/internal/huohanhan/upload_test.go b/internal/huohanhan/upload_test.go new file mode 100644 index 0000000..be24bd2 --- /dev/null +++ b/internal/huohanhan/upload_test.go @@ -0,0 +1,185 @@ +package huohanhan + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "go-admin/internal/logx" +) + +func Test上传素材使用HAR规定的multipart字段(t *testing.T) { + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/product/material/uploadFiles" { + t.Errorf("上传路径不正确:%s", r.URL.Path) + } + if err := r.ParseMultipartForm(10 << 20); err != nil { + t.Fatalf("解析上传表单失败:%v", err) + } + file, header, err := r.FormFile("files") + if err != nil { + t.Fatalf("上传表单缺少 files:%v", err) + } + defer file.Close() + if header.Filename != "本地视频.mp4" { + t.Errorf("文件名应取本地文件名,实际 %q", header.Filename) + } + if got := header.Header.Get("Content-Type"); got != "video/mp4" { + t.Errorf("视频 Content-Type 应为 video/mp4,实际 %q", got) + } + content, _ := io.ReadAll(file) + if string(content) != "fake-mp4" { + t.Errorf("视频内容不正确:%q", content) + } + if r.Form.Get("isLocalFile") != "true" || r.Form.Get("fileType") != "1" { + t.Errorf("上传固定字段不正确:%v", r.Form) + } + writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200", "bean": []string{"https://cos.example.invalid/video.mp4"}}) + }) + + url, err := client.UploadVideo(context.Background(), `C:\下载\本地视频.mp4`, []byte("fake-mp4")) + if err != nil || url != "https://cos.example.invalid/video.mp4" { + t.Fatalf("上传结果不正确:url=%q err=%v", url, err) + } +} + +func Test上传素材bean为空必须失败(t *testing.T) { + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200", "bean": []string{}}) + }) + _, err := client.UploadVideo(context.Background(), "empty.mp4", []byte("fake-mp4")) + if err == nil || !strings.Contains(err.Error(), "未返回视频地址") { + t.Fatalf("bean 为空必须返回可读错误,实际 %v", err) + } +} + +func Test关联商品视频请求必须是三个字段的裸数组(t *testing.T) { + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/product/batchEdit/batchUpdateShopProductVideo" { + t.Errorf("关联路径不正确:%s", r.URL.Path) + } + var body []map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("关联请求不是 JSON:%v", err) + } + if len(body) != 1 { + t.Fatalf("关联请求必须是仅含一个元素的裸数组,实际 %v", body) + } + want := map[string]string{"id": "货憨憨内部ID", "platformShopId": "店铺ID", "videoUrl": "https://cos.example.invalid/video.mp4"} + if len(body[0]) != len(want) { + t.Fatalf("关联元素必须恰好三个字段,实际 %v", body[0]) + } + for key, value := range want { + if body[0][key] != value { + t.Errorf("字段 %s 期望 %q,实际 %q", key, value, body[0][key]) + } + } + writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200"}) + }) + if err := client.UpdateShopProductVideo(context.Background(), "货憨憨内部ID", "店铺ID", "https://cos.example.invalid/video.mp4"); err != nil { + t.Fatalf("关联应成功,实际 %v", err) + } +} + +func Test非SUCCESS响应只请求一次并返回中文错误(t *testing.T) { + calls := 0 + client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + calls++ + writeJSON(w, map[string]any{"type": "ERROR", "code": "LIMIT", "message": "空间不足"}) + }) + err := client.UpdateShopProductVideo(context.Background(), "内部ID", "店铺ID", "https://cos.example.invalid/video.mp4") + if err == nil || !strings.Contains(err.Error(), "关联商品视频失败") || !strings.Contains(err.Error(), "空间不足") { + t.Fatalf("非 SUCCESS 应返回可读中文错误,实际 %v", err) + } + if calls != 1 { + t.Fatalf("非 SUCCESS 不得重试,实际请求 %d 次", calls) + } +} + +// 回读用的假服务器,records[0] 直接用给定字段。 +func newVideoCheckClient(t *testing.T, record map[string]any) *Client { + return newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("解析回读表单失败:%v", err) + } + if r.Form.Get("ids") != "货憨憨内部ID" || r.Form.Get("fields") != "video,videoUploadIdStr,videoFailReason,tempVideoUrl" { + t.Errorf("回读表单不符合 HAR:%v", r.Form) + } + writeJSON(w, map[string]any{"records": []any{record}, "total": "1"}) + }) +} + +func Test回读四个字段全空判定为未关联(t *testing.T) { + client := newVideoCheckClient(t, map[string]any{ + "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "", "videoFailReason": "", + }) + check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") + if err != nil { + t.Fatalf("回读失败:%v", err) + } + if check.Confirmed() || check.LiveOnShopee() { + t.Fatalf("四个字段全空必须判为未关联:%+v", check) + } +} + +// 这条是本项目踩过的真实坑:保存成功后货憨憨推送到 Shopee 是异步的, +// video 必然还是空的,视频这时在 tempVideoUrl 里。 +// 只认 video 会把成功的上传误判成失败,55066525387 就是这样报错的。 +// 证据:payloads/huohanhan_save_product_info.har 第 3 个请求。 +func Test刚保存完video为空但tempVideoUrl有值应判为成功(t *testing.T) { + client := newVideoCheckClient(t, map[string]any{ + "video": []any{}, + "tempVideoUrl": "https://hhh-prod-1307856765.cos.ap-guangzhou.myqcloud.com/video/1126859448838946817.mp4", + "videoUploadIdStr": "sg-11110106-6vbma-msnl9mrjxxqd2d", + "videoFailReason": "", + }) + check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") + if err != nil { + t.Fatalf("回读失败:%v", err) + } + if !check.Confirmed() { + t.Fatalf("tempVideoUrl 有值必须判为已设置成功:%+v", check) + } + if check.LiveOnShopee() { + t.Fatalf("video 为空时不得声称已在 Shopee 生效:%+v", check) + } +} + +func Test只有videoUploadIdStr有值也判为成功(t *testing.T) { + client := newVideoCheckClient(t, map[string]any{ + "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "sg-11110106-abc", "videoFailReason": "", + }) + check, _ := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") + if !check.Confirmed() { + t.Fatalf("videoUploadIdStr 有值必须判为已设置成功:%+v", check) + } +} + +func Test字段video有值判为已在Shopee生效(t *testing.T) { + client := newVideoCheckClient(t, map[string]any{ + "video": []any{map[string]any{"videoUrl": "https://cvf.shopee.tw/file/xxx.mp4"}}, + "tempVideoUrl": "", "videoUploadIdStr": "", "videoFailReason": "", + }) + check, _ := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") + if !check.Confirmed() || !check.LiveOnShopee() { + t.Fatalf("video 有值必须同时判为已确认且已生效:%+v", check) + } +} + +func Test回读带失败原因时暴露原因(t *testing.T) { + client := newVideoCheckClient(t, map[string]any{ + "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "", + "videoFailReason": "视频时长超过限制", + }) + check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") + if err != nil { + t.Fatalf("回读失败:%v", err) + } + if check.FailReason != "视频时长超过限制" { + t.Fatalf("失败原因必须原样带出:%+v", check) + } +} + diff --git a/internal/logx/logx.go b/internal/logx/logx.go new file mode 100644 index 0000000..5d905e3 --- /dev/null +++ b/internal/logx/logx.go @@ -0,0 +1,164 @@ +// Package logx 提供带脱敏的日志。 +// +// 为什么要专门写一个包,而不是直接用标准库 log: +// 本项目会接触淘宝 Cookie、货憨憨 token 和账号密码。这些东西 +// 一旦进了日志文件,就等于泄漏了。所以所有日志都必须经过这里, +// 由 Mask() 统一把敏感内容替换掉。 +// +// 规则很简单:往日志里写东西时,永远调用本包的方法,不要直接 +// 用 fmt.Println 或 log.Printf。 +package logx + +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" +) + +// Level 是日志级别。界面上的运行日志按它上色和过滤。 +type Level string + +const ( + LevelInfo Level = "info" // 普通进度 + LevelSuccess Level = "success" // 成功完成一步 + LevelWarn Level = "warn" // 有问题但还能继续 + LevelError Level = "error" // 失败 +) + +// Entry 是一条日志。前端「运行日志」窗口直接显示它。 +type Entry struct { + Time string `json:"time"` // 形如 15:04:05,只给人看 + Level Level `json:"level"` // 前端按它上色和过滤 + Message string `json:"message"` // 已经脱敏过的正文 +} + +// 下面这些正则用来找出不该出现在日志里的内容。 +// +// 新增敏感字段时,在这里加一条,并在 logx_test.go 里补一个用例。 +var maskPatterns = []struct { + name string + pattern *regexp.Regexp + replace string +}{ + // 淘宝 MTOP 令牌,形如 _m_h5_tk=abc123_1699... + {"淘宝 token", regexp.MustCompile(`(?i)(_m_h5_tk(_enc)?=)[^;&\s]+`), "${1}***"}, + // 淘宝其它登录相关 Cookie + {"淘宝 cookie", regexp.MustCompile(`(?i)(_tb_token_=|tracknick=)[^;&\s]+`), "${1}***"}, + // HTTP 认证头 + {"Authorization", regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)\S+`), "${1}***"}, + // 整个 Cookie 请求头 + {"Cookie 头", regexp.MustCompile(`(?i)(cookie:\s*)\S.*`), "${1}***"}, + // 常见的密码字段,覆盖 password=xxx、"password":"xxx"、密码:xxx + {"密码", regexp.MustCompile(`(?i)((password|passwd|pwd)\s*[":=]+\s*"?)[^"\s,}]+`), "${1}***"}, + {"中文密码", regexp.MustCompile(`(密码[::]\s*)\S+`), "${1}***"}, + // Base64 图片数据,打印出来既没用又刷屏 + {"图片 base64", regexp.MustCompile(`data:image/[a-z]+;base64,[A-Za-z0-9+/=]+`), "data:image/*;base64,***"}, +} + +// Mask 把文本里的敏感内容替换成 ***。 +// +// 它只做替换,不判断“这条日志该不该记”。要不要记由调用方决定。 +func Mask(text string) string { + for _, p := range maskPatterns { + text = p.pattern.ReplaceAllString(text, p.replace) + } + return text +} + +// Logger 是一个内存日志缓冲区。 +// +// 为什么放内存:界面上的「运行日志」窗口需要随时能拿到最近的日志。 +// 为什么有上限:任务可能跑几个小时,不限制会把内存吃光。 +// +// 它是并发安全的,多个下载协程可以同时往里写。 +type Logger struct { + mu sync.Mutex + entries []Entry + limit int + // onEntry 在每条日志写入后被调用,用来推送给前端。 + // 为 nil 时不推送,方便在单元测试里使用。 + onEntry func(Entry) +} + +// New 创建一个日志缓冲区。limit 是最多保留多少条,超出后丢弃最旧的。 +func New(limit int) *Logger { + if limit <= 0 { + limit = 1000 + } + return &Logger{limit: limit} +} + +// SetHandler 设置日志推送回调。Wails 启动后用它把日志发给前端。 +func (l *Logger) SetHandler(handler func(Entry)) { + l.mu.Lock() + defer l.mu.Unlock() + l.onEntry = handler +} + +func (l *Logger) write(level Level, format string, args ...any) { + entry := Entry{ + Time: time.Now().Format("15:04:05"), + Level: level, + Message: Mask(fmt.Sprintf(format, args...)), + } + + l.mu.Lock() + l.entries = append(l.entries, entry) + if len(l.entries) > l.limit { + // 丢掉最旧的那些。用 copy 而不是切片头部截取, + // 是为了让底层数组能被回收,避免内存一直涨。 + drop := len(l.entries) - l.limit + l.entries = append(l.entries[:0], l.entries[drop:]...) + } + handler := l.onEntry + l.mu.Unlock() + + if handler != nil { + handler(entry) + } +} + +// Info 记录一条普通进度。 +func (l *Logger) Info(format string, args ...any) { l.write(LevelInfo, format, args...) } + +// Success 记录一条成功。 +func (l *Logger) Success(format string, args ...any) { l.write(LevelSuccess, format, args...) } + +// Warn 记录一条警告:有问题但任务还能继续。 +func (l *Logger) Warn(format string, args ...any) { l.write(LevelWarn, format, args...) } + +// Error 记录一条失败。 +func (l *Logger) Error(format string, args ...any) { l.write(LevelError, format, args...) } + +// Entries 返回当前保留的全部日志副本。 +// +// 返回副本而不是内部切片,是为了防止调用方在外面改动它, +// 导致并发读写崩溃。 +func (l *Logger) Entries() []Entry { + l.mu.Lock() + defer l.mu.Unlock() + out := make([]Entry, len(l.entries)) + copy(out, l.entries) + return out +} + +// Clear 清空日志。对应界面上「运行日志」窗口的清空按钮。 +func (l *Logger) Clear() { + l.mu.Lock() + defer l.mu.Unlock() + l.entries = nil +} + +// Text 把全部日志拼成纯文本,用于「导出日志」。 +func (l *Logger) Text() string { + var sb strings.Builder + for _, e := range l.Entries() { + sb.WriteString(e.Time) + sb.WriteString(" ") + sb.WriteString(e.Message) + sb.WriteString("\n") + } + return sb.String() +} diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go new file mode 100644 index 0000000..4acee2c --- /dev/null +++ b/internal/logx/logx_test.go @@ -0,0 +1,182 @@ +package logx + +import ( + "strings" + "sync" + "testing" +) + +// 这是本包最重要的测试:敏感内容绝不能出现在日志里。 +// 每新增一条脱敏规则,都要在这里补一个用例。 +func TestMaskHidesSecrets(t *testing.T) { + cases := []struct { + name string + in string + // leak 是绝对不能出现在结果里的片段 + leak string + }{ + { + name: "淘宝 MTOP token", + in: "cookie 里 _m_h5_tk=abc123def456_1699999999999 已读取", + leak: "abc123def456", + }, + { + name: "淘宝 token enc", + in: "_m_h5_tk_enc=zzz999yyy888", + leak: "zzz999yyy888", + }, + { + name: "淘宝 tb_token", + in: "_tb_token_=e3b0c44298fc; path=/", + leak: "e3b0c44298fc", + }, + { + name: "Authorization 头", + in: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.fake.token", + leak: "eyJhbGciOiJIUzI1NiJ9", + }, + { + name: "Cookie 请求头", + in: "Cookie: a=1; b=2; secret=3", + leak: "secret=3", + }, + { + name: "JSON 里的密码", + in: `{"account":"13500000000","password":"不该出现的密码"}`, + leak: "不该出现的密码", + }, + { + name: "等号形式的密码", + in: "password=hunter2&next=1", + leak: "hunter2", + }, + { + name: "中文冒号密码", + in: "登录失败,密码:明文密码值", + leak: "明文密码值", + }, + { + name: "图片 base64", + in: "上传 data:image/jpeg;base64,AAAABBBBCCCCDDDD 完成", + leak: "AAAABBBBCCCCDDDD", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Mask(tc.in) + if strings.Contains(got, tc.leak) { + t.Fatalf("脱敏后仍然泄漏了 %q\n原文:%s\n结果:%s", tc.leak, tc.in, got) + } + if !strings.Contains(got, "***") { + t.Fatalf("应当出现 *** 占位符,结果是:%s", got) + } + }) + } +} + +// 普通文本不应该被误伤。 +func TestMaskKeepsNormalText(t *testing.T) { + in := "[3/6] 商品 40571188442 图搜返回 48 个同款,取前 20 个" + if got := Mask(in); got != in { + t.Fatalf("普通文本不应被改动\n原文:%s\n结果:%s", in, got) + } +} + +// 写日志时也必须脱敏,不能只有直接调用 Mask 才生效。 +func TestLoggerMasksOnWrite(t *testing.T) { + l := New(10) + l.Info("登录请求 password=真实密码") + + entries := l.Entries() + if len(entries) != 1 { + t.Fatalf("应当有 1 条日志,实际 %d 条", len(entries)) + } + if strings.Contains(entries[0].Message, "真实密码") { + t.Fatalf("日志里泄漏了密码:%s", entries[0].Message) + } +} + +// 超过上限时要丢掉最旧的,防止长时间任务把内存吃光。 +func TestLoggerRespectsLimit(t *testing.T) { + l := New(3) + for i := 0; i < 10; i++ { + l.Info("第 %d 条", i) + } + + entries := l.Entries() + if len(entries) != 3 { + t.Fatalf("应当只保留 3 条,实际 %d 条", len(entries)) + } + // 保留的应当是最后 3 条。 + if !strings.Contains(entries[0].Message, "第 7 条") { + t.Fatalf("应当保留最新的日志,第一条却是:%s", entries[0].Message) + } +} + +// 下载任务会有多个协程同时写日志,不能崩。 +func TestLoggerIsConcurrentSafe(t *testing.T) { + l := New(1000) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 50; j++ { + l.Info("协程 %d 第 %d 条", n, j) + } + }(i) + } + wg.Wait() + + if len(l.Entries()) != 1000 { + t.Fatalf("应当有 1000 条日志,实际 %d 条", len(l.Entries())) + } +} + +func TestLoggerLevels(t *testing.T) { + l := New(10) + l.Info("信息") + l.Success("成功") + l.Warn("警告") + l.Error("失败") + + want := []Level{LevelInfo, LevelSuccess, LevelWarn, LevelError} + entries := l.Entries() + for i, level := range want { + if entries[i].Level != level { + t.Fatalf("第 %d 条级别应当是 %s,实际 %s", i, level, entries[i].Level) + } + } +} + +func TestLoggerClearAndText(t *testing.T) { + l := New(10) + l.Info("第一条") + l.Info("第二条") + + text := l.Text() + if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") { + t.Fatalf("导出文本应当包含全部日志,实际:%s", text) + } + + l.Clear() + if len(l.Entries()) != 0 { + t.Fatalf("清空后不应还有日志") + } +} + +// 日志写入后要能推给前端。 +func TestLoggerHandler(t *testing.T) { + l := New(10) + var got []Entry + l.SetHandler(func(e Entry) { got = append(got, e) }) + + l.Info("一条日志") + if len(got) != 1 { + t.Fatalf("回调应当被调用 1 次,实际 %d 次", len(got)) + } + if got[0].Message != "一条日志" { + t.Fatalf("回调收到的内容不对:%s", got[0].Message) + } +} diff --git a/internal/store/diagnosis.go b/internal/store/diagnosis.go new file mode 100644 index 0000000..4d39010 --- /dev/null +++ b/internal/store/diagnosis.go @@ -0,0 +1,72 @@ +package store + +import "fmt" + +// Diagnosis 是货憨憨返回的一条商品质量诊断。 +type Diagnosis struct { + ProductID string `json:"productId"` + Field string `json:"field"` + Type string `json:"type"` + Solution string `json:"solution"` +} + +// ReplaceDiagnoses 全量替换一个商品的诊断明细。 +// +// 必须先删后写并放在同一个事务里。商品被修好后,货憨憨不会再返回 +// 原来的诊断;只做追加或 upsert 会让旧诊断永久残留。 +func (s *Store) ReplaceDiagnoses(productID string, items []Diagnosis, now string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启商品诊断事务失败:%w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM product_diagnoses WHERE product_id = ?`, productID); err != nil { + return fmt.Errorf("清空商品 %s 的旧诊断失败:%w", productID, err) + } + + stmt, err := tx.Prepare(` + INSERT INTO product_diagnoses (product_id, field, type, solution, updated_at) + VALUES (?, ?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("准备写入商品诊断失败:%w", err) + } + defer stmt.Close() + + for _, item := range items { + if _, err := stmt.Exec(productID, item.Field, item.Type, item.Solution, now); err != nil { + return fmt.Errorf("写入商品 %s 的诊断失败:%w", productID, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交商品诊断事务失败:%w", err) + } + return nil +} + +// ListDiagnoses 返回一个商品的全部诊断明细。 +func (s *Store) ListDiagnoses(productID string) ([]Diagnosis, error) { + rows, err := s.db.Query(` + SELECT product_id, field, type, solution + FROM product_diagnoses + WHERE product_id = ? + ORDER BY rowid`, productID) + if err != nil { + return nil, fmt.Errorf("读取商品 %s 的诊断失败:%w", productID, err) + } + defer rows.Close() + + items := make([]Diagnosis, 0) + for rows.Next() { + var item Diagnosis + if err := rows.Scan(&item.ProductID, &item.Field, &item.Type, &item.Solution); err != nil { + return nil, fmt.Errorf("读取商品 %s 的诊断行失败:%w", productID, err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历商品 %s 的诊断失败:%w", productID, err) + } + return items, nil +} diff --git a/internal/store/diagnosis_test.go b/internal/store/diagnosis_test.go new file mode 100644 index 0000000..7d3bdae --- /dev/null +++ b/internal/store/diagnosis_test.go @@ -0,0 +1,31 @@ +package store + +import "testing" + +func Test商品诊断全量替换(t *testing.T) { + s := newTestStore(t) + first := []Diagnosis{ + {Field: "ALL", Type: "缺少视频", Solution: "上传视频"}, + {Field: "ALL", Type: "缺少品牌信息", Solution: "填写品牌"}, + {Field: "ALL", Type: "缺少尺寸表", Solution: "上传尺寸表"}, + } + if err := s.ReplaceDiagnoses("商品-1", first, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("首次写入诊断失败:%v", err) + } + + second := []Diagnosis{{Field: "ALL", Type: "缺少标准变体", Solution: "补充变体"}} + if err := s.ReplaceDiagnoses("商品-1", second, "2026-09-02 11:00:00"); err != nil { + t.Fatalf("替换诊断失败:%v", err) + } + + got, err := s.ListDiagnoses("商品-1") + if err != nil { + t.Fatalf("读取诊断失败:%v", err) + } + if len(got) != 1 { + t.Fatalf("全量替换后应当只剩 1 条,实际 %d 条:%+v", len(got), got) + } + if got[0].ProductID != "商品-1" || got[0].Type != "缺少标准变体" { + t.Fatalf("替换后的诊断不正确:%+v", got[0]) + } +} diff --git a/internal/store/product.go b/internal/store/product.go new file mode 100644 index 0000000..a862a84 --- /dev/null +++ b/internal/store/product.go @@ -0,0 +1,396 @@ +package store + +import ( + "database/sql" + "fmt" + "strings" +) + +// 商品诊断和三个本地状态字段各有一组取值。 +// +// 为什么用字符串常量而不是数字:出问题时直接 SQL 查库就能看懂, +// 不用回来翻代码对照数字含义。 +const ( + // video_diagnosis:货憨憨的商品质量诊断。 + // 负责人 2026-09-02 的阶段性决定是只认明确的「缺少视频」; + // diagnosisInfo 为 null 和其它所有情况都归入 ok,不引入第三种状态。 + VideoDiagnosisMissing = "missing" + VideoDiagnosisOK = "ok" + + // video_status:有没有找到同款视频 + VideoPending = "pending" // 还没搜过 + VideoFound = "found" // 找到了 + VideoNone = "none" // 搜过了,但没有同款视频 + + // download_status:视频下载进度 + DownloadPending = "pending" // 待下载 + DownloadRunning = "running" // 下载中 + DownloadDone = "done" // 已下载 + DownloadFailed = "failed" // 失败 + + // upload_status:上传回货憨憨的进度 + UploadPending = "pending" // 待上传 + UploadRunning = "running" // 上传中 + UploadDone = "done" // 已上传 + UploadFailed = "failed" // 失败 + UploadSkippedExisting = "existing" // 货憨憨已有视频,批量时跳过 + UploadMissingVideo = "missing" // 子目录里没有 mp4 + UploadInvalidVideo = "invalid" // 有文件但不符合货憨憨要求 +) + +// Product 是一个商品。字段和 products 表一一对应。 +// +// json tag 决定了前端拿到的字段名,改名会让界面显示空白。 +type Product struct { + ID string `json:"id"` // 货憨憨内部记录 ID + ItemID string `json:"itemId"` // Shopee 商品 ID,界面显示为「蝦皮ID」 + ItemName string `json:"itemName"` // 标题 + MainImage string `json:"mainImage"` // 主图地址,用作淘宝以图搜的输入 + ShopName string `json:"shopName"` // 店铺名 + PlatformShopID string `json:"platformShopId"` // 平台店铺 ID + Currency string `json:"currency"` // 币种,例如 TWD + MinSkuPrice float64 `json:"minSkuPrice"` // 最低价 + ItemStatus string `json:"itemStatus"` // 货憨憨的商品状态,例如 NORMAL + CreatedAt string `json:"createdAt"` // 货憨憨侧创建时间 + VideoDiagnosis string `json:"videoDiagnosis"` // 货憨憨质量诊断中的视频结果 + QualityLevel string `json:"qualityLevel"` // 货憨憨质量等级 + VideoStatus string `json:"videoStatus"` // 本工具维护 + DownloadStatus string `json:"downloadStatus"` // 本工具维护 + UploadStatus string `json:"uploadStatus"` // 本工具维护 + LastError string `json:"lastError"` // 最近一次失败原因 + SyncedAt string `json:"syncedAt"` // 本地同步时间 +} + +// ProductQuery 是查询条件,对应界面工具栏上行的筛选框。 +// +// 所有字段都可以留空,留空表示不按这个条件过滤。 +type ProductQuery struct { + PlatformShopID string `json:"platformShopId"` // 店铺 + ItemIDs string `json:"itemIds"` // 蝦皮ID,多个用逗号分隔 + CreatedFrom string `json:"createdFrom"` // 创建时间起,形如 2026-08-01 + CreatedTo string `json:"createdTo"` // 创建时间止 + ItemStatus string `json:"itemStatus"` // 商品状态 + VideoDiagnosis string `json:"videoDiagnosis"` // 视频诊断 + UploadStatus string `json:"uploadStatus"` // 上传状态 + Page int `json:"page"` // 页码,从 1 开始 + PageSize int `json:"pageSize"` // 每页条数 +} + +// ProductPage 是一页查询结果。 +type ProductPage struct { + Items []Product `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +// UpsertProducts 批量写入商品:已存在就更新,不存在就插入。 +// +// 关键点:video_diagnosis 和 quality_level 来自货憨憨,需要随同步更新; +// video_status、download_status、upload_status 是本地状态,绝不能覆盖。 +// 否则每次「下载数据」都会把已经下载好的进度清零,同事会白干。 +func (s *Store) UpsertProducts(items []Product, now string) error { + if len(items) == 0 { + return nil + } + + // 用事务包起来:要么全写成功,要么一条都不写。 + // 中途断网时不会留下写了一半的数据。 + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启事务失败:%w", err) + } + // Rollback 在已经 Commit 后调用会返回错误,这里忽略即可。 + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT INTO products ( + id, item_id, item_name, main_image, shop_name, platform_shop_id, + currency, min_sku_price, item_status, created_at, video_diagnosis, + quality_level, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + item_id = excluded.item_id, + item_name = excluded.item_name, + main_image = excluded.main_image, + shop_name = excluded.shop_name, + platform_shop_id = excluded.platform_shop_id, + currency = excluded.currency, + min_sku_price = excluded.min_sku_price, + item_status = excluded.item_status, + created_at = excluded.created_at, + video_diagnosis = excluded.video_diagnosis, + quality_level = excluded.quality_level, + synced_at = excluded.synced_at`) + if err != nil { + return fmt.Errorf("准备写入语句失败:%w", err) + } + defer stmt.Close() + + for _, p := range items { + if p.ID == "" { + // 没有货憨憨记录 ID 的数据没法关联,直接跳过, + // 不要用 item_id 顶替,两者不是一回事。 + continue + } + // 写入层也守住两态约束:只有明确的 missing 保留为缺少视频, + // 空值或其它值都按负责人决定归入 ok。 + if p.VideoDiagnosis != VideoDiagnosisMissing { + p.VideoDiagnosis = VideoDiagnosisOK + } + _, err := stmt.Exec(p.ID, p.ItemID, p.ItemName, p.MainImage, p.ShopName, + p.PlatformShopID, p.Currency, p.MinSkuPrice, p.ItemStatus, + p.CreatedAt, p.VideoDiagnosis, p.QualityLevel, now) + if err != nil { + return fmt.Errorf("写入商品 %s 失败:%w", p.ID, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败:%w", err) + } + return nil +} + +// ListProducts 按条件分页查询商品。 +func (s *Store) ListProducts(q ProductQuery) (ProductPage, error) { + if q.Page < 1 { + q.Page = 1 + } + if q.PageSize < 1 || q.PageSize > 200 { + q.PageSize = 20 + } + + where, args := buildWhere(q) + + var total int + countSQL := "SELECT COUNT(*) FROM products" + where + if err := s.db.QueryRow(countSQL, args...).Scan(&total); err != nil { + return ProductPage{}, fmt.Errorf("统计商品数量失败:%w", err) + } + + listSQL := `SELECT id, item_id, item_name, main_image, shop_name, + platform_shop_id, currency, min_sku_price, item_status, created_at, + video_diagnosis, quality_level, video_status, download_status, + upload_status, last_error, synced_at + FROM products` + where + + ` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?` + + listArgs := append(append([]any{}, args...), q.PageSize, (q.Page-1)*q.PageSize) + rows, err := s.db.Query(listSQL, listArgs...) + if err != nil { + return ProductPage{}, fmt.Errorf("查询商品失败:%w", err) + } + defer rows.Close() + + items := make([]Product, 0, q.PageSize) + for rows.Next() { + var p Product + if err := rows.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage, + &p.ShopName, &p.PlatformShopID, &p.Currency, &p.MinSkuPrice, + &p.ItemStatus, &p.CreatedAt, &p.VideoDiagnosis, &p.QualityLevel, + &p.VideoStatus, &p.DownloadStatus, &p.UploadStatus, &p.LastError, + &p.SyncedAt); err != nil { + return ProductPage{}, fmt.Errorf("读取商品行失败:%w", err) + } + items = append(items, p) + } + if err := rows.Err(); err != nil { + return ProductPage{}, fmt.Errorf("遍历商品结果失败:%w", err) + } + + return ProductPage{Items: items, Total: total, Page: q.Page, Size: q.PageSize}, nil +} + +// buildWhere 根据查询条件拼出 WHERE 子句和参数。 +// +// 全部用 ? 占位符传参,不要用字符串拼接把用户输入拼进 SQL, +// 那是 SQL 注入。 +func buildWhere(q ProductQuery) (string, []any) { + var conds []string + var args []any + + if v := strings.TrimSpace(q.PlatformShopID); v != "" { + conds = append(conds, "platform_shop_id = ?") + args = append(args, v) + } + if v := strings.TrimSpace(q.ItemStatus); v != "" { + conds = append(conds, "item_status = ?") + args = append(args, v) + } + if v := strings.TrimSpace(q.VideoDiagnosis); v != "" { + conds = append(conds, "video_diagnosis = ?") + args = append(args, v) + } + if v := strings.TrimSpace(q.UploadStatus); v != "" { + conds = append(conds, "upload_status = ?") + args = append(args, v) + } + if v := strings.TrimSpace(q.CreatedFrom); v != "" { + conds = append(conds, "created_at >= ?") + args = append(args, v) + } + if v := strings.TrimSpace(q.CreatedTo); v != "" { + // 用户填的是日期,加上时间上界,否则当天的数据会被漏掉。 + conds = append(conds, "created_at <= ?") + args = append(args, v+" 23:59:59") + } + if ids := splitIDs(q.ItemIDs); len(ids) > 0 { + // IN (?, ?, ?) 的占位符个数要和参数个数一致。 + holders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + conds = append(conds, "item_id IN ("+holders+")") + for _, id := range ids { + args = append(args, id) + } + } + + if len(conds) == 0 { + return "", nil + } + return " WHERE " + strings.Join(conds, " AND "), args +} + +// splitIDs 把「多个用逗号分隔」的输入拆成一个个 ID。 +// +// 同事可能用中文逗号、空格或换行分隔,都要能认。 +func splitIDs(raw string) []string { + replacer := strings.NewReplacer(",", ",", " ", ",", "\n", ",", "\t", ",", "、", ",") + parts := strings.Split(replacer.Replace(raw), ",") + + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// CountProducts 返回商品总数,界面底部显示「共 N 条」用。 +func (s *Store) CountProducts() (int, error) { + var n int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&n); err != nil { + return 0, fmt.Errorf("统计商品总数失败:%w", err) + } + return n, nil +} + +// GetProduct 按货憨憨记录 ID 取一个商品。找不到时返回 false,不算错误。 +func (s *Store) GetProduct(id string) (Product, bool, error) { + var p Product + row := s.db.QueryRow(`SELECT id, item_id, item_name, main_image, shop_name, + platform_shop_id, currency, min_sku_price, item_status, created_at, + video_diagnosis, quality_level, video_status, download_status, + upload_status, last_error, synced_at + FROM products WHERE id = ?`, id) + + err := row.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage, &p.ShopName, + &p.PlatformShopID, &p.Currency, &p.MinSkuPrice, &p.ItemStatus, + &p.CreatedAt, &p.VideoDiagnosis, &p.QualityLevel, &p.VideoStatus, + &p.DownloadStatus, &p.UploadStatus, &p.LastError, &p.SyncedAt) + switch { + case err == sql.ErrNoRows: + return Product{}, false, nil + case err != nil: + return Product{}, false, fmt.Errorf("读取商品失败:%w", err) + default: + return p, true, nil + } +} + +// UpdateProductStatus 更新一个商品的处理状态。 +// +// 传空字符串表示这一项不改,这样调用方只想改下载状态时不用先查一次。 +func (s *Store) UpdateProductStatus(id, videoStatus, downloadStatus, uploadStatus, lastError string) error { + var sets []string + var args []any + + if videoStatus != "" { + sets = append(sets, "video_status = ?") + args = append(args, videoStatus) + } + if downloadStatus != "" { + sets = append(sets, "download_status = ?") + args = append(args, downloadStatus) + } + if uploadStatus != "" { + sets = append(sets, "upload_status = ?") + args = append(args, uploadStatus) + } + // last_error 允许写空字符串,表示清除上一次的错误, + // 所以它不跟着上面的“空表示不改”规则。 + sets = append(sets, "last_error = ?") + args = append(args, lastError) + + args = append(args, id) + query := "UPDATE products SET " + strings.Join(sets, ", ") + " WHERE id = ?" + if _, err := s.db.Exec(query, args...); err != nil { + return fmt.Errorf("更新商品状态失败:%w", err) + } + return nil +} + +// ResetRunningStatuses 把上次进程残留的运行中状态改回待处理。 +func (s *Store) ResetRunningStatuses() (int, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("开启重置运行中状态事务失败:%w", err) + } + defer tx.Rollback() + + downloadResult, err := tx.Exec(`UPDATE products SET download_status = ? WHERE download_status = ?`, + DownloadPending, DownloadRunning) + if err != nil { + return 0, fmt.Errorf("重置下载运行中状态失败:%w", err) + } + uploadResult, err := tx.Exec(`UPDATE products SET upload_status = ? WHERE upload_status = ?`, + UploadPending, UploadRunning) + if err != nil { + return 0, fmt.Errorf("重置上传运行中状态失败:%w", err) + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("提交重置运行中状态事务失败:%w", err) + } + + downloadCount, err := downloadResult.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取重置下载状态数量失败:%w", err) + } + uploadCount, err := uploadResult.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取重置上传状态数量失败:%w", err) + } + return int(downloadCount + uploadCount), nil +} + +// CountResettableNoneProducts 返回当前被标记为「无同款视频」的商品数量。 +func (s *Store) CountResettableNoneProducts() (int, error) { + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM products WHERE video_status = ?`, VideoNone).Scan(&count); err != nil { + return 0, fmt.Errorf("统计可重置无视频商品失败:%w", err) + } + return count, nil +} + +// ResetNoneProducts 是一次性的人工数据订正工具,用来清掉风控期间被误标为 +// 「无同款视频」的记录。它不触碰诊断和 videos 历史。 +// +// 这里刻意不做日期过滤。曾经想用 synced_at 限定范围,但那个字段记录的是 +// 「商品数据什么时候从货憨憨拉下来的」,和「video_status 什么时候被写成 none」 +// 没有关系——每点一次「下载数据」它就会被刷成当天,过滤条件随即失效。 +// +// 「某个商品是否需要重做」的长期答案来自货憨憨每次全量拉取覆盖的 +// video_diagnosis,不来自本地时间戳。R4c 之后风控也不会再误写 none, +// 所以本工具用完基本不会再需要。 +func (s *Store) ResetNoneProducts() (int, error) { + result, err := s.db.Exec(`UPDATE products SET video_status = ?, download_status = ?, last_error = '' WHERE video_status = ?`, + VideoPending, DownloadPending, VideoNone) + if err != nil { + return 0, fmt.Errorf("重置无视频商品失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("读取重置商品数量失败:%w", err) + } + return int(count), nil +} diff --git a/internal/store/product_test.go b/internal/store/product_test.go new file mode 100644 index 0000000..27f8b66 --- /dev/null +++ b/internal/store/product_test.go @@ -0,0 +1,256 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func Test重置残留运行中状态只影响运行中字段(t *testing.T) { + s := newTestStore(t) + items := []Product{ + {ID: "下载运行中", ItemID: "1", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "上传运行中", ItemID: "2", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "两个都运行中", ItemID: "3", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "已完成", ItemID: "4", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "已失败", ItemID: "5", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "待处理", ItemID: "6", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "none状态", ItemID: "7", VideoDiagnosis: VideoDiagnosisMissing}, + } + if err := s.UpsertProducts(items, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + statuses := map[string]struct{ download, upload string }{ + "下载运行中": {DownloadRunning, UploadDone}, + "上传运行中": {DownloadDone, UploadRunning}, + "两个都运行中": {DownloadRunning, UploadRunning}, + "已完成": {DownloadDone, UploadDone}, + "已失败": {DownloadFailed, UploadFailed}, + "待处理": {DownloadPending, UploadPending}, + "none状态": {"none", "none"}, + } + for id, status := range statuses { + if err := s.UpdateProductStatus(id, "", status.download, status.upload, ""); err != nil { + t.Fatalf("准备商品 %s 状态失败:%v", id, err) + } + } + if _, err := s.DB().Exec(`INSERT INTO videos (product_id, source_item, status) VALUES ('下载运行中', 'source', 'downloaded')`); err != nil { + t.Fatalf("准备视频记录失败:%v", err) + } + + changed, err := s.ResetRunningStatuses() + if err != nil { + t.Fatalf("重置残留运行中状态失败:%v", err) + } + if changed != 4 { + t.Fatalf("应重置 4 个状态字段,实际 %d", changed) + } + + want := map[string]struct { + download, upload, diagnosis string + }{ + "下载运行中": {DownloadPending, UploadDone, VideoDiagnosisMissing}, + "上传运行中": {DownloadDone, UploadPending, VideoDiagnosisOK}, + "两个都运行中": {DownloadPending, UploadPending, VideoDiagnosisMissing}, + "已完成": {DownloadDone, UploadDone, VideoDiagnosisOK}, + "已失败": {DownloadFailed, UploadFailed, VideoDiagnosisMissing}, + "待处理": {DownloadPending, UploadPending, VideoDiagnosisOK}, + "none状态": {"none", "none", VideoDiagnosisMissing}, + } + for id, expected := range want { + got, found, err := s.GetProduct(id) + if err != nil || !found { + t.Fatalf("读取商品 %s 失败:err=%v found=%v", id, err, found) + } + if got.DownloadStatus != expected.download || got.UploadStatus != expected.upload { + t.Fatalf("商品 %s 状态不正确:download=%q upload=%q", id, got.DownloadStatus, got.UploadStatus) + } + if got.VideoDiagnosis != expected.diagnosis || got.VideoStatus != VideoPending { + t.Fatalf("商品 %s 的视频状态被错误改动:diagnosis=%q videoStatus=%q", id, got.VideoDiagnosis, got.VideoStatus) + } + } + + var videos int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM videos WHERE product_id = '下载运行中'`).Scan(&videos); err != nil || videos != 1 { + t.Fatalf("重置不得删除 videos 记录:count=%d err=%v", videos, err) + } +} + +func Test老库升级保留商品和本地状态(t *testing.T) { + path := filepath.Join(t.TempDir(), "旧版.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("打开旧版测试数据库失败:%v", err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL)`); err != nil { + t.Fatalf("创建旧版版本表失败:%v", err) + } + + oldMigrationCount := len(migrations) - 4 + for i := 0; i < oldMigrationCount; i++ { + if _, err := db.Exec(migrations[i]); err != nil { + t.Fatalf("执行旧版第 %d 条迁移失败:%v", i+1, err) + } + if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, i+1); err != nil { + t.Fatalf("记录旧版第 %d 条迁移失败:%v", i+1, err) + } + } + if _, err := db.Exec(` + INSERT INTO products (id, item_id, item_name, download_status) + VALUES ('旧商品-1', '蝦皮-1', '升级前商品', 'done')`); err != nil { + t.Fatalf("写入旧版商品失败:%v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("关闭旧版数据库失败:%v", err) + } + + s, err := Open(path) + if err != nil { + t.Fatalf("用完整迁移升级旧库失败:%v", err) + } + defer s.Close() + + version, err := s.Version() + if err != nil { + t.Fatalf("读取升级后版本失败:%v", err) + } + if version != len(migrations) || version != oldMigrationCount+4 { + t.Fatalf("升级后版本应为 %d,实际 %d", len(migrations), version) + } + product, found, err := s.GetProduct("旧商品-1") + if err != nil || !found { + t.Fatalf("升级后原商品应当保留:err=%v found=%v", err, found) + } + if product.DownloadStatus != DownloadDone { + t.Fatalf("升级不能清掉本地下载状态,实际 %q", product.DownloadStatus) + } + if product.VideoDiagnosis != VideoDiagnosisOK || product.QualityLevel != "" { + t.Fatalf("新列默认值不正确:videoDiagnosis=%q qualityLevel=%q", product.VideoDiagnosis, product.QualityLevel) + } +} + +func Test按视频诊断筛选商品(t *testing.T) { + s := newTestStore(t) + items := []Product{ + {ID: "缺视频-1", ItemID: "1", VideoDiagnosis: VideoDiagnosisMissing}, + {ID: "有视频-1", ItemID: "2", VideoDiagnosis: VideoDiagnosisOK}, + {ID: "有视频-2", ItemID: "3", VideoDiagnosis: VideoDiagnosisOK}, + } + if err := s.UpsertProducts(items, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + + missing, err := s.ListProducts(ProductQuery{VideoDiagnosis: VideoDiagnosisMissing}) + if err != nil { + t.Fatalf("筛选缺少视频失败:%v", err) + } + if missing.Total != 1 || missing.Items[0].ID != "缺视频-1" { + t.Fatalf("缺少视频筛选结果不正确:%+v", missing.Items) + } + ok, err := s.ListProducts(ProductQuery{VideoDiagnosis: VideoDiagnosisOK}) + if err != nil { + t.Fatalf("筛选有视频失败:%v", err) + } + if ok.Total != 2 { + t.Fatalf("有视频筛选应返回 2 条,实际 %d", ok.Total) + } +} + +func Test按上传状态筛选商品(t *testing.T) { + s := newTestStore(t) + items := []Product{{ID: "缺少", ItemID: "1"}, {ID: "不合规", ItemID: "2"}, {ID: "已有", ItemID: "3"}} + if err := s.UpsertProducts(items, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + for id, status := range map[string]string{"缺少": UploadMissingVideo, "不合规": UploadInvalidVideo, "已有": UploadSkippedExisting} { + if err := s.UpdateProductStatus(id, "", "", status, ""); err != nil { + t.Fatalf("准备上传状态失败:%v", err) + } + } + for status, id := range map[string]string{UploadMissingVideo: "缺少", UploadInvalidVideo: "不合规", UploadSkippedExisting: "已有"} { + page, err := s.ListProducts(ProductQuery{UploadStatus: status}) + if err != nil || page.Total != 1 || page.Items[0].ID != id { + t.Fatalf("按上传状态 %q 筛选不正确:page=%+v err=%v", status, page, err) + } + } +} + +func Test重复写入商品不会清空诊断结果(t *testing.T) { + s := newTestStore(t) + product := Product{ + ID: "商品-1", ItemID: "蝦皮-1", + VideoDiagnosis: VideoDiagnosisMissing, QualityLevel: "1", + } + if err := s.UpsertProducts([]Product{product}, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("首次写入商品失败:%v", err) + } + product.ItemName = "更新后的标题" + if err := s.UpsertProducts([]Product{product}, "2026-09-02 11:00:00"); err != nil { + t.Fatalf("重复写入商品失败:%v", err) + } + + got, found, err := s.GetProduct(product.ID) + if err != nil || !found { + t.Fatalf("读取商品失败:err=%v found=%v", err, found) + } + if got.VideoDiagnosis != VideoDiagnosisMissing || got.QualityLevel != "1" { + t.Fatalf("重复写入后诊断结果被清空:videoDiagnosis=%q qualityLevel=%q", got.VideoDiagnosis, got.QualityLevel) + } +} + +func Test重置误判无视频商品只动none且保留其它状态(t *testing.T) { + s := newTestStore(t) + items := []Product{{ID: "none-1", ItemID: "1"}, {ID: "none-2", ItemID: "2"}, {ID: "done", ItemID: "3"}, {ID: "found", ItemID: "4"}} + if err := s.UpsertProducts(items, "2026-09-01 12:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + for _, id := range []string{"none-1", "none-2"} { + if err := s.UpdateProductStatus(id, VideoNone, DownloadFailed, "", "旧错误"); err != nil { + t.Fatalf("准备 none 状态失败:%v", err) + } + } + if err := s.UpdateProductStatus("done", VideoFound, DownloadDone, "", ""); err != nil { + t.Fatalf("准备 done 状态失败:%v", err) + } + if err := s.UpdateProductStatus("found", VideoFound, DownloadPending, "", ""); err != nil { + t.Fatalf("准备 found 状态失败:%v", err) + } + if _, err := s.DB().Exec(`INSERT INTO videos (product_id, source_item, status) VALUES ('none-1', 'source', 'downloaded')`); err != nil { + t.Fatalf("准备视频记录失败:%v", err) + } + + before, _, _ := s.GetProduct("none-1") + + count, err := s.CountResettableNoneProducts() + if err != nil || count != 2 { + t.Fatalf("应统计 2 条 none,count=%d err=%v", count, err) + } + changed, err := s.ResetNoneProducts() + if err != nil || changed != 2 { + t.Fatalf("应重置 2 条,changed=%d err=%v", changed, err) + } + for _, id := range []string{"none-1", "none-2"} { + p, _, _ := s.GetProduct(id) + if p.VideoStatus != VideoPending || p.DownloadStatus != DownloadPending || p.LastError != "" { + t.Fatalf("%s 未正确重置:%+v", id, p) + } + } + // 不是 none 的商品一律不得被碰。 + done, _, _ := s.GetProduct("done") + found, _, _ := s.GetProduct("found") + if done.VideoStatus != VideoFound || done.DownloadStatus != DownloadDone { + t.Fatalf("已完成商品被误改:%+v", done) + } + if found.VideoStatus != VideoFound { + t.Fatalf("已找到视频的商品被误改:%+v", found) + } + // 诊断和 videos 历史都不受影响。 + if p, _, _ := s.GetProduct("none-1"); p.VideoDiagnosis != before.VideoDiagnosis { + t.Fatalf("诊断不得被重置:%+v", p) + } + var videos int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM videos WHERE product_id = 'none-1'`).Scan(&videos); err != nil || videos != 1 { + t.Fatalf("videos 记录不得删除:count=%d err=%v", videos, err) + } +} diff --git a/internal/store/shop.go b/internal/store/shop.go new file mode 100644 index 0000000..3bb774b --- /dev/null +++ b/internal/store/shop.go @@ -0,0 +1,101 @@ +package store + +import "fmt" + +// Shop 是店铺列表缓存。字段和 shops 表一一对应。 +// +// 这里只保留界面展示和后续查询商品需要的白名单字段,不得加入 +// accessToken、refreshToken、createUser 等凭据或个人信息。 +type Shop struct { + PlatformShopID string `json:"platformShopId"` + ID string `json:"id"` + ShopName string `json:"shopName"` + ShopAlias string `json:"shopAlias"` + Region string `json:"region"` + RegionName string `json:"regionName"` + Platform string `json:"platform"` + Status string `json:"status"` + UpdatedAt string `json:"updatedAt"` +} + +// ReplaceShops 用线上店铺列表全量替换本地缓存。 +// +// 必须先删后写并放在同一个事务里:店铺在货憨憨被删除或停用后, +// 缓存也要跟着消失。只做 upsert 会让已删除的店铺永远留在下拉框里, +// 使用者选中后却拉不到数据。 +func (s *Store) ReplaceShops(shops []Shop, now string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启店铺缓存事务失败:%w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM shops`); err != nil { + return fmt.Errorf("清空旧店铺缓存失败:%w", err) + } + + stmt, err := tx.Prepare(` + INSERT INTO shops ( + platform_shop_id, id, shop_name, shop_alias, region, + region_name, platform, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("准备写入店铺缓存失败:%w", err) + } + defer stmt.Close() + + for _, shop := range shops { + if _, err := stmt.Exec( + shop.PlatformShopID, shop.ID, shop.ShopName, shop.ShopAlias, + shop.Region, shop.RegionName, shop.Platform, shop.Status, now, + ); err != nil { + return fmt.Errorf("写入店铺 %s 失败:%w", shop.ShopName, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交店铺缓存事务失败:%w", err) + } + return nil +} + +// ListShops 按店铺名排序返回本地缓存,不会联网。 +func (s *Store) ListShops() ([]Shop, error) { + rows, err := s.db.Query(` + SELECT platform_shop_id, id, shop_name, shop_alias, region, + region_name, platform, status, updated_at + FROM shops + ORDER BY shop_name, platform_shop_id`) + if err != nil { + return nil, fmt.Errorf("读取店铺缓存失败:%w", err) + } + defer rows.Close() + + shops := make([]Shop, 0) + for rows.Next() { + var shop Shop + if err := rows.Scan( + &shop.PlatformShopID, &shop.ID, &shop.ShopName, &shop.ShopAlias, + &shop.Region, &shop.RegionName, &shop.Platform, &shop.Status, + &shop.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("读取店铺缓存行失败:%w", err) + } + shops = append(shops, shop) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历店铺缓存失败:%w", err) + } + return shops, nil +} + +// ShopsUpdatedAt 返回店铺缓存的更新时间;没有缓存时返回空字符串。 +func (s *Store) ShopsUpdatedAt() (string, error) { + var updatedAt string + if err := s.db.QueryRow( + `SELECT COALESCE(MAX(updated_at), '') FROM shops`, + ).Scan(&updatedAt); err != nil { + return "", fmt.Errorf("读取店铺缓存时间失败:%w", err) + } + return updatedAt, nil +} diff --git a/internal/store/shop_test.go b/internal/store/shop_test.go new file mode 100644 index 0000000..5f5083f --- /dev/null +++ b/internal/store/shop_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "encoding/json" + "testing" +) + +func 测试店铺列表() []Shop { + return []Shop{ + {PlatformShopID: "shop-3", ID: "3", ShopName: "朝阳店", Region: "TW", Platform: "0", Status: "NORMAL"}, + {PlatformShopID: "shop-1", ID: "1", ShopName: "白云店", Region: "MY", Platform: "0", Status: "NORMAL"}, + {PlatformShopID: "shop-2", ID: "2", ShopName: "春风店", Region: "SG", Platform: "0", Status: "NORMAL"}, + } +} + +func Test建表后版本等于迁移条数(t *testing.T) { + s := newTestStore(t) + version, err := s.Version() + if err != nil { + t.Fatalf("读取迁移版本失败:%v", err) + } + if version != len(migrations) { + t.Fatalf("迁移版本应为 %d,实际为 %d", len(migrations), version) + } +} + +func Test店铺缓存写入后按名称排序读回(t *testing.T) { + s := newTestStore(t) + if err := s.ReplaceShops(测试店铺列表(), "2026-09-02 17:30:00"); err != nil { + t.Fatalf("写入店铺缓存失败:%v", err) + } + + shops, err := s.ListShops() + if err != nil { + t.Fatalf("读取店铺缓存失败:%v", err) + } + if len(shops) != 3 { + t.Fatalf("应读回 3 个店铺,实际为 %d", len(shops)) + } + if shops[0].ShopName != "春风店" || shops[1].ShopName != "朝阳店" || shops[2].ShopName != "白云店" { + t.Fatalf("店铺未按名称排序:%v", []string{shops[0].ShopName, shops[1].ShopName, shops[2].ShopName}) + } + if shops[0].UpdatedAt != "2026-09-02 17:30:00" { + t.Fatalf("缓存时间应写入每个店铺,实际为 %q", shops[0].UpdatedAt) + } +} + +func Test店铺缓存采用全量替换(t *testing.T) { + s := newTestStore(t) + shops := 测试店铺列表() + if err := s.ReplaceShops(shops, "2026-09-02 17:30:00"); err != nil { + t.Fatalf("首次写入 3 个店铺失败:%v", err) + } + if err := s.ReplaceShops(shops[:2], "2026-09-02 17:31:00"); err != nil { + t.Fatalf("用 2 个店铺全量替换失败:%v", err) + } + + got, err := s.ListShops() + if err != nil { + t.Fatalf("读取替换后的店铺失败:%v", err) + } + if len(got) != 2 { + t.Fatalf("全量替换后应只剩 2 个店铺,实际为 %d", len(got)) + } + for _, shop := range got { + if shop.PlatformShopID == "shop-2" { + t.Fatalf("已从线上消失的第 3 个店铺仍留在缓存中") + } + } +} + +func Test店铺缓存时间在无缓存时为空写入后可读(t *testing.T) { + s := newTestStore(t) + updatedAt, err := s.ShopsUpdatedAt() + if err != nil { + t.Fatalf("读取空缓存时间失败:%v", err) + } + if updatedAt != "" { + t.Fatalf("无缓存时更新时间应为空,实际为 %q", updatedAt) + } + + const now = "2026-09-02 17:30:00" + if err := s.ReplaceShops(测试店铺列表(), now); err != nil { + t.Fatalf("写入店铺缓存失败:%v", err) + } + updatedAt, err = s.ShopsUpdatedAt() + if err != nil { + t.Fatalf("读取缓存时间失败:%v", err) + } + if updatedAt != now { + t.Fatalf("缓存时间应为 %q,实际为 %q", now, updatedAt) + } +} + +func Test店铺缓存JSON不含凭据或手机号字段(t *testing.T) { + raw, err := json.Marshal(Shop{PlatformShopID: "shop-1", ShopName: "测试店铺"}) + if err != nil { + t.Fatalf("序列化店铺缓存失败:%v", err) + } + + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatalf("解析店铺缓存 JSON 失败:%v", err) + } + for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} { + if _, exists := fields[forbidden]; exists { + t.Fatalf("店铺缓存 JSON 不得包含敏感字段 %q:%s", forbidden, raw) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..10a683d --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,250 @@ +// Package store 负责本地 SQLite 数据库。 +// +// 本项目的所有状态(商品、视频、下载和上传进度、货憨憨登录态) +// 都保存在这里,它是唯一的事实来源。不要再用 JSON 文件另存一份, +// 那样两边一定会不一致。 +// +// 用的是 modernc.org/sqlite —— 纯 Go 实现的 SQLite,不需要 CGO, +// 也就不需要在机器上装 gcc。换成 mattn/go-sqlite3 会让新同事 +// 第一次编译就卡在编译器上,所以不要换。 +package store + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + "go-admin/internal/config" + + _ "modernc.org/sqlite" // 注册名为 "sqlite" 的驱动 +) + +// Store 是数据库连接。用完要 Close。 +type Store struct { + db *sql.DB +} + +// Open 打开(不存在就创建)数据库文件,并把表结构升级到最新。 +// +// path 传 ":memory:" 可以开一个只存在于内存里的库,单元测试就用这个。 +func Open(path string) (*Store, error) { + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("创建数据库目录失败:%w", err) + } + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("打开数据库失败:%w", err) + } + + // SQLite 同一时间只允许一个写入者。把连接数限制为 1, + // 可以避免多个下载协程同时写时报 "database is locked"。 + // 本项目数据量很小,这点性能损失可以忽略。 + db.SetMaxOpenConns(1) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("连接数据库失败:%w", err) + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close 关闭数据库。 +func (s *Store) Close() error { + return s.db.Close() +} + +// DB 返回底层连接,只给同一个包里的文件用。 +func (s *Store) DB() *sql.DB { return s.db } + +// migrations 是按顺序执行的建表和改表语句。 +// +// 怎么加一次改动(很重要,改错会丢数据): +// 1. 在切片末尾追加一条,绝不要修改或删除已有的任何一条; +// 2. 已经发布过的语句改了,老用户的库就升不上来了; +// 3. 加完记得在 store_test.go 里补一个测试。 +// +// 每条语句都必须能重复执行而不报错(用 IF NOT EXISTS), +// 因为程序每次启动都会把它们全跑一遍。 +var migrations = []string{ + // 1. 商品表。一行对应货憨憨里的一个 Shopee 在线商品。 + `CREATE TABLE IF NOT EXISTS products ( + -- 货憨憨内部记录 ID,是后续所有写操作的关联键。 + -- 注意不是 Shopee 商品 ID,两者不同,别搞混。 + id TEXT PRIMARY KEY, + -- Shopee 商品 ID,界面上显示为「蝦皮ID」。 + item_id TEXT NOT NULL DEFAULT '', + item_name TEXT NOT NULL DEFAULT '', + main_image TEXT NOT NULL DEFAULT '', + shop_name TEXT NOT NULL DEFAULT '', + platform_shop_id TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT '', + min_sku_price REAL NOT NULL DEFAULT 0, + item_status TEXT NOT NULL DEFAULT '', + -- 货憨憨侧的创建时间,原样保存字符串,不做时区换算。 + created_at TEXT NOT NULL DEFAULT '', + -- 下面几个是本工具自己维护的状态,货憨憨不知道这些。 + -- 取值见 product.go 里的常量。 + video_status TEXT NOT NULL DEFAULT 'pending', + download_status TEXT NOT NULL DEFAULT 'pending', + upload_status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + -- 本地记录的同步时间,方便排查“这条什么时候拉下来的”。 + synced_at TEXT NOT NULL DEFAULT '' + )`, + + // 2. 按店铺查是最常用的操作,加个索引。 + `CREATE INDEX IF NOT EXISTS idx_products_shop + ON products (platform_shop_id)`, + + // 3. 视频表。一个商品可能有多个视频,所以单独一张表。 + `CREATE TABLE IF NOT EXISTS videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- 对应 products.id + product_id TEXT NOT NULL, + -- 淘宝同款商品 ID 和视频原始地址,用于排查和去重 + source_item TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + -- 下载到本地后的文件路径 + local_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + -- 上传到货憨憨素材空间后拿到的地址 + remote_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '' + )`, + + `CREATE INDEX IF NOT EXISTS idx_videos_product + ON videos (product_id)`, + + // 4. 键值表。存货憨憨登录态这类零散数据。 + // 存进来的值可能含 token,读写时不要往日志里打。 + `CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )`, + + // 6. 店铺缓存。只保存界面需要的白名单字段,不保存 token 或手机号。 + `CREATE TABLE IF NOT EXISTS shops ( + platform_shop_id TEXT PRIMARY KEY, + id TEXT NOT NULL DEFAULT '', + shop_name TEXT NOT NULL DEFAULT '', + shop_alias TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + region_name TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )`, + + `ALTER TABLE products ADD COLUMN video_diagnosis TEXT NOT NULL DEFAULT 'ok'`, + + `ALTER TABLE products ADD COLUMN quality_level TEXT NOT NULL DEFAULT ''`, + + `CREATE TABLE IF NOT EXISTS product_diagnoses ( + product_id TEXT NOT NULL, + field TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT '', + solution TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )`, + + `CREATE INDEX IF NOT EXISTS idx_product_diagnoses_product + ON product_diagnoses (product_id)`, + + // 11. 修正历史脏值。 + // + // 开发期间有一版实现用过三态(missing / ok / unknown),那版迁移 + // 已经在部分机器上执行过,把整表刷成了 unknown。当前实现只认 + // missing 和 ok,unknown 会让「缺少视频」和「有视频」都筛不出东西。 + // 这里把任何非法值统一收敛成 ok,下次「下载数据」写入真实诊断。 + `UPDATE products SET video_diagnosis = 'ok' + WHERE video_diagnosis NOT IN ('missing', 'ok')`, +} + +// migrate 把表结构升级到最新。 +// +// 用 schema_version 记录已经执行到第几条,这样已经跑过的语句不会重复跑。 +func (s *Store) migrate() error { + if _, err := s.db.Exec( + `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`, + ); err != nil { + return fmt.Errorf("创建版本表失败:%w", err) + } + + var current int + row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`) + if err := row.Scan(¤t); err != nil { + return fmt.Errorf("读取数据库版本失败:%w", err) + } + + for i := current; i < len(migrations); i++ { + if _, err := s.db.Exec(migrations[i]); err != nil { + // 带上第几条,出问题时能直接定位到 migrations 切片。 + return fmt.Errorf("执行第 %d 条建表语句失败:%w", i+1, err) + } + if _, err := s.db.Exec( + `INSERT INTO schema_version (version) VALUES (?)`, i+1, + ); err != nil { + return fmt.Errorf("记录数据库版本失败:%w", err) + } + } + return nil +} + +// Version 返回当前数据库结构版本,等于已执行的建表语句条数。 +func (s *Store) Version() (int, error) { + var v int + row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`) + if err := row.Scan(&v); err != nil { + return 0, fmt.Errorf("读取数据库版本失败:%w", err) + } + return v, nil +} + +// SetKV 写入一个键值对。已存在就覆盖。 +func (s *Store) SetKV(key, value, now string) error { + _, err := s.db.Exec(` + INSERT INTO kv (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, + updated_at = excluded.updated_at`, + key, value, now) + if err != nil { + return fmt.Errorf("写入 kv 失败:%w", err) + } + return nil +} + +// GetKV 读取一个键值对。键不存在时返回空字符串和 false,不当成错误。 +func (s *Store) GetKV(key string) (string, bool, error) { + var value string + row := s.db.QueryRow(`SELECT value FROM kv WHERE key = ?`, key) + switch err := row.Scan(&value); { + case err == sql.ErrNoRows: + return "", false, nil + case err != nil: + return "", false, fmt.Errorf("读取 kv 失败:%w", err) + default: + return value, true, nil + } +} + +// DefaultPath 返回数据库文件的默认位置。 +// +// 查找顺序和配置文件一致,原因见 internal/config 的 DefaultPath 注释: +// 开发模式下 exe 在 build\bin\ 里,不能只按 exe 目录算。 +func DefaultPath() string { + return config.ResolveDataPath("cmsp.db") +} + diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2250a4c --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,276 @@ +package store + +import ( + "path/filepath" + "testing" +) + +// newTestStore 开一个只存在于内存里的数据库。 +// 测试之间互不影响,也不会在磁盘上留垃圾文件。 +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := Open(":memory:") + if err != nil { + t.Fatalf("打开测试数据库失败:%v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestOpenCreatesAllTables(t *testing.T) { + s := newTestStore(t) + + version, err := s.Version() + if err != nil { + t.Fatalf("读取版本失败:%v", err) + } + if version != len(migrations) { + t.Fatalf("版本应当等于建表语句条数 %d,实际 %d", len(migrations), version) + } + + // 每张表都要真的存在。 + for _, table := range []string{"products", "videos", "kv", "schema_version"} { + var name string + err := s.DB().QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, + table).Scan(&name) + if err != nil { + t.Fatalf("表 %s 不存在:%v", table, err) + } + } +} + +// 程序每次启动都会跑一遍建表语句,重复打开不能出错,也不能重复升版本。 +func TestMigrateIsIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "cmsp.db") + + first, err := Open(path) + if err != nil { + t.Fatalf("第一次打开失败:%v", err) + } + v1, _ := first.Version() + first.Close() + + second, err := Open(path) + if err != nil { + t.Fatalf("第二次打开失败:%v", err) + } + defer second.Close() + v2, _ := second.Version() + + if v1 != v2 { + t.Fatalf("重复打开不应改变版本,第一次 %d,第二次 %d", v1, v2) + } +} + +func sampleProducts() []Product { + return []Product{ + { + ID: "1118154275420983296", ItemID: "40583431295", + ItemName: "短袖連衣裙", MainImage: "https://example.invalid/a.jpg", + ShopName: "集物生活life", PlatformShopID: "406655548", + Currency: "TWD", MinSkuPrice: 859, ItemStatus: "NORMAL", + CreatedAt: "2026-08-10 10:38:51", + }, + { + ID: "1118154275420983297", ItemID: "40583429107", + ItemName: "純棉短袖T恤", MainImage: "https://example.invalid/b.jpg", + ShopName: "集物生活life", PlatformShopID: "406655548", + Currency: "TWD", MinSkuPrice: 299, ItemStatus: "NORMAL", + CreatedAt: "2026-08-11 09:12:00", + }, + { + ID: "1118154275420983298", ItemID: "40571188442", + ItemName: "高腰闊腿牛仔褲", MainImage: "https://example.invalid/c.jpg", + ShopName: "YUNQISHI", PlatformShopID: "411902773", + Currency: "TWD", MinSkuPrice: 645, ItemStatus: "UNLIST", + CreatedAt: "2026-08-12 14:05:00", + }, + } +} + +func TestUpsertAndList(t *testing.T) { + s := newTestStore(t) + + if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入商品失败:%v", err) + } + + page, err := s.ListProducts(ProductQuery{}) + if err != nil { + t.Fatalf("查询失败:%v", err) + } + if page.Total != 3 { + t.Fatalf("应当有 3 条,实际 %d 条", page.Total) + } + // 默认按创建时间倒序,最新的应当排第一。 + if page.Items[0].ItemID != "40571188442" { + t.Fatalf("应当按创建时间倒序,第一条却是 %s", page.Items[0].ItemID) + } + // 新写入的商品,三个处理状态都应当是 pending。 + if page.Items[0].DownloadStatus != DownloadPending { + t.Fatalf("新商品下载状态应当是 pending,实际 %s", page.Items[0].DownloadStatus) + } +} + +// 这是本包最关键的一条规则:重复同步不能把已有的处理进度清零。 +func TestUpsertKeepsLocalStatus(t *testing.T) { + s := newTestStore(t) + items := sampleProducts() + if err := s.UpsertProducts(items, "2026-09-02 10:00:00"); err != nil { + t.Fatalf("首次写入失败:%v", err) + } + + // 假装已经下载并上传完成了。 + id := items[0].ID + if err := s.UpdateProductStatus(id, VideoFound, DownloadDone, UploadDone, ""); err != nil { + t.Fatalf("更新状态失败:%v", err) + } + + // 再同步一次,标题在货憨憨那边被改了。 + items[0].ItemName = "改过的标题" + if err := s.UpsertProducts(items, "2026-09-02 11:00:00"); err != nil { + t.Fatalf("再次写入失败:%v", err) + } + + got, found, err := s.GetProduct(id) + if err != nil || !found { + t.Fatalf("读取商品失败:err=%v found=%v", err, found) + } + if got.ItemName != "改过的标题" { + t.Fatalf("来自货憨憨的字段应当被更新,实际 %q", got.ItemName) + } + if got.DownloadStatus != DownloadDone { + t.Fatalf("下载状态不能被同步覆盖,期望 %s,实际 %s", DownloadDone, got.DownloadStatus) + } + if got.UploadStatus != UploadDone { + t.Fatalf("上传状态不能被同步覆盖,期望 %s,实际 %s", UploadDone, got.UploadStatus) + } +} + +func TestListProductsFilters(t *testing.T) { + s := newTestStore(t) + if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入失败:%v", err) + } + + cases := []struct { + name string + query ProductQuery + want int + }{ + {"按店铺", ProductQuery{PlatformShopID: "406655548"}, 2}, + {"按状态", ProductQuery{ItemStatus: "UNLIST"}, 1}, + {"按单个蝦皮ID", ProductQuery{ItemIDs: "40583431295"}, 1}, + {"按多个蝦皮ID", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2}, + {"中文逗号分隔", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2}, + {"空格分隔", ProductQuery{ItemIDs: "40583431295 40583429107"}, 2}, + {"创建时间起", ProductQuery{CreatedFrom: "2026-08-11"}, 2}, + {"创建时间止", ProductQuery{CreatedTo: "2026-08-11"}, 2}, + {"时间范围", ProductQuery{CreatedFrom: "2026-08-11", CreatedTo: "2026-08-11"}, 1}, + {"条件叠加", ProductQuery{PlatformShopID: "406655548", ItemStatus: "NORMAL"}, 2}, + {"查不到", ProductQuery{PlatformShopID: "不存在的店铺"}, 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + page, err := s.ListProducts(tc.query) + if err != nil { + t.Fatalf("查询失败:%v", err) + } + if page.Total != tc.want { + t.Fatalf("期望 %d 条,实际 %d 条", tc.want, page.Total) + } + }) + } +} + +func TestListProductsPaging(t *testing.T) { + s := newTestStore(t) + if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入失败:%v", err) + } + + page, err := s.ListProducts(ProductQuery{Page: 2, PageSize: 2}) + if err != nil { + t.Fatalf("查询失败:%v", err) + } + if page.Total != 3 { + t.Fatalf("总数应当是 3,实际 %d", page.Total) + } + if len(page.Items) != 1 { + t.Fatalf("第 2 页应当只有 1 条,实际 %d 条", len(page.Items)) + } +} + +// 没有货憨憨记录 ID 的数据要跳过,不能拿 item_id 顶替。 +func TestUpsertSkipsRowsWithoutID(t *testing.T) { + s := newTestStore(t) + err := s.UpsertProducts([]Product{{ItemID: "40583431295"}}, "2026-09-02 10:00:00") + if err != nil { + t.Fatalf("不应报错:%v", err) + } + + n, err := s.CountProducts() + if err != nil { + t.Fatalf("统计失败:%v", err) + } + if n != 0 { + t.Fatalf("缺少 ID 的数据不应写入,实际写入 %d 条", n) + } +} + +func TestGetProductNotFound(t *testing.T) { + s := newTestStore(t) + _, found, err := s.GetProduct("不存在") + if err != nil { + t.Fatalf("查不到不应报错:%v", err) + } + if found { + t.Fatalf("不应当找到") + } +} + +func TestKV(t *testing.T) { + s := newTestStore(t) + + if _, found, err := s.GetKV("token"); err != nil || found { + t.Fatalf("键不存在时应当返回 false,err=%v found=%v", err, found) + } + if err := s.SetKV("token", "第一次的值", "2026-09-02 10:00:00"); err != nil { + t.Fatalf("写入失败:%v", err) + } + if err := s.SetKV("token", "第二次的值", "2026-09-02 11:00:00"); err != nil { + t.Fatalf("覆盖失败:%v", err) + } + + value, found, err := s.GetKV("token") + if err != nil || !found { + t.Fatalf("读取失败:err=%v found=%v", err, found) + } + if value != "第二次的值" { + t.Fatalf("应当读到覆盖后的值,实际 %q", value) + } +} + +func TestSplitIDs(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"", 0}, + {" ", 0}, + {"111", 1}, + {"111,222", 2}, + {"111,222", 2}, + {"111 222 333", 3}, + {"111,,222", 2}, + {"111、222", 2}, + {" 111 , 222 ", 2}, + } + for _, tc := range cases { + if got := splitIDs(tc.in); len(got) != tc.want { + t.Fatalf("输入 %q 期望 %d 个 ID,实际 %d 个:%v", tc.in, tc.want, len(got), got) + } + } +} diff --git a/internal/store/video.go b/internal/store/video.go new file mode 100644 index 0000000..1ce31bd --- /dev/null +++ b/internal/store/video.go @@ -0,0 +1,137 @@ +package store + +import ( + "fmt" + "os" + "strings" +) + +const ( + VideoStatusPending = "pending" + VideoStatusDownloaded = "downloaded" + VideoStatusFailed = "failed" + VideoStatusUploaded = "uploaded" +) + +// Video 对应 videos 表的一行。 +type Video struct { + ID int64 `json:"id"` + ProductID string `json:"productId"` + SourceItem string `json:"sourceItem"` + SourceURL string `json:"sourceUrl"` + LocalPath string `json:"localPath"` + FileSize int64 `json:"fileSize"` + RemoteURL string `json:"remoteUrl"` + Status string `json:"status"` + LastError string `json:"lastError"` + CreatedAt string `json:"createdAt"` +} + +// ReplaceVideos 在一个事务内按商品全量替换视频记录。 +func (s *Store) ReplaceVideos(productID string, items []Video, now string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开启视频替换事务失败:%w", err) + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM videos WHERE product_id = ?`, productID); err != nil { + return fmt.Errorf("清理商品旧视频失败:%w", err) + } + stmt, err := tx.Prepare(`INSERT INTO videos ( + product_id, source_item, source_url, local_path, file_size, + remote_url, status, last_error, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("准备写入视频语句失败:%w", err) + } + defer stmt.Close() + for _, item := range items { + createdAt := item.CreatedAt + if createdAt == "" { + createdAt = now + } + if _, err := stmt.Exec(productID, item.SourceItem, item.SourceURL, + item.LocalPath, item.FileSize, item.RemoteURL, item.Status, + item.LastError, createdAt); err != nil { + return fmt.Errorf("写入商品视频失败:%w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交视频替换事务失败:%w", err) + } + return nil +} + +func (s *Store) ListVideos(productID string) ([]Video, error) { + rows, err := s.db.Query(`SELECT id, product_id, source_item, source_url, + local_path, file_size, remote_url, status, last_error, created_at + FROM videos WHERE product_id = ? ORDER BY id`, productID) + if err != nil { + return nil, fmt.Errorf("查询商品视频失败:%w", err) + } + defer rows.Close() + items := make([]Video, 0) + for rows.Next() { + var item Video + if err := rows.Scan(&item.ID, &item.ProductID, &item.SourceItem, + &item.SourceURL, &item.LocalPath, &item.FileSize, &item.RemoteURL, + &item.Status, &item.LastError, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("读取商品视频行失败:%w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历商品视频失败:%w", err) + } + return items, nil +} + +// FirstUploadableVideo 返回第一个已下载且本地文件仍存在的记录。 +func (s *Store) FirstUploadableVideo(productID string) (Video, bool, error) { + items, err := s.ListVideos(productID) + if err != nil { + return Video{}, false, err + } + for _, item := range items { + if item.Status != VideoStatusDownloaded || strings.TrimSpace(item.LocalPath) == "" { + continue + } + info, err := os.Stat(item.LocalPath) + if err != nil || info.IsDir() { + continue + } + return item, true, nil + } + return Video{}, false, nil +} + +// MarkVideoUploaded 写回成功上传后的素材地址和状态。 +func (s *Store) MarkVideoUploaded(videoID int64, remoteURL string) error { + if _, err := s.db.Exec(`UPDATE videos SET remote_url = ?, status = ?, last_error = '' WHERE id = ?`, remoteURL, VideoStatusUploaded, videoID); err != nil { + return fmt.Errorf("更新视频上传状态失败:%w", err) + } + return nil +} + +// UpsertUploadedVideo 按商品和本地路径补写手工放入目录的视频上传结果。 +// 它绝不能使用 ReplaceVideos:同一商品可能还有其它视频来源记录。 +func (s *Store) UpsertUploadedVideo(productID, localPath string, fileSize int64, remoteURL, now string) error { + result, err := s.db.Exec(`UPDATE videos SET file_size = ?, remote_url = ?, status = ?, last_error = '' + WHERE product_id = ? AND local_path = ?`, fileSize, remoteURL, VideoStatusUploaded, productID, localPath) + if err != nil { + return fmt.Errorf("更新已上传视频记录失败:%w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取已上传视频更新数量失败:%w", err) + } + if changed > 0 { + return nil + } + if _, err := s.db.Exec(`INSERT INTO videos ( + product_id, source_item, source_url, local_path, file_size, remote_url, status, last_error, created_at + ) VALUES (?, '', '', ?, ?, ?, ?, '', ?)`, productID, localPath, fileSize, remoteURL, VideoStatusUploaded, now); err != nil { + return fmt.Errorf("补写已上传视频记录失败:%w", err) + } + return nil +} diff --git a/internal/store/video_test.go b/internal/store/video_test.go new file mode 100644 index 0000000..d73a776 --- /dev/null +++ b/internal/store/video_test.go @@ -0,0 +1,101 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReplaceVideos按商品全量替换(t *testing.T) { + s := newTestStore(t) + first := []Video{ + {SourceItem: "a", SourceURL: "https://example.invalid/a.mp4", Status: VideoStatusPending}, + {SourceItem: "b", SourceURL: "https://example.invalid/b.mp4", Status: VideoStatusDownloaded}, + {SourceItem: "c", SourceURL: "https://example.invalid/c.mp4", Status: VideoStatusFailed}, + } + if err := s.ReplaceVideos("product-1", first, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("首次写入 3 条视频失败:%v", err) + } + second := []Video{{SourceItem: "d", SourceURL: "https://example.invalid/d.mp4", Status: VideoStatusDownloaded}} + if err := s.ReplaceVideos("product-1", second, "2026-09-03 11:00:00"); err != nil { + t.Fatalf("用 1 条视频替换失败:%v", err) + } + got, err := s.ListVideos("product-1") + if err != nil { + t.Fatalf("读取替换后视频失败:%v", err) + } + if len(got) != 1 || got[0].SourceItem != "d" { + t.Fatalf("全量替换后应只剩 d,实际:%+v", got) + } + if got[0].ProductID != "product-1" || got[0].CreatedAt != "2026-09-03 11:00:00" { + t.Fatalf("商品 ID 或创建时间没有按参数写入:%+v", got[0]) + } +} + +func Test取第一个存在的已下载视频并写回上传状态(t *testing.T) { + s := newTestStore(t) + dir := t.TempDir() + missing := filepath.Join(dir, "missing.mp4") + available := filepath.Join(dir, "available.mp4") + if err := os.WriteFile(available, []byte("fake-mp4"), 0o600); err != nil { + t.Fatalf("准备本地视频失败:%v", err) + } + if err := s.ReplaceVideos("商品-1", []Video{ + {LocalPath: missing, Status: VideoStatusDownloaded}, + {LocalPath: available, Status: VideoStatusDownloaded}, + }, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("写入视频失败:%v", err) + } + video, found, err := s.FirstUploadableVideo("商品-1") + if err != nil || !found || video.LocalPath != available { + t.Fatalf("应跳过不存在文件并取第一个可上传视频,video=%+v found=%v err=%v", video, found, err) + } + if err := s.MarkVideoUploaded(video.ID, "https://cos.example.invalid/video.mp4"); err != nil { + t.Fatalf("写回上传状态失败:%v", err) + } + items, err := s.ListVideos("商品-1") + if err != nil { + t.Fatalf("读取视频失败:%v", err) + } + if items[1].Status != VideoStatusUploaded || items[1].RemoteURL != "https://cos.example.invalid/video.mp4" { + t.Fatalf("上传状态或远端地址没有写回:%+v", items[1]) + } +} + +func Test按本地路径补写上传记录不删除其它视频(t *testing.T) { + s := newTestStore(t) + if err := s.ReplaceVideos("商品-1", []Video{ + {LocalPath: "D:/videos/其它.mp4", SourceItem: "淘宝-1", Status: VideoStatusDownloaded}, + {LocalPath: "D:/videos/目标.mp4", SourceItem: "淘宝-2", Status: VideoStatusDownloaded}, + }, "2026-09-03 10:00:00"); err != nil { + t.Fatalf("准备已有视频记录失败:%v", err) + } + if err := s.UpsertUploadedVideo("商品-1", "D:/videos/目标.mp4", 123, "https://cos.example.invalid/target.mp4", "2026-09-03 11:00:00"); err != nil { + t.Fatalf("更新同路径上传记录失败:%v", err) + } + if err := s.UpsertUploadedVideo("商品-1", "D:/videos/新增.mp4", 456, "https://cos.example.invalid/new.mp4", "2026-09-03 11:00:00"); err != nil { + t.Fatalf("新增上传记录失败:%v", err) + } + items, err := s.ListVideos("商品-1") + if err != nil { + t.Fatalf("读取视频记录失败:%v", err) + } + if len(items) != 3 { + t.Fatalf("补写不能删除其它视频或重复同路径,实际 %d 条:%+v", len(items), items) + } + var target, added Video + for _, item := range items { + switch item.LocalPath { + case "D:/videos/目标.mp4": + target = item + case "D:/videos/新增.mp4": + added = item + } + } + if target.Status != VideoStatusUploaded || target.FileSize != 123 || target.RemoteURL == "" || target.SourceItem != "淘宝-2" { + t.Fatalf("同路径记录未正确更新:%+v", target) + } + if added.Status != VideoStatusUploaded || added.FileSize != 456 || added.RemoteURL == "" || added.SourceItem != "" { + t.Fatalf("新增记录未按磁盘来源写入:%+v", added) + } +} diff --git a/tickets/LOCAL-002-huohanhan-contract.md b/tickets/LOCAL-002-huohanhan-contract.md index 1fef4af..7184e60 100644 --- a/tickets/LOCAL-002-huohanhan-contract.md +++ b/tickets/LOCAL-002-huohanhan-contract.md @@ -1,7 +1,7 @@ # 工单:货憨憨接口契约确认 - 编号:LOCAL-002 -- 状态:待验收 +- 状态:已完成 - 来源:用户需求(2026-09-11) ## 目标 @@ -20,5 +20,6 @@ - 在线商品与待发布商品的状态参数; - 单商品详情接口是否存在。 -## 验收 +## 契约结论`r`n`r`n- 登录页面:`GET /login`;客户端配置:`POST /api/butler/client/getCltConf`;验证码:`GET /api/butler/vrify/kaptcha`;登录提交:`POST /api/login`;在线校验:`POST /api/butler/app-version/info`。`r`n- 商品查询:`POST /api/product/shop/getPage`,`application/x-www-form-urlencoded`,使用 `itemIds`、`size`、`current` 等分页字段。`r`n- 认证失败包括 HTTP 401 和 `authentication_required`、`invalid_token`、`invalid_token_expired`,客户端最多重新登录并重放一次。`r`n- cmsp 已有脱敏模拟测试覆盖验证码更换、不可重试错误、认证态持久化和失效处理。`r`n`r`n## 验收 形成脱敏接口契约文档和最小模拟响应测试,不记录账号、密码、Cookie 或 Token。 + diff --git a/tickets/LOCAL-003-huohanhan-client.md b/tickets/LOCAL-003-huohanhan-client.md index b531dbc..8dde7d0 100644 --- a/tickets/LOCAL-003-huohanhan-client.md +++ b/tickets/LOCAL-003-huohanhan-client.md @@ -25,4 +25,5 @@ - `go test ./...` 通过。 ## 实施记录 -当前 GoAdmin 已有查询 API 和落库模型;客户端正式迁移待 LOCAL-002 契约确认后完成。 +已将 cmsp 的 `config`、`logx`、`store`、`huohanhan` 包迁移到 GoAdmin 的 `internal/`,并完成导入路径适配;`go test ./internal/...` 已通过。GoAdmin API 与 GORM 认证态存储的最终接线仍待下一步完成。 +