feat: migrate huohanhan client and document contract

This commit is contained in:
QiuSW
2026-09-11 16:06:45 +08:00
parent 34839eae33
commit bb2a19bfbe
27 changed files with 5176 additions and 4 deletions
+2 -1
View File
@@ -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 (
+472
View File
@@ -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)
}
+328
View File
@@ -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)
}
}
+656
View File
@@ -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
}
+341
View File
@@ -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))
}
}
+133
View File
@@ -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]
}
+169
View File
@@ -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)
}
}
+277
View File
@@ -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
}
+207
View File
@@ -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)
}
}
+60
View File
@@ -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
}
+96
View File
@@ -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)
}
}
}
+169
View File
@@ -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"`
}
+185
View File
@@ -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)
}
}
+164
View File
@@ -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()
}
+182
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+31
View File
@@ -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])
}
}
+396
View File
@@ -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
}
+256
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+110
View File
@@ -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)
}
}
}
+250
View File
@@ -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(&current); 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")
}
+276
View File
@@ -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)
}
}
}
+137
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+3 -2
View File
@@ -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。
+2 -1
View File
@@ -25,4 +25,5 @@
- `go test ./...` 通过。
## 实施记录
当前 GoAdmin 已有查询 API 和落库模型;客户端正式迁移待 LOCAL-002 契约确认后完成。
已将 cmsp 的 `config`、`logx`、`store`、`huohanhan` 包迁移到 GoAdmin 的 `internal/`,并完成导入路径适配;`go test ./internal/...` 已通过。GoAdmin API 与 GORM 认证态存储的最终接线仍待下一步完成。