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 }} + +