feat: migrate huohanhan client and document contract
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Package store 负责本地 SQLite 数据库。
|
||||
//
|
||||
// 本项目的所有状态(商品、视频、下载和上传进度、货憨憨登录态)
|
||||
// 都保存在这里,它是唯一的事实来源。不要再用 JSON 文件另存一份,
|
||||
// 那样两边一定会不一致。
|
||||
//
|
||||
// 用的是 modernc.org/sqlite —— 纯 Go 实现的 SQLite,不需要 CGO,
|
||||
// 也就不需要在机器上装 gcc。换成 mattn/go-sqlite3 会让新同事
|
||||
// 第一次编译就卡在编译器上,所以不要换。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go-admin/internal/config"
|
||||
|
||||
_ "modernc.org/sqlite" // 注册名为 "sqlite" 的驱动
|
||||
)
|
||||
|
||||
// Store 是数据库连接。用完要 Close。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open 打开(不存在就创建)数据库文件,并把表结构升级到最新。
|
||||
//
|
||||
// path 传 ":memory:" 可以开一个只存在于内存里的库,单元测试就用这个。
|
||||
func Open(path string) (*Store, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据库目录失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库失败:%w", err)
|
||||
}
|
||||
|
||||
// SQLite 同一时间只允许一个写入者。把连接数限制为 1,
|
||||
// 可以避免多个下载协程同时写时报 "database is locked"。
|
||||
// 本项目数据量很小,这点性能损失可以忽略。
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("连接数据库失败:%w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库。
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB 返回底层连接,只给同一个包里的文件用。
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// migrations 是按顺序执行的建表和改表语句。
|
||||
//
|
||||
// 怎么加一次改动(很重要,改错会丢数据):
|
||||
// 1. 在切片末尾追加一条,绝不要修改或删除已有的任何一条;
|
||||
// 2. 已经发布过的语句改了,老用户的库就升不上来了;
|
||||
// 3. 加完记得在 store_test.go 里补一个测试。
|
||||
//
|
||||
// 每条语句都必须能重复执行而不报错(用 IF NOT EXISTS),
|
||||
// 因为程序每次启动都会把它们全跑一遍。
|
||||
var migrations = []string{
|
||||
// 1. 商品表。一行对应货憨憨里的一个 Shopee 在线商品。
|
||||
`CREATE TABLE IF NOT EXISTS products (
|
||||
-- 货憨憨内部记录 ID,是后续所有写操作的关联键。
|
||||
-- 注意不是 Shopee 商品 ID,两者不同,别搞混。
|
||||
id TEXT PRIMARY KEY,
|
||||
-- Shopee 商品 ID,界面上显示为「蝦皮ID」。
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
item_name TEXT NOT NULL DEFAULT '',
|
||||
main_image TEXT NOT NULL DEFAULT '',
|
||||
shop_name TEXT NOT NULL DEFAULT '',
|
||||
platform_shop_id TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT '',
|
||||
min_sku_price REAL NOT NULL DEFAULT 0,
|
||||
item_status TEXT NOT NULL DEFAULT '',
|
||||
-- 货憨憨侧的创建时间,原样保存字符串,不做时区换算。
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
-- 下面几个是本工具自己维护的状态,货憨憨不知道这些。
|
||||
-- 取值见 product.go 里的常量。
|
||||
video_status TEXT NOT NULL DEFAULT 'pending',
|
||||
download_status TEXT NOT NULL DEFAULT 'pending',
|
||||
upload_status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
-- 本地记录的同步时间,方便排查“这条什么时候拉下来的”。
|
||||
synced_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
|
||||
// 2. 按店铺查是最常用的操作,加个索引。
|
||||
`CREATE INDEX IF NOT EXISTS idx_products_shop
|
||||
ON products (platform_shop_id)`,
|
||||
|
||||
// 3. 视频表。一个商品可能有多个视频,所以单独一张表。
|
||||
`CREATE TABLE IF NOT EXISTS videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- 对应 products.id
|
||||
product_id TEXT NOT NULL,
|
||||
-- 淘宝同款商品 ID 和视频原始地址,用于排查和去重
|
||||
source_item TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
-- 下载到本地后的文件路径
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
-- 上传到货憨憨素材空间后拿到的地址
|
||||
remote_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_videos_product
|
||||
ON videos (product_id)`,
|
||||
|
||||
// 4. 键值表。存货憨憨登录态这类零散数据。
|
||||
// 存进来的值可能含 token,读写时不要往日志里打。
|
||||
`CREATE TABLE IF NOT EXISTS kv (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
|
||||
// 6. 店铺缓存。只保存界面需要的白名单字段,不保存 token 或手机号。
|
||||
`CREATE TABLE IF NOT EXISTS shops (
|
||||
platform_shop_id TEXT PRIMARY KEY,
|
||||
id TEXT NOT NULL DEFAULT '',
|
||||
shop_name TEXT NOT NULL DEFAULT '',
|
||||
shop_alias TEXT NOT NULL DEFAULT '',
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
region_name TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
|
||||
`ALTER TABLE products ADD COLUMN video_diagnosis TEXT NOT NULL DEFAULT 'ok'`,
|
||||
|
||||
`ALTER TABLE products ADD COLUMN quality_level TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS product_diagnoses (
|
||||
product_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
solution TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_product_diagnoses_product
|
||||
ON product_diagnoses (product_id)`,
|
||||
|
||||
// 11. 修正历史脏值。
|
||||
//
|
||||
// 开发期间有一版实现用过三态(missing / ok / unknown),那版迁移
|
||||
// 已经在部分机器上执行过,把整表刷成了 unknown。当前实现只认
|
||||
// missing 和 ok,unknown 会让「缺少视频」和「有视频」都筛不出东西。
|
||||
// 这里把任何非法值统一收敛成 ok,下次「下载数据」写入真实诊断。
|
||||
`UPDATE products SET video_diagnosis = 'ok'
|
||||
WHERE video_diagnosis NOT IN ('missing', 'ok')`,
|
||||
}
|
||||
|
||||
// migrate 把表结构升级到最新。
|
||||
//
|
||||
// 用 schema_version 记录已经执行到第几条,这样已经跑过的语句不会重复跑。
|
||||
func (s *Store) migrate() error {
|
||||
if _, err := s.db.Exec(
|
||||
`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("创建版本表失败:%w", err)
|
||||
}
|
||||
|
||||
var current int
|
||||
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
return fmt.Errorf("读取数据库版本失败:%w", err)
|
||||
}
|
||||
|
||||
for i := current; i < len(migrations); i++ {
|
||||
if _, err := s.db.Exec(migrations[i]); err != nil {
|
||||
// 带上第几条,出问题时能直接定位到 migrations 切片。
|
||||
return fmt.Errorf("执行第 %d 条建表语句失败:%w", i+1, err)
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`INSERT INTO schema_version (version) VALUES (?)`, i+1,
|
||||
); err != nil {
|
||||
return fmt.Errorf("记录数据库版本失败:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version 返回当前数据库结构版本,等于已执行的建表语句条数。
|
||||
func (s *Store) Version() (int, error) {
|
||||
var v int
|
||||
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
|
||||
if err := row.Scan(&v); err != nil {
|
||||
return 0, fmt.Errorf("读取数据库版本失败:%w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetKV 写入一个键值对。已存在就覆盖。
|
||||
func (s *Store) SetKV(key, value, now string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO kv (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
||||
updated_at = excluded.updated_at`,
|
||||
key, value, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入 kv 失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKV 读取一个键值对。键不存在时返回空字符串和 false,不当成错误。
|
||||
func (s *Store) GetKV(key string) (string, bool, error) {
|
||||
var value string
|
||||
row := s.db.QueryRow(`SELECT value FROM kv WHERE key = ?`, key)
|
||||
switch err := row.Scan(&value); {
|
||||
case err == sql.ErrNoRows:
|
||||
return "", false, nil
|
||||
case err != nil:
|
||||
return "", false, fmt.Errorf("读取 kv 失败:%w", err)
|
||||
default:
|
||||
return value, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPath 返回数据库文件的默认位置。
|
||||
//
|
||||
// 查找顺序和配置文件一致,原因见 internal/config 的 DefaultPath 注释:
|
||||
// 开发模式下 exe 在 build\bin\ 里,不能只按 exe 目录算。
|
||||
func DefaultPath() string {
|
||||
return config.ResolveDataPath("cmsp.db")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@
|
||||
- `go test ./...` 通过。
|
||||
|
||||
## 实施记录
|
||||
当前 GoAdmin 已有查询 API 和落库模型;客户端正式迁移待 LOCAL-002 契约确认后完成。
|
||||
已将 cmsp 的 `config`、`logx`、`store`、`huohanhan` 包迁移到 GoAdmin 的 `internal/`,并完成导入路径适配;`go test ./internal/...` 已通过。GoAdmin API 与 GORM 认证态存储的最终接线仍待下一步完成。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user