From 375f20cdfed6de024209b582f595c4f556414f04 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 30 Sep 2026 09:42:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=20erpgo=20=E5=B9=82=E7=AD=89=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=8E=A5=E5=8F=A3=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- app.go | 153 +--- app_upload.go | 234 +++++++ app_upload_test.go | 94 +++ docs/00-project-profile.md | 23 +- docs/02-architecture-and-code-map.md | 34 +- docs/03-business-rules-and-glossary.md | 17 +- docs/04-local-development-and-verification.md | 11 +- docs/06-troubleshooting.md | 11 +- docs/09-product-requirements-overview.md | 23 +- frontend/src/App.vue | 3 - frontend/src/components/Sidebar.vue | 6 - frontend/src/views/ProductListView.vue | 2 +- frontend/src/views/SettingsView.vue | 42 +- internal/config/config.go | 25 +- internal/config/config_test.go | 12 +- internal/erpgo/client.go | 2 +- internal/erpgo/video.go | 247 +++++++ internal/erpgo/video_test.go | 117 ++++ internal/huohanhan/auth.go | 655 ------------------ internal/huohanhan/auth_test.go | 340 --------- internal/huohanhan/client.go | 132 ---- internal/huohanhan/client_test.go | 168 ----- internal/huohanhan/product.go | 276 -------- internal/huohanhan/product_test.go | 206 ------ internal/huohanhan/shop.go | 60 -- internal/huohanhan/shop_test.go | 95 --- internal/huohanhan/upload.go | 169 ----- internal/huohanhan/upload_test.go | 184 ----- internal/store/store.go | 20 +- internal/store/store_test.go | 2 +- internal/store/upload_operation.go | 81 +++ internal/store/upload_operation_test.go | 46 ++ 33 files changed, 942 insertions(+), 2554 deletions(-) create mode 100644 app_upload.go create mode 100644 app_upload_test.go create mode 100644 internal/erpgo/video.go create mode 100644 internal/erpgo/video_test.go delete mode 100644 internal/huohanhan/auth.go delete mode 100644 internal/huohanhan/auth_test.go delete mode 100644 internal/huohanhan/client.go delete mode 100644 internal/huohanhan/client_test.go delete mode 100644 internal/huohanhan/product.go delete mode 100644 internal/huohanhan/product_test.go delete mode 100644 internal/huohanhan/shop.go delete mode 100644 internal/huohanhan/shop_test.go delete mode 100644 internal/huohanhan/upload.go delete mode 100644 internal/huohanhan/upload_test.go create mode 100644 internal/store/upload_operation.go create mode 100644 internal/store/upload_operation_test.go diff --git a/AGENTS.md b/AGENTS.md index 6419da9..199ce62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,7 +261,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 淘宝登录必须由使用者在专属 Chrome 中手动完成。不得代填账号密码,不得绕过验证码、滑块或其他安全验证,不得实现任何形式的自动过验证。 - 淘宝登录态只保存在专属 Chrome Profile 目录。程序不读取、不复制、不落盘、不上传 Cookie;只从当前会话读取 `_m_h5_tk` 用于计算签名,不缓存、不长期复用、不写入日志。 - 日志禁止输出完整 Cookie、`_m_h5_tk` 完整值、账号密码、Authorization、图片完整 Base64 和完整签名原文。 -- 货憨憨账号密码由使用者在「参数设置」页填写并保存在本机;认证 token 保存在本机 SQLite,日志必须脱敏。 +- 商品查询与指定商品视频上传只通过 erpgo 的 `X-API-Key` 接口进行;API Key 仅保存在本机配置,界面掩码显示,日志脱敏。历史 `huohanhan` 配置仅为兼容旧文件而保留,不得用于新请求。 ### 业务边界 @@ -269,7 +269,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 - 淘宝登录失效是全局停止门:必须中断整批任务并保留断点,不得记为单商品失败后继续,不得自动反复请求 MTOP 接口。 - 每次新启动或手动恢复的取视频批次,仅在首次实际访问淘宝时打开「我的淘宝」做服务端深度登录检查;后续商品在当前页面检查 Cookie 名称、登录重定向和访问异常,不再为检查导航到「我的淘宝」。单商品/独立图搜入口各自做首次检查;使用者显式点击登录检查仍可深度检查。 - 首次检查通过不代表后续永久有效:图搜认证失败、安全验证、访问异常和详情登录失效仍须立即全局停止并保留断点,不自动登录、重试或恢复轮询。 -- 货憨憨的写操作(上传素材、批量修改商品、删除素材)必须由调用方显式确认,不得作为查询流程的副作用发生。 +- 经 erpgo 触发的货憨憨写操作必须由调用方显式确认,不得作为查询流程的副作用发生;未决上传操作只能用原幂等键查询,不得自动换键重放。 - 对真实店铺的批量写入、覆盖和删除属于不可逆操作,必须获得用户明确授权后才执行。 - SQLite 是任务状态的唯一事实来源,不得再用 JSON 文件维护第二份任务状态。 - 前端不做安全判断;按钮禁用只是提示,真正的校验必须在 Go 侧执行。前端不得依赖中文错误文本分支,一律按错误码判断。 @@ -280,4 +280,4 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才 ### 未确认事项 -货憨憨视频上传链路的接口契约尚未取得,见[需求总览](docs/09-product-requirements-overview.md)的 Q1 与 Q2。在抓包确认前不得凭猜测实现该链路,也不得在真实店铺上反复试错。 +视频写入使用 erpgo 的 `PUT /api/v1/shopee/products/{shopeeId}/video` 和同键操作查询接口。HTTP 202 仅表示已受理;只有操作状态为 `succeeded` 才能记录完成。真实店铺写入仍须使用者明确指定商品并确认。 diff --git a/app.go b/app.go index b2348f6..819079e 100644 --- a/app.go +++ b/app.go @@ -18,7 +18,6 @@ import ( "cmsp/internal/config" "cmsp/internal/downloader" "cmsp/internal/erpgo" - "cmsp/internal/huohanhan" "cmsp/internal/logx" "cmsp/internal/store" "cmsp/internal/taobao" @@ -53,6 +52,7 @@ type App struct { videoTaskMu sync.Mutex videoTask *task.Runner productSyncMu sync.Mutex + uploadMu sync.Mutex } // NewApp 创建应用对象。真正的初始化在 startup 里做。 @@ -105,9 +105,8 @@ func (a *App) startup(ctx context.Context) { a.log.Info("启动时重置了 %d 个残留的运行中状态", count) } - // 未配置账号时不要刷新。同事第一次安装还没填账号就启动, - // 同步刷新必然失败,一开机就弹「登录失败」,体验很差。 - if cfg.Huohanhan.Account != "" && cfg.Huohanhan.Password != "" { + // 未配置 erpgo 时不发起店铺刷新。 + if cfg.ERPGo.BaseURL != "" && cfg.ERPGo.APIKey != "" { go a.refreshShopsOnStartup() } } @@ -151,21 +150,6 @@ func (a *App) ResetConfig() config.Config { return config.Default() } -// TestHuohanhanLogin 使用当前设置重新登录并在线验证认证状态。 -// 设置页用它确认账号、密码、网址和 OCR 服务可以协同工作。 -func (a *App) TestHuohanhanLogin() error { - if a.db == nil { - return fmt.Errorf("数据库未就绪,请查看运行日志") - } - ctx := a.ctx - if ctx == nil { - ctx = context.Background() - } - manager := huohanhan.NewAuthManager(a.cfg.Huohanhan, a.db, a.log, huohanhan.AuthOptions{}) - _, err := manager.ForceLogin(ctx) - return err -} - // OpenTaobaoLogin 打开专属 Chrome 的淘宝登录页。 // 登录、验证码和安全验证全部由使用者在 Chrome 中手动完成。 func (a *App) OpenTaobaoLogin() error { @@ -974,126 +958,6 @@ func (a *App) DownloadVideos(productIDs []string) error { return a.StartVideoTask(productIDs) } -// UploadVideos 把本次勾选的商品逐个串行上传。每个商品的失败只写回自身状态, -// 不会把批量任务当成淘宝登录失效那样全局停止。 -func (a *App) UploadVideos(productIDs []string) error { - if a.db == nil { - return fmt.Errorf("数据库未就绪,请查看运行日志") - } - ids := cleanProductIDs(productIDs) - if len(ids) == 0 { - return fmt.Errorf("请先勾选要上传的商品") - } - client, err := a.newHuohanhanClient() - if err != nil { - return err - } - ctx := a.appContext() - for _, productID := range ids { - a.uploadOneVideo(ctx, client, productID, len(ids)) - } - return nil -} - -func (a *App) uploadOneVideo(ctx context.Context, client *huohanhan.Client, productID string, selectedCount int) { - product, found, err := a.db.GetProduct(productID) - if err != nil { - a.log.Error("商品 %s 读取上传信息失败:%v", productID, err) - return - } - if !found { - a.log.Error("商品 %s 不存在,跳过上传", productID) - return - } - fail := func(cause error) { - if updateErr := a.db.UpdateProductStatus(product.ID, "", "", store.UploadFailed, cause.Error()); updateErr != nil { - a.log.Error("商品 %s 记录上传失败状态时出错:%v", product.ID, updateErr) - } - a.log.Error("商品 %s 上传失败:%v", product.ID, cause) - } - - check, err := client.CheckShopProductVideo(ctx, product.ID) - if err != nil { - fail(err) - return - } - if check.Confirmed() && selectedCount > 1 { - if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadSkippedExisting, ""); err != nil { - a.log.Error("商品 %s 记录已有视频状态失败:%v", product.ID, err) - return - } - a.log.Info("商品 %s 货憨憨已有视频,批量上传跳过", product.ID) - return - } - - localPath, info, found, err := a.findUploadVideo(product) - if err != nil { - fail(err) - return - } - if !found { - if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadMissingVideo, ""); err != nil { - a.log.Error("商品 %s 记录缺少视频状态失败:%v", product.ID, err) - return - } - a.log.Info("商品 %s 子目录没有 mp4 文件,跳过上传", product.ID) - return - } - if invalidReason, err := a.validateUploadVideo(ctx, localPath, info); err != nil { - fail(err) - return - } else if invalidReason != "" { - if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadInvalidVideo, invalidReason); err != nil { - a.log.Error("商品 %s 记录视频不合规状态失败:%v", product.ID, err) - return - } - a.log.Info("商品 %s 视频不合规,跳过上传:%s", product.ID, invalidReason) - return - } - if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadRunning, ""); err != nil { - a.log.Error("商品 %s 写入上传中状态失败:%v", product.ID, err) - return - } - content, err := os.ReadFile(localPath) - if err != nil { - fail(fmt.Errorf("读取本地视频失败:%w", err)) - return - } - a.log.Info("商品 %s 开始上传视频文件 %s(%d 字节)", product.ID, filepath.Base(localPath), len(content)) - remoteURL, err := client.UploadVideo(ctx, localPath, content) - if err != nil { - fail(err) - return - } - a.log.Info("商品 %s 素材上传完成,COS 主机:%s", product.ID, videoURLHost(remoteURL)) - if err := client.UpdateShopProductVideo(ctx, product.ID, product.PlatformShopID, remoteURL); err != nil { - fail(err) - return - } - check, err = client.CheckShopProductVideo(ctx, product.ID) - if err != nil { - fail(err) - return - } - if reason := strings.TrimSpace(check.FailReason); reason != "" { - fail(fmt.Errorf("货憨憨处理视频失败:%s", reason)) - return - } - if !check.Confirmed() { - fail(fmt.Errorf("回读商品视频失败:货憨憨没有记录到刚设置的视频")) - return - } - if err := a.db.UpsertUploadedVideo(product.ID, localPath, info.Size(), remoteURL, time.Now().Format("2006-01-02 15:04:05")); err != nil { - fail(err) - return - } - if err := a.db.UpdateProductStatus(product.ID, "", "", store.UploadDone, ""); err != nil { - a.log.Error("商品 %s 写入上传完成状态失败:%v", product.ID, err) - return - } - a.log.Success("商品 %s 视频上传并回读确认完成", product.ID) -} - // GetUploadPreview 只扫描本地磁盘并执行本地预检;批量确认框不发远端请求。 // 单商品保留 R5 首版的覆盖警告,因此额外读取一次远端当前状态。 func (a *App) GetUploadPreview(productIDs []string) (UploadPreview, error) { @@ -1137,11 +1001,11 @@ func (a *App) GetUploadPreview(productIDs []string) (UploadPreview, error) { preview.UploadableSize += info.Size() } if len(ids) == 1 { - client, err := a.newHuohanhanClient() + client, err := erpgo.NewClient(a.cfg.ERPGo, nil) if err != nil { return UploadPreview{}, err } - check, err := client.CheckShopProductVideo(a.appContext(), single.ID) + check, err := client.GetCurrentVideo(a.appContext(), single.ItemID) if err != nil { return UploadPreview{}, err } @@ -1274,13 +1138,6 @@ func (a *App) refreshShopsOnStartup() { }) } -// newHuohanhanClient 使用当前配置创建业务客户端。 -// 每次创建可确保设置页刚保存的账号或网址立即生效。 -func (a *App) newHuohanhanClient() (*huohanhan.Client, error) { - manager := huohanhan.NewAuthManager(a.cfg.Huohanhan, a.db, a.log, huohanhan.AuthOptions{}) - return huohanhan.NewClient(a.cfg.Huohanhan, manager, a.log, nil) -} - // videoAccessStop 把淘宝包的认证/访问异常映射为任务全局停止门。 func (a *App) videoAccessStop(err error) error { if errors.Is(err, taobao.ErrAccessBlocked) { diff --git a/app_upload.go b/app_upload.go new file mode 100644 index 0000000..a4a74d2 --- /dev/null +++ b/app_upload.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "time" + + "cmsp/internal/erpgo" + "cmsp/internal/store" +) + +// UploadVideos 只在使用者确认后逐件执行;未决操作只能查询,不能生成新键重放写入。 +func (a *App) UploadVideos(productIDs []string) error { + if a.db == nil { + return fmt.Errorf("数据库未就绪,请查看运行日志") + } + ids := cleanProductIDs(productIDs) + if len(ids) == 0 { + return fmt.Errorf("请先勾选要上传的商品") + } + if !a.uploadMu.TryLock() { + return fmt.Errorf("已有视频上传正在进行") + } + defer a.uploadMu.Unlock() + client, err := erpgo.NewClient(a.cfg.ERPGo, nil) + if err != nil { + return err + } + for _, id := range ids { + a.uploadOneVideo(a.appContext(), client, id, len(ids)) + } + return nil +} + +func (a *App) uploadOneVideo(ctx context.Context, client *erpgo.Client, id string, count int) { + p, found, err := a.db.GetProduct(id) + if err != nil || !found { + a.log.Error("商品 %s 无法读取上传信息", id) + return + } + fail := func(code string) { + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadFailed, code); err != nil { + a.log.Error("商品 %s 更新上传状态失败:%v", p.ID, err) + } + a.log.Error("商品 %s 视频上传失败:%s", p.ID, code) + } + pending := func(code string) { + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadPending, code); err != nil { + a.log.Error("商品 %s 更新上传状态失败:%v", p.ID, err) + } + a.log.Warn("商品 %s 上传结果待确认:%s;再次操作只查询原任务", p.ID, code) + } + previous, exists, err := a.db.GetUploadOperation(p.ID) + if err != nil { + fail("LOCAL_OPERATION_ERROR") + return + } + if exists && previous.Status == "succeeded" && p.UploadStatus != store.UploadDone { + if previous.VideoURL == "" { + pending("INVALID_RESPONSE") + return + } + if err := a.db.UpsertUploadedVideo(p.ID, previous.FilePath, previous.FileSize, previous.VideoURL, time.Now().Format("2006-01-02 15:04:05")); err != nil { + pending("LOCAL_VIDEO_ERROR") + return + } + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadDone, ""); err != nil { + pending("LOCAL_STATUS_ERROR") + } + return + } + if exists && previous.Status != "succeeded" && previous.Status != "failed" { + a.waitVideoOperation(ctx, client, previous, p, pending, fail) + return + } + current, err := client.GetCurrentVideo(ctx, p.ItemID) + if err != nil { + fail(videoCode(err)) + return + } + if current.Confirmed() && count > 1 { + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadSkippedExisting, ""); err != nil { + a.log.Error("商品 %s 更新上传状态失败:%v", p.ID, err) + } + return + } + localPath, info, found, err := a.findUploadVideo(p) + if err != nil { + fail("LOCAL_VIDEO_ERROR") + return + } + if !found { + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadMissingVideo, ""); err != nil { + a.log.Error("商品 %s 更新上传状态失败:%v", p.ID, err) + } + return + } + reason, err := a.validateUploadVideo(ctx, localPath, info) + if err != nil { + fail("LOCAL_VIDEO_CHECK_FAILED") + return + } + if reason != "" { + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadInvalidVideo, reason); err != nil { + a.log.Error("商品 %s 更新上传状态失败:%v", p.ID, err) + } + return + } + file, err := os.Open(localPath) + if err != nil { + fail("LOCAL_VIDEO_ERROR") + return + } + defer file.Close() + hash := sha256.New() + readSize, err := io.Copy(hash, file) + if err != nil || readSize != info.Size() { + fail("LOCAL_VIDEO_ERROR") + return + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + fail("LOCAL_VIDEO_ERROR") + return + } + keyBytes := make([]byte, 24) + if _, err := rand.Read(keyBytes); err != nil { + fail("LOCAL_OPERATION_ERROR") + return + } + op := store.UploadOperation{ProductID: p.ID, ShopeeID: p.ItemID, IdempotencyKey: hex.EncodeToString(keyBytes), FilePath: localPath, FileSHA256: hex.EncodeToString(hash.Sum(nil)), FileSize: info.Size(), Status: "created", UpdatedAt: time.Now().Format(time.RFC3339)} + if err := a.db.BeginUploadOperation(op); err != nil { + pending("LOCAL_OPERATION_ERROR") + return + } + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadRunning, ""); err != nil { + pending("LOCAL_STATUS_ERROR") + return + } + result, putErr := client.PutVideo(ctx, p.ItemID, op.IdempotencyKey, file, op.FileSize) + if putErr == nil { + op.Status, op.OperationID, op.VideoURL, op.ErrorCode = result.Status, result.OperationID, result.VideoURL, result.ErrorCode + op.UpdatedAt = time.Now().Format(time.RFC3339) + if err := a.db.UpdateUploadOperation(op); err != nil { + pending("LOCAL_OPERATION_ERROR") + return + } + } + // HTTP/网络异常后的结果有歧义;查询原键,不再次 PUT。 + if putErr != nil { + a.log.Warn("商品 %s 提交结果未确认:%s", p.ID, videoCode(putErr)) + } + a.waitVideoOperation(ctx, client, op, p, pending, fail) +} + +func (a *App) waitVideoOperation(ctx context.Context, client *erpgo.Client, op store.UploadOperation, p store.Product, pending, fail func(string)) { + for attempt := 0; attempt < 6; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + pending("REQUEST_CANCELLED") + return + case <-time.After(5 * time.Second): + } + } + remote, err := client.GetVideoOperation(ctx, op.ShopeeID, op.IdempotencyKey) + if err != nil { + pending(videoCode(err)) + return + } + op.Status, op.OperationID, op.VideoURL, op.ErrorCode = remote.Status, remote.OperationID, remote.VideoURL, remote.ErrorCode + op.UpdatedAt = time.Now().Format(time.RFC3339) + if err := a.db.UpdateUploadOperation(op); err != nil { + pending("LOCAL_OPERATION_ERROR") + return + } + switch remote.Status { + case "succeeded": + if remote.VideoURL == "" { + pending("INVALID_RESPONSE") + return + } + if err := a.db.UpsertUploadedVideo(p.ID, op.FilePath, op.FileSize, remote.VideoURL, time.Now().Format("2006-01-02 15:04:05")); err != nil { + pending("LOCAL_VIDEO_ERROR") + return + } + if err := a.db.UpdateProductStatus(p.ID, "", "", store.UploadDone, ""); err != nil { + pending("LOCAL_STATUS_ERROR") + return + } + a.log.Success("商品 %s 的 erpgo 视频操作已确认成功", p.ID) + return + case "failed": + code := remote.ErrorCode + if code == "" { + code = "REMOTE_VIDEO_FAILED" + } + if !safeUploadCode(code) { + code = "REMOTE_VIDEO_FAILED" + } + fail(code) + return + case "unknown": + pending("VIDEO_RESULT_UNKNOWN") + return + } + } + pending("VIDEO_PROCESSING") +} + +func videoCode(err error) string { + var videoErr *erpgo.VideoError + if errors.As(err, &videoErr) && safeUploadCode(videoErr.Code) { + return videoErr.Code + } + return "VIDEO_REQUEST_FAILED" +} + +func safeUploadCode(value string) bool { + if len(value) == 0 || len(value) > 64 { + return false + } + for _, r := range value { + if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_') { + return false + } + } + return true +} diff --git a/app_upload_test.go b/app_upload_test.go new file mode 100644 index 0000000..d46a527 --- /dev/null +++ b/app_upload_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "cmsp/internal/downloader" + "cmsp/internal/erpgo" + "cmsp/internal/store" +) + +func TestVideoUploadUsesERPGoAndPersistsOutcome(t *testing.T) { + for _, status := range []string{"succeeded", "unknown"} { + t.Run(status, func(t *testing.T) { + putCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("X-API-Key") != "fictional-key" { + t.Error("missing API key") + } + switch { + case r.Method == "GET" && r.URL.Path == "/api/v1/shopee/products/123/video": + fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-09-30T00:00:00Z","video":[]}}`) + case r.Method == "PUT" && r.URL.Path == "/api/v1/shopee/products/123/video": + putCount++ + if r.Header.Get("Idempotency-Key") == "" { + t.Error("missing idempotency key") + } + w.WriteHeader(202) + fmt.Fprint(w, `{"code":202,"data":{"operationId":"op-1","shopeeId":"123","status":"submitted"}}`) + case r.Method == "GET" && r.URL.Path == "/api/v1/shopee/products/123/video/operation": + fmt.Fprintf(w, `{"code":200,"data":{"operationId":"op-1","shopeeId":"123","status":%q,"videoUrl":"https://example.invalid/video.mp4"}}`, status) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + db, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123", UploadStatus: store.UploadPending}}, "2026-09-30 00:00:00"); err != nil { + t.Fatal(err) + } + a := NewApp() + a.db = db + a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key" + a.cfg.Download.VideoDir = t.TempDir() + a.probe = func(context.Context, string) (downloader.ProbeResult, error) { + return downloader.ProbeResult{Duration: 20, FormatName: "mp4", Width: 640, Height: 480}, nil + } + dir := filepath.Join(a.cfg.Download.VideoDir, "123") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("fictional-mp4"), 0600); err != nil { + t.Fatal(err) + } + client, err := erpgo.NewClient(a.cfg.ERPGo, nil) + if err != nil { + t.Fatal(err) + } + a.uploadOneVideo(context.Background(), client, "internal-1", 1) + p, _, err := db.GetProduct("internal-1") + if err != nil { + t.Fatal(err) + } + want := store.UploadPending + if status == "succeeded" { + want = store.UploadDone + } + if p.UploadStatus != want || putCount != 1 { + t.Fatalf("status=%s put=%d", p.UploadStatus, putCount) + } + if status == "succeeded" { + videos, err := db.ListVideos(p.ID) + if err != nil || len(videos) != 1 || videos[0].Status != store.VideoStatusUploaded { + t.Fatalf("videos=%+v err=%v", videos, err) + } + } else { + a.uploadOneVideo(context.Background(), client, "internal-1", 1) + if putCount != 1 { + t.Fatalf("unknown outcome repeated PUT: %d", putCount) + } + } + }) + } +} diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 49705af..2aa3376 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,21 +2,26 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Project-Profile.- -wiki_revision: 86664a13bbe51f695279c7e0f01271f518e5526d -synchronized_at: 2026-09-28T09:47:37Z +wiki_revision: a6dc3ff73d753db3750b9d2421fcd0661b6bc169 +synchronized_at: 2026-09-30T01:39:55Z # 项目档案 +## erpgo 视频上传现状(2026-09-30) + +cmsp 的商品查询和指定商品视频上传统一通过 erpgo。旧 huohanhan 配置在本机文件中保留兼容,但不用于请求。真实店铺写入须由使用者指定商品并在上传确认框确认。 + + ## erpgo 查询依赖与配置(2026-09-28) -cmsp 仍为单交付桌面应用,没有自身常驻服务。店铺刷新与商品同步依赖已有 erpgo 服务,由其统一使用服务端配置的货憨憨账号查询。视频上传仍通过本机货憨憨配置直连,不迁移上传链路。 +cmsp 仍为单交付桌面应用,没有自身常驻服务。店铺刷新与商品同步依赖已有 erpgo 服务,由其统一使用服务端配置的货憨憨账号查询。指定商品视频上传也通过 erpgo;cmsp 不再直连货憨憨。 | 本机配置 | 用途与边界 | |---|---| | erpgo.base_url | erpgo 服务根地址,包含 http:// 或 https://,不能带用户信息、查询参数或片段 | | erpgo.api_key | 查询凭据,仅保存在本机 config.yaml;界面默认掩码,不进日志、工单、Wiki 或提交 | -| huohanhan.* | 保留现有视频上传及认证配置,不因查询接入而删除 | +| huohanhan.* | 仅兼容旧 config.yaml;界面不显示,也不用于请求 | 有效 Key 可访问 erpgo 配置账号全部 Shopee 店铺,没有独立只读权限体系。旧配置缺少 erpgo 字段仍可读取;未配置查询凭据时保留本地缓存和上传设置,查询明确失败,不回退另一账号。config.yaml 不提交、不打包或共享。 @@ -65,7 +70,7 @@ cmsp 仍为单交付桌面应用,没有自身常驻服务。店铺刷新与商 | 子项目 / 交付单元 | 职责 | 技术栈 | 构建与测试 | 版本与发布方式 | 规则入口 | 共享边界 | |---|---|---|---|---|---|---| -| cmsp 桌面应用 | 商品同步、淘宝以图搜与视频下载、视频上传货憨憨的 GUI 与任务引擎 | Go + Wails v2 + Vue 3 + Naive UI + SQLite | 计划中,见[本地开发与验证](04-local-development-and-verification.md) | 内部分发 Windows 单文件可执行程序,不公开发布 | 根目录 `AGENTS.md` | 查询消费 erpgo 契约;上传仍依赖货憨憨 ERP,淘宝流程仍在本机 | +| cmsp 桌面应用 | 商品同步、淘宝以图搜与视频下载、视频上传货憨憨的 GUI 与任务引擎 | Go + Wails v2 + Vue 3 + Naive UI + SQLite | 计划中,见[本地开发与验证](04-local-development-and-verification.md) | 内部分发 Windows 单文件可执行程序,不公开发布 | 根目录 `AGENTS.md` | 查询消费 erpgo 契约;查询及视频写入均消费 erpgo,淘宝流程仍在本机 | 当前是单交付单元,不拆分子仓库。 @@ -86,7 +91,7 @@ cmsp 仍为单交付桌面应用,没有自身常驻服务。店铺刷新与商 | 项目 | 内容 | |---|---| | 语言与框架 | Go 1.22+、Wails v2、Vue 3、Naive UI | -| 本地状态存储 | SQLite(商品、任务、视频、下载与上传状态、货憨憨认证状态) | +| 本地状态存储 | SQLite(商品、任务、视频、下载与上传状态、上传操作身份) | | 外部依赖程序 | Google Chrome(专属 Profile,用于淘宝登录与 CDP)、`ffprobe`(视频完整性校验) | | 外部服务 | erpgo 店铺与商品查询接口;货憨憨 ERP 上传 HTTP 接口;淘宝 MTOP 以图搜接口;验证码识别为外部 HTTP OCR 服务 | | 支持环境 | Windows 10 及以上;不支持 macOS 与 Linux | @@ -140,11 +145,11 @@ Go 与前端目录在项目骨架建立后补入[架构与代码地图](02-archi | 凭据或配置 | 用途 | 提供方式 | |---|---|---| -| 货憨憨账号与密码 | 登录 ERP 上传视频(商品查询改走 erpgo) | 由使用者在程序「参数设置」页填写,保存在本机配置文件;**不得写入仓库、日志、工单或 Wiki** | -| 货憨憨认证 token 与 cookies | 复用登录态 | 运行时保存在本机 SQLite;日志必须脱敏 | +| erpgo API Key | 商品查询与指定商品视频上传 | 在「参数设置」页填写,仅保存在本机 config.yaml,界面掩码显示;不得写入仓库、日志、工单或 Wiki | +| 历史货憨憨配置与认证数据 | 兼容旧本机文件与数据库 | 不再参与运行时请求,不自动删除;日志必须脱敏 | | 淘宝登录态 | 以图搜与访问商品详情 | 只保存在专属 Chrome Profile 目录,程序不读取、不落盘、不上传 | | Chrome 可执行文件路径、用户数据目录 | 启动专属浏览器 | 「参数设置」页配置,属于路径不属于凭据 | -| OCR 服务地址 | 识别货憨憨登录验证码 | 配置项,默认值记录在代码常量中 | +| 历史 OCR 服务地址 | 兼容旧配置 | 不再参与运行时请求 | | Gitea token | 文档同步 | 只从进程环境或 MCP 安全配置读取,**不得写入仓库** | 专属 Chrome Profile 目录默认为 `%LOCALAPPDATA%\电商视频自动下载工具\淘宝浏览器\默认账号`,沿用已验证 Python 流程的路径,避免使用者重新登录。该目录不得提交、打包或共享。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5f5d32f..0296f10 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,12 +2,17 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Architecture-and-Code-Map.- -wiki_revision: b6f38cfa6fd1abde9bf864e070843eceaa8f1651 -synchronized_at: 2026-09-29T09:17:31Z +wiki_revision: 1b601f7265139262fe9f9e3338517a8d87b97c01 +synchronized_at: 2026-09-30T01:39:56Z # 架构与代码地图 +## erpgo 视频写入路径(2026-09-30) + +`app_upload.go` 以本地 SQLite 的 `video_upload_operations` 保存幂等键、Shopee ID、文件路径/摘要及远端状态。使用者确认后先 GET 当前视频,再 PUT `/api/v1/shopee/products/{shopeeId}/video`,后续同键 GET `/video/operation`。仅 `succeeded` 写入本地完成状态;未决或网络异常保留原键,避免自动重复 PUT。 + + ## 全部店铺商品同步(2026-09-29,#26) DownloadProductData(platformShopID) 返回 internal/erpgo.SyncResult 和 error。指定店铺只查询该店铺;空/空白参数先实时 ListShops 并更新缓存,再按返回顺序串行执行 DownloadAllProducts,每店每页200。internal/erpgo/sync.go 编排查询和逐店事务,不依赖界面当前筛选条件。 @@ -46,7 +51,7 @@ store.ListProducts 接受1—500的 pageSize,空/非正数/大于500仍回退2 ## 当前查询调用链(2026-09-28) -本节描述已实现的店铺/商品查询路径,优先于本页历史目标设计中的直连查询描述。视频上传与淘宝流程沿用原实现。 +本节描述已实现的店铺/商品查询路径,优先于本页历史目标设计中的直连查询描述。视频上传已改走 erpgo,淘宝流程沿用原实现。 ```text 参数设置 → internal/config(erpgo 服务地址、API Key) @@ -63,7 +68,7 @@ DownloadProductData → internal/erpgo.Client.SyncProductData(选店单店, 主要入口是 app.go 的 RefreshShops、DownloadProductData;internal/erpgo/client.go 负责白名单转换、稳定错误码及分页完整性;internal/store/product.go 的 SyncProducts 使用与诊断仓储共用的事务辅助函数,不改变 SQLite 表结构。 -查询只读取货憨憨现有数据,不触发 Shopee 同步;不自动回退直连。每店请求/校验失败不返回该店可落库的部分数据;数据库失败回滚该店商品与诊断。全店模式保留此前已成功店铺。下载、上传、视频记录和断点状态保留,不依据列表删除商品。internal/huohanhan 仍用于现有视频上传,原客户端代码保留用于回归与回退。 +查询只读取货憨憨现有数据,不触发 Shopee 同步;不自动回退直连。每店请求/校验失败不返回该店可落库的部分数据;数据库失败回滚该店商品与诊断。全店模式保留此前已成功店铺。下载、上传、视频记录和断点状态保留,不依据列表删除商品。视频上传通过 internal/erpgo/video.go 完成,不再包含 internal/huohanhan 客户端。 ## 当前实现状态 @@ -97,12 +102,7 @@ cmsp/ ├─ internal/ │ ├─ config/ 参数设置的读取、校验与持久化 │ ├─ store/ SQLite 打开、迁移与仓储;商品、任务、视频、认证状态 -│ ├─ erpgo/ 店铺/商品只读查询、字段转换与分页校验 -│ ├─ huohanhan/ 货憨憨 ERP 客户端 -│ │ ├─ auth.go 登录、验证码 OCR、认证状态复用与失效重登 -│ │ ├─ client.go 统一请求、401 重试 -│ │ ├─ product.go 商品查询与分页 -│ │ └─ upload.go 素材上传与商品视频批量更新 +│ ├─ erpgo/ 店铺/商品查询、视频上传与操作结果查询 │ ├─ taobao/ 淘宝流程,结构见设计规范 │ │ ├─ chrome_manager.go 专属 Chrome 的端口扫描、启动、归属校验、复用与关闭 │ │ ├─ cdp_client.go CDP 连接、导航与在页面上下文执行 JavaScript @@ -117,7 +117,7 @@ cmsp/ └─ frontend/ └─ src/ ├─ views/ProductsView 商品数据页:表格、多选、同步/下载/上传按钮、进度与日志 - └─ views/SettingsView 参数设置页:货憨憨账号、Chrome 路径与用户数据目录、下载目录、并发数 + └─ views/SettingsView 参数设置页:erpgo 地址与 API Key、Chrome 路径、下载目录、并发数 ``` 界面为两个标签页:**商品数据**与**参数设置**。 @@ -137,14 +137,14 @@ cmsp/ → 事件推送进度,前端刷新表格 使用者勾选商品点击「上传视频」 - → internal/task 逐个取出本地视频文件 - → internal/huohanhan/upload 上传素材取得线上地址 - → internal/huohanhan/upload 批量更新商品视频 - → internal/store 更新上传状态 - → 事件推送进度 + → app_upload.go 串行处理,预检本地 mp4 并查询 erpgo 当前视频 + → SQLite 保存幂等键、Shopee ID 与文件指纹 + → internal/erpgo/video.go 以 X-API-Key PUT 单个 multipart video + → 同键 GET /video/operation 查询结果;仅 succeeded 更新本地视频和上传状态 + → 未决操作保留原键和任务状态,再次操作只查询不重传 ``` -上传链路的接口细节尚未确认,见[需求总览](09-product-requirements-overview.md)的未决项。 +视频写入契约见 erpgo 的 Shopee 商品视频 API 文档;HTTP 202 只是已受理,succeeded 为 erpgo 对本次视频的关联回读,不是 Shopee 独立发布验证。 ### 路径二:淘宝以图搜与视频下载 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1313756..f6695d4 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,12 +2,17 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Business-Rules-and-Glossary.- -wiki_revision: 34592c1fc27459e30f215ef053c3d0c603526505 -synchronized_at: 2026-09-29T09:17:36Z +wiki_revision: fa648af69b496386790f9597a1834285a514a323 +synchronized_at: 2026-09-30T01:39:56Z # 业务规则与术语 +## 指定商品视频上传规则(2026-09-30) + +单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。 + + ## 未选择店铺的同步范围(2026-09-29,#26) “下载数据”选店时同步所选店铺,不选店时按 erpgo 当前返回的全部店铺逐店串行同步。下载的是在售商品数据,不会自动下载/上传视频或触发 Shopee 同步;列表的缺视频、下载状态与分页筛选不缩小远端同步范围。 @@ -45,7 +50,7 @@ MTOP 搜索方式、签名与请求参数保持现状。商品原链接以实际 ## erpgo 查询与本地状态规则(2026-09-28) -- 查询由 erpgo 负责货憨憨认证;本机认证状态仍用于原上传链路。SQLite 继续是本地任务、视频和工作流状态的唯一事实来源。 +- 查询和指定商品视频上传均由 erpgo 负责货憨憨认证;本机不再直连。SQLite 继续是本地任务、视频和工作流状态的唯一事实来源。 - 在售商品固定 NORMAL,串行每页 200 条,最多 200 页。total/pages 表示全部在售商品,不是缺少视频数量。 - id 为货憨憨内部商品 ID;itemId 为 Shopee 商品号;platformShopId 为店铺号。按 id 去重,重复记录采用后取得的数据及诊断,不以 itemId 代替内部 ID。 - videoDiagnosis=missing 表示明确诊断“缺少视频”;ok 只表示未出现该诊断,包括无诊断,不证明真实视频存在或已在 Shopee 生效。前端使用稳定枚举,不按中文诊断文本分支。 @@ -115,14 +120,14 @@ MTOP 搜索方式、签名与请求参数保持现状。商品原链接以实际 ### 货憨憨 -16. 认证状态优先复用本地保存的 token;过期或被服务端拒绝时重新登录,同一次请求最多重试一次。 +16. erpgo 认证使用本机保存的 API Key;历史货憨憨 token 不参与请求。 17. 登录验证码由外部 OCR 服务识别,识别错误时更换验证码重试,次数有上限;账号密码错误或账号禁用不重试。 18. 上传素材、批量修改商品、删除素材属于写操作,必须由调用方显式确认,不得作为查询的副作用发生。 19. 商品视频先上传到素材空间取得线上地址,再通过批量更新接口关联到商品。**该链路的接口字段尚未确认,见[需求总览](09-product-requirements-overview.md)。** ### 数据与状态 -20. 商品、任务、视频、下载与上传状态、货憨憨认证状态全部保存在本机 SQLite,是唯一事实来源。 +20. 商品、任务、视频、下载与上传状态及上传操作幂等键保存在本机 SQLite,是唯一事实来源。 21. 任务必须支持中断后从断点继续,不要求重跑已成功的部分。 22. 日志可以记录 Chrome 路径、PID、端口、Profile 路径、缺失的 Cookie 名称、页面标题、接口状态码和商品数量;不得记录完整 Cookie、`_m_h5_tk` 完整值、账号密码、Authorization、图片完整 Base64 和完整签名原文。 @@ -130,7 +135,7 @@ MTOP 搜索方式、签名与请求参数保持现状。商品原链接以实际 以下内容尚未确认,属于假设或空白,不得当作事实使用: -- 货憨憨商品视频上传链路的完整接口契约(素材上传的文件字段名、`batchUpdateShopProductVideo` 的请求体字段)。 +- erpgo 已提供 Shopee 商品视频上传与操作查询契约;实际 Shopee 发布生效时间和独立验证仍待真实业务验收。 - SQLite 表结构与迁移方式。 - 并发下载与上传的默认并发数,以及淘宝风控的实际容忍阈值。 - 视频与商品的匹配规则:一个商品搜到多个同款时,选哪一个的视频,是否需要人工确认。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index f64a85f..cabba9e 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,12 +2,17 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Local-Development-and-Verification.- -wiki_revision: 1b97e699e41e235f146e2f26c703e5246cc9eba5 -synchronized_at: 2026-09-29T09:17:38Z +wiki_revision: 023ef74aa74316cd47ee50757d356634ddd37c41 +synchronized_at: 2026-09-30T01:39:57Z # 本地开发与验证 +## erpgo 视频上传验证(2026-09-30) + +运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、`npm --prefix frontend run build` 和 Wails 构建。模拟 HTTP 和临时 SQLite 覆盖 multipart `video`、`X-API-Key`、幂等键、结果查询、成功落库及 unknown 不重发;真实店铺写入与 Shopee 页面生效需单独验收。 + + ## 全店铺同步验证(2026-09-29,#26) internal/erpgo/sync_test.go 使用模拟HTTP与临时SQLite验证最新店铺列表、串行店铺/分页、每店完整提交、同店ID去重、分页及本地事务失败继续、全局错误/取消停止、空店铺/列表失败、跨店ID冲突,以及诊断、下载/上传状态和已上传视频记录保留。app_sync_test.go 验证单店请求及Go侧重叠同步拒绝。 @@ -50,7 +55,7 @@ go vet ./... ## erpgo 查询配置与验证(2026-09-28) -内部使用者从「参数设置 → erpgo 商品查询」填写服务根地址和 API Key,再点击「保存设置」。Key 默认遮蔽,可主动查看;保存失败保留输入。原「货憨憨 ERP 账号」继续用于视频上传,不能因为查询接入而删掉。 +内部使用者从「参数设置 → erpgo 商品查询与视频上传」填写服务根地址和 API Key,再点击「保存设置」。Key 默认遮蔽,可主动查看;保存失败保留输入。旧 huohanhan 配置只为兼容本机文件保留,界面不再显示,也不用于请求。 本机 YAML 字段示例(不含真实凭据): diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index eaf997d..17cb7f7 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,12 +2,17 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Troubleshooting -wiki_revision: c94bf22f0832f7665e9c866f95ee7f85bdc10bdf -synchronized_at: 2026-09-29T09:18:00Z +wiki_revision: c2123be24a49770df27072d1da69596444f28029 +synchronized_at: 2026-09-30T01:39:57Z # 故障排查 +## 视频上传经 erpgo(2026-09-30) + +先核对参数设置中的 erpgo 地址和 API Key。视频目录使用 Shopee 商品 ID;需 mp4、10—60 秒、宽高不超过 1280、文件不超过 30 MB。操作不确定时 SQLite 会保留原幂等键;重复点击只查询原操作,不会重新提交。`succeeded` 是 erpgo 对本次货憨憨视频关联的回读结果,Shopee 页面仍需人工验收。 + + ## 全店铺同步与部分成功(2026-09-29,#26) 不选店铺点击“下载数据”先获取最新全部店铺,逐店串行查询;耗时随店铺及商品量增加,运行日志显示店铺序号和页数。完成汇总来自实际保存数量,与当前缺视频/未下载筛选的列表总数不同。 @@ -121,7 +126,7 @@ SYNC_IN_PROGRESS 表示已有商品同步,请等待结束;Go侧拒绝重叠 ### 7. 检查上传 -上传链路的接口契约尚未确认,出现问题时先记录请求与响应摘要(不含凭据),在工单中处理,不要在生产店铺上反复试。 +上传经 erpgo 视频接口:HTTP 202 仅表示已受理,待处理或 unknown 时保留 SQLite 操作键;再次操作只查询原键,不自动重传。请记录稳定 errorCode、requestId(不含凭据)并在工单排查。 ## 必须停止的情况 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index b1742f6..c25f526 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -2,12 +2,17 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements-Overview wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Product-Requirements-Overview.- -wiki_revision: decc7537a5b852dbafa74bde6f51feaebedd9785 -synchronized_at: 2026-09-03T01:50:54Z +wiki_revision: 319eabeaa72308751312e13361a177469a81992b +synchronized_at: 2026-09-30T01:39:58Z # 需求总览 +## R5 当前实现状态(2026-09-30) + +cmsp 已改经 erpgo 上传指定 Shopee 商品视频,不再直连货憨憨。上传使用 `X-API-Key`、multipart `video`、持久化幂等键,并以操作查询的 `succeeded` 为本地完成条件。以下原始 MVP 排序与抓包动机保留作历史背景;真实店铺写入及 Shopee 独立生效尚待验收。 + + ## 本页用途 本页是 cmsp 长期需求的索引,回答「现在有哪些需求、各自处在什么状态、详细规则和工单在哪里」。它不复制工单全文,也不保存聊天记录。 @@ -31,10 +36,10 @@ synchronized_at: 2026-09-03T01:50:54Z | R2 | 专属 Chrome 与淘宝登录 | 启动固定 Profile 的专属 Chrome,由使用者手动登录,程序做两层登录检查 | 已确认,未实现 | [业务规则与术语](03-business-rules-and-glossary.md) | 待创建 | | R3 | 淘宝以图搜 | 用商品主图调用 MTOP 以图搜接口,解析同款商品列表 | 已确认,未实现 | [业务规则与术语](03-business-rules-and-glossary.md) | 待创建 | | R4 | 详情视频提取与下载 | 打开同款商品详情页,提取介绍视频地址,下载 MP4 并校验完整性 | 已确认,未实现 | [业务规则与术语](03-business-rules-and-glossary.md) | 待创建 | -| R5 | 视频上传货憨憨 | 把本地视频上传到货憨憨素材空间并关联到对应商品,使 Shopee 可见 | **接口契约未确认,排在最后** | [业务规则与术语](03-business-rules-and-glossary.md) 规则 19 | 待创建 | +| R5 | 视频上传货憨憨 | 经 erpgo 为指定 Shopee 商品上传本地视频;Shopee 独立发布效果待真实验收 | **erpgo 契约已接入,待真实验收** | [业务规则与术语](03-business-rules-and-glossary.md) 规则 19 | 待创建 | | R6 | 桌面界面 | 左侧可折叠导航(商品列表 / 参数设置)+ 两行工具栏 + 商品表格 + 运行日志子窗口 | 原型已确认,未实现 | [架构与代码地图](02-architecture-and-code-map.md) | 待创建 | | R7 | 任务引擎与断点 | 批量任务的队列、并发、进度事件、断点续传与全局停止门 | 已确认,未实现 | [业务规则与术语](03-business-rules-and-glossary.md) | 待创建 | -| R8 | 本地状态存储 | SQLite 保存商品、任务、视频、上传状态与货憨憨认证状态 | **表结构未确认** | [架构与代码地图](02-architecture-and-code-map.md) | 待创建 | +| R8 | 本地状态存储 | SQLite 保存商品、任务、视频、上传状态与上传操作身份 | **表结构已实现** | [架构与代码地图](02-architecture-and-code-map.md) | 待创建 | 状态取值见下方「状态规则」。工单创建后把编号填入表格,不要另建第二份清单。 @@ -46,7 +51,7 @@ synchronized_at: 2026-09-03T01:50:54Z |---|---|---|---| | MVP1 | R1 商品同步(含 R6 界面、R8 存储的基础部分) | 看到指定店铺的商品列表,数据落本地 | 无 | | MVP2 | R2 淘宝登录 + R3 以图搜 + R4 视频下载 | **本地拿到视频文件,人工上传即可使用** | 无 | -| MVP3 | R5 回传货憨憨(含 R7 任务引擎完善) | 全自动,Shopee 商品页可见视频 | Q1、Q2 未解决前不能开始 | +| MVP3 | R5 回传货憨憨(含 R7 任务引擎完善) | 指定商品上传与 Shopee 生效验收 | erpgo 写入契约已具备;Shopee 独立发布效果待验收 | 把 R5 放在最后有三个理由,都是硬约束而不是偏好: @@ -70,8 +75,8 @@ synchronized_at: 2026-09-03T01:50:54Z | 编号 | 问题 | 影响需求 | 当前状态 | |---|---|---|---| -| Q1 | 货憨憨素材上传的文件字段名,以及视频与图片是否走同一接口 | R5 | 待抓包确认 | -| Q2 | `batchUpdateShopProductVideo` 的完整请求体字段与返回结构 | R5 | 只在货憨憨接口文档的接口清单中出现,无字段说明,待抓包确认 | +| Q1 | cmsp 所需视频上传文件字段 | R5 | 已由 erpgo 的 multipart `video` 契约解决;货憨憨内部实现由 erpgo 维护 | +| Q2 | cmsp 所需商品视频绑定结果 | R5 | 已由 erpgo 幂等操作查询契约解决;货憨憨内部字段由 erpgo 维护 | | Q4 | 下载与上传的默认并发数,以及淘宝风控的实际容忍阈值 | R4、R7 | 待实测 | | Q5 | SQLite 表结构与迁移方式 | R8 | 待设计 | @@ -79,9 +84,9 @@ synchronized_at: 2026-09-03T01:50:54Z Q3 已于 2026-09-03 关闭:负责人实测图搜结果准确,决定批量全自动、不做人工确认,但采用的同款商品 ID 必须落库。 -Q1 与 Q2 是整条链路上唯一没有已验证参考实现的环节:仓库外的 Python 项目只实现了图片上传,视频上传接口没有代码;负责人提供的商品页 HAR 中这三个接口也出现 0 次。 +Q1 与 Q2 曾限制 cmsp 直连开发;现改由 erpgo 承担货憨憨内部接口,cmsp 消费其已定义的视频契约。 -**解决时机**:MVP2 交付后,使用者手动上传视频时抓包。在此之前 MVP3 不启动。 +**当前待验收**:以明确指定的商品和文件进行真实业务验收;不得把 erpgo 的 succeeded 等同于 Shopee 独立发布验证。 ## 已确认的界面决定 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c28ff98..8737742 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,8 +18,6 @@ const page = ref('list') const collapsed = ref(false) const showLogs = ref(false) -// 登录状态。R1 和 R2 实现后由后端事件更新,现在先都当未登录。 -const huohanhanOk = ref(false) const taobaoOk = ref(false) @@ -31,7 +29,6 @@ const taobaoOk = ref(false)
-
- - 货憨憨 {{ huohanhanOk ? '已登录' : '未登录' }} -
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index 8a29e94..92f1f98 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -638,7 +638,7 @@ async function uploadSelectedVideo() { checkedIds.value.length > 1 ? ' 已有视频:执行时逐个检查,已有视频的会跳过\n' : '', - '无法查询货憨憨剩余容量(接口未确认)。图片空间已用约 98.7%,剩余约 26 GB,请自行确认后再继续。', + '视频将通过 erpgo 提交。提交后会查询原操作状态;结果未确认时保持待处理,不会自动重复上传。', preview.hasExistingVideo ? h('div', { style: 'color: var(--n-error-color); font-weight: 600; margin-top: 8px' }, '该商品已有视频,上传会覆盖原视频且无法恢复') : null, diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 128e324..7bd8309 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2,7 +2,7 @@ /** * 参数设置页。 * - * 参数卡片:erpgo 查询、货憨憨上传、淘宝专属浏览器、下载与任务、货憨憨图片空间。 + * 参数卡片:erpgo 查询与上传、淘宝专属浏览器、下载与任务。 * * 两条不能违反的规则: * 1. 淘宝那张卡片里没有账号密码输入框,将来也不要加。 @@ -155,9 +155,9 @@ onUnmounted(() => {
- + @@ -166,31 +166,14 @@ onUnmounted(() => { -
保存后查询生效。未配置或服务不可用时,已有商品和任务会保留。
+
保存后生效。视频上传仅在商品列表确认后执行;未配置或服务不可用时,已有商品和任务会保留。
- - + -
- - - - - - -
-
- - - - - - -
@@ -315,23 +298,12 @@ onUnmounted(() => { - - - - - 容量查询还没实现(需求 R5)。上线前必须接上 - product/material/getMaterialSize: - 上传返回「容量不足」时任务要立即停止,不能反复重试。 - -
- 配置保存在本机 config.yaml,密码与 token 不写入仓库、日志和工单 + 配置保存在本机 config.yaml,API Key 不写入仓库、日志和工单
还原默认 diff --git a/internal/config/config.go b/internal/config/config.go index d02a660..d161a53 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,7 +61,7 @@ func (c ERPGoConfig) Validate() error { return nil } -// HuohanhanConfig 是货憨憨 ERP 的连接信息。 +// HuohanhanConfig 仅保留旧 config.yaml 的字段兼容,运行时不再使用。 type HuohanhanConfig struct { // BaseURL 是货憨憨网站地址,不带结尾的斜杠。 BaseURL string `yaml:"base_url" json:"baseUrl"` @@ -186,17 +186,7 @@ func (c Config) Validate() error { if err := c.ERPGo.Validate(); err != nil { return err } - 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 识别服务地址不能为空") - } - + // 旧 huohanhan 配置只为兼容现有 config.yaml 保留,不再用于请求。 t := c.Taobao if strings.TrimSpace(t.ChromePath) == "" { return fmt.Errorf("Chrome 可执行文件路径不能为空") @@ -339,23 +329,22 @@ func (c Config) Render() string { # 整数,读取时直接报错,前导 0 也会丢。 erpgo: - # 店铺刷新和商品同步使用的服务根地址,未填写时只能查看本地缓存。 + # 店铺刷新、商品同步和指定商品视频上传使用的服务根地址。 base_url: %s # API Key 只保存在本机,不得提交、分享或写入日志。 api_key: %s huohanhan: - # 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。 + # 历史配置,保留旧值兼容;程序不再直接请求货憨憨。 base_url: %s - # 登录账号。 + # 历史登录账号,不再用于请求。 account: %s - # [必须] 登录密码,加双引号。 + # 历史登录密码,继续保存在本机以便兼容旧文件。 password: %s - # 登录验证码的自动识别服务。 - # [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。 + # 历史 OCR 服务地址,不再使用。 ocr_url: %s taobao: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2eae40f..f326392 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,9 +60,6 @@ func TestValidateRejectsBadValues(t *testing.T) { 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"}, @@ -89,6 +86,15 @@ func TestValidateRejectsBadValues(t *testing.T) { } } +func TestLegacyHuohanhanFieldsDoNotBlockERPGo(t *testing.T) { + cfg := Default() + cfg.Huohanhan.BaseURL = "" + cfg.Huohanhan.OCRURL = "" + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} + // 配置文件不存在时必须返回默认配置,而不是报错。 // 第一次启动程序就是这个场景。 func TestLoadMissingFileReturnsDefault(t *testing.T) { diff --git a/internal/erpgo/client.go b/internal/erpgo/client.go index 158abf2..6223f89 100644 --- a/internal/erpgo/client.go +++ b/internal/erpgo/client.go @@ -1,4 +1,4 @@ -// Package erpgo 只消费 ERPGo 的货憨憨查询接口,不调用同步或写入接口。 +// Package erpgo 消费 ERPGo 的货憨憨查询与 Shopee 商品视频接口。 package erpgo import ( diff --git a/internal/erpgo/video.go b/internal/erpgo/video.go new file mode 100644 index 0000000..b68154c --- /dev/null +++ b/internal/erpgo/video.go @@ -0,0 +1,247 @@ +package erpgo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// VideoError 只暴露稳定错误码及请求编号,不包含完整 URL、API Key、文件或上游原文。 +type VideoError struct { + Code string `json:"errorCode"` + Stage string `json:"stage,omitempty"` + SubmissionState string `json:"submissionState,omitempty"` + RequestID string `json:"requestId,omitempty"` + Status int `json:"status,omitempty"` +} + +func (e *VideoError) Error() string { + b, _ := json.Marshal(e) + return string(b) +} + +type VideoOperation struct { + OperationID string `json:"operationId"` + ShopeeID string `json:"shopeeId"` + Status string `json:"status"` + Phase string `json:"phase"` + SubmissionState string `json:"submissionState"` + VideoURL string `json:"videoUrl"` + ErrorCode string `json:"errorCode"` +} + +type CurrentVideo struct { + ShopeeID string `json:"shopeeId"` + Source string `json:"source"` + FetchedAt string `json:"fetchedAt"` + Video []json.RawMessage `json:"video"` + TempVideoURL string `json:"tempVideoUrl"` + UploadIDStr string `json:"videoUploadIdStr"` +} + +func (v CurrentVideo) Confirmed() bool { + return len(v.Video) > 0 || v.TempVideoURL != "" || v.UploadIDStr != "" +} + +type videoEnvelope struct { + Code int `json:"code"` + ErrorCode string `json:"errorCode"` + Stage string `json:"stage"` + SubmissionState string `json:"submissionState"` + RequestID string `json:"requestId"` + Data json.RawMessage `json:"data"` +} + +func videoFailure(code string, status int, payload videoEnvelope, key string) *VideoError { + if !knownVideoCode(code) { + code = "INVALID_RESPONSE" + } + stage, submission := payload.Stage, payload.SubmissionState + if !knownVideoStage(stage) { + stage = "" + } + if submission != "not_submitted" && submission != "submitted" && submission != "unknown" { + submission = "" + } + return &VideoError{Code: code, Status: status, + Stage: stage, SubmissionState: submission, + RequestID: safeRequestID(payload.RequestID, key)} +} + +func knownVideoCode(code string) bool { + switch code { + case "INVALID_VIDEO", "INVALID_SHOPEE_ID", "INVALID_IDEMPOTENCY_KEY", "VIDEO_TOO_LARGE", + "PRODUCT_NOT_FOUND", "VIDEO_OPERATION_NOT_FOUND", "IDEMPOTENCY_CONFLICT", + "VIDEO_OPERATION_PENDING", "VIDEO_ALREADY_PROCESSING", "VIDEO_ACCOUNT_CHANGED", + "SERVICE_UNAVAILABLE", "VIDEO_DATABASE_ERROR", "HHH_AUTH_FAILED", "HHH_UPSTREAM_ERROR", + "HHH_UPSTREAM_TIMEOUT", "VIDEO_BIND_REJECTED", "VIDEO_RESULT_UNKNOWN", "VIDEO_PUBLISH_FAILED", + "VIDEO_INTERRUPTED", "API_KEY_INVALID", "INVALID_ARGUMENT": + return true + default: + return false + } +} + +func knownVideoStage(stage string) bool { + switch stage { + case "validate", "lookup", "snapshot", "upload", "bind", "check", "completed": + return true + } + return false +} + +func videoShopID(id string) bool { + if len(id) < 1 || len(id) > 20 { + return false + } + for _, c := range id { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func videoOperationStatus(status string) bool { + switch status { + case "created", "uploaded", "submitted", "processing", "succeeded", "failed", "unknown": + return true + default: + return false + } +} + +func (c *Client) videoRequest(ctx context.Context, method, shopID, suffix, key string, body io.Reader, contentType string, contentLength int64, timeout time.Duration, dest any) error { + if !videoShopID(shopID) { + return &VideoError{Code: "INVALID_SHOPEE_ID"} + } + u := c.baseURL + "/api/v1/shopee/products/" + url.PathEscape(shopID) + "/video" + suffix + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return &VideoError{Code: "INVALID_ARGUMENT"} + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Accept", "application/json") + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if contentLength >= 0 { + req.ContentLength = contentLength + } + if key != "" && method == http.MethodPut { + req.Header.Set("Idempotency-Key", key) + } + client := *c.http + client.Timeout = timeout + resp, err := client.Do(req) + if err != nil { + code := "NETWORK_ERROR" + if errors.Is(err, context.Canceled) { + code = "REQUEST_CANCELLED" + } else if errors.Is(err, context.DeadlineExceeded) { + code = "REQUEST_TIMEOUT" + } + return &VideoError{Code: code} + } + defer resp.Body.Close() + const maxResponse = 1 << 20 + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse+1)) + if err != nil || len(raw) > maxResponse { + return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode} + } + var result videoEnvelope + if json.Unmarshal(raw, &result) != nil || result.Code != resp.StatusCode { + if resp.StatusCode == http.StatusRequestEntityTooLarge { + return &VideoError{Code: "VIDEO_TOO_LARGE", Status: resp.StatusCode} + } + return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode} + } + if resp.StatusCode != http.StatusOK && (method != http.MethodPut || resp.StatusCode != http.StatusAccepted) { + if result.ErrorCode == "" { + return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode} + } + return videoFailure(result.ErrorCode, resp.StatusCode, result, c.apiKey) + } + if len(result.Data) == 0 || string(result.Data) == "null" || json.Unmarshal(result.Data, dest) != nil { + return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode} + } + return nil +} + +func (c *Client) GetCurrentVideo(ctx context.Context, shopID string) (CurrentVideo, error) { + var current CurrentVideo + err := c.videoRequest(ctx, http.MethodGet, shopID, "", "", nil, "", -1, 30*time.Second, ¤t) + if err != nil { + return CurrentVideo{}, err + } + if current.ShopeeID != shopID || current.Source != "huohanhan" || current.Video == nil { + return CurrentVideo{}, &VideoError{Code: "INVALID_RESPONSE"} + } + if _, err := time.Parse(time.RFC3339Nano, current.FetchedAt); err != nil { + return CurrentVideo{}, &VideoError{Code: "INVALID_RESPONSE"} + } + return current, nil +} + +func (c *Client) GetVideoOperation(ctx context.Context, shopID, key string) (VideoOperation, error) { + var op VideoOperation + suffix := "/operation?idempotencyKey=" + url.QueryEscape(key) + err := c.videoRequest(ctx, http.MethodGet, shopID, suffix, "", nil, "", -1, 30*time.Second, &op) + if err != nil { + return VideoOperation{}, err + } + if op.ShopeeID != shopID || !videoOperationStatus(op.Status) { + return VideoOperation{}, &VideoError{Code: "INVALID_RESPONSE"} + } + if op.ErrorCode != "" && !knownVideoCode(op.ErrorCode) { + op.ErrorCode = "REMOTE_VIDEO_FAILED" + } + return op, nil +} + +// PutVideo 流式上传单个本地文件。调用方必须先持久化幂等键并完成内容预检。 +func (c *Client) PutVideo(ctx context.Context, shopID, key string, file *os.File, size int64) (VideoOperation, error) { + if file == nil || size <= 0 || size > 30*1024*1024 || !videoShopID(shopID) { + return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"} + } + if len(key) < 1 || len(key) > 128 || strings.IndexFunc(key, func(r rune) bool { return r < 33 || r > 126 }) >= 0 { + return VideoOperation{}, &VideoError{Code: "INVALID_IDEMPOTENCY_KEY"} + } + var envelope bytes.Buffer + writer := multipart.NewWriter(&envelope) + header := textproto.MIMEHeader{} + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="video"; filename=%q`, filepath.Base(file.Name()))) + header.Set("Content-Type", "video/mp4") + if _, err := writer.CreatePart(header); err != nil { + return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"} + } + prefixSize := envelope.Len() + if err := writer.Close(); err != nil { + return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"} + } + encoded := envelope.Bytes() + body := io.MultiReader(bytes.NewReader(encoded[:prefixSize]), io.LimitReader(file, size), bytes.NewReader(encoded[prefixSize:])) + var op VideoOperation + err := c.videoRequest(ctx, http.MethodPut, shopID, "", key, body, writer.FormDataContentType(), int64(len(encoded))+size, 6*time.Minute, &op) + if err != nil { + return VideoOperation{}, err + } + if op.ShopeeID != shopID || !videoOperationStatus(op.Status) { + return VideoOperation{}, &VideoError{Code: "INVALID_RESPONSE"} + } + if op.ErrorCode != "" && !knownVideoCode(op.ErrorCode) { + op.ErrorCode = "REMOTE_VIDEO_FAILED" + } + return op, nil +} diff --git a/internal/erpgo/video_test.go b/internal/erpgo/video_test.go new file mode 100644 index 0000000..f3c0ced --- /dev/null +++ b/internal/erpgo/video_test.go @@ -0,0 +1,117 @@ +package erpgo + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "cmsp/internal/config" +) + +func TestVideoEndpointsAndSafeFailure(t *testing.T) { + const key = "fictional-video-key" + putCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != key { + t.Error("missing API key") + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/shopee/products/123/video": + io.WriteString(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-09-30T00:00:00Z","video":[]}}`) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/shopee/products/123/video": + putCount++ + if r.Header.Get("Idempotency-Key") != "operation-1" { + t.Error("wrong idempotency key") + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Error(err) + return + } + f, h, err := r.FormFile("video") + if err != nil || h.Header.Get("Content-Type") != "video/mp4" { + t.Error("wrong multipart video") + return + } + b, _ := io.ReadAll(f) + if string(b) != "fictional mp4" { + t.Error("wrong upload bytes") + } + w.WriteHeader(202) + io.WriteString(w, `{"code":202,"data":{"operationId":"op-1","shopeeId":"123","status":"submitted","videoUrl":"https://example.invalid/new.mp4"}}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/shopee/products/123/video/operation": + if r.URL.Query().Get("idempotencyKey") != "operation-1" { + t.Error("wrong query key") + } + io.WriteString(w, `{"code":200,"data":{"operationId":"op-1","shopeeId":"123","status":"succeeded","videoUrl":"https://example.invalid/new.mp4"}}`) + default: + w.WriteHeader(400) + io.WriteString(w, `{"code":400,"errorCode":"BAD/fictional-secret","stage":"bad/url","requestId":"safe-id"}`) + } + })) + defer server.Close() + c, err := NewClient(config.ERPGoConfig{BaseURL: server.URL, APIKey: key}, nil) + if err != nil { + t.Fatal(err) + } + current, err := c.GetCurrentVideo(context.Background(), "123") + if err != nil || current.Confirmed() { + t.Fatalf("current: %+v %v", current, err) + } + path := filepath.Join(t.TempDir(), "clip.mp4") + if err := os.WriteFile(path, []byte("fictional mp4"), 0600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + op, err := c.PutVideo(context.Background(), "123", "operation-1", f, 13) + if err != nil || op.Status != "submitted" || putCount != 1 { + t.Fatalf("put: %+v %v count=%d", op, err, putCount) + } + op, err = c.GetVideoOperation(context.Background(), "123", "operation-1") + if err != nil || op.Status != "succeeded" { + t.Fatalf("operation: %+v %v", op, err) + } + _, err = c.GetCurrentVideo(context.Background(), "124") + if err == nil || !strings.Contains(err.Error(), "INVALID_RESPONSE") || strings.Contains(err.Error(), "fictional-secret") { + t.Fatalf("unsafe error: %v", err) + } +} + +func TestVideoEndpointErrorCodes(t *testing.T) { + for _, tc := range []struct { + status int + code string + }{ + {400, "INVALID_VIDEO"}, {401, "API_KEY_INVALID"}, {404, "PRODUCT_NOT_FOUND"}, + {409, "VIDEO_OPERATION_PENDING"}, {413, "VIDEO_TOO_LARGE"}, + } { + t.Run(tc.code, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, fmt.Sprintf(`{"code":%d,"errorCode":%q,"requestId":"fictional-request"}`, tc.status, tc.code)) + })) + defer server.Close() + c, err := NewClient(config.ERPGoConfig{BaseURL: server.URL, APIKey: "fictional-key"}, nil) + if err != nil { + t.Fatal(err) + } + _, err = c.GetCurrentVideo(context.Background(), "123") + var apiErr *VideoError + if !errors.As(err, &apiErr) || apiErr.Code != tc.code || apiErr.Status != tc.status { + t.Fatalf("error=%v", err) + } + }) + } +} diff --git a/internal/huohanhan/auth.go b/internal/huohanhan/auth.go deleted file mode 100644 index 8c358a9..0000000 --- a/internal/huohanhan/auth.go +++ /dev/null @@ -1,655 +0,0 @@ -// 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" - - "cmsp/internal/config" - "cmsp/internal/logx" - "cmsp/internal/store" -) - -const ( - authStateKey = "huohanhan.auth" - defaultRequestTimeout = 30 * time.Second - defaultCaptchaAttempts = 3 - defaultAuthExpirySkew = 5 * time.Minute - maximumResponseBodyBytes = 8 << 20 -) - -var ( - cidPattern = regexp.MustCompile(`["']?CID["']?\s*:\s*["']([^"']+)["']`) - cstPattern = regexp.MustCompile(`["']?CST["']?\s*:\s*["']([^"']+)["']`) - captchaPattern = regexp.MustCompile(`^[A-Za-z0-9]{4,8}$`) -) - -// AuthOptions 是登录流程中需要调整的运行参数。 -// -// 零值会使用安全默认值。HTTPClient 和 Now 主要供离线测试注入; -// MaxCaptchaAttempts 让上层配置接入后无需修改登录逻辑。 -type AuthOptions struct { - HTTPClient *http.Client - RequestTimeout time.Duration - MaxCaptchaAttempts int - ExpirySkew time.Duration - Now func() time.Time -} - -// AuthState 是一次登录后需要复用的完整认证状态。 -// -// 该结构会序列化到 SQLite,字段可能含敏感内容,禁止整体写入日志。 -type AuthState struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - LoginTime int64 `json:"login_time"` - Cookies map[string]string `json:"cookies"` -} - -// AuthorizationValue 返回业务请求使用的 Authorization 值。 -func (s AuthState) AuthorizationValue() string { - tokenType := strings.TrimSpace(s.TokenType) - if tokenType == "" { - tokenType = "Bearer" - } - return tokenType + " " + s.AccessToken -} - -// Expired 判断 token 是否已经过期或进入安全提前量。 -func (s AuthState) Expired(now time.Time, skew time.Duration) bool { - if s.ExpiresIn <= 0 || s.LoginTime <= 0 { - return true - } - expiresAt := time.UnixMilli(s.LoginTime).Add(time.Duration(s.ExpiresIn) * time.Second) - return !now.Add(skew).Before(expiresAt) -} - -// AuthManager 串行管理内存与 SQLite 中的认证状态。 -type AuthManager struct { - cfg config.HuohanhanConfig - db *store.Store - log *logx.Logger - httpClient *http.Client - attempts int - expirySkew time.Duration - now func() time.Time - - mu sync.Mutex - current *AuthState -} - -// NewAuthManager 创建认证管理器。opts 的零值会补成默认配置。 -func NewAuthManager(cfg config.HuohanhanConfig, db *store.Store, logger *logx.Logger, opts AuthOptions) *AuthManager { - timeout := opts.RequestTimeout - if timeout <= 0 { - timeout = defaultRequestTimeout - } - client := opts.HTTPClient - if client == nil { - client = &http.Client{Timeout: timeout} - } - attempts := opts.MaxCaptchaAttempts - if attempts <= 0 { - attempts = defaultCaptchaAttempts - } - skew := opts.ExpirySkew - if skew <= 0 { - skew = defaultAuthExpirySkew - } - now := opts.Now - if now == nil { - now = time.Now - } - if logger == nil { - logger = logx.New(1000) - } - return &AuthManager{ - cfg: cfg, - db: db, - log: logger, - httpClient: client, - attempts: attempts, - expirySkew: skew, - now: now, - } -} - -// GetValidAuth 按“内存、SQLite、重新登录”的顺序取得有效认证。 -// -// SQLite 中的状态在进程首次使用时必须在线验证,不能只相信本地时间。 -func (m *AuthManager) GetValidAuth(ctx context.Context) (AuthState, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.current != nil && !m.current.Expired(m.now(), m.expirySkew) { - return cloneAuthState(*m.current), nil - } - - stored, found, err := m.load() - if err != nil { - return AuthState{}, err - } - if found { - valid, validateErr := m.validate(ctx, stored) - if validateErr != nil { - return AuthState{}, validateErr - } - if valid { - m.current = &stored - m.log.Info("已复用本机保存的货憨憨认证状态") - return cloneAuthState(stored), nil - } - if err := m.clearLocked(); err != nil { - return AuthState{}, err - } - } - - return m.loginAndSaveLocked(ctx) -} - -// ForceLogin 忽略旧状态并重新登录,供测试连接和认证失败恢复使用。 -func (m *AuthManager) ForceLogin(ctx context.Context) (AuthState, error) { - m.mu.Lock() - defer m.mu.Unlock() - // 显式测试连接或服务端已拒绝认证时,不能让失败的重新登录继续留下旧 token。 - if err := m.clearLocked(); err != nil { - return AuthState{}, err - } - return m.loginAndSaveLocked(ctx) -} - -// Invalidate 清除内存与 SQLite 中已被服务端拒绝的认证状态。 -func (m *AuthManager) Invalidate() error { - m.mu.Lock() - defer m.mu.Unlock() - return m.clearLocked() -} - -func (m *AuthManager) loginAndSaveLocked(ctx context.Context) (AuthState, error) { - state, err := m.login(ctx) - if err != nil { - return AuthState{}, err - } - if err := m.save(state); err != nil { - return AuthState{}, err - } - m.current = &state - m.log.Success("货憨憨登录成功") - return cloneAuthState(state), nil -} - -func (m *AuthManager) login(ctx context.Context) (AuthState, error) { - if m.db == nil { - return AuthState{}, fmt.Errorf("本地数据库未就绪") - } - baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/")) - if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { - return AuthState{}, fmt.Errorf("货憨憨网址不正确") - } - if strings.TrimSpace(m.cfg.Account) == "" || m.cfg.Password == "" { - return AuthState{}, fmt.Errorf("请先填写货憨憨账号和密码") - } - if strings.TrimSpace(m.cfg.OCRURL) == "" { - return AuthState{}, fmt.Errorf("OCR 识别服务地址不能为空") - } - - jar, err := cookiejar.New(nil) - if err != nil { - return AuthState{}, fmt.Errorf("创建登录会话失败:%w", err) - } - session := *m.httpClient - session.Jar = jar - - cid, cst, clientID, err := m.loadLoginParameters(ctx, &session, baseURL) - if err != nil { - return AuthState{}, err - } - - for attempt := 1; attempt <= m.attempts; attempt++ { - m.log.Info("正在识别货憨憨登录验证码,第 %d/%d 次", attempt, m.attempts) - captchaKey, image, err := m.downloadCaptcha(ctx, &session, baseURL) - if err != nil { - return AuthState{}, err - } - captchaCode, err := m.recognizeCaptcha(ctx, image) - if err != nil { - return AuthState{}, err - } - - state, code, message, err := m.submitLogin( - ctx, &session, baseURL, cid, cst, clientID, captchaCode, captchaKey, - ) - if err != nil { - return AuthState{}, err - } - if state.AccessToken != "" { - valid, validateErr := m.validateWithClient(ctx, &session, baseURL, state) - if validateErr != nil { - return AuthState{}, validateErr - } - if !valid { - return AuthState{}, fmt.Errorf("登录成功,但服务端未通过认证校验") - } - return state, nil - } - - switch code { - case "invalid_verify_code": - m.log.Warn("验证码不正确,准备更换验证码") - continue - case "invalid_credentials": - return AuthState{}, fmt.Errorf("账号或密码错误") - case "disabled_credentials": - return AuthState{}, fmt.Errorf("账号已被禁用") - default: - if strings.TrimSpace(code) == "" { - code = "未知错误" - } - if strings.TrimSpace(message) == "" { - message = "登录失败" - } - return AuthState{}, fmt.Errorf("登录失败:%s,%s", code, message) - } - } - return AuthState{}, fmt.Errorf("连续多次验证码识别失败,请稍后重试") -} - -func (m *AuthManager) loadLoginParameters(ctx context.Context, client *http.Client, baseURL *url.URL) (string, string, string, error) { - page, err := m.do(ctx, client, http.MethodGet, baseURL.String()+"/login", nil, "", "", "") - if err != nil { - return "", "", "", fmt.Errorf("读取货憨憨登录页失败:%w", err) - } - cid := findRuntimeValue(page, cidPattern) - if cid == "" { - return "", "", "", fmt.Errorf("登录页中没有找到 CID,网页可能已经改版") - } - cst := findRuntimeValue(page, cstPattern) - if cst == "" { - return "", "", "", fmt.Errorf("登录页中没有找到 CST,网页可能已经改版") - } - - form := url.Values{"domain": {baseURL.Hostname()}} - body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "butler/client/getCltConf"), - []byte(form.Encode()), "application/x-www-form-urlencoded", "", "") - if err != nil { - return "", "", "", fmt.Errorf("读取货憨憨网站配置失败:%w", err) - } - var payload struct { - ID json.RawMessage `json:"id"` - } - if err := json.Unmarshal(body, &payload); err != nil { - return "", "", "", fmt.Errorf("货憨憨网站配置返回的不是有效 JSON") - } - clientID := rawString(payload.ID) - if clientID == "" { - return "", "", "", fmt.Errorf("货憨憨网站配置中没有 clientId") - } - return cid, cst, clientID, nil -} - -func (m *AuthManager) downloadCaptcha(ctx context.Context, client *http.Client, baseURL *url.URL) (string, []byte, error) { - key, err := newUUID() - if err != nil { - return "", nil, fmt.Errorf("生成验证码标识失败:%w", err) - } - endpoint := apiURL(baseURL, "butler/vrify/kaptcha") + "?" + url.Values{"kaptchaKey": {key}}.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return "", nil, fmt.Errorf("创建验证码请求失败:%w", err) - } - applyCommonHeaders(req) - resp, err := client.Do(req) - if err != nil { - return "", nil, fmt.Errorf("下载验证码失败:%w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", nil, fmt.Errorf("下载验证码失败:HTTP %d", resp.StatusCode) - } - if !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "image") { - return "", nil, fmt.Errorf("验证码接口没有返回图片") - } - image, err := readBody(resp.Body) - if err != nil { - return "", nil, fmt.Errorf("读取验证码图片失败:%w", err) - } - return key, image, nil -} - -func (m *AuthManager) recognizeCaptcha(ctx context.Context, image []byte) (string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - header := make(textproto.MIMEHeader) - header.Set("Content-Disposition", `form-data; name="file"; filename="captcha.jpg"`) - header.Set("Content-Type", "image/jpeg") - part, err := writer.CreatePart(header) - if err != nil { - return "", fmt.Errorf("准备验证码图片失败:%w", err) - } - if _, err := part.Write(image); err != nil { - return "", fmt.Errorf("准备验证码图片失败:%w", err) - } - if err := writer.Close(); err != nil { - return "", fmt.Errorf("准备验证码图片失败:%w", err) - } - - payload, err := m.do(ctx, m.httpClient, http.MethodPost, m.cfg.OCRURL, - body.Bytes(), writer.FormDataContentType(), "", "") - if err != nil { - return "", fmt.Errorf("OCR 识别失败:%w", err) - } - var decoded any - if json.Unmarshal(payload, &decoded) != nil { - decoded = string(payload) - } - code := findCaptchaText(decoded) - if code == "" { - return "", fmt.Errorf("OCR 返回成功,但没有找到 4~8 位验证码") - } - return code, nil -} - -func (m *AuthManager) submitLogin(ctx context.Context, client *http.Client, baseURL *url.URL, cid, cst, clientID, captchaCode, captchaKey string) (AuthState, string, string, error) { - form := url.Values{ - "username": {m.cfg.Account}, - "password": {m.cfg.Password}, - "clientId": {clientID}, - "kaptchaCode": {captchaCode}, - "kaptchaKey": {captchaKey}, - } - body, err := m.do(ctx, client, http.MethodPost, apiURL(baseURL, "login"), - []byte(form.Encode()), "application/x-www-form-urlencoded", cid, cst) - if err != nil { - return AuthState{}, "", "", fmt.Errorf("提交货憨憨登录失败:%w", err) - } - var payload map[string]any - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - if err := decoder.Decode(&payload); err != nil { - return AuthState{}, "", "", fmt.Errorf("货憨憨登录接口返回的不是有效 JSON") - } - token := stringValue(payload["access_token"]) - if token == "" { - return AuthState{}, stringValue(payload["code"]), stringValue(payload["message"]), nil - } - - state := AuthState{ - AccessToken: token, - TokenType: stringValue(payload["token_type"]), - ExpiresIn: int64Value(payload["expires_in"]), - LoginTime: int64Value(payload["login_time"]), - Cookies: make(map[string]string), - } - if state.TokenType == "" { - state.TokenType = "Bearer" - } - if state.LoginTime <= 0 { - state.LoginTime = m.now().UnixMilli() - } - for _, cookie := range client.Jar.Cookies(baseURL) { - state.Cookies[cookie.Name] = cookie.Value - } - return state, "", "", nil -} - -func (m *AuthManager) validate(ctx context.Context, state AuthState) (bool, error) { - baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(m.cfg.BaseURL), "/")) - if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { - return false, fmt.Errorf("货憨憨网址不正确") - } - return m.validateWithClient(ctx, m.httpClient, baseURL, state) -} - -func (m *AuthManager) validateWithClient(ctx context.Context, client *http.Client, baseURL *url.URL, state AuthState) (bool, error) { - form := url.Values{"appCode": {"HHH"}} - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(baseURL, "butler/app-version/info"), strings.NewReader(form.Encode())) - if err != nil { - return false, fmt.Errorf("创建认证校验请求失败:%w", err) - } - applyCommonHeaders(req) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", state.AuthorizationValue()) - for name, value := range state.Cookies { - req.AddCookie(&http.Cookie{Name: name, Value: value}) - } - resp, err := client.Do(req) - if err != nil { - return false, fmt.Errorf("校验货憨憨认证失败:%w", err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - return false, nil - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return false, fmt.Errorf("校验货憨憨认证失败:HTTP %d", resp.StatusCode) - } - body, err := readBody(resp.Body) - if err != nil { - return false, fmt.Errorf("读取认证校验结果失败:%w", err) - } - var payload struct { - AppCode string `json:"appCode"` - } - if json.Unmarshal(body, &payload) != nil { - return false, nil - } - return payload.AppCode == "HHH", nil -} - -func (m *AuthManager) do(ctx context.Context, client *http.Client, method, endpoint string, body []byte, contentType, basicUser, basicPassword string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - applyCommonHeaders(req) - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if basicUser != "" || basicPassword != "" { - req.SetBasicAuth(basicUser, basicPassword) - } - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("HTTP %d", resp.StatusCode) - } - return readBody(resp.Body) -} - -func (m *AuthManager) save(state AuthState) error { - raw, err := json.Marshal(state) - if err != nil { - return fmt.Errorf("编码货憨憨认证状态失败:%w", err) - } - if err := m.db.SetKV(authStateKey, string(raw), m.now().Format(time.RFC3339)); err != nil { - return fmt.Errorf("保存货憨憨认证状态失败:%w", err) - } - return nil -} - -func (m *AuthManager) load() (AuthState, bool, error) { - if m.db == nil { - return AuthState{}, false, fmt.Errorf("本地数据库未就绪") - } - raw, found, err := m.db.GetKV(authStateKey) - if err != nil { - return AuthState{}, false, fmt.Errorf("读取货憨憨认证状态失败:%w", err) - } - if !found || strings.TrimSpace(raw) == "" { - return AuthState{}, false, nil - } - var state AuthState - if err := json.Unmarshal([]byte(raw), &state); err != nil || state.AccessToken == "" { - m.log.Warn("本机保存的货憨憨认证状态无法读取,将重新登录") - return AuthState{}, false, nil - } - if state.Cookies == nil { - state.Cookies = make(map[string]string) - } - return state, true, nil -} - -func (m *AuthManager) clearLocked() error { - m.current = nil - if m.db == nil { - return fmt.Errorf("本地数据库未就绪") - } - if err := m.db.SetKV(authStateKey, "", m.now().Format(time.RFC3339)); err != nil { - return fmt.Errorf("清除货憨憨认证状态失败:%w", err) - } - return nil -} - -func cloneAuthState(state AuthState) AuthState { - cloned := state - cloned.Cookies = make(map[string]string, len(state.Cookies)) - for name, value := range state.Cookies { - cloned.Cookies[name] = value - } - return cloned -} - -func findRuntimeValue(page []byte, pattern *regexp.Regexp) string { - match := pattern.FindSubmatch(page) - if len(match) != 2 { - return "" - } - return string(match[1]) -} - -func findCaptchaText(value any) string { - switch typed := value.(type) { - case string: - cleaned := strings.Join(strings.Fields(typed), "") - if captchaPattern.MatchString(cleaned) { - return cleaned - } - case map[string]any: - for _, key := range []string{"text", "result", "data", "content", "captcha", "code"} { - if child, ok := typed[key]; ok { - if result := findCaptchaText(child); result != "" { - return result - } - } - } - for key, child := range typed { - if isPreferredCaptchaKey(key) { - continue - } - if result := findCaptchaText(child); result != "" { - return result - } - } - case []any: - for _, child := range typed { - if result := findCaptchaText(child); result != "" { - return result - } - } - } - return "" -} - -func isPreferredCaptchaKey(key string) bool { - for _, preferred := range []string{"text", "result", "data", "content", "captcha", "code"} { - if key == preferred { - return true - } - } - return false -} - -func stringValue(value any) string { - switch typed := value.(type) { - case string: - return typed - case json.Number: - return typed.String() - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - default: - return "" - } -} - -func int64Value(value any) int64 { - switch typed := value.(type) { - case json.Number: - result, _ := typed.Int64() - return result - case float64: - return int64(typed) - case string: - result, _ := strconv.ParseInt(typed, 10, 64) - return result - default: - return 0 - } -} - -func rawString(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var text string - if json.Unmarshal(raw, &text) == nil { - return text - } - var number json.Number - if json.Unmarshal(raw, &number) == nil { - return number.String() - } - return "" -} - -func apiURL(baseURL *url.URL, path string) string { - return strings.TrimRight(baseURL.String(), "/") + "/api/" + strings.TrimLeft(path, "/") -} - -func applyCommonHeaders(req *http.Request) { - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152") -} - -func readBody(reader io.Reader) ([]byte, error) { - return io.ReadAll(io.LimitReader(reader, maximumResponseBodyBytes)) -} - -func newUUID() (string, error) { - var raw [16]byte - if _, err := rand.Read(raw[:]); err != nil { - return "", err - } - raw[6] = (raw[6] & 0x0f) | 0x40 - raw[8] = (raw[8] & 0x3f) | 0x80 - encoded := hex.EncodeToString(raw[:]) - return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil -} diff --git a/internal/huohanhan/auth_test.go b/internal/huohanhan/auth_test.go deleted file mode 100644 index f7c768d..0000000 --- a/internal/huohanhan/auth_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync" - "testing" - "time" - - "cmsp/internal/config" - "cmsp/internal/logx" - "cmsp/internal/store" -) - -const ( - testAccount = "13500000000" - testPassword = "test-password" - testToken = "test-token" - testCookie = "test-cookie" -) - -type fakeLoginBackend struct { - t *testing.T - mu sync.Mutex - loginCodes []string - loginCount int - captchaCount int - validateCount int - captchaKeys []string - issuedToken string - businessHandler http.HandlerFunc - server *httptest.Server -} - -func newFakeLoginBackend(t *testing.T, loginCodes ...string) *fakeLoginBackend { - t.Helper() - backend := &fakeLoginBackend{t: t, loginCodes: loginCodes, issuedToken: testToken} - backend.server = httptest.NewServer(http.HandlerFunc(backend.serveHTTP)) - t.Cleanup(backend.server.Close) - return backend -} - -func (b *fakeLoginBackend) serveHTTP(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/login": - http.SetCookie(w, &http.Cookie{Name: "login-session", Value: testCookie, Path: "/"}) - _, _ = io.WriteString(w, `window.config={"CID":"test-cid", 'CST': 'test-cst'}`) - case "/api/butler/client/getCltConf": - if err := r.ParseForm(); err != nil { - b.t.Errorf("解析网站配置表单失败:%v", err) - } - serverURL, _ := url.Parse(b.server.URL) - if got := r.Form.Get("domain"); got != serverURL.Hostname() { - b.t.Errorf("domain 期望 %q,实际 %q", serverURL.Hostname(), got) - } - writeJSON(w, map[string]any{"id": 12345}) - case "/api/butler/vrify/kaptcha": - b.mu.Lock() - b.captchaCount++ - b.captchaKeys = append(b.captchaKeys, r.URL.Query().Get("kaptchaKey")) - b.mu.Unlock() - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("fake-image")) - case "/api/login": - b.handleLogin(w, r) - case "/api/butler/app-version/info": - b.mu.Lock() - b.validateCount++ - b.mu.Unlock() - if err := r.ParseForm(); err != nil { - b.t.Errorf("解析认证校验表单失败:%v", err) - } - if r.Form.Get("appCode") != "HHH" { - b.t.Errorf("认证校验 appCode 期望 HHH,实际 %q", r.Form.Get("appCode")) - } - if r.Header.Get("Authorization") != "Bearer "+b.issuedToken { - w.WriteHeader(http.StatusUnauthorized) - return - } - if cookie, err := r.Cookie("auth-session"); err != nil || cookie.Value != testCookie { - b.t.Errorf("认证校验应携带登录 cookie,实际 cookie=%v err=%v", cookie, err) - } - writeJSON(w, map[string]any{"appCode": "HHH"}) - default: - if b.businessHandler != nil { - b.businessHandler(w, r) - return - } - http.NotFound(w, r) - } -} - -func (b *fakeLoginBackend) handleLogin(w http.ResponseWriter, r *http.Request) { - user, password, ok := r.BasicAuth() - if !ok || user != "test-cid" || password != "test-cst" { - b.t.Errorf("登录请求 Basic 认证不正确") - } - if err := r.ParseForm(); err != nil { - b.t.Errorf("解析登录表单失败:%v", err) - } - if r.Form.Get("username") != testAccount || r.Form.Get("password") != testPassword { - b.t.Errorf("登录表单账号或密码不正确") - } - if r.Form.Get("clientId") != "12345" || r.Form.Get("kaptchaCode") != "A1b2" { - b.t.Errorf("登录表单 clientId 或验证码不正确:%v", r.Form) - } - if r.Form.Get("kaptchaKey") == "" { - b.t.Errorf("登录表单缺少 kaptchaKey") - } - - b.mu.Lock() - index := b.loginCount - b.loginCount++ - code := "" - if index < len(b.loginCodes) { - code = b.loginCodes[index] - } - b.mu.Unlock() - - if code != "" { - writeJSON(w, map[string]any{"code": code, "message": "fake login error"}) - return - } - http.SetCookie(w, &http.Cookie{Name: "auth-session", Value: testCookie, Path: "/"}) - writeJSON(w, map[string]any{ - "access_token": b.issuedToken, - "token_type": "Bearer", - "expires_in": 3600, - "login_time": time.Now().UnixMilli(), - }) -} - -func newFakeOCRServer(t *testing.T, calls *int) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - (*calls)++ - if err := r.ParseMultipartForm(1 << 20); err != nil { - t.Errorf("OCR multipart 解析失败:%v", err) - http.Error(w, "bad multipart", http.StatusBadRequest) - return - } - file, header, err := r.FormFile("file") - if err != nil { - t.Errorf("OCR 请求缺少 file 字段:%v", err) - http.Error(w, "missing file", http.StatusBadRequest) - return - } - defer file.Close() - if header.Filename != "captcha.jpg" { - t.Errorf("OCR 文件名期望 captcha.jpg,实际 %q", header.Filename) - } - if got := header.Header.Get("Content-Type"); got != "image/jpeg" { - t.Errorf("OCR 文件类型期望 image/jpeg,实际 %q", got) - } - image, _ := io.ReadAll(file) - if string(image) != "fake-image" { - t.Errorf("OCR 图片内容不正确,实际 %q", image) - } - writeJSON(w, map[string]any{"data": map[string]any{"text": " A1 b2 "}}) - })) - t.Cleanup(server.Close) - return server -} - -func newTestAuthManager(t *testing.T, backend *fakeLoginBackend, database *store.Store, logger *logx.Logger, attempts int) *AuthManager { - t.Helper() - ocrCalls := 0 - ocr := newFakeOCRServer(t, &ocrCalls) - cfg := config.HuohanhanConfig{ - BaseURL: backend.server.URL, - Account: testAccount, - Password: testPassword, - OCRURL: ocr.URL, - } - return NewAuthManager(cfg, database, logger, AuthOptions{ - HTTPClient: backend.server.Client(), - MaxCaptchaAttempts: attempts, - }) -} - -func newTestStore(t *testing.T) *store.Store { - t.Helper() - database, err := store.Open(":memory:") - if err != nil { - t.Fatalf("打开测试数据库失败:%v", err) - } - t.Cleanup(func() { _ = database.Close() }) - return database -} - -func Test登录成功并保存认证状态(t *testing.T) { - backend := newFakeLoginBackend(t) - database := newTestStore(t) - logger := logx.New(100) - manager := newTestAuthManager(t, backend, database, logger, 3) - - state, err := manager.ForceLogin(context.Background()) - if err != nil { - t.Fatalf("登录应当成功,实际错误:%v", err) - } - if state.AccessToken != testToken { - t.Fatalf("token 期望 %q,实际 %q", testToken, state.AccessToken) - } - raw, found, err := database.GetKV(authStateKey) - if err != nil || !found || raw == "" { - t.Fatalf("认证状态应写入 SQLite,found=%v err=%v", found, err) - } - if backend.loginCount != 1 || backend.validateCount != 1 { - t.Fatalf("登录和在线校验都应各执行 1 次,实际登录 %d 次、校验 %d 次", backend.loginCount, backend.validateCount) - } -} - -func Test验证码错误后更换图片重试成功(t *testing.T) { - backend := newFakeLoginBackend(t, "invalid_verify_code") - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("第二张验证码应登录成功,实际错误:%v", err) - } - if backend.loginCount != 2 || backend.captchaCount != 2 { - t.Fatalf("应下载并提交 2 张验证码,实际下载 %d 次、提交 %d 次", backend.captchaCount, backend.loginCount) - } - if len(backend.captchaKeys) != 2 || backend.captchaKeys[0] == backend.captchaKeys[1] { - t.Fatalf("重试必须更换 kaptchaKey,实际 %v", backend.captchaKeys) - } -} - -func Test账号错误和禁用都不重试(t *testing.T) { - cases := []struct { - name string - code string - want string - }{ - {"账号密码错误", "invalid_credentials", "账号或密码错误"}, - {"账号已禁用", "disabled_credentials", "账号已被禁用"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - backend := newFakeLoginBackend(t, tc.code) - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - _, err := manager.ForceLogin(context.Background()) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("期望错误包含 %q,实际 %v", tc.want, err) - } - if backend.loginCount != 1 || backend.captchaCount != 1 { - t.Fatalf("不可重试的错误应只请求 1 次,实际登录 %d 次、验证码 %d 次", backend.loginCount, backend.captchaCount) - } - }) - } -} - -func Test验证码错误达到上限后停止(t *testing.T) { - backend := newFakeLoginBackend(t, "invalid_verify_code", "invalid_verify_code", "invalid_verify_code") - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 2) - - _, err := manager.ForceLogin(context.Background()) - if err == nil || !strings.Contains(err.Error(), "连续多次验证码识别失败") { - t.Fatalf("达到上限应返回可读错误,实际 %v", err) - } - if backend.loginCount != 2 { - t.Fatalf("上限为 2 时应只提交 2 次,实际 %d 次", backend.loginCount) - } -} - -func Test其它登录错误包含Code和Message且不重试(t *testing.T) { - backend := newFakeLoginBackend(t, "server_rejected") - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - - _, err := manager.ForceLogin(context.Background()) - if err == nil || !strings.Contains(err.Error(), "server_rejected") || !strings.Contains(err.Error(), "fake login error") { - t.Fatalf("其它错误应包含 code 和 message,实际 %v", err) - } - if backend.loginCount != 1 { - t.Fatalf("其它登录错误不应重试,实际登录 %d 次", backend.loginCount) - } -} - -func TestSQLite中的Token重启后复用且内存不重复校验(t *testing.T) { - backend := newFakeLoginBackend(t) - database := newTestStore(t) - logger := logx.New(100) - first := newTestAuthManager(t, backend, database, logger, 3) - if _, err := first.ForceLogin(context.Background()); err != nil { - t.Fatalf("首次登录失败:%v", err) - } - - second := newTestAuthManager(t, backend, database, logger, 3) - if _, err := second.GetValidAuth(context.Background()); err != nil { - t.Fatalf("重启后读取认证状态失败:%v", err) - } - validatedAfterLoad := backend.validateCount - if _, err := second.GetValidAuth(context.Background()); err != nil { - t.Fatalf("内存复用认证状态失败:%v", err) - } - if backend.loginCount != 1 { - t.Fatalf("第二个管理器不应重新登录,实际登录 %d 次", backend.loginCount) - } - if backend.validateCount != validatedAfterLoad { - t.Fatalf("未过期内存状态不应再次在线校验,校验次数从 %d 变成 %d", validatedAfterLoad, backend.validateCount) - } -} - -func Test日志不出现密码Token和Cookie(t *testing.T) { - backend := newFakeLoginBackend(t) - logger := logx.New(100) - manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("登录失败:%v", err) - } - - logs := logger.Text() - for _, secret := range []string{testPassword, testToken, testCookie} { - if strings.Contains(logs, secret) { - t.Fatalf("日志中不应出现敏感测试值 %q,实际日志:%s", secret, logs) - } - } -} - -func TestOCR递归优先读取常见字段(t *testing.T) { - payload := map[string]any{ - "unrelated": "ZZZZ", - "result": map[string]any{"content": " A1 b2 "}, - } - if got := findCaptchaText(payload); got != "A1b2" { - t.Fatalf("应优先从 result/content 读取 A1b2,实际 %q", got) - } -} - -func writeJSON(w http.ResponseWriter, value any) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(value); err != nil { - panic(fmt.Sprintf("写入假服务响应失败:%v", err)) - } -} diff --git a/internal/huohanhan/client.go b/internal/huohanhan/client.go deleted file mode 100644 index 0acb892..0000000 --- a/internal/huohanhan/client.go +++ /dev/null @@ -1,132 +0,0 @@ -package huohanhan - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "cmsp/internal/config" - "cmsp/internal/logx" -) - -var authFailureCodes = map[string]bool{ - "authentication_required": true, - "invalid_token": true, - "invalid_token_expired": true, -} - -// Client 是货憨憨业务接口的统一 HTTP 客户端。 -// -// Request 会自动添加 Authorization 和登录 cookies。只有服务端明确表示 -// 认证失效时才重新登录并重放一次;超时、HTTP 500 和普通业务错误不会重试。 -// 请求体使用 []byte,是为了让认证失败后的唯一一次重放不依赖可回卷的 Reader。 -type Client struct { - baseURL *url.URL - auth *AuthManager - httpClient *http.Client - log *logx.Logger -} - -// NewClient 创建业务请求客户端。 -func NewClient(cfg config.HuohanhanConfig, auth *AuthManager, logger *logx.Logger, httpClient *http.Client) (*Client, error) { - baseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")) - if err != nil || baseURL.Scheme == "" || baseURL.Hostname() == "" { - return nil, fmt.Errorf("货憨憨网址不正确") - } - if auth == nil { - return nil, fmt.Errorf("货憨憨认证管理器不能为空") - } - if httpClient == nil { - httpClient = auth.httpClient - } - if logger == nil { - logger = logx.New(1000) - } - return &Client{baseURL: baseURL, auth: auth, httpClient: httpClient, log: logger}, nil -} - -// Request 请求一个相对于 /api/ 的货憨憨接口。 -// -// 返回的 response 由调用方关闭。非 2xx 状态会返回中文错误;认证失败 -// 的 response 在内部关闭后重试,不会泄漏给调用方。 -func (c *Client) Request(ctx context.Context, method, path string, body []byte, contentType string) (*http.Response, error) { - for attempt := 0; attempt < 2; attempt++ { - state, err := c.auth.GetValidAuth(ctx) - if err != nil { - return nil, err - } - resp, payload, err := c.do(ctx, method, path, body, contentType, state) - if err != nil { - return nil, err - } - - if isAuthFailure(resp.StatusCode, payload) { - resp.Body.Close() - if attempt == 1 { - return nil, fmt.Errorf("重新登录后认证仍然失效") - } - c.log.Warn("货憨憨认证已失效,正在重新登录后重试一次") - if err := c.auth.Invalidate(); err != nil { - return nil, err - } - if _, err := c.auth.ForceLogin(ctx); err != nil { - return nil, err - } - continue - } - - resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(payload)) - resp.ContentLength = int64(len(payload)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - resp.Body.Close() - return nil, fmt.Errorf("货憨憨接口请求失败:HTTP %d", resp.StatusCode) - } - return resp, nil - } - return nil, fmt.Errorf("货憨憨接口请求失败") -} - -func (c *Client) do(ctx context.Context, method, path string, body []byte, contentType string, state AuthState) (*http.Response, []byte, error) { - req, err := http.NewRequestWithContext(ctx, method, apiURL(c.baseURL, path), bytes.NewReader(body)) - if err != nil { - return nil, nil, fmt.Errorf("创建货憨憨接口请求失败:%w", err) - } - applyCommonHeaders(req) - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - req.Header.Set("Authorization", state.AuthorizationValue()) - for name, value := range state.Cookies { - req.AddCookie(&http.Cookie{Name: name, Value: value}) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, nil, fmt.Errorf("请求货憨憨接口失败:%w", err) - } - payload, err := readBody(resp.Body) - if err != nil { - resp.Body.Close() - return nil, nil, fmt.Errorf("读取货憨憨接口响应失败:%w", err) - } - return resp, payload, nil -} - -func isAuthFailure(statusCode int, body []byte) bool { - if statusCode == http.StatusUnauthorized { - return true - } - var payload struct { - Code string `json:"code"` - } - if json.Unmarshal(body, &payload) != nil { - return false - } - return authFailureCodes[payload.Code] -} diff --git a/internal/huohanhan/client_test.go b/internal/huohanhan/client_test.go deleted file mode 100644 index 2397ca4..0000000 --- a/internal/huohanhan/client_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "cmsp/internal/logx" -) - -func Test业务请求401后自动重登并只重试一次(t *testing.T) { - backend := newFakeLoginBackend(t) - logger := logx.New(100) - manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("准备初始认证失败:%v", err) - } - - businessCalls := 0 - backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { - businessCalls++ - if r.URL.Path != "/api/product/list" { - t.Errorf("业务路径期望 /api/product/list,实际 %s", r.URL.Path) - } - assertBusinessAuth(t, r) - if businessCalls == 1 { - w.WriteHeader(http.StatusUnauthorized) - return - } - writeJSON(w, map[string]any{"ok": true}) - } - - client, err := NewClient(manager.cfg, manager, logger, backend.server.Client()) - if err != nil { - t.Fatalf("创建客户端失败:%v", err) - } - response, err := client.Request(context.Background(), http.MethodPost, "product/list", []byte("page=1"), "application/x-www-form-urlencoded") - if err != nil { - t.Fatalf("401 后重登重试应成功,实际错误:%v", err) - } - defer response.Body.Close() - body, _ := io.ReadAll(response.Body) - if !strings.Contains(string(body), `"ok":true`) { - t.Fatalf("重试响应内容不正确:%s", body) - } - if businessCalls != 2 { - t.Fatalf("业务请求应执行 2 次,实际 %d 次", businessCalls) - } - if backend.loginCount != 2 { - t.Fatalf("初始登录加失效重登应共 2 次,实际 %d 次", backend.loginCount) - } -} - -func Test业务请求连续401不会无限重试(t *testing.T) { - backend := newFakeLoginBackend(t) - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("准备初始认证失败:%v", err) - } - - businessCalls := 0 - backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { - businessCalls++ - w.WriteHeader(http.StatusUnauthorized) - } - client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) - if err != nil { - t.Fatalf("创建客户端失败:%v", err) - } - _, err = client.Request(context.Background(), http.MethodGet, "always-unauthorized", nil, "") - if err == nil || !strings.Contains(err.Error(), "重新登录后认证仍然失效") { - t.Fatalf("第二次 401 应停止并返回可读错误,实际 %v", err) - } - if businessCalls != 2 { - t.Fatalf("同一请求最多执行 2 次,实际 %d 次", businessCalls) - } - if backend.loginCount != 2 { - t.Fatalf("只应额外重登 1 次,实际总登录 %d 次", backend.loginCount) - } -} - -func Test认证失败业务码触发一次重登(t *testing.T) { - backend := newFakeLoginBackend(t) - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("准备初始认证失败:%v", err) - } - - businessCalls := 0 - backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { - businessCalls++ - if businessCalls == 1 { - writeJSON(w, map[string]any{"code": "invalid_token_expired"}) - return - } - writeJSON(w, map[string]any{"data": "ok"}) - } - client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) - if err != nil { - t.Fatalf("创建客户端失败:%v", err) - } - response, err := client.Request(context.Background(), http.MethodGet, "auth-code", nil, "") - if err != nil { - t.Fatalf("认证失败业务码后应重试成功,实际错误:%v", err) - } - response.Body.Close() - if businessCalls != 2 || backend.loginCount != 2 { - t.Fatalf("应请求 2 次且总登录 2 次,实际请求 %d 次、登录 %d 次", businessCalls, backend.loginCount) - } -} - -func TestHTTP500和普通业务错误不重试(t *testing.T) { - cases := []struct { - name string - statusCode int - body any - wantError bool - }{ - {"HTTP 500", http.StatusInternalServerError, map[string]any{"message": "fake failure"}, true}, - {"普通业务错误", http.StatusOK, map[string]any{"code": "product_not_found"}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - backend := newFakeLoginBackend(t) - manager := newTestAuthManager(t, backend, newTestStore(t), logx.New(100), 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("准备初始认证失败:%v", err) - } - calls := 0 - backend.businessHandler = func(w http.ResponseWriter, r *http.Request) { - calls++ - w.WriteHeader(tc.statusCode) - _ = json.NewEncoder(w).Encode(tc.body) - } - client, err := NewClient(manager.cfg, manager, logx.New(100), backend.server.Client()) - if err != nil { - t.Fatalf("创建客户端失败:%v", err) - } - response, requestErr := client.Request(context.Background(), http.MethodGet, "ordinary-error", nil, "") - if tc.wantError && requestErr == nil { - t.Fatalf("期望返回错误,实际成功") - } - if !tc.wantError && requestErr != nil { - t.Fatalf("普通业务响应应交给调用方处理,实际错误:%v", requestErr) - } - if response != nil { - response.Body.Close() - } - if calls != 1 { - t.Fatalf("非认证错误不能重试,实际请求 %d 次", calls) - } - }) - } -} - -func assertBusinessAuth(t *testing.T, r *http.Request) { - t.Helper() - if got := r.Header.Get("Authorization"); got != "Bearer "+testToken { - t.Errorf("Authorization 不正确,实际 %q", got) - } - cookie, err := r.Cookie("auth-session") - if err != nil || cookie.Value != testCookie { - t.Errorf("业务请求应携带登录 cookie,实际 cookie=%v err=%v", cookie, err) - } -} diff --git a/internal/huohanhan/product.go b/internal/huohanhan/product.go deleted file mode 100644 index ed95e56..0000000 --- a/internal/huohanhan/product.go +++ /dev/null @@ -1,276 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - - "cmsp/internal/store" -) - -const ( - // 每页条数。实测该接口 size=500 也能返回,但 200 是速度与响应体积的 - // 平衡点:某个 4304 商品的店铺,size=20 要 216 页约 108 秒, - // size=200 只要 22 页约 18 秒。改大之前先实测,不要凭感觉调。 - defaultProductPageSize = 200 - // 分页上限,防止 pages 字段异常导致死循环。 - // 按每页 200 条算,上限对应 4 万个商品,远超实际店铺规模。 - maximumProductPages = 200 -) - -// ProductPageParams 是商品单页查询所需的可变参数。 -// 其它筛选字段由 GetProductPage 按真实网页请求补为空值。 -type ProductPageParams struct { - Size int - Current int - PlatformShopID string -} - -// ProductRecord 是货憨憨商品响应中需要保存的字段白名单。 -type ProductRecord struct { - ID string `json:"id"` - ItemID string `json:"itemId"` - ItemName string `json:"itemName"` - MainImage string `json:"mainImage"` - ShopName string `json:"shopName"` - PlatformShopID string `json:"platformShopId"` - Currency string `json:"currency"` - MinSkuPrice float64 `json:"minSkuPrice"` - ItemStatus string `json:"itemStatus"` - CreateTime string `json:"createTime"` - DiagnosisInfo *DiagnosisInfo `json:"diagnosisInfo"` -} - -// DiagnosisInfo 是货憨憨返回的商品质量诊断对象。 -// 指针字段能保留 JSON null,便于按已确认的两态规则明确处理边界。 -type DiagnosisInfo struct { - ItemID string `json:"itemId"` - QualityLevel string `json:"qualityLevel"` - Diagnoses []DiagnosisGroup `json:"diagnoses"` -} - -// DiagnosisGroup 是按商品字段分组的诊断结果。 -type DiagnosisGroup struct { - Field string `json:"field"` - DiagnosisResults []DiagnosisResult `json:"diagnosisResults"` -} - -// DiagnosisResult 是一条具体的诊断类型和处理建议。 -type DiagnosisResult struct { - Type string `json:"type"` - Solution string `json:"solution"` -} - -// ProductPage 对应货憨憨商品接口返回的裸分页对象。 -type ProductPage struct { - Records []ProductRecord `json:"records"` - Total int `json:"total"` - Size int `json:"size"` - Current int `json:"current"` - Pages int `json:"pages"` -} - -// UnmarshalJSON 兼容分页数字既可能是 JSON 数字、也可能是字符串的响应。 -func (p *ProductPage) UnmarshalJSON(data []byte) error { - var raw struct { - Records []ProductRecord `json:"records"` - Total json.RawMessage `json:"total"` - Size json.RawMessage `json:"size"` - Current json.RawMessage `json:"current"` - Pages json.RawMessage `json:"pages"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - fields := []struct { - name string - raw json.RawMessage - dest *int - }{ - {"total", raw.Total, &p.Total}, - {"size", raw.Size, &p.Size}, - {"current", raw.Current, &p.Current}, - {"pages", raw.Pages, &p.Pages}, - } - for _, field := range fields { - value, err := parsePageInteger(field.raw) - if err != nil { - return fmt.Errorf("分页字段 %s 格式不正确:%w", field.name, err) - } - *field.dest = value - } - p.Records = raw.Records - return nil -} - -func parsePageInteger(raw json.RawMessage) (int, error) { - text := strings.TrimSpace(string(raw)) - if text == "" || text == "null" { - return 0, nil - } - if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' { - var decoded string - if err := json.Unmarshal(raw, &decoded); err != nil { - return 0, err - } - text = decoded - } - return strconv.Atoi(text) -} - -// GetProductPage 按真实网页使用的 form 编码读取一页在售商品。 -func (c *Client) GetProductPage(ctx context.Context, params ProductPageParams) (ProductPage, error) { - if params.Size <= 0 { - params.Size = defaultProductPageSize - } - if params.Current <= 0 { - params.Current = 1 - } - - form := url.Values{ - "size": {strconv.Itoa(params.Size)}, - "current": {strconv.Itoa(params.Current)}, - "descs": {""}, - "ascs": {""}, - "itemStatus": {"NORMAL"}, - "marked": {""}, - "region": {""}, - "platform": {"0"}, - "platformShopId": {strings.TrimSpace(params.PlatformShopID)}, - "itemName": {""}, - "itemIds": {""}, - "itemSkus": {""}, - "modelSku": {""}, - "categoryId": {""}, - "hasSizeChart": {""}, - "sourceId": {""}, - "sourcePlatformCode": {""}, - "isPreOrder": {""}, - "nextDayArrive": {""}, - "createTimeStart": {""}, - "createTimeEnd": {""}, - "minSkuPrice": {""}, - "maxSkuPrice": {""}, - "minSale": {""}, - "maxSale": {""}, - "minViews": {""}, - "maxViews": {""}, - "minLikes": {""}, - "maxLikes": {""}, - "minCommentCount": {""}, - "maxCommentCount": {""}, - "minRatingStar": {""}, - "maxRatingStar": {""}, - "sortField": {"updateTime"}, - "sortType": {"desc"}, - "groupIds": {""}, - } - response, err := c.Request( - ctx, - http.MethodPost, - "product/shop/getPage", - []byte(form.Encode()), - "application/x-www-form-urlencoded;charset=UTF-8", - ) - if err != nil { - return ProductPage{}, fmt.Errorf("读取商品第 %d 页失败:%w", params.Current, err) - } - defer response.Body.Close() - - var page ProductPage - if err := json.NewDecoder(response.Body).Decode(&page); err != nil { - return ProductPage{}, fmt.Errorf("商品分页返回的不是有效 JSON:%w", err) - } - return page, nil -} - -// DownloadAllProducts 逐页下载一个店铺的全部在售商品。 -// -// 最多请求 200 页。服务端分页异常时返回已取得的数据并写警告日志, -// 避免桌面程序陷入无法结束的循环。 -func (c *Client) DownloadAllProducts(ctx context.Context, platformShopID string, onProgress func(current, total int)) ([]store.Product, map[string][]store.Diagnosis, error) { - platformShopID = strings.TrimSpace(platformShopID) - if platformShopID == "" { - return nil, nil, fmt.Errorf("请先选择店铺") - } - - products := make([]store.Product, 0) - diagnoses := make(map[string][]store.Diagnosis) - lastCurrent := 0 - lastPages := 0 - for requestedPage := 1; requestedPage <= maximumProductPages; requestedPage++ { - page, err := c.GetProductPage(ctx, ProductPageParams{ - Size: defaultProductPageSize, - Current: requestedPage, - PlatformShopID: platformShopID, - }) - if err != nil { - return nil, nil, err - } - - for _, record := range page.Records { - product, productDiagnoses := convertProductRecord(record) - products = append(products, product) - // 即使没有明细也保留这个键,落库时才能清掉该商品的旧诊断。 - diagnoses[record.ID] = productDiagnoses - } - - lastCurrent = page.Current - lastPages = page.Pages - if onProgress != nil { - onProgress(page.Current, page.Pages) - } - if page.Current >= page.Pages { - return products, diagnoses, nil - } - } - - if lastCurrent < lastPages { - c.log.Warn("商品分页超过 %d 页上限,已停止拉取;服务端报告进度 %d/%d 页", maximumProductPages, lastCurrent, lastPages) - } - return products, diagnoses, nil -} - -// convertProductRecord 把一条货憨憨记录转换为本地商品和全部诊断明细。 -func convertProductRecord(record ProductRecord) (store.Product, []store.Diagnosis) { - videoDiagnosis := store.VideoDiagnosisOK - qualityLevel := "" - diagnoses := make([]store.Diagnosis, 0) - - if record.DiagnosisInfo != nil { - qualityLevel = record.DiagnosisInfo.QualityLevel - for _, group := range record.DiagnosisInfo.Diagnoses { - for _, result := range group.DiagnosisResults { - diagnoses = append(diagnoses, store.Diagnosis{ - ProductID: record.ID, - Field: group.Field, - Type: result.Type, - Solution: result.Solution, - }) - if result.Type == "缺少视频" { - videoDiagnosis = store.VideoDiagnosisMissing - } - } - } - } - - return store.Product{ - ID: record.ID, - ItemID: record.ItemID, - ItemName: record.ItemName, - MainImage: record.MainImage, - ShopName: record.ShopName, - PlatformShopID: record.PlatformShopID, - Currency: record.Currency, - MinSkuPrice: record.MinSkuPrice, - ItemStatus: record.ItemStatus, - CreatedAt: record.CreateTime, - VideoDiagnosis: videoDiagnosis, - QualityLevel: qualityLevel, - }, diagnoses -} diff --git a/internal/huohanhan/product_test.go b/internal/huohanhan/product_test.go deleted file mode 100644 index 02223e3..0000000 --- a/internal/huohanhan/product_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package huohanhan - -import ( - "context" - "net/http" - "strconv" - "strings" - "testing" - - "cmsp/internal/logx" -) - -func Test商品分页兼容字符串数字并发送完整表单(t *testing.T) { - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/product/shop/getPage" { - t.Errorf("商品接口路径不正确:%s", r.URL.Path) - } - if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { - t.Errorf("商品请求必须使用 form 编码,实际 %q", r.Header.Get("Content-Type")) - } - if err := r.ParseForm(); err != nil { - t.Fatalf("解析商品请求表单失败:%v", err) - } - if r.Form.Get("platformShopId") != "1664202094" || r.Form.Get("current") != "2" || r.Form.Get("size") != "20" { - t.Errorf("商品分页参数不正确:%v", r.Form) - } - if r.Form.Get("platform") != "0" || r.Form.Get("itemStatus") != "NORMAL" || r.Form.Get("sortField") != "updateTime" || r.Form.Get("sortType") != "desc" { - t.Errorf("商品固定筛选参数不正确:%v", r.Form) - } - for _, name := range []string{"descs", "ascs", "marked", "region", "itemName", "itemIds", "itemSkus", "modelSku", "categoryId", "hasSizeChart", "sourceId", "sourcePlatformCode", "isPreOrder", "nextDayArrive", "createTimeStart", "createTimeEnd", "minSkuPrice", "maxSkuPrice", "minSale", "maxSale", "minViews", "maxViews", "minLikes", "maxLikes", "minCommentCount", "maxCommentCount", "minRatingStar", "maxRatingStar", "groupIds"} { - if _, exists := r.Form[name]; !exists { - t.Errorf("商品请求缺少空表单字段 %s", name) - } - } - writeJSON(w, map[string]any{ - "records": []any{}, "total": "1256", "size": "20", - "current": "2", "pages": "63", - }) - }) - - page, err := client.GetProductPage(context.Background(), ProductPageParams{ - Size: 20, Current: 2, PlatformShopID: "1664202094", - }) - if err != nil { - t.Fatalf("读取商品分页失败:%v", err) - } - if page.Total != 1256 || page.Size != 20 || page.Current != 2 || page.Pages != 63 { - t.Fatalf("字符串分页数字解析不正确:%+v", page) - } -} - -func Test商品下载拉完三页并正确转换主键(t *testing.T) { - calls := 0 - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Fatalf("解析商品表单失败:%v", err) - } - current, _ := strconv.Atoi(r.Form.Get("current")) - calls++ - record := map[string]any{ - "id": "hhh-" + strconv.Itoa(current), - "itemId": "shopee-" + strconv.Itoa(current), - "itemName": "商品", "mainImage": "https://example.invalid/image.jpg", - "shopName": "测试店铺", "platformShopId": "1664202094", - "currency": "TWD", "minSkuPrice": 88.5, - "itemStatus": "NORMAL", "createTime": "2026-08-31 01:54:08", - } - if current == 1 { - record["diagnosisInfo"] = map[string]any{ - "itemId": "shopee-1", "qualityLevel": "1", - "diagnoses": []map[string]any{{ - "field": "ALL", - "diagnosisResults": []map[string]any{ - {"type": "缺少视频", "solution": "上传相应的视频"}, - {"type": "缺少品牌信息", "solution": "填写品牌信息"}, - }, - }}, - } - } - writeJSON(w, map[string]any{ - "records": []map[string]any{record}, - "total": 3, "size": 20, "current": current, "pages": 3, - }) - }) - - var progress []int - products, diagnoses, err := client.DownloadAllProducts(context.Background(), "1664202094", func(current, total int) { - if total != 3 { - t.Errorf("总页数应为 3,实际 %d", total) - } - progress = append(progress, current) - }) - if err != nil { - t.Fatalf("下载全部商品失败:%v", err) - } - if calls != 3 || len(progress) != 3 || len(products) != 3 { - t.Fatalf("应完整拉取 3 页,实际请求 %d 次、进度 %v、商品 %d 条", calls, progress, len(products)) - } - first := products[0] - if first.ID != "hhh-1" || first.ItemID != "shopee-1" { - t.Fatalf("id 和 itemId 映射错误:ID=%q ItemID=%q", first.ID, first.ItemID) - } - if first.CreatedAt != "2026-08-31 01:54:08" || first.MinSkuPrice != 88.5 { - t.Fatalf("商品字段转换不完整:%+v", first) - } - if first.VideoDiagnosis != "missing" || first.QualityLevel != "1" { - t.Fatalf("JSON 中的诊断摘要转换不正确:%+v", first) - } - if len(diagnoses[first.ID]) != 2 || diagnoses[first.ID][1].Type != "缺少品牌信息" { - t.Fatalf("JSON 中的全部诊断明细应当返回:%+v", diagnoses[first.ID]) - } -} - -func Test商品分页超过二百页时警告并停止(t *testing.T) { - calls := 0 - logger := logx.New(500) - client := newBusinessTestClient(t, logger, func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Fatalf("解析商品表单失败:%v", err) - } - current, _ := strconv.Atoi(r.Form.Get("current")) - calls++ - writeJSON(w, map[string]any{ - "records": []any{}, "total": 99999, "size": 20, - "current": current, "pages": 99999, - }) - }) - - products, _, err := client.DownloadAllProducts(context.Background(), "1664202094", nil) - if err != nil { - t.Fatalf("达到分页保护上限不应报错:%v", err) - } - if calls != maximumProductPages { - t.Fatalf("最多应请求 %d 页,实际 %d 页", maximumProductPages, calls) - } - if len(products) != 0 { - t.Fatalf("假服务未返回商品,实际得到 %d 条", len(products)) - } - if !strings.Contains(logger.Text(), "超过 200 页上限") { - t.Fatalf("达到上限必须写警告日志,实际日志:%s", logger.Text()) - } -} - -func Test诊断为空时按负责人决定归入有视频(t *testing.T) { - product, diagnoses := convertProductRecord(ProductRecord{ID: "商品-1"}) - - if product.VideoDiagnosis != "ok" { - t.Fatalf("diagnosisInfo 为 null 时应当归入 ok,实际 %q", product.VideoDiagnosis) - } - if len(diagnoses) != 0 { - t.Fatalf("diagnosisInfo 为 null 时不应生成诊断明细,实际 %d 条", len(diagnoses)) - } -} - -func Test含缺少视频时标记为缺少并保存全部诊断(t *testing.T) { - record := ProductRecord{ - ID: "商品-2", - DiagnosisInfo: &DiagnosisInfo{ - QualityLevel: "1", - Diagnoses: []DiagnosisGroup{{ - Field: "ALL", - DiagnosisResults: []DiagnosisResult{ - {Type: "缺少视频", Solution: "上传相应的视频"}, - {Type: "缺少品牌信息", Solution: "填写品牌信息"}, - }, - }}, - }, - } - - product, diagnoses := convertProductRecord(record) - if product.VideoDiagnosis != "missing" { - t.Fatalf("含缺少视频时应当标记 missing,实际 %q", product.VideoDiagnosis) - } - if product.QualityLevel != "1" { - t.Fatalf("质量等级应当完整转换,实际 %q", product.QualityLevel) - } - if len(diagnoses) != 2 { - t.Fatalf("全部诊断类型都应保留,期望 2 条,实际 %d 条", len(diagnoses)) - } - if diagnoses[1].Type != "缺少品牌信息" || diagnoses[1].ProductID != record.ID { - t.Fatalf("非视频诊断或商品关联丢失:%+v", diagnoses[1]) - } -} - -func Test有诊断但不含缺少视频时归入有视频(t *testing.T) { - record := ProductRecord{ - ID: "商品-3", - DiagnosisInfo: &DiagnosisInfo{ - QualityLevel: "2", - Diagnoses: []DiagnosisGroup{{ - Field: "ALL", - DiagnosisResults: []DiagnosisResult{{ - Type: "缺少尺寸表", Solution: "上传尺寸表", - }}, - }}, - }, - } - - product, diagnoses := convertProductRecord(record) - if product.VideoDiagnosis != "ok" { - t.Fatalf("未报缺少视频时应当归入 ok,实际 %q", product.VideoDiagnosis) - } - if len(diagnoses) != 1 || diagnoses[0].Type != "缺少尺寸表" { - t.Fatalf("其它诊断仍应完整保留:%+v", diagnoses) - } -} diff --git a/internal/huohanhan/shop.go b/internal/huohanhan/shop.go deleted file mode 100644 index 9d628a6..0000000 --- a/internal/huohanhan/shop.go +++ /dev/null @@ -1,60 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "sort" - "strings" -) - -// Shop 是界面选择商品来源时需要的店铺信息。 -// -// 货憨憨响应还包含 OAuth token 和手机号等敏感字段。这里刻意只声明 -// 界面需要的白名单字段,避免凭据进入内存模型、日志、SQLite 或前端。 -type Shop struct { - ID string `json:"id"` - ShopName string `json:"shopName"` - ShopAlias string `json:"shopAlias"` - Region string `json:"region"` - RegionName string `json:"regionName"` - Platform string `json:"platform"` - PlatformShopID string `json:"platformShopId"` - Status string `json:"status"` -} - -// ListShops 读取当前账号的 Shopee 店铺,并按店铺名排序。 -func (c *Client) ListShops(ctx context.Context) ([]Shop, error) { - form := url.Values{"userId": {""}} - response, err := c.Request( - ctx, - http.MethodPost, - "erp/shop/all", - []byte(form.Encode()), - "application/x-www-form-urlencoded", - ) - if err != nil { - return nil, fmt.Errorf("读取店铺列表失败:%w", err) - } - defer response.Body.Close() - - var payload []Shop - if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("店铺列表返回的不是有效 JSON:%w", err) - } - - shops := make([]Shop, 0, len(payload)) - for _, shop := range payload { - if shop.Platform != "0" { - continue - } - shop.ShopName = strings.TrimSpace(shop.ShopName) - shops = append(shops, shop) - } - sort.Slice(shops, func(i, j int) bool { - return shops[i].ShopName < shops[j].ShopName - }) - return shops, nil -} diff --git a/internal/huohanhan/shop_test.go b/internal/huohanhan/shop_test.go deleted file mode 100644 index 027dc01..0000000 --- a/internal/huohanhan/shop_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "net/http" - "reflect" - "strings" - "testing" - - "cmsp/internal/logx" -) - -func newBusinessTestClient(t *testing.T, logger *logx.Logger, handler http.HandlerFunc) *Client { - t.Helper() - backend := newFakeLoginBackend(t) - manager := newTestAuthManager(t, backend, newTestStore(t), logger, 3) - if _, err := manager.ForceLogin(context.Background()); err != nil { - t.Fatalf("准备测试认证失败:%v", err) - } - backend.businessHandler = handler - client, err := NewClient(manager.cfg, manager, logger, backend.server.Client()) - if err != nil { - t.Fatalf("创建测试业务客户端失败:%v", err) - } - return client -} - -func Test店铺列表解析裸数组并过滤排序(t *testing.T) { - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/erp/shop/all" { - t.Errorf("店铺接口路径不正确:%s", r.URL.Path) - } - if err := r.ParseForm(); err != nil { - t.Fatalf("解析店铺请求表单失败:%v", err) - } - if _, exists := r.Form["userId"]; !exists || r.Form.Get("userId") != "" { - t.Errorf("店铺请求必须包含空 userId,实际表单:%v", r.Form) - } - if _, exists := r.Form["type"]; exists { - t.Errorf("店铺请求不应包含 Python 版的 type 参数") - } - writeJSON(w, []map[string]any{ - { - "id": "shop-2", "shopName": "B店铺 ", "shopAlias": "乙", - "region": "TW", "regionName": "台湾", "platform": "0", - "platformShopId": "200", "status": "NORMAL", - "accessToken": "fake-oauth-access", "refreshToken": "fake-oauth-refresh", - "createUser": "13000000000", - }, - { - "id": "other", "shopName": "其它平台", "platform": "1", - "platformShopId": "999", "status": "NORMAL", - }, - { - "id": "shop-1", "shopName": "A店铺", "shopAlias": "甲", - "region": "TW", "regionName": "台湾", "platform": "0", - "platformShopId": "100", "status": "NORMAL", - }, - }) - }) - - shops, err := client.ListShops(context.Background()) - if err != nil { - t.Fatalf("读取店铺失败:%v", err) - } - if len(shops) != 2 { - t.Fatalf("应只保留 2 个 Shopee 店铺,实际 %d 个", len(shops)) - } - if shops[0].ShopName != "A店铺" || shops[1].ShopName != "B店铺" { - t.Fatalf("店铺应去掉尾部空格并按名称排序,实际:%v", shops) - } -} - -func Test店铺结构不包含凭据和手机号字段(t *testing.T) { - typ := reflect.TypeOf(Shop{}) - for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} { - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - if field.Name == forbidden || strings.Split(field.Tag.Get("json"), ",")[0] == forbidden { - t.Fatalf("Shop 不得声明敏感字段 %s", forbidden) - } - } - } - - encoded, err := json.Marshal(Shop{ID: "fake-shop", ShopName: "测试店铺"}) - if err != nil { - t.Fatalf("序列化店铺失败:%v", err) - } - for _, forbidden := range []string{"accessToken", "refreshToken", "createUser"} { - if strings.Contains(string(encoded), forbidden) { - t.Fatalf("店铺 JSON 不得包含敏感字段 %s:%s", forbidden, encoded) - } - } -} diff --git a/internal/huohanhan/upload.go b/internal/huohanhan/upload.go deleted file mode 100644 index a125ffb..0000000 --- a/internal/huohanhan/upload.go +++ /dev/null @@ -1,169 +0,0 @@ -package huohanhan - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "mime/multipart" - "net/http" - "net/textproto" - "net/url" - "path/filepath" - "strings" -) - -// UploadVideo 上传一份本地 MP4 素材,并返回货憨憨保存后的 COS 地址。 -// 素材上传与商品关联是两个独立接口;此方法绝不附带商品信息。 -func (c *Client) UploadVideo(ctx context.Context, localPath string, content []byte) (string, error) { - var body bytes.Buffer - writer := multipart.NewWriter(&body) - fileHeader := make(textproto.MIMEHeader) - fileHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files"; filename=%q`, filepath.Base(localPath))) - fileHeader.Set("Content-Type", "video/mp4") - part, err := writer.CreatePart(fileHeader) - if err != nil { - return "", fmt.Errorf("构造视频上传表单失败:%w", err) - } - if _, err := part.Write(content); err != nil { - return "", fmt.Errorf("写入视频上传表单失败:%w", err) - } - if err := writer.WriteField("isLocalFile", "true"); err != nil { - return "", fmt.Errorf("写入本地文件标记失败:%w", err) - } - if err := writer.WriteField("fileType", "1"); err != nil { - return "", fmt.Errorf("写入文件类型失败:%w", err) - } - if err := writer.Close(); err != nil { - return "", fmt.Errorf("完成视频上传表单失败:%w", err) - } - - response, err := c.Request(ctx, http.MethodPost, "product/material/uploadFiles", body.Bytes(), writer.FormDataContentType()) - if err != nil { - return "", fmt.Errorf("上传视频失败:%w", err) - } - defer response.Body.Close() - - var payload successResponse - if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("上传视频返回的不是有效 JSON:%w", err) - } - if err := payload.check("上传视频"); err != nil { - return "", err - } - if len(payload.Bean) == 0 || strings.TrimSpace(payload.Bean[0]) == "" { - return "", fmt.Errorf("上传视频失败:服务端未返回视频地址") - } - return strings.TrimSpace(payload.Bean[0]), nil -} - -// UpdateShopProductVideo 用上传后的地址覆盖关联到一个商品的视频。 -func (c *Client) UpdateShopProductVideo(ctx context.Context, id, platformShopID, videoURL string) error { - body, err := json.Marshal([]productVideoUpdate{{ - ID: id, PlatformShopID: platformShopID, VideoURL: videoURL, - }}) - if err != nil { - return fmt.Errorf("构造视频关联请求失败:%w", err) - } - response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/batchUpdateShopProductVideo", body, "application/json;charset=UTF-8") - if err != nil { - return fmt.Errorf("关联商品视频失败:%w", err) - } - defer response.Body.Close() - var payload successResponse - if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { - return fmt.Errorf("关联商品视频返回的不是有效 JSON:%w", err) - } - return payload.check("关联商品视频") -} - -// VideoCheck 是回读商品视频字段的结果。 -// -// 这几个字段的含义不一样,别混用: -// -// Video 已经在 Shopee 上生效的视频。货憨憨推送成功后才有值,是异步的。 -// TempVideoURL 刚设置进去、还没同步到 Shopee 的视频地址。 -// UploadIDStr 货憨憨/Shopee 侧的媒体 ID,和 TempVideoURL 同时出现。 -// FailReason 货憨憨推送失败的原因,非空就是真失败。 -type VideoCheck struct { - Video []json.RawMessage - TempVideoURL string - UploadIDStr string - FailReason string -} - -// Confirmed 表示视频已经设置成功。 -// -// 注意不能只看 Video:保存成功后 Shopee 侧的同步是异步的, -// 刚设置完 Video 必然还是空的,此时视频在 TempVideoURL 里。 -// payloads/huohanhan_save_product_info.har 第 3 个请求就是一次保存成功后 -// 立刻发起的 getDetail,那里 video=[] 而 tempVideoUrl 有值。 -// 只认 Video 会把成功的上传误判成失败。 -func (v VideoCheck) Confirmed() bool { - return len(v.Video) > 0 || strings.TrimSpace(v.TempVideoURL) != "" || - strings.TrimSpace(v.UploadIDStr) != "" -} - -// LiveOnShopee 表示视频已经同步到 Shopee 并生效,比 Confirmed 更强。 -func (v VideoCheck) LiveOnShopee() bool { return len(v.Video) > 0 } - -// CheckShopProductVideo 回读货憨憨的商品视频字段。 -func (c *Client) CheckShopProductVideo(ctx context.Context, id string) (VideoCheck, error) { - form := url.Values{ - "size": {"1"}, "current": {"1"}, "descs": {""}, "ascs": {""}, "ids": {id}, - "fields": {"video,videoUploadIdStr,videoFailReason,tempVideoUrl"}, - } - response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/getShopItemInfoPage", []byte(form.Encode()), "application/x-www-form-urlencoded;charset=UTF-8") - if err != nil { - return VideoCheck{}, fmt.Errorf("回读商品视频失败:%w", err) - } - defer response.Body.Close() - // 这个接口没有 bean 外层,直接就是 {"records":[...]},和其它接口不一样。 - var payload struct { - Records []struct { - Video []json.RawMessage `json:"video"` - TempVideoURL string `json:"tempVideoUrl"` - VideoUploadIDStr string `json:"videoUploadIdStr"` - VideoFailReason string `json:"videoFailReason"` - } `json:"records"` - } - if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { - return VideoCheck{}, fmt.Errorf("回读商品视频返回的不是有效 JSON:%w", err) - } - if len(payload.Records) == 0 { - return VideoCheck{}, fmt.Errorf("回读商品视频失败:服务端未返回商品记录") - } - record := payload.Records[0] - return VideoCheck{ - Video: record.Video, TempVideoURL: record.TempVideoURL, - UploadIDStr: record.VideoUploadIDStr, FailReason: record.VideoFailReason, - }, nil -} - -type successResponse struct { - Type string `json:"type"` - Message string `json:"message"` - Code string `json:"code"` - Bean []string `json:"bean"` -} - -func (p successResponse) check(action string) error { - if p.Type == "SUCCESS" { - return nil - } - detail := strings.TrimSpace(p.Message) - if detail == "" { - detail = "服务端未说明原因" - } - if code := strings.TrimSpace(p.Code); code != "" { - return fmt.Errorf("%s失败:%s(错误码 %s)", action, detail, code) - } - return fmt.Errorf("%s失败:%s", action, detail) -} - -// JSON 字段必须恰好是这三个。不要在这里增加商品其它字段,接口是覆盖语义。 -type productVideoUpdate struct { - ID string `json:"id"` - PlatformShopID string `json:"platformShopId"` - VideoURL string `json:"videoUrl"` -} diff --git a/internal/huohanhan/upload_test.go b/internal/huohanhan/upload_test.go deleted file mode 100644 index 0a3dd8a..0000000 --- a/internal/huohanhan/upload_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package huohanhan - -import ( - "context" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "cmsp/internal/logx" -) - -func Test上传素材使用HAR规定的multipart字段(t *testing.T) { - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/product/material/uploadFiles" { - t.Errorf("上传路径不正确:%s", r.URL.Path) - } - if err := r.ParseMultipartForm(10 << 20); err != nil { - t.Fatalf("解析上传表单失败:%v", err) - } - file, header, err := r.FormFile("files") - if err != nil { - t.Fatalf("上传表单缺少 files:%v", err) - } - defer file.Close() - if header.Filename != "本地视频.mp4" { - t.Errorf("文件名应取本地文件名,实际 %q", header.Filename) - } - if got := header.Header.Get("Content-Type"); got != "video/mp4" { - t.Errorf("视频 Content-Type 应为 video/mp4,实际 %q", got) - } - content, _ := io.ReadAll(file) - if string(content) != "fake-mp4" { - t.Errorf("视频内容不正确:%q", content) - } - if r.Form.Get("isLocalFile") != "true" || r.Form.Get("fileType") != "1" { - t.Errorf("上传固定字段不正确:%v", r.Form) - } - writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200", "bean": []string{"https://cos.example.invalid/video.mp4"}}) - }) - - url, err := client.UploadVideo(context.Background(), `C:\下载\本地视频.mp4`, []byte("fake-mp4")) - if err != nil || url != "https://cos.example.invalid/video.mp4" { - t.Fatalf("上传结果不正确:url=%q err=%v", url, err) - } -} - -func Test上传素材bean为空必须失败(t *testing.T) { - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200", "bean": []string{}}) - }) - _, err := client.UploadVideo(context.Background(), "empty.mp4", []byte("fake-mp4")) - if err == nil || !strings.Contains(err.Error(), "未返回视频地址") { - t.Fatalf("bean 为空必须返回可读错误,实际 %v", err) - } -} - -func Test关联商品视频请求必须是三个字段的裸数组(t *testing.T) { - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/product/batchEdit/batchUpdateShopProductVideo" { - t.Errorf("关联路径不正确:%s", r.URL.Path) - } - var body []map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("关联请求不是 JSON:%v", err) - } - if len(body) != 1 { - t.Fatalf("关联请求必须是仅含一个元素的裸数组,实际 %v", body) - } - want := map[string]string{"id": "货憨憨内部ID", "platformShopId": "店铺ID", "videoUrl": "https://cos.example.invalid/video.mp4"} - if len(body[0]) != len(want) { - t.Fatalf("关联元素必须恰好三个字段,实际 %v", body[0]) - } - for key, value := range want { - if body[0][key] != value { - t.Errorf("字段 %s 期望 %q,实际 %q", key, value, body[0][key]) - } - } - writeJSON(w, map[string]any{"type": "SUCCESS", "code": "200"}) - }) - if err := client.UpdateShopProductVideo(context.Background(), "货憨憨内部ID", "店铺ID", "https://cos.example.invalid/video.mp4"); err != nil { - t.Fatalf("关联应成功,实际 %v", err) - } -} - -func Test非SUCCESS响应只请求一次并返回中文错误(t *testing.T) { - calls := 0 - client := newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - calls++ - writeJSON(w, map[string]any{"type": "ERROR", "code": "LIMIT", "message": "空间不足"}) - }) - err := client.UpdateShopProductVideo(context.Background(), "内部ID", "店铺ID", "https://cos.example.invalid/video.mp4") - if err == nil || !strings.Contains(err.Error(), "关联商品视频失败") || !strings.Contains(err.Error(), "空间不足") { - t.Fatalf("非 SUCCESS 应返回可读中文错误,实际 %v", err) - } - if calls != 1 { - t.Fatalf("非 SUCCESS 不得重试,实际请求 %d 次", calls) - } -} - -// 回读用的假服务器,records[0] 直接用给定字段。 -func newVideoCheckClient(t *testing.T, record map[string]any) *Client { - return newBusinessTestClient(t, logx.New(100), func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Fatalf("解析回读表单失败:%v", err) - } - if r.Form.Get("ids") != "货憨憨内部ID" || r.Form.Get("fields") != "video,videoUploadIdStr,videoFailReason,tempVideoUrl" { - t.Errorf("回读表单不符合 HAR:%v", r.Form) - } - writeJSON(w, map[string]any{"records": []any{record}, "total": "1"}) - }) -} - -func Test回读四个字段全空判定为未关联(t *testing.T) { - client := newVideoCheckClient(t, map[string]any{ - "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "", "videoFailReason": "", - }) - check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") - if err != nil { - t.Fatalf("回读失败:%v", err) - } - if check.Confirmed() || check.LiveOnShopee() { - t.Fatalf("四个字段全空必须判为未关联:%+v", check) - } -} - -// 这条是本项目踩过的真实坑:保存成功后货憨憨推送到 Shopee 是异步的, -// video 必然还是空的,视频这时在 tempVideoUrl 里。 -// 只认 video 会把成功的上传误判成失败,55066525387 就是这样报错的。 -// 证据:payloads/huohanhan_save_product_info.har 第 3 个请求。 -func Test刚保存完video为空但tempVideoUrl有值应判为成功(t *testing.T) { - client := newVideoCheckClient(t, map[string]any{ - "video": []any{}, - "tempVideoUrl": "https://hhh-prod-1307856765.cos.ap-guangzhou.myqcloud.com/video/1126859448838946817.mp4", - "videoUploadIdStr": "sg-11110106-6vbma-msnl9mrjxxqd2d", - "videoFailReason": "", - }) - check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") - if err != nil { - t.Fatalf("回读失败:%v", err) - } - if !check.Confirmed() { - t.Fatalf("tempVideoUrl 有值必须判为已设置成功:%+v", check) - } - if check.LiveOnShopee() { - t.Fatalf("video 为空时不得声称已在 Shopee 生效:%+v", check) - } -} - -func Test只有videoUploadIdStr有值也判为成功(t *testing.T) { - client := newVideoCheckClient(t, map[string]any{ - "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "sg-11110106-abc", "videoFailReason": "", - }) - check, _ := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") - if !check.Confirmed() { - t.Fatalf("videoUploadIdStr 有值必须判为已设置成功:%+v", check) - } -} - -func Test字段video有值判为已在Shopee生效(t *testing.T) { - client := newVideoCheckClient(t, map[string]any{ - "video": []any{map[string]any{"videoUrl": "https://cvf.shopee.tw/file/xxx.mp4"}}, - "tempVideoUrl": "", "videoUploadIdStr": "", "videoFailReason": "", - }) - check, _ := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") - if !check.Confirmed() || !check.LiveOnShopee() { - t.Fatalf("video 有值必须同时判为已确认且已生效:%+v", check) - } -} - -func Test回读带失败原因时暴露原因(t *testing.T) { - client := newVideoCheckClient(t, map[string]any{ - "video": []any{}, "tempVideoUrl": "", "videoUploadIdStr": "", - "videoFailReason": "视频时长超过限制", - }) - check, err := client.CheckShopProductVideo(context.Background(), "货憨憨内部ID") - if err != nil { - t.Fatalf("回读失败:%v", err) - } - if check.FailReason != "视频时长超过限制" { - t.Fatalf("失败原因必须原样带出:%+v", check) - } -} diff --git a/internal/store/store.go b/internal/store/store.go index 060530f..f8dc287 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,6 @@ // Package store 负责本地 SQLite 数据库。 // -// 本项目的所有状态(商品、视频、下载和上传进度、货憨憨登录态) +// 本项目的所有状态(商品、视频、下载和上传进度、上传操作身份) // 都保存在这里,它是唯一的事实来源。不要再用 JSON 文件另存一份, // 那样两边一定会不一致。 // @@ -78,7 +78,7 @@ func (s *Store) DB() *sql.DB { return s.db } var migrations = []string{ // 1. 商品表。一行对应货憨憨里的一个 Shopee 在线商品。 `CREATE TABLE IF NOT EXISTS products ( - -- 货憨憨内部记录 ID,是后续所有写操作的关联键。 + -- 货憨憨内部记录 ID,只作为本地关联键;erpgo 视频写接口使用 Shopee ID。 -- 注意不是 Shopee 商品 ID,两者不同,别搞混。 id TEXT PRIMARY KEY, -- Shopee 商品 ID,界面上显示为「蝦皮ID」。 @@ -127,7 +127,7 @@ var migrations = []string{ `CREATE INDEX IF NOT EXISTS idx_videos_product ON videos (product_id)`, - // 4. 键值表。存货憨憨登录态这类零散数据。 + // 4. 旧键值表保留迁移兼容;货憨憨认证状态不再读取。 // 存进来的值可能含 token,读写时不要往日志里打。 `CREATE TABLE IF NOT EXISTS kv ( key TEXT PRIMARY KEY, @@ -171,6 +171,20 @@ var migrations = []string{ // 这里把任何非法值统一收敛成 ok,下次「下载数据」写入真实诊断。 `UPDATE products SET video_diagnosis = 'ok' WHERE video_diagnosis NOT IN ('missing', 'ok')`, + // 视频绑定的幂等键必须先于远端 PUT 落盘;旧商品和视频记录不迁移。 + `CREATE TABLE IF NOT EXISTS video_upload_operations ( + product_id TEXT PRIMARY KEY, + shopee_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + file_path TEXT NOT NULL, + file_sha256 TEXT NOT NULL, + file_size INTEGER NOT NULL, + status TEXT NOT NULL, + operation_id TEXT NOT NULL DEFAULT '', + video_url TEXT NOT NULL DEFAULT '', + error_code TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + )`, } // migrate 把表结构升级到最新。 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 2250a4c..94cdc3e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -29,7 +29,7 @@ func TestOpenCreatesAllTables(t *testing.T) { } // 每张表都要真的存在。 - for _, table := range []string{"products", "videos", "kv", "schema_version"} { + for _, table := range []string{"products", "videos", "kv", "schema_version", "video_upload_operations"} { var name string err := s.DB().QueryRow( `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, diff --git a/internal/store/upload_operation.go b/internal/store/upload_operation.go new file mode 100644 index 0000000..bf2b15f --- /dev/null +++ b/internal/store/upload_operation.go @@ -0,0 +1,81 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +var ErrUploadOperationPending = errors.New("视频上传操作尚未终结") + +// UploadOperation 记录本次远端写操作的恢复身份,绝不包含 API Key 或响应原文。 +type UploadOperation struct { + ProductID string + ShopeeID string + IdempotencyKey string + FilePath string + FileSHA256 string + FileSize int64 + Status string + OperationID string + VideoURL string + ErrorCode string + UpdatedAt string +} + +func (s *Store) GetUploadOperation(productID string) (UploadOperation, bool, error) { + var op UploadOperation + err := s.db.QueryRow(`SELECT product_id, shopee_id, idempotency_key, file_path, file_sha256, + file_size, status, operation_id, video_url, error_code, updated_at + FROM video_upload_operations WHERE product_id = ?`, productID).Scan( + &op.ProductID, &op.ShopeeID, &op.IdempotencyKey, &op.FilePath, &op.FileSHA256, + &op.FileSize, &op.Status, &op.OperationID, &op.VideoURL, &op.ErrorCode, &op.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return UploadOperation{}, false, nil + } + if err != nil { + return UploadOperation{}, false, fmt.Errorf("读取视频上传操作失败:%w", err) + } + return op, true, nil +} + +// BeginUploadOperation 只允许覆盖明确终结的记录。并发/重启后未决操作不能换键再写。 +func (s *Store) BeginUploadOperation(op UploadOperation) error { + result, err := s.db.Exec(`INSERT INTO video_upload_operations ( + product_id, shopee_id, idempotency_key, file_path, file_sha256, file_size, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'created', ?) + ON CONFLICT(product_id) DO UPDATE SET + shopee_id=excluded.shopee_id, idempotency_key=excluded.idempotency_key, + file_path=excluded.file_path, file_sha256=excluded.file_sha256, + file_size=excluded.file_size, status='created', operation_id='', video_url='', error_code='', + updated_at=excluded.updated_at + WHERE video_upload_operations.status IN ('succeeded', 'failed')`, + op.ProductID, op.ShopeeID, op.IdempotencyKey, op.FilePath, op.FileSHA256, op.FileSize, op.UpdatedAt) + if err != nil { + return fmt.Errorf("保存视频上传操作失败:%w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("确认视频上传操作失败:%w", err) + } + if changed != 1 { + return ErrUploadOperationPending + } + return nil +} + +// UpdateUploadOperation 只更新与当前幂等键匹配的操作,避免旧响应覆盖新操作。 +func (s *Store) UpdateUploadOperation(op UploadOperation) error { + result, err := s.db.Exec(`UPDATE video_upload_operations SET status=?, operation_id=?, + video_url=?, error_code=?, updated_at=? WHERE product_id=? AND idempotency_key=?`, + op.Status, op.OperationID, op.VideoURL, op.ErrorCode, op.UpdatedAt, + op.ProductID, op.IdempotencyKey) + if err != nil { + return fmt.Errorf("更新视频上传操作失败:%w", err) + } + changed, err := result.RowsAffected() + if err != nil || changed != 1 { + return fmt.Errorf("视频上传操作身份已变化") + } + return nil +} diff --git a/internal/store/upload_operation_test.go b/internal/store/upload_operation_test.go new file mode 100644 index 0000000..d293de9 --- /dev/null +++ b/internal/store/upload_operation_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestUploadOperationSurvivesRestartAndBlocksNewKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + op := UploadOperation{ProductID: "fictional-internal-id", ShopeeID: "123", IdempotencyKey: "fictional-key-1", FilePath: "fictional.mp4", FileSize: 10, UpdatedAt: "2026-09-30T00:00:00Z"} + if err := s.BeginUploadOperation(op); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + got, found, err := s.GetUploadOperation(op.ProductID) + if err != nil || !found || got.IdempotencyKey != op.IdempotencyKey { + t.Fatalf("operation lost: %+v %v", got, err) + } + op.IdempotencyKey = "fictional-key-2" + if err := s.BeginUploadOperation(op); !errors.Is(err, ErrUploadOperationPending) { + t.Fatalf("pending operation overwritten: %v", err) + } + got.Status = "succeeded" + if err := s.UpdateUploadOperation(got); err != nil { + t.Fatal(err) + } + if err := s.BeginUploadOperation(op); err != nil { + t.Fatal(err) + } + got, _, err = s.GetUploadOperation(op.ProductID) + if err != nil || got.IdempotencyKey != op.IdempotencyKey { + t.Fatalf("terminal operation not replaced: %+v %v", got, err) + } +}