fix: ERPGo 上传直连并阻止处理中重复提交 (#28)
This commit is contained in:
@@ -1014,6 +1014,9 @@ func (a *App) GetUploadPreview(productIDs []string) (preview UploadPreview, prev
|
||||
if err != nil {
|
||||
return UploadPreview{}, err
|
||||
}
|
||||
if check.Processing() {
|
||||
return UploadPreview{}, &erpgo.VideoError{Code: "VIDEO_ALREADY_PROCESSING", Stage: "check", Status: 409}
|
||||
}
|
||||
preview.HasExistingVideo = check.Confirmed()
|
||||
}
|
||||
return preview, nil
|
||||
|
||||
@@ -101,6 +101,10 @@ func (a *App) uploadOneVideo(ctx context.Context, client *erpgo.Client, id strin
|
||||
fail(videoCode(err))
|
||||
return
|
||||
}
|
||||
if current.Processing() {
|
||||
pending("VIDEO_ALREADY_PROCESSING")
|
||||
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)
|
||||
|
||||
@@ -210,3 +210,51 @@ func TestUnknownCurrentVideoDoesNotSubmitUpload(t *testing.T) {
|
||||
t.Fatalf("unknown state created operation: %v %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessingVideoDoesNotCreateUploadOperation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, tempURL, uploadID string
|
||||
}{
|
||||
{"tempVideoUrl", "https://example.invalid/processing.mp4", ""},
|
||||
{"videoUploadIdStr", "", "fictional-processing-id"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
putCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
putCount++
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-10-05T00:00:00Z","video":[],"tempVideoUrl":%q,"videoUploadIdStr":%q}}`, tc.tempURL, tc.uploadID)
|
||||
}))
|
||||
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-10-05 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()
|
||||
client, err := erpgo.NewClient(a.cfg.ERPGo, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.GetUploadPreview([]string{"internal-1"}); err == nil || !strings.Contains(err.Error(), "VIDEO_ALREADY_PROCESSING") {
|
||||
t.Fatalf("preview should block processing video: %v", err)
|
||||
}
|
||||
a.uploadOneVideo(context.Background(), client, "internal-1", 1)
|
||||
p, _, err := db.GetProduct("internal-1")
|
||||
if err != nil || p.UploadStatus != store.UploadPending || p.LastError != "VIDEO_ALREADY_PROCESSING" || putCount != 0 {
|
||||
t.Fatalf("unexpected upload state: %+v err=%v put=%d", p, err, putCount)
|
||||
}
|
||||
if _, found, err := db.GetUploadOperation("internal-1"); err != nil || found {
|
||||
t.Fatalf("processing video created operation: found=%v err=%v", found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
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: 21b539622946d275a78fc6c469a50a14cd5eb4b4
|
||||
synchronized_at: 2026-09-30T01:43:09Z
|
||||
wiki_revision: 55761e55811a7eaab87ff41a97de07375425c0ab
|
||||
synchronized_at: 2026-10-05T02:08:22Z
|
||||
<!-- 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。
|
||||
`app_upload.go` 以本地 SQLite 的 `video_upload_operations` 保存幂等键、Shopee ID、文件路径/摘要及远端状态。使用者确认后先 GET 当前视频,再 PUT `/api/v1/shopee/products/{shopeeId}/video`,后续同键 GET `/video/operation`。仅 `succeeded` 写入本地完成状态;未决或网络异常保留原键,避免自动重复 PUT。`internal/erpgo.NewClient` 只为 ERPGo 请求禁用本机环境代理,直连配置的服务地址;淘宝及其他 HTTP 请求的代理设置不变。
|
||||
|
||||
|
||||
## 全部店铺商品同步(2026-09-29,#26)
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
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: b2969f370a6186860f5769ac374afe90498cf3a2
|
||||
synchronized_at: 2026-09-30T03:11:24Z
|
||||
wiki_revision: 07e5b780520ce93c657ce4ee0504e279abfac89f
|
||||
synchronized_at: 2026-10-05T02:08:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
|
||||
## 指定商品视频上传规则(2026-09-30)
|
||||
|
||||
单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。ERPGo 预检返回 200 且 `video: []` 时才确认远端当前无视频;返回 502 / `HHH_UPSTREAM_ERROR` / `stage=check` 表示视频状态未知,显示“视频状态无法确认,上传未提交”,不创建上传操作、不发送 PUT,保留本地 MP4 与 requestId。批量路径的该商品上传状态为 `unconfirmed`,不记作上传失败。
|
||||
单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。ERPGo 预检返回 200 且 `video: []` 时才确认远端当前无视频;返回 502 / `HHH_UPSTREAM_ERROR` / `stage=check` 表示视频状态未知,显示“视频状态无法确认,上传未提交”,不创建上传操作、不发送 PUT,保留本地 MP4 与 requestId。批量路径的该商品上传状态为 `unconfirmed`,不记作上传失败。`tempVideoUrl` 或 `videoUploadIdStr` 非空表示远端仍有处理中标记:单商品预览停止,执行前再次检查;批量遇到该商品标为待处理,不创建新操作、不发送 PUT。只有普通已存在视频且没有处理中标记时,单商品才可在明确覆盖确认后继续。
|
||||
|
||||
|
||||
## 未选择店铺的同步范围(2026-09-29,#26)
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Troubleshooting
|
||||
wiki_revision: 3d7d777fd2dc91801cdf56499c0314f303feeb1e
|
||||
synchronized_at: 2026-09-30T03:11:24Z
|
||||
wiki_revision: b73573e8b1db7c2179e7063dc4ab5c16e0fc40d3
|
||||
synchronized_at: 2026-10-05T02:08:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
## 视频上传经 erpgo(2026-09-30)
|
||||
|
||||
先核对参数设置中的 erpgo 地址和 API Key。视频目录使用 Shopee 商品 ID;需 mp4、10—60 秒、宽高不超过 1280、文件不超过 30 MB。操作不确定时 SQLite 会保留原幂等键;重复点击只查询原操作,不会重新提交。`succeeded` 是 erpgo 对本次货憨憨视频关联的回读结果,Shopee 页面仍需人工验收。预览若出现 502 / `HHH_UPSTREAM_ERROR` / `stage=check`,界面提示“视频状态无法确认,上传未提交”并保留 requestId;此时 ERPGo 未确认货憨憨 `video` 字段,cmsp 不会发送 PUT。不能把字段缺失当成 `video: []`,排除上游回读问题后再手动重试。
|
||||
先核对参数设置中的 erpgo 地址和 API Key。视频目录使用 Shopee 商品 ID;需 mp4、10—60 秒、宽高不超过 1280、文件不超过 30 MB。操作不确定时 SQLite 会保留原幂等键;重复点击只查询原操作,不会重新提交。`succeeded` 是 erpgo 对本次货憨憨视频关联的回读结果,Shopee 页面仍需人工验收。预览若出现 502 / `HHH_UPSTREAM_ERROR` / `stage=check`,界面提示“视频状态无法确认,上传未提交”并保留 requestId;此时 ERPGo 未确认货憨憨 `video` 字段,cmsp 不会发送 PUT。不能把字段缺失当成 `video: []`,排除上游回读问题后再手动重试。若返回 `VIDEO_ALREADY_PROCESSING`,等待 ERPGo 核对处理中标记及原操作,不新建幂等键。cmsp 的 ERPGo 客户端直连配置的服务地址,避免本机环境代理截断 MP4 PUT;若仍有 `NETWORK_ERROR`,按请求时段核对网络、代理入口、ERPGo 应用日志和原键操作查询。`VIDEO_OPERATION_NOT_FOUND` 不自动证明视频未提交,不删除本地记录或换键重放。运维用另一操作成功上传的视频不自动认领为 cmsp 原操作成功。
|
||||
|
||||
|
||||
## 全店铺同步与部分成功(2026-09-29,#26)
|
||||
|
||||
@@ -643,6 +643,14 @@ function uploadPreviewStateUnknown(err) {
|
||||
}
|
||||
}
|
||||
|
||||
function uploadPreviewErrorCode(err) {
|
||||
try {
|
||||
return JSON.parse(String(err)).errorCode || ''
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSelectedVideo() {
|
||||
if (checkedIds.value.length === 0) {
|
||||
message.warning('请先勾选要上传的商品')
|
||||
@@ -677,6 +685,8 @@ async function uploadSelectedVideo() {
|
||||
const unknown = uploadPreviewStateUnknown(err)
|
||||
if (unknown) {
|
||||
uploadPreviewNotice.value = unknown
|
||||
} else if (uploadPreviewErrorCode(err) === 'VIDEO_ALREADY_PROCESSING') {
|
||||
message.warning('该商品的视频正在货憨憨处理,请稍后重试')
|
||||
} else {
|
||||
message.error(`读取上传信息失败:${err}`)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ func NewClient(cfg config.ERPGoConfig, httpClient *http.Client) (*Client, error)
|
||||
client := http.Client{Timeout: 150 * time.Second}
|
||||
if httpClient != nil {
|
||||
client = *httpClient
|
||||
} else {
|
||||
// ERPGo 是用户配置的内部服务。本机系统代理曾截断视频 PUT,
|
||||
// 因此只让 ERPGo 客户端直连,不改变淘宝及其他 HTTP 请求的代理设置。
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
client.Transport = transport
|
||||
}
|
||||
// 防止重定向将 X-API-Key 转发到其他主机;维护者应填写最终服务地址。
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -14,6 +15,54 @@ import (
|
||||
"cmsp/internal/store"
|
||||
)
|
||||
|
||||
func TestERPGoClientBypassesEnvironmentProxy(t *testing.T) {
|
||||
originCalls, proxyCalls := 0, 0
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
originCalls++
|
||||
writeResponse(w, http.StatusOK, map[string]any{
|
||||
"shopeeId": "123", "source": "huohanhan",
|
||||
"fetchedAt": "2026-10-05T00:00:00Z", "video": []any{},
|
||||
}, "")
|
||||
}))
|
||||
defer origin.Close()
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyCalls++
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("NO_PROXY", "")
|
||||
|
||||
// 将测试域名直接路由到本地服务,只有客户端错误地使用环境代理时才会到代理服务。
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
req, err := http.NewRequest(http.MethodGet, "http://erpgo.internal.invalid/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxyURL, err := transport.Proxy(req)
|
||||
if err != nil || proxyURL == nil {
|
||||
t.Fatalf("test environment must select proxy: %v %v", proxyURL, err)
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, origin.Listener.Addr().String())
|
||||
}
|
||||
previous := http.DefaultTransport
|
||||
http.DefaultTransport = transport
|
||||
defer func() { http.DefaultTransport = previous }()
|
||||
|
||||
client, err := NewClient(config.ERPGoConfig{BaseURL: "http://erpgo.internal.invalid", APIKey: fictionalKey}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.GetCurrentVideo(context.Background(), "123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if originCalls != 1 || proxyCalls != 0 {
|
||||
t.Fatalf("origin=%d proxy=%d", originCalls, proxyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
const fictionalKey = "fictional-api-key-for-tests"
|
||||
|
||||
func writeResponse(w http.ResponseWriter, status int, data any, code string) {
|
||||
|
||||
@@ -54,6 +54,10 @@ func (v CurrentVideo) Confirmed() bool {
|
||||
return len(v.Video) > 0 || v.TempVideoURL != "" || v.UploadIDStr != ""
|
||||
}
|
||||
|
||||
func (v CurrentVideo) Processing() bool {
|
||||
return v.TempVideoURL != "" || v.UploadIDStr != ""
|
||||
}
|
||||
|
||||
type videoEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
|
||||
Reference in New Issue
Block a user