diff --git a/app/admin/apis/shopee_product_update.go b/app/admin/apis/shopee_product_update.go index 0a60d89..60b5840 100644 --- a/app/admin/apis/shopee_product_update.go +++ b/app/admin/apis/shopee_product_update.go @@ -185,6 +185,9 @@ func (e ShopeeProduct) updateCover(c *gin.Context, shopeeID, coverURL, filename, if err != nil { return nil, fmt.Errorf("商品编辑信息准备失败:%w", err) } + if strategy == "replace_first" && !info.HasCompleteImageList() { + return nil, fmt.Errorf("商品编辑信息缺少完整图片列表,已拒绝更新主图") + } sourceURL := coverURL if len(content) > 0 { coverURL, err = client.UploadImage(ctx, filename, content, contentType) diff --git a/internal/huohanhan/update.go b/internal/huohanhan/update.go index 69ae6e6..a6919d8 100644 --- a/internal/huohanhan/update.go +++ b/internal/huohanhan/update.go @@ -15,17 +15,62 @@ import ( ) type ProductEditInfo struct { - ID string `json:"id"` - ItemID string `json:"itemId"` - ItemName string `json:"itemName"` - MainImage string `json:"mainImage"` - PlatformShopID string `json:"platformShopId"` - Platform string `json:"platform"` - CategoryID string `json:"categoryId"` - Image struct { - ImageIDList []string `json:"imageIdList"` - ImageURLList []string `json:"imageUrlList"` - } `json:"image"` + ID string `json:"id"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + MainImage string `json:"mainImage"` + PlatformShopID string `json:"platformShopId"` + Platform string `json:"platform"` + CategoryID string `json:"categoryId"` + Image ProductEditImage `json:"image"` + imagePresent bool +} + +type ProductEditImage struct { + ImageIDList []string `json:"imageIdList"` + ImageURLList []string `json:"imageUrlList"` + imageIDPresent bool + imageURLPresent bool +} + +// UnmarshalJSON tracks whether Huohanhan explicitly supplied each list. A +// missing/null field must never be treated as a valid empty image list. +func (p *ProductEditInfo) UnmarshalJSON(data []byte) error { + type plain ProductEditInfo + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *p = ProductEditInfo(decoded) + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + rawImage, ok := fields["image"] + if !ok || bytes.Equal(bytes.TrimSpace(rawImage), []byte("null")) { + return nil + } + var imageFields map[string]json.RawMessage + if err := json.Unmarshal(rawImage, &imageFields); err != nil || imageFields == nil { + if err == nil { + err = fmt.Errorf("image 不是对象") + } + return err + } + p.imagePresent = true + if raw, ok := imageFields["imageUrlList"]; ok && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + p.Image.imageURLPresent = true + } + if raw, ok := imageFields["imageIdList"]; ok && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + p.Image.imageIDPresent = true + } + return nil +} + +// HasCompleteImageList accepts a confirmed empty list but rejects missing, +// null, malformed, or mismatched lists before a cover update is submitted. +func (p ProductEditInfo) HasCompleteImageList() bool { + return p.imagePresent && p.Image.imageURLPresent && p.Image.imageIDPresent && len(p.Image.ImageURLList) == len(p.Image.ImageIDList) } type ProductEditTaskStatus struct { @@ -179,7 +224,10 @@ func CoverImageListWithStrategy(info ProductEditInfo, coverURL, strategy string) if strategy == "replace_first" { var urls, ids []string for i, u := range info.Image.ImageURLList { - if strings.TrimSpace(u) == "" || u == coverURL { + // Index zero is the image being replaced. Remove duplicate copies of + // the new image only from the remaining references; doing that before + // dropping index zero would incorrectly drop ref1 for [new, ref1]. + if i == 0 || strings.TrimSpace(u) == "" || u == coverURL { continue } urls = append(urls, u) @@ -189,9 +237,6 @@ func CoverImageListWithStrategy(info ProductEditInfo, coverURL, strategy string) ids = append(ids, "") } } - if len(urls) > 0 { - urls, ids = urls[1:], ids[1:] - } urls, ids = append([]string{coverURL}, urls...), append([]string{""}, ids...) return urls, ids } diff --git a/internal/huohanhan/update_test.go b/internal/huohanhan/update_test.go index e461119..67161e0 100644 --- a/internal/huohanhan/update_test.go +++ b/internal/huohanhan/update_test.go @@ -132,6 +132,7 @@ func Test统一替换第一张策略(t *testing.T) { {"一张旧图", []string{"a"}, []string{"new"}}, {"八张旧图", []string{"a", "b", "c", "d", "e", "f", "g", "h"}, []string{"new", "b", "c", "d", "e", "f", "g", "h"}}, {"九张旧图", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"}, []string{"new", "b", "c", "d", "e", "f", "g", "h", "i"}}, + {"新图已在首位", []string{"new", "ref1", "ref2"}, []string{"new", "ref1", "ref2"}}, {"新图原在后面", []string{"a", "new", "b"}, []string{"new", "b"}}, } for _, tc := range cases { @@ -147,6 +148,29 @@ func Test统一替换第一张策略(t *testing.T) { } } +func Test编辑信息必须明确返回完整图片列表(t *testing.T) { + cases := []struct { + name, raw string + ok bool + }{ + {"完整空列表", `{"image":{"imageUrlList":[],"imageIdList":[]}}`, true}, + {"缺少 image", `{}`, false}, + {"缺少 URL", `{"image":{"imageIdList":[]}}`, false}, + {"URL 为 null", `{"image":{"imageUrlList":null,"imageIdList":[]}}`, false}, + {"ID 为 null", `{"image":{"imageUrlList":[],"imageIdList":null}}`, false}, + {"长度不等", `{"image":{"imageUrlList":["a"],"imageIdList":[]}}`, false}, + } + for _, tc := range cases { + var info ProductEditInfo + if err := json.Unmarshal([]byte(tc.raw), &info); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if got := info.HasCompleteImageList(); got != tc.ok { + t.Fatalf("%s: got %v", tc.name, got) + } + } +} + func Test按URL转存主图(t *testing.T) { src := "http://122.228.200.167:8020/uploads/images/24720421377.jpg?v=1789627038" bean := `["https://hhh-prod-1307856765.cos.ap-guangzhou.myqcloud.com/video/1131990643264757761.jpg"]` diff --git a/tickets/LOCAL-022-cover-list-safety.md b/tickets/LOCAL-022-cover-list-safety.md new file mode 100644 index 0000000..039ecc3 --- /dev/null +++ b/tickets/LOCAL-022-cover-list-safety.md @@ -0,0 +1,17 @@ +# LOCAL-022:主图替换列表安全修复(R1、R4) + +## 目标 + +修复 `coverStrategy=replace_first` 的参考图误删问题,并在 Huohanhan 编辑信息未返回完整图片列表时拒绝更新,防止把异常响应误当成空图片列表。 + +## 验收 + +- `[new, ref1, ref2]` 更新为 `new` 后保留 `ref1, ref2`。 +- `[old, new, ref1]` 更新后只保留一份 `new`,且保留 `ref1`。 +- 0/1/8/9 张旧图均保持正确数量和 URL/ID 下标对应。 +- 缺少 `image`、缺少/为 null 的 `imageUrlList` 或 `imageIdList`、两表长度不等时,不提交 Huohanhan 主图更新。 +- 明确返回合法双空数组时允许新图成为唯一图片。 + +## 状态 + +已完成。