真机验证时 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
170 lines
6.4 KiB
Go
170 lines
6.4 KiB
Go
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"`
|
||
}
|