From 1f83a34b4f34c48ea34d05c8fcc0cd6eb09b16ed Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Wed, 30 Sep 2026 11:13:17 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E5=89=8D=E8=A7=86?=
=?UTF-8?q?=E9=A2=91=E7=8A=B6=E6=80=81=E6=9C=AA=E7=9F=A5=E6=97=B6=E9=98=BB?=
=?UTF-8?q?=E6=AD=A2=E6=8F=90=E4=BA=A4=20(#27)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app_upload.go | 12 +++++
app_upload_test.go | 61 ++++++++++++++++++++++++++
docs/03-business-rules-and-glossary.md | 6 +--
docs/06-troubleshooting.md | 6 +--
frontend/src/views/ProductListView.vue | 49 +++++++++++++++++++--
internal/store/product.go | 15 ++++---
6 files changed, 132 insertions(+), 17 deletions(-)
diff --git a/app_upload.go b/app_upload.go
index 8ef9343..6586088 100644
--- a/app_upload.go
+++ b/app_upload.go
@@ -86,6 +86,18 @@ func (a *App) uploadOneVideo(ctx context.Context, client *erpgo.Client, id strin
}
current, err := client.GetCurrentVideo(ctx, p.ItemID)
if err != nil {
+ var videoErr *erpgo.VideoError
+ if errors.As(err, &videoErr) && videoErr.Status == 502 && videoErr.Code == "HHH_UPSTREAM_ERROR" && videoErr.Stage == "check" {
+ lastError := "VIDEO_STATE_UNKNOWN"
+ if videoErr.RequestID != "" {
+ lastError += " requestId=" + videoErr.RequestID
+ }
+ if updateErr := a.db.UpdateProductStatus(p.ID, "", "", store.UploadUnconfirmed, lastError); updateErr != nil {
+ a.log.Error("商品 %s 保存视频状态未确认结果失败:%v", p.ID, updateErr)
+ }
+ a.log.Warn("商品 %s 视频状态无法确认,上传未提交:%s", p.ID, uploadRequestSummary(err))
+ return
+ }
fail(videoCode(err))
return
}
diff --git a/app_upload_test.go b/app_upload_test.go
index 511eaec..2c09a4d 100644
--- a/app_upload_test.go
+++ b/app_upload_test.go
@@ -149,3 +149,64 @@ func TestUploadStartFailureIsLogged(t *testing.T) {
t.Fatalf("wrong log: %+v", entries)
}
}
+
+func TestUnknownCurrentVideoDoesNotSubmitUpload(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")
+ w.WriteHeader(http.StatusBadGateway)
+ fmt.Fprint(w, `{"code":502,"errorCode":"HHH_UPSTREAM_ERROR","stage":"check","submissionState":"not_submitted","requestId":"fictional-request"}`)
+ }))
+ 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)
+ }
+ videoPath := filepath.Join(dir, "video.mp4")
+ if err := os.WriteFile(videoPath, []byte("fictional-mp4"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.GetUploadPreview([]string{"internal-1"}); err == nil {
+ t.Fatal("unknown state must block preview")
+ }
+ p, _, err := db.GetProduct("internal-1")
+ if err != nil || p.UploadStatus != store.UploadPending || putCount != 0 {
+ t.Fatalf("preview changed state: %+v %v put=%d", p, err, putCount)
+ }
+ if _, found, err := db.GetUploadOperation(p.ID); err != nil || found {
+ t.Fatalf("preview created operation: %v %v", found, err)
+ }
+ client, err := erpgo.NewClient(a.cfg.ERPGo, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.uploadOneVideo(context.Background(), client, p.ID, 2)
+ p, _, err = db.GetProduct(p.ID)
+ if err != nil || p.UploadStatus != store.UploadUnconfirmed || !strings.Contains(p.LastError, "fictional-request") || putCount != 0 {
+ t.Fatalf("unknown state was not preserved: %+v %v put=%d", p, err, putCount)
+ }
+ if _, err := os.Stat(videoPath); err != nil {
+ t.Fatalf("local video removed: %v", err)
+ }
+ if _, found, err := db.GetUploadOperation(p.ID); err != nil || found {
+ t.Fatalf("unknown state created operation: %v %v", found, err)
+ }
+}
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index f6695d4..7451f50 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -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: fa648af69b496386790f9597a1834285a514a323
-synchronized_at: 2026-09-30T01:39:56Z
+wiki_revision: b2969f370a6186860f5769ac374afe90498cf3a2
+synchronized_at: 2026-09-30T03:11:24Z
# 业务规则与术语
## 指定商品视频上传规则(2026-09-30)
-单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。
+单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 Shopee ID 子目录下排序首个 mp4,先完成时长、格式、像素与大小校验。写入前在 SQLite 持久化幂等键。HTTP 202 和 `processing`/`unknown` 均不代表成功;再次操作仅查询原键,`succeeded` 才更新本地视频与上传状态。ERPGo 预检返回 200 且 `video: []` 时才确认远端当前无视频;返回 502 / `HHH_UPSTREAM_ERROR` / `stage=check` 表示视频状态未知,显示“视频状态无法确认,上传未提交”,不创建上传操作、不发送 PUT,保留本地 MP4 与 requestId。批量路径的该商品上传状态为 `unconfirmed`,不记作上传失败。
## 未选择店铺的同步范围(2026-09-29,#26)
diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md
index 17cb7f7..dd078dc 100644
--- a/docs/06-troubleshooting.md
+++ b/docs/06-troubleshooting.md
@@ -2,15 +2,15 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/chengma/cmsp/wiki/Troubleshooting
-wiki_revision: c2123be24a49770df27072d1da69596444f28029
-synchronized_at: 2026-09-30T01:39:57Z
+wiki_revision: 3d7d777fd2dc91801cdf56499c0314f303feeb1e
+synchronized_at: 2026-09-30T03:11:24Z
# 故障排查
## 视频上传经 erpgo(2026-09-30)
-先核对参数设置中的 erpgo 地址和 API Key。视频目录使用 Shopee 商品 ID;需 mp4、10—60 秒、宽高不超过 1280、文件不超过 30 MB。操作不确定时 SQLite 会保留原幂等键;重复点击只查询原操作,不会重新提交。`succeeded` 是 erpgo 对本次货憨憨视频关联的回读结果,Shopee 页面仍需人工验收。
+先核对参数设置中的 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: []`,排除上游回读问题后再手动重试。
## 全店铺同步与部分成功(2026-09-29,#26)
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue
index 92f1f98..a8f91a0 100644
--- a/frontend/src/views/ProductListView.vue
+++ b/frontend/src/views/ProductListView.vue
@@ -22,7 +22,7 @@
* (2026-09-03 负责人决定)。所以本地总数可能大于店铺当前商品数,
* 这是预期行为,不要加自动清理。
*/
-import { computed, h, onMounted, onUnmounted, ref } from 'vue'
+import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue'
import { NButton, useDialog, useMessage } from 'naive-ui'
import { EventsOff, EventsOn } from '../../wailsjs/runtime/runtime'
import * as AppBindings from '../../wailsjs/go/main/App'
@@ -85,6 +85,7 @@ const uploadStatusOptions = [
{ label: '上传中', value: 'running' },
{ label: '已上传', value: 'done' },
{ label: '上传失败', value: 'failed' },
+ { label: '视频状态未确认', value: 'unconfirmed' },
{ label: '已有视频', value: 'existing' },
{ label: '缺少视频', value: 'missing' },
{ label: '视频不合规', value: 'invalid' },
@@ -227,6 +228,8 @@ const rows = ref([])
const total = ref(0)
const loading = ref(false)
const checkedIds = ref([])
+const uploadPreviewNotice = ref(null)
+watch(checkedIds, () => { uploadPreviewNotice.value = null })
const searchingProductID = ref('')
// ---- 主图原图弹窗 ----
@@ -307,8 +310,15 @@ const columns = [
{
title: '上传状态',
key: 'uploadStatus',
- width: 100,
- render: (row) => renderStatus(row.uploadStatus, uploadText),
+ width: 125,
+ render: (row) => {
+ const requestId = row.uploadStatus === 'unconfirmed'
+ ? String(row.lastError || '').match(/requestId=([A-Za-z0-9_-]{1,80})/)?.[1] : ''
+ return h('span', {
+ title: row.uploadStatus === 'unconfirmed'
+ ? `视频状态无法确认,上传未提交${requestId ? `;请求编号:${requestId}` : ''}` : '',
+ }, renderStatus(row.uploadStatus, uploadText))
+ },
},
{ title: '创建时间', key: 'createdAt', width: 150 },
{
@@ -369,6 +379,7 @@ const uploadText = {
running: ['上传中', 'primary'],
done: ['已上传', 'ok'],
failed: ['失败', 'bad'],
+ unconfirmed: ['视频状态未确认', 'warn'],
existing: ['已有视频', 'muted'],
missing: ['缺少视频', 'warn'],
invalid: ['视频不合规', 'bad'],
@@ -620,12 +631,25 @@ function formatFileSize(size) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`
}
+function uploadPreviewStateUnknown(err) {
+ try {
+ const detail = JSON.parse(String(err))
+ if (detail.status !== 502 || detail.errorCode !== 'HHH_UPSTREAM_ERROR' || detail.stage !== 'check') return null
+ const requestId = typeof detail.requestId === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(detail.requestId)
+ ? detail.requestId : ''
+ return { requestId }
+ } catch (_) {
+ return null
+ }
+}
+
async function uploadSelectedVideo() {
if (checkedIds.value.length === 0) {
message.warning('请先勾选要上传的商品')
return
}
try {
+ uploadPreviewNotice.value = null
// 使用 Wails 生成绑定的命名空间访问新方法;审核者生成绑定后即可直接使用。
const preview = await AppBindings.GetUploadPreview([...checkedIds.value])
dialog.warning({
@@ -650,7 +674,12 @@ async function uploadSelectedVideo() {
},
})
} catch (err) {
- message.error(`读取上传信息失败:${err}`)
+ const unknown = uploadPreviewStateUnknown(err)
+ if (unknown) {
+ uploadPreviewNotice.value = unknown
+ } else {
+ message.error(`读取上传信息失败:${err}`)
+ }
}
}
@@ -859,6 +888,18 @@ onUnmounted(() => {
+
+ erpgo 无法确认该商品在货憨憨的当前视频状态。没有发送上传请求,本地 MP4 保留;稍后可重新尝试。
+ 请求编号:{{ uploadPreviewNotice.requestId }}
+
+