Files
QiuSWandClaude Opus 5 0fe24f70f4 fix: 回读不能只看 video 字段,会把成功的上传误判为失败 (#17)
真机验证时 55066525387 报「回读商品视频失败:货憨憨尚未显示已关联的视频」,
但视频其实已经上传成功。两个写接口都返回了 SUCCESS,是回读判据写错了。

video 字段装的是已经在 Shopee 上生效的视频,不是刚设置的那个。货憨憨推送
到 Shopee 是异步的,保存成功后 video 必然还是空的,视频这时在 tempVideoUrl
和 videoUploadIdStr 里。证据是 payloads/huohanhan_save_product_info.har 的
第 3 个请求——一次保存成功后立刻发起的 getDetail,那里 video=[] 而
tempVideoUrl 有值。这条证据本来就在仓库里,之前定判据时没有用上。

HasShopProductVideo 改为 CheckShopProductVideo,返回四个字段:

  videoFailReason 非空                    → 失败,把原因写进 last_error
  video 非空                              → 已在 Shopee 生效
  tempVideoUrl 或 videoUploadIdStr 非空   → 已提交,等待同步,判为成功
  四者皆空                                → 确实没设上,失败

upload_status=done 的语义随之明确为「已提交并回读确认」,
而不是「Shopee 已可见」——后者是异步结果,本地无法立刻断言,
日志会分别提示这两种情况。

确认框的覆盖警告改用 Confirmed():等待同步中的视频同样会被覆盖,
只看 video 会漏掉这种情况,导致该警告不显示。

同时订正 55066525387 被误判写成的 failed 状态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
2026-09-03 16:02:27 +08:00

170 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package huohanhan
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"
)
// UploadVideo 上传一份本地 MP4 素材,并返回货憨憨保存后的 COS 地址。
// 素材上传与商品关联是两个独立接口;此方法绝不附带商品信息。
func (c *Client) UploadVideo(ctx context.Context, localPath string, content []byte) (string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
fileHeader := make(textproto.MIMEHeader)
fileHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files"; filename=%q`, filepath.Base(localPath)))
fileHeader.Set("Content-Type", "video/mp4")
part, err := writer.CreatePart(fileHeader)
if err != nil {
return "", fmt.Errorf("构造视频上传表单失败:%w", err)
}
if _, err := part.Write(content); err != nil {
return "", fmt.Errorf("写入视频上传表单失败:%w", err)
}
if err := writer.WriteField("isLocalFile", "true"); err != nil {
return "", fmt.Errorf("写入本地文件标记失败:%w", err)
}
if err := writer.WriteField("fileType", "1"); err != nil {
return "", fmt.Errorf("写入文件类型失败:%w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("完成视频上传表单失败:%w", err)
}
response, err := c.Request(ctx, http.MethodPost, "product/material/uploadFiles", body.Bytes(), writer.FormDataContentType())
if err != nil {
return "", fmt.Errorf("上传视频失败:%w", err)
}
defer response.Body.Close()
var payload successResponse
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("上传视频返回的不是有效 JSON:%w", err)
}
if err := payload.check("上传视频"); err != nil {
return "", err
}
if len(payload.Bean) == 0 || strings.TrimSpace(payload.Bean[0]) == "" {
return "", fmt.Errorf("上传视频失败:服务端未返回视频地址")
}
return strings.TrimSpace(payload.Bean[0]), nil
}
// UpdateShopProductVideo 用上传后的地址覆盖关联到一个商品的视频。
func (c *Client) UpdateShopProductVideo(ctx context.Context, id, platformShopID, videoURL string) error {
body, err := json.Marshal([]productVideoUpdate{{
ID: id, PlatformShopID: platformShopID, VideoURL: videoURL,
}})
if err != nil {
return fmt.Errorf("构造视频关联请求失败:%w", err)
}
response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/batchUpdateShopProductVideo", body, "application/json;charset=UTF-8")
if err != nil {
return fmt.Errorf("关联商品视频失败:%w", err)
}
defer response.Body.Close()
var payload successResponse
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return fmt.Errorf("关联商品视频返回的不是有效 JSON:%w", err)
}
return payload.check("关联商品视频")
}
// VideoCheck 是回读商品视频字段的结果。
//
// 这几个字段的含义不一样,别混用:
//
// Video 已经在 Shopee 上生效的视频。货憨憨推送成功后才有值,是异步的。
// TempVideoURL 刚设置进去、还没同步到 Shopee 的视频地址。
// UploadIDStr 货憨憨/Shopee 侧的媒体 ID,和 TempVideoURL 同时出现。
// FailReason 货憨憨推送失败的原因,非空就是真失败。
type VideoCheck struct {
Video []json.RawMessage
TempVideoURL string
UploadIDStr string
FailReason string
}
// Confirmed 表示视频已经设置成功。
//
// 注意不能只看 Video:保存成功后 Shopee 侧的同步是异步的,
// 刚设置完 Video 必然还是空的,此时视频在 TempVideoURL 里。
// payloads/huohanhan_save_product_info.har 第 3 个请求就是一次保存成功后
// 立刻发起的 getDetail,那里 video=[] 而 tempVideoUrl 有值。
// 只认 Video 会把成功的上传误判成失败。
func (v VideoCheck) Confirmed() bool {
return len(v.Video) > 0 || strings.TrimSpace(v.TempVideoURL) != "" ||
strings.TrimSpace(v.UploadIDStr) != ""
}
// LiveOnShopee 表示视频已经同步到 Shopee 并生效,比 Confirmed 更强。
func (v VideoCheck) LiveOnShopee() bool { return len(v.Video) > 0 }
// CheckShopProductVideo 回读货憨憨的商品视频字段。
func (c *Client) CheckShopProductVideo(ctx context.Context, id string) (VideoCheck, error) {
form := url.Values{
"size": {"1"}, "current": {"1"}, "descs": {""}, "ascs": {""}, "ids": {id},
"fields": {"video,videoUploadIdStr,videoFailReason,tempVideoUrl"},
}
response, err := c.Request(ctx, http.MethodPost, "product/batchEdit/getShopItemInfoPage", []byte(form.Encode()), "application/x-www-form-urlencoded;charset=UTF-8")
if err != nil {
return VideoCheck{}, fmt.Errorf("回读商品视频失败:%w", err)
}
defer response.Body.Close()
// 这个接口没有 bean 外层,直接就是 {"records":[...]},和其它接口不一样。
var payload struct {
Records []struct {
Video []json.RawMessage `json:"video"`
TempVideoURL string `json:"tempVideoUrl"`
VideoUploadIDStr string `json:"videoUploadIdStr"`
VideoFailReason string `json:"videoFailReason"`
} `json:"records"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return VideoCheck{}, fmt.Errorf("回读商品视频返回的不是有效 JSON:%w", err)
}
if len(payload.Records) == 0 {
return VideoCheck{}, fmt.Errorf("回读商品视频失败:服务端未返回商品记录")
}
record := payload.Records[0]
return VideoCheck{
Video: record.Video, TempVideoURL: record.TempVideoURL,
UploadIDStr: record.VideoUploadIDStr, FailReason: record.VideoFailReason,
}, nil
}
type successResponse struct {
Type string `json:"type"`
Message string `json:"message"`
Code string `json:"code"`
Bean []string `json:"bean"`
}
func (p successResponse) check(action string) error {
if p.Type == "SUCCESS" {
return nil
}
detail := strings.TrimSpace(p.Message)
if detail == "" {
detail = "服务端未说明原因"
}
if code := strings.TrimSpace(p.Code); code != "" {
return fmt.Errorf("%s失败:%s(错误码 %s)", action, detail, code)
}
return fmt.Errorf("%s失败:%s", action, detail)
}
// JSON 字段必须恰好是这三个。不要在这里增加商品其它字段,接口是覆盖语义。
type productVideoUpdate struct {
ID string `json:"id"`
PlatformShopID string `json:"platformShopId"`
VideoURL string `json:"videoUrl"`
}