fix: 未决视频操作使用原键恢复确认 (#29)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<!-- 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`,不记作上传失败。`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)
|
||||
|
||||
@@ -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
|
||||
<!-- 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: []`,排除上游回读问题后再手动重试。若返回 `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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user