feat: 视频上传改走 erpgo 幂等操作接口 (#27)
This commit is contained in:
@@ -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` 才能记录完成。真实店铺写入仍须使用者明确指定商品并确认。
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 流程的路径,避免使用者重新登录。该目录不得提交、打包或共享。
|
||||
|
||||
@@ -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 独立发布验证。
|
||||
|
||||
### 路径二:淘宝以图搜与视频下载
|
||||
|
||||
|
||||
@@ -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 字段示例(不含真实凭据):
|
||||
|
||||
|
||||
@@ -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(不含凭据)并在工单排查。
|
||||
|
||||
## 必须停止的情况
|
||||
|
||||
|
||||
@@ -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 独立发布验证。
|
||||
|
||||
## 已确认的界面决定
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
// Package erpgo 只消费 ERPGo 的货憨憨查询接口,不调用同步或写入接口。
|
||||
// Package erpgo 消费 ERPGo 的货憨憨查询与 Shopee 商品视频接口。
|
||||
package erpgo
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package erpgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VideoError 只暴露稳定错误码及请求编号,不包含完整 URL、API Key、文件或上游原文。
|
||||
type VideoError struct {
|
||||
Code string `json:"errorCode"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
SubmissionState string `json:"submissionState,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (e *VideoError) Error() string {
|
||||
b, _ := json.Marshal(e)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type VideoOperation struct {
|
||||
OperationID string `json:"operationId"`
|
||||
ShopeeID string `json:"shopeeId"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
SubmissionState string `json:"submissionState"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
}
|
||||
|
||||
type CurrentVideo struct {
|
||||
ShopeeID string `json:"shopeeId"`
|
||||
Source string `json:"source"`
|
||||
FetchedAt string `json:"fetchedAt"`
|
||||
Video []json.RawMessage `json:"video"`
|
||||
TempVideoURL string `json:"tempVideoUrl"`
|
||||
UploadIDStr string `json:"videoUploadIdStr"`
|
||||
}
|
||||
|
||||
func (v CurrentVideo) Confirmed() bool {
|
||||
return len(v.Video) > 0 || v.TempVideoURL != "" || v.UploadIDStr != ""
|
||||
}
|
||||
|
||||
type videoEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
Stage string `json:"stage"`
|
||||
SubmissionState string `json:"submissionState"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func videoFailure(code string, status int, payload videoEnvelope, key string) *VideoError {
|
||||
if !knownVideoCode(code) {
|
||||
code = "INVALID_RESPONSE"
|
||||
}
|
||||
stage, submission := payload.Stage, payload.SubmissionState
|
||||
if !knownVideoStage(stage) {
|
||||
stage = ""
|
||||
}
|
||||
if submission != "not_submitted" && submission != "submitted" && submission != "unknown" {
|
||||
submission = ""
|
||||
}
|
||||
return &VideoError{Code: code, Status: status,
|
||||
Stage: stage, SubmissionState: submission,
|
||||
RequestID: safeRequestID(payload.RequestID, key)}
|
||||
}
|
||||
|
||||
func knownVideoCode(code string) bool {
|
||||
switch code {
|
||||
case "INVALID_VIDEO", "INVALID_SHOPEE_ID", "INVALID_IDEMPOTENCY_KEY", "VIDEO_TOO_LARGE",
|
||||
"PRODUCT_NOT_FOUND", "VIDEO_OPERATION_NOT_FOUND", "IDEMPOTENCY_CONFLICT",
|
||||
"VIDEO_OPERATION_PENDING", "VIDEO_ALREADY_PROCESSING", "VIDEO_ACCOUNT_CHANGED",
|
||||
"SERVICE_UNAVAILABLE", "VIDEO_DATABASE_ERROR", "HHH_AUTH_FAILED", "HHH_UPSTREAM_ERROR",
|
||||
"HHH_UPSTREAM_TIMEOUT", "VIDEO_BIND_REJECTED", "VIDEO_RESULT_UNKNOWN", "VIDEO_PUBLISH_FAILED",
|
||||
"VIDEO_INTERRUPTED", "API_KEY_INVALID", "INVALID_ARGUMENT":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func knownVideoStage(stage string) bool {
|
||||
switch stage {
|
||||
case "validate", "lookup", "snapshot", "upload", "bind", "check", "completed":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func videoShopID(id string) bool {
|
||||
if len(id) < 1 || len(id) > 20 {
|
||||
return false
|
||||
}
|
||||
for _, c := range id {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func videoOperationStatus(status string) bool {
|
||||
switch status {
|
||||
case "created", "uploaded", "submitted", "processing", "succeeded", "failed", "unknown":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) videoRequest(ctx context.Context, method, shopID, suffix, key string, body io.Reader, contentType string, contentLength int64, timeout time.Duration, dest any) error {
|
||||
if !videoShopID(shopID) {
|
||||
return &VideoError{Code: "INVALID_SHOPEE_ID"}
|
||||
}
|
||||
u := c.baseURL + "/api/v1/shopee/products/" + url.PathEscape(shopID) + "/video" + suffix
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||
if err != nil {
|
||||
return &VideoError{Code: "INVALID_ARGUMENT"}
|
||||
}
|
||||
req.Header.Set("X-API-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if contentLength >= 0 {
|
||||
req.ContentLength = contentLength
|
||||
}
|
||||
if key != "" && method == http.MethodPut {
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
client := *c.http
|
||||
client.Timeout = timeout
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
code := "NETWORK_ERROR"
|
||||
if errors.Is(err, context.Canceled) {
|
||||
code = "REQUEST_CANCELLED"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
code = "REQUEST_TIMEOUT"
|
||||
}
|
||||
return &VideoError{Code: code}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
const maxResponse = 1 << 20
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse+1))
|
||||
if err != nil || len(raw) > maxResponse {
|
||||
return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode}
|
||||
}
|
||||
var result videoEnvelope
|
||||
if json.Unmarshal(raw, &result) != nil || result.Code != resp.StatusCode {
|
||||
if resp.StatusCode == http.StatusRequestEntityTooLarge {
|
||||
return &VideoError{Code: "VIDEO_TOO_LARGE", Status: resp.StatusCode}
|
||||
}
|
||||
return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && (method != http.MethodPut || resp.StatusCode != http.StatusAccepted) {
|
||||
if result.ErrorCode == "" {
|
||||
return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode}
|
||||
}
|
||||
return videoFailure(result.ErrorCode, resp.StatusCode, result, c.apiKey)
|
||||
}
|
||||
if len(result.Data) == 0 || string(result.Data) == "null" || json.Unmarshal(result.Data, dest) != nil {
|
||||
return &VideoError{Code: "INVALID_RESPONSE", Status: resp.StatusCode}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCurrentVideo(ctx context.Context, shopID string) (CurrentVideo, error) {
|
||||
var current CurrentVideo
|
||||
err := c.videoRequest(ctx, http.MethodGet, shopID, "", "", nil, "", -1, 30*time.Second, ¤t)
|
||||
if err != nil {
|
||||
return CurrentVideo{}, err
|
||||
}
|
||||
if current.ShopeeID != shopID || current.Source != "huohanhan" || current.Video == nil {
|
||||
return CurrentVideo{}, &VideoError{Code: "INVALID_RESPONSE"}
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, current.FetchedAt); err != nil {
|
||||
return CurrentVideo{}, &VideoError{Code: "INVALID_RESPONSE"}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetVideoOperation(ctx context.Context, shopID, key string) (VideoOperation, error) {
|
||||
var op VideoOperation
|
||||
suffix := "/operation?idempotencyKey=" + url.QueryEscape(key)
|
||||
err := c.videoRequest(ctx, http.MethodGet, shopID, suffix, "", nil, "", -1, 30*time.Second, &op)
|
||||
if err != nil {
|
||||
return VideoOperation{}, err
|
||||
}
|
||||
if op.ShopeeID != shopID || !videoOperationStatus(op.Status) {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_RESPONSE"}
|
||||
}
|
||||
if op.ErrorCode != "" && !knownVideoCode(op.ErrorCode) {
|
||||
op.ErrorCode = "REMOTE_VIDEO_FAILED"
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
|
||||
// PutVideo 流式上传单个本地文件。调用方必须先持久化幂等键并完成内容预检。
|
||||
func (c *Client) PutVideo(ctx context.Context, shopID, key string, file *os.File, size int64) (VideoOperation, error) {
|
||||
if file == nil || size <= 0 || size > 30*1024*1024 || !videoShopID(shopID) {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"}
|
||||
}
|
||||
if len(key) < 1 || len(key) > 128 || strings.IndexFunc(key, func(r rune) bool { return r < 33 || r > 126 }) >= 0 {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_IDEMPOTENCY_KEY"}
|
||||
}
|
||||
var envelope bytes.Buffer
|
||||
writer := multipart.NewWriter(&envelope)
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="video"; filename=%q`, filepath.Base(file.Name())))
|
||||
header.Set("Content-Type", "video/mp4")
|
||||
if _, err := writer.CreatePart(header); err != nil {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"}
|
||||
}
|
||||
prefixSize := envelope.Len()
|
||||
if err := writer.Close(); err != nil {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_VIDEO"}
|
||||
}
|
||||
encoded := envelope.Bytes()
|
||||
body := io.MultiReader(bytes.NewReader(encoded[:prefixSize]), io.LimitReader(file, size), bytes.NewReader(encoded[prefixSize:]))
|
||||
var op VideoOperation
|
||||
err := c.videoRequest(ctx, http.MethodPut, shopID, "", key, body, writer.FormDataContentType(), int64(len(encoded))+size, 6*time.Minute, &op)
|
||||
if err != nil {
|
||||
return VideoOperation{}, err
|
||||
}
|
||||
if op.ShopeeID != shopID || !videoOperationStatus(op.Status) {
|
||||
return VideoOperation{}, &VideoError{Code: "INVALID_RESPONSE"}
|
||||
}
|
||||
if op.ErrorCode != "" && !knownVideoCode(op.ErrorCode) {
|
||||
op.ErrorCode = "REMOTE_VIDEO_FAILED"
|
||||
}
|
||||
return op, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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 把表结构升级到最新。
|
||||
|
||||
@@ -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=?`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user