fix: 上传前视频状态未知时阻止提交 (#27)

This commit is contained in:
QiuSW
2026-09-30 11:13:17 +08:00
parent e0bedd1cd1
commit 1f83a34b4f
6 changed files with 132 additions and 17 deletions
+12
View File
@@ -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
}
+61
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 指定商品视频上传规则(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)
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 故障排查
## 视频上传经 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)
+45 -4
View File
@@ -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(() => {
</div>
</div>
<n-alert
v-if="uploadPreviewNotice"
type="warning"
title="视频状态无法确认,上传未提交"
closable
aria-live="assertive"
@close="uploadPreviewNotice = null"
>
erpgo 无法确认该商品在货憨憨的当前视频状态。没有发送上传请求,本地 MP4 保留;稍后可重新尝试。
<span v-if="uploadPreviewNotice.requestId">请求编号:{{ uploadPreviewNotice.requestId }}</span>
</n-alert>
<n-alert
v-if="productSyncResult"
:type="productSyncResult.failedShops || productSyncResult.stopError ? 'warning' : productSyncResult.totalShops ? 'success' : 'info'"
+8 -7
View File
@@ -31,13 +31,14 @@ const (
DownloadNotDownloaded = "not_downloaded"
// upload_status:上传回货憨憨的进度
UploadPending = "pending" // 待上传
UploadRunning = "running" // 上传中
UploadDone = "done" // 已上传
UploadFailed = "failed" // 失败
UploadSkippedExisting = "existing" // 货憨憨已有视频,批量时跳过
UploadMissingVideo = "missing" // 子目录里没有 mp4
UploadInvalidVideo = "invalid" // 有文件但不符合货憨憨要求
UploadPending = "pending" // 待上传
UploadRunning = "running" // 上传中
UploadDone = "done" // 已上传
UploadFailed = "failed" // 失败
UploadUnconfirmed = "unconfirmed" // 远端当前视频状态无法确认,上传未提交
UploadSkippedExisting = "existing" // 货憨憨已有视频,批量时跳过
UploadMissingVideo = "missing" // 子目录里没有 mp4
UploadInvalidVideo = "invalid" // 有文件但不符合货憨憨要求
)
// Product 是一个商品。字段和 products 表一一对应。