From 5702286f42b6c66538e918e542d109600a508806 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 5 Oct 2026 10:30:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9C=AA=E5=86=B3=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=BD=BF=E7=94=A8=E5=8E=9F=E9=94=AE=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E7=A1=AE=E8=AE=A4=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.go | 10 ++++++ app_upload_test.go | 48 ++++++++++++++++++++++++++ docs/03-business-rules-and-glossary.md | 6 ++-- docs/06-troubleshooting.md | 6 ++-- frontend/src/views/ProductListView.vue | 13 ++++--- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/app.go b/app.go index 0e5aeb2..cebcd8f 100644 --- a/app.go +++ b/app.go @@ -366,6 +366,7 @@ type UploadPreview struct { MissingCount int `json:"missingCount"` InvalidCount int `json:"invalidCount"` HasExistingVideo bool `json:"hasExistingVideo"` + ResumeOperation bool `json:"resumeOperation"` } // FetchVideosForProduct 为一个商品依次搜同款、提取视频并下载到本地。 @@ -985,6 +986,15 @@ func (a *App) GetUploadPreview(productIDs []string) (preview UploadPreview, prev } if len(ids) == 1 { single = product + previous, exists, err := a.db.GetUploadOperation(product.ID) + if err != nil { + return UploadPreview{}, err + } + if exists && (previous.Status != "failed" && previous.Status != "succeeded" || + previous.Status == "succeeded" && product.UploadStatus != store.UploadDone) { + preview.ResumeOperation = true + return preview, nil + } } localPath, info, exists, err := a.findUploadVideo(product) if err != nil { diff --git a/app_upload_test.go b/app_upload_test.go index 285c02e..25c34c9 100644 --- a/app_upload_test.go +++ b/app_upload_test.go @@ -258,3 +258,51 @@ func TestProcessingVideoDoesNotCreateUploadOperation(t *testing.T) { }) } } + +func TestUploadPreviewResumesExistingOperationWithoutNewPut(t *testing.T) { + currentReads, operationReads, putCount := 0, 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/video/operation"): + operationReads++ + if r.URL.Query().Get("idempotencyKey") != "fictional-operation-key" { + t.Error("operation key changed") + } + fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","status":"unknown"}}`) + case r.Method == http.MethodGet: + currentReads++ + fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-10-05T00:00:00Z","video":[{}]}}`) + case r.Method == http.MethodPut: + putCount++ + 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-10-05 00:00:00"); err != nil { + t.Fatal(err) + } + if err := db.BeginUploadOperation(store.UploadOperation{ProductID: "internal-1", ShopeeID: "123", IdempotencyKey: "fictional-operation-key", Status: "created", UpdatedAt: "2026-10-05T00:00:00Z"}); err != nil { + t.Fatal(err) + } + a := NewApp() + a.db = db + a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key" + preview, err := a.GetUploadPreview([]string{"internal-1"}) + if err != nil || !preview.ResumeOperation || preview.HasExistingVideo || currentReads != 0 { + t.Fatalf("wrong resume preview: %+v err=%v currentReads=%d", preview, err, currentReads) + } + if err := a.UploadVideos([]string{"internal-1"}); err != nil { + t.Fatal(err) + } + p, _, err := db.GetProduct("internal-1") + if err != nil || p.UploadStatus != store.UploadUnconfirmed || p.LastError != "VIDEO_RESULT_UNKNOWN" || operationReads != 1 || currentReads != 0 || putCount != 0 { + t.Fatalf("unexpected resume result: %+v err=%v operationReads=%d currentReads=%d put=%d", p, err, operationReads, currentReads, putCount) + } +} diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index b91fc71..a6ced42 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: 5856776c55b67b5c0b6e10dd25ed8463c2e355bf -synchronized_at: 2026-10-05T02:24:26Z +wiki_revision: 337733444b6e824e5c847a6648bbbd70e11ea8c5 +synchronized_at: 2026-10-05T02:28:55Z # 业务规则与术语 ## 指定商品视频上传规则(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`,不记作上传失败。`tempVideoUrl` 或 `videoUploadIdStr` 非空表示远端仍有处理中标记:单商品预览停止,执行前再次检查;批量遇到该商品标为待处理,不创建新操作、不发送 PUT。只有普通已存在视频且没有处理中标记时,单商品才可在明确覆盖确认后继续。用户确认后弹窗立即关闭,上传按钮在调用期间继续显示运行中;弹窗关闭不代表远端成功。远端操作 processing/unknown 或操作查询异常保留原键,商品上传状态为 `unconfirmed`,列表显示“待确认”;只有本次操作返回 `succeeded` 才显示“已上传”。 +单商品显示远端已有视频的覆盖警告;批量发现远端已有视频则跳过。上传文件取 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。只有普通已存在视频且没有处理中标记时,单商品才可在明确覆盖确认后继续。用户确认后弹窗立即关闭,上传按钮在调用期间继续显示运行中;弹窗关闭不代表远端成功。单商品已有本地未决操作时,确认框改为“恢复原上传操作”,只查询或恢复原键,不提示覆盖、不发新 PUT;操作明确失败后才按新上传流程检查当前视频。远端操作 processing/unknown 或操作查询异常保留原键,商品上传状态为 `unconfirmed`,列表显示“待确认”;只有本次操作返回 `succeeded` 才显示“已上传”。 ## 未选择店铺的同步范围(2026-09-29,#26) diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 5877bc6..bd141ab 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: e3146f8c3a628e166bc54be6192930b3340cb4cd -synchronized_at: 2026-10-05T02:24:28Z +wiki_revision: 0a176fa4cf7f3bd6a2c6b11ae3c6a58453500c85 +synchronized_at: 2026-10-05T02:28:58Z # 故障排查 ## 视频上传经 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: []`,排除上游回读问题后再手动重试。若返回 `VIDEO_ALREADY_PROCESSING`,等待 ERPGo 核对处理中标记及原操作,不新建幂等键。cmsp 的 ERPGo 客户端直连配置的服务地址,避免本机环境代理截断 MP4 PUT;若仍有 `NETWORK_ERROR`,按请求时段核对网络、代理入口、ERPGo 应用日志和原键操作查询。`VIDEO_OPERATION_NOT_FOUND` 不自动证明视频未提交,不删除本地记录或换键重放。运维用另一操作成功上传的视频不自动认领为 cmsp 原操作成功。确认上传后弹窗应立即关闭,上传按钮继续转圈直到当前调用结束。列表“待确认”表示视频状态或本次操作结果尚未证实;即使货憨憨当前有视频,也要按原幂等键查询 ERPGo 操作状态。`processing/check/submitted` 不能改为已上传,尤其是商品此前已有其他操作的视频;等待 ERPGo 核对本次操作的素材归属,只有 `succeeded` 才更新本地完成状态。 +先核对参数设置中的 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 原操作成功。确认上传后弹窗应立即关闭,上传按钮继续转圈直到当前调用结束。已有未决操作时,确认框显示“恢复原上传操作”,点击后只查询原键,不会重新上传或覆盖当前视频。列表“待确认”表示视频状态或本次操作结果尚未证实;即使货憨憨当前有视频,也要按原幂等键查询 ERPGo 操作状态。`processing/check/submitted` 不能改为已上传,尤其是商品此前已有其他操作的视频;等待 ERPGo 核对本次操作的素材归属,只有 `succeeded` 才更新本地完成状态。 ## 全店铺同步与部分成功(2026-09-29,#26) diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index bf3f026..ce7851b 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -658,11 +658,14 @@ async function uploadSelectedVideo() { } try { uploadPreviewNotice.value = null + const selectedIDs = [...checkedIds.value] // 使用 Wails 生成绑定的命名空间访问新方法;审核者生成绑定后即可直接使用。 - const preview = await AppBindings.GetUploadPreview([...checkedIds.value]) + const preview = await AppBindings.GetUploadPreview(selectedIDs) dialog.warning({ - title: '确认上传视频', - content: () => h('div', { style: 'white-space: pre-line; line-height: 1.75' }, [ + title: preview.resumeOperation ? '恢复原上传操作' : '确认上传视频', + content: () => preview.resumeOperation + ? '这次只查询或恢复原上传操作状态,不会重新提交视频。结果未确认时仍保留原幂等键。' + : h('div', { style: 'white-space: pre-line; line-height: 1.75' }, [ `将处理 ${preview.total} 个商品\n`, ` 可上传 ${preview.uploadableCount} 个,共 ${formatFileSize(preview.uploadableSize)}\n`, ` 缺少视频 ${preview.missingCount} 个\n`, @@ -675,11 +678,11 @@ async function uploadSelectedVideo() { ? h('div', { style: 'color: var(--n-error-color); font-weight: 600; margin-top: 8px' }, '该商品已有视频,上传会覆盖原视频且无法恢复') : null, ]), - positiveText: '确认上传', + positiveText: preview.resumeOperation ? '检查状态' : '确认上传', negativeText: '取消', onPositiveClick: () => { // 让确认框立即关闭;按钮与日志继续反映上传调用的执行状态。 - void run('上传数据', () => UploadVideos([...checkedIds.value])) + void run('上传数据', () => UploadVideos(selectedIDs)) }, }) } catch (err) {