feat: 视频上传改走 erpgo 幂等操作接口 (#27)

This commit is contained in:
QiuSW
2026-09-30 09:42:58 +08:00
parent 5447e3f63a
commit 375f20cdfe
33 changed files with 942 additions and 2554 deletions
+3 -3
View File
@@ -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` 才能记录完成。真实店铺写入仍须使用者明确指定商品并确认。
+5 -148
View File
@@ -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) {
+234
View File
@@ -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
}
+94
View File
@@ -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)
}
}
})
}
}
+14 -9
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 项目档案
## 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 流程的路径,避免使用者重新登录。该目录不得提交、打包或共享。
+17 -17
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
## 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 独立发布验证。
### 路径二:淘宝以图搜与视频下载
+11 -6
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 指定商品视频上传规则(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 表结构与迁移方式。
- 并发下载与上传的默认并发数,以及淘宝风控的实际容忍阈值。
- 视频与商品的匹配规则:一个商品搜到多个同款时,选哪一个的视频,是否需要人工确认。
@@ -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
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
## 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 字段示例(不含真实凭据):
+8 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 故障排查
## 视频上传经 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(不含凭据)并在工单排查。
## 必须停止的情况
+14 -9
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 需求总览
## 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 独立发布验证。
## 已确认的界面决定
-3
View File
@@ -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)
</script>
@@ -31,7 +29,6 @@ const taobaoOk = ref(false)
<Sidebar
:active="page"
:collapsed="collapsed"
:huohanhan-ok="huohanhanOk"
:taobao-ok="taobaoOk"
@navigate="page = $event"
@toggle="collapsed = !collapsed"
-6
View File
@@ -15,8 +15,6 @@ const props = defineProps({
active: { type: String, required: true },
// 是否收起。收起后只剩图标,表格能多出 112px。
collapsed: { type: Boolean, default: false },
// 货憨憨是否已登录
huohanhanOk: { type: Boolean, default: false },
// 淘宝登录是否有效
taobaoOk: { type: Boolean, default: false },
})
@@ -68,10 +66,6 @@ const items = [
</nav>
<div class="foot">
<div class="status" :title="huohanhanOk ? '货憨憨 已登录' : '货憨憨 未登录'">
<i class="dot" :class="huohanhanOk ? 'ok' : 'bad'"></i>
<span v-if="!collapsed">货憨憨 {{ huohanhanOk ? '已登录' : '未登录' }}</span>
</div>
<div class="status" :title="currentTaobaoOk ? '淘宝 登录有效' : '淘宝 登录失效'">
<i class="dot" :class="currentTaobaoOk ? 'ok' : 'bad'"></i>
<span v-if="!collapsed" :class="{ warnText: !currentTaobaoOk }">
+1 -1
View File
@@ -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,
+7 -35
View File
@@ -2,7 +2,7 @@
/**
* 参数设置页。
*
* 参数卡片:erpgo 查询、货憨憨上传、淘宝专属浏览器、下载与任务、货憨憨图片空间。
* 参数卡片:erpgo 查询与上传、淘宝专属浏览器、下载与任务。
*
* 两条不能违反的规则:
* 1. 淘宝那张卡片里没有账号密码输入框,将来也不要加。
@@ -155,9 +155,9 @@ onUnmounted(() => {
<div class="body">
<div class="grid">
<n-card title="erpgo 商品查询" size="small">
<n-card title="erpgo 商品查询与视频上传" size="small">
<template #header-extra>
<span class="faint">用于店铺刷新和商品同步</span>
<span class="faint">用于店铺刷新、商品同步和指定商品视频上传</span>
</template>
<n-form label-placement="top" size="small">
<n-form-item label="服务地址">
@@ -166,31 +166,14 @@ onUnmounted(() => {
<n-form-item label="API Key">
<n-input v-model:value="cfg.erpgo.apiKey" type="password" show-password-on="click" :input-props="{ 'aria-label': 'erpgo API Key', autocomplete: 'off' }" />
</n-form-item>
<div class="faint">保存后查询生效。未配置或服务不可用时,已有商品和任务会保留。</div>
<div class="faint">保存后生效。视频上传仅在商品列表确认后执行;未配置或服务不可用时,已有商品和任务会保留。</div>
</n-form>
</n-card>
<!-- 货憨憨 -->
<n-card title="货憨憨 ERP 账号" size="small">
<n-card title="下载与任务" size="small">
<template #header-extra>
<span class="faint">用于现有视频上传</span>
<span class="faint">淘宝视频采集参数</span>
</template>
<n-form label-placement="top" size="small">
<div class="two">
<n-form-item label="网址">
<n-input v-model:value="cfg.huohanhan.baseUrl" />
</n-form-item>
<n-form-item label="OCR 识别服务">
<n-input v-model:value="cfg.huohanhan.ocrUrl" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="账号">
<n-input v-model:value="cfg.huohanhan.account" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="cfg.huohanhan.password" type="password" show-password-on="click" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="详情页加载等待秒数(3—30)">
<n-input-number v-model:value="cfg.download.detailWaitSeconds" :min="3" :max="30" />
@@ -315,23 +298,12 @@ onUnmounted(() => {
</n-form>
</n-card>
<!-- 图片空间:容量快满是真实风险,先占位,接口接上后填真实数据 -->
<n-card title="货憨憨图片空间" size="small">
<template #header-extra>
<span class="faint">视频体积远大于图片,批量上传前先看容量</span>
</template>
<n-alert type="warning" :bordered="false">
容量查询还没实现(需求 R5)。上线前必须接上
<code>product/material/getMaterialSize</code>:
上传返回「容量不足」时任务要立即停止,不能反复重试。
</n-alert>
</n-card>
</div>
</div>
<div class="savebar">
<span class="faint">
配置保存在本机 config.yaml,密码与 token 不写入仓库、日志和工单
配置保存在本机 config.yaml,API Key 不写入仓库、日志和工单
</span>
<div>
<n-button @click="reset">还原默认</n-button>
+7 -18
View File
@@ -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:
+9 -3
View File
@@ -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) {
+1 -1
View File
@@ -1,4 +1,4 @@
// Package erpgo 只消费 ERPGo 的货憨憨查询接口,不调用同步或写入接口。
// Package erpgo 消费 ERPGo 的货憨憨查询与 Shopee 商品视频接口。
package erpgo
import (
+247
View File
@@ -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, &current)
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
}
+117
View File
@@ -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)
}
})
}
}
-655
View File
@@ -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
}
-340
View File
@@ -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))
}
}
-132
View File
@@ -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]
}
-168
View File
@@ -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)
}
}
-276
View File
@@ -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
}
-206
View File
@@ -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)
}
}
-60
View File
@@ -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
}
-95
View File
@@ -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)
}
}
}
-169
View File
@@ -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"`
}
-184
View File
@@ -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)
}
}
+17 -3
View File
@@ -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 把表结构升级到最新。
+1 -1
View File
@@ -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=?`,
+81
View File
@@ -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
}
+46
View File
@@ -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)
}
}