feat(pdd): 商品编辑线图片素材校验(轮播/商详/SKU)

- PddGoodsMaterialRole + PddGoodsMaterialImageValidator,规则与商详宽/高>1/3(理解A)写清注释
- 图床路径下载后校验,material-validation-enabled 可关
- 同一 URL 多角色时须同时满足各角色规则

Made-with: Cursor
This commit is contained in:
huangyujie 2026-03-26 10:20:34 +08:00
parent 7b2d6b4894
commit ec1af6c757
6 changed files with 253 additions and 17 deletions

View File

@ -55,6 +55,8 @@ external:
fill-goods-properties-from-cat-rule: true fill-goods-properties-from-cat-rule: true
# 发品前 pdd.goods.image.uploadurlencodedimage=Base64与开放平台 curl/SDK 一致) # 发品前 pdd.goods.image.uploadurlencodedimage=Base64与开放平台 curl/SDK 一致)
image-upload-enabled: true image-upload-enabled: true
# 下载后、上传拼多多前:按商品编辑规则校验轮播/商详/SKU 图(须开启图床上传才有下载字节)
material-validation-enabled: true
image-upload-max-bytes: 3145728 image-upload-max-bytes: 3145728
image-upload-allowed-host-suffixes: image-upload-allowed-host-suffixes:
- etjbooks.com.cn - etjbooks.com.cn

View File

@ -52,6 +52,7 @@ external:
isbn-goods-property-ref-pid: 425 isbn-goods-property-ref-pid: 425
fill-goods-properties-from-cat-rule: true fill-goods-properties-from-cat-rule: true
image-upload-enabled: true image-upload-enabled: true
material-validation-enabled: true
image-upload-max-bytes: 3145728 image-upload-max-bytes: 3145728
image-upload-allowed-host-suffixes: image-upload-allowed-host-suffixes:
- etjbooks.com.cn - etjbooks.com.cn

View File

@ -45,6 +45,12 @@ public class ExternalPddProperties {
private List<String> imageUploadSkipHostSuffixes = new ArrayList<>(List.of( private List<String> imageUploadSkipHostSuffixes = new ArrayList<>(List.of(
"pddpic.com", "pinduoduo.com", "yangkeduo.com")); "pddpic.com", "pinduoduo.com", "yangkeduo.com"));
/**
* 是否在图床上传路径上对下载后的图片做商品编辑线素材规则校验轮播/商详/SKU
* 关闭后仅上传不校验关闭图床上传{@link #imageUploadEnabled}=false时本项不生效无下载字节
*/
private boolean materialValidationEnabled = true;
/** /**
* POP 网关地址 * POP 网关地址
*/ */

View File

@ -15,9 +15,8 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.EnumSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@ -25,6 +24,8 @@ import java.util.Set;
/** /**
* 发品前将 {@code pdd.goods.add} JSON 中的外链图片经 {@code pdd.goods.image.upload}urlencoded + Base64 {@code image}上传并替换 URL * 发品前将 {@code pdd.goods.add} JSON 中的外链图片经 {@code pdd.goods.image.upload}urlencoded + Base64 {@code image}上传并替换 URL
* <p> {@link ExternalPddProperties#isMaterialValidationEnabled()} true在每张图下载后上传前按
* {@link PddGoodsMaterialImageValidator} 商品编辑线规则校验轮播/商详/SKU规则见各枚举与校验类注释</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@ -54,19 +55,23 @@ public class PddGoodsImageRehostService {
if (root == null || root.isEmpty()) { if (root == null || root.isEmpty()) {
return goodsAddRootJson; return goodsAddRootJson;
} }
List<String> toUpload = new ArrayList<>(); Map<String, Set<PddGoodsMaterialRole>> urlToRoles = collectUrlsWithRolesNeedingRehost(root);
collectUrlsNeedingRehost(root, toUpload); if (urlToRoles.isEmpty()) {
if (toUpload.isEmpty()) {
log.debug("[PDD] image rehost skipped (no URLs requiring upload)"); log.debug("[PDD] image rehost skipped (no URLs requiring upload)");
return goodsAddRootJson; return goodsAddRootJson;
} }
log.info("[PDD] image rehost begin distinctUrls={} gatewayHost={}", toUpload.size(), hostForLog(gatewayUrl)); log.info("[PDD] image rehost begin distinctUrls={} gatewayHost={}", urlToRoles.size(), hostForLog(gatewayUrl));
Map<String, String> sourceToPdd = new LinkedHashMap<>(); Map<String, String> sourceToPdd = new LinkedHashMap<>();
for (String src : toUpload) { for (Map.Entry<String, Set<PddGoodsMaterialRole>> e : urlToRoles.entrySet()) {
String src = e.getKey();
Set<PddGoodsMaterialRole> roles = e.getValue();
if (sourceToPdd.containsKey(src)) { if (sourceToPdd.containsKey(src)) {
continue; continue;
} }
byte[] bytes = downloadImageBytes(src); byte[] bytes = downloadImageBytes(src);
if (props.isMaterialValidationEnabled()) {
PddGoodsMaterialImageValidator.validate(bytes, roles, PddOpenApiSupport.snippet(src, 96));
}
String raw = pddPopClient.invokeGoodsImageUpload(gatewayUrl, clientId, clientSecret, accessToken, bytes); String raw = pddPopClient.invokeGoodsImageUpload(gatewayUrl, clientId, clientSecret, accessToken, bytes);
String pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw); String pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw);
if (!StringUtils.hasText(pddUrl)) { if (!StringUtils.hasText(pddUrl)) {
@ -80,10 +85,13 @@ public class PddGoodsImageRehostService {
return root.toJSONString(); return root.toJSONString();
} }
private void collectUrlsNeedingRehost(JSONObject root, List<String> out) { /**
Set<String> seen = new LinkedHashSet<>(); * 收集需上传的外链 URL并为每个 URL 标注其在商品中出现的素材角色轮播/商详/SKU供校验时套用对应规则
addStringsFromJsonArray(root.getJSONArray("carousel_gallery"), seen); */
addStringsFromJsonArray(root.getJSONArray("detail_gallery"), seen); private Map<String, Set<PddGoodsMaterialRole>> collectUrlsWithRolesNeedingRehost(JSONObject root) {
Map<String, Set<PddGoodsMaterialRole>> map = new LinkedHashMap<>();
addUrlsWithRole(root.getJSONArray("carousel_gallery"), map, PddGoodsMaterialRole.CAROUSEL);
addUrlsWithRole(root.getJSONArray("detail_gallery"), map, PddGoodsMaterialRole.DETAIL);
JSONArray skuList = root.getJSONArray("sku_list"); JSONArray skuList = root.getJSONArray("sku_list");
if (skuList != null) { if (skuList != null) {
for (int i = 0; i < skuList.size(); i++) { for (int i = 0; i < skuList.size(); i++) {
@ -91,19 +99,21 @@ public class PddGoodsImageRehostService {
if (sku != null) { if (sku != null) {
String u = sku.getString("thumb_url"); String u = sku.getString("thumb_url");
if (StringUtils.hasText(u)) { if (StringUtils.hasText(u)) {
seen.add(u.trim()); addRole(map, u.trim(), PddGoodsMaterialRole.SKU_THUMB);
} }
} }
} }
} }
for (String u : seen) { Map<String, Set<PddGoodsMaterialRole>> out = new LinkedHashMap<>();
if (needsRehost(u)) { for (Map.Entry<String, Set<PddGoodsMaterialRole>> e : map.entrySet()) {
out.add(u); if (needsRehost(e.getKey())) {
out.put(e.getKey(), e.getValue());
} }
} }
return out;
} }
private static void addStringsFromJsonArray(JSONArray arr, Set<String> target) { private static void addUrlsWithRole(JSONArray arr, Map<String, Set<PddGoodsMaterialRole>> map, PddGoodsMaterialRole role) {
if (arr == null || arr.isEmpty()) { if (arr == null || arr.isEmpty()) {
return; return;
} }
@ -114,11 +124,15 @@ public class PddGoodsImageRehostService {
} }
String u = el instanceof String s ? s : el.toString(); String u = el instanceof String s ? s : el.toString();
if (StringUtils.hasText(u)) { if (StringUtils.hasText(u)) {
target.add(u.trim()); addRole(map, u.trim(), role);
} }
} }
} }
private static void addRole(Map<String, Set<PddGoodsMaterialRole>> map, String url, PddGoodsMaterialRole role) {
map.computeIfAbsent(url, k -> EnumSet.noneOf(PddGoodsMaterialRole.class)).add(role);
}
private boolean needsRehost(String url) { private boolean needsRehost(String url) {
if (!StringUtils.hasText(url)) { if (!StringUtils.hasText(url)) {
return false; return false;

View File

@ -0,0 +1,164 @@
package cn.qihangerp.service.external.pdd;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 拼多多发品前商品编辑线图片素材校验轮播 / 商详 / SKU
* <p>
* 规则来源运营/平台对商品编辑素材的要求整理其中<strong>商详图宽高比</strong>采用业务确认的
* <strong>理解 A</strong>{@code / > 1/3} {@link PddGoodsMaterialRole#DETAIL} 注释
* </p>
* <p>
* 校验在<strong>已下载的图片字节</strong>上进行文件大小取字节数组长度像素尺寸依赖 {@link ImageIO}
* 若环境无法解码如部分 WEBP将抛出带说明的异常建议源站改为 JPG/PNG
* </p>
*/
public final class PddGoodsMaterialImageValidator {
/*
* -------------------------------------------------------------------------
* 商品编辑 轮播图carousel_gallery
* -------------------------------------------------------------------------
* - 等宽高展示区域为正方形故要求解码后 width == height整数像素相等
* - 最小边长文档最小 480px按闭区间处理 width >= 480 height >= 480与等宽结合即边长 >= 480
* - 文件大小单张不超过 1MB1024*1024 字节
*/
private static final long CAROUSEL_MAX_BYTES = 1024L * 1024L;
private static final int CAROUSEL_MIN_SIDE = 480;
/*
* -------------------------------------------------------------------------
* 商品编辑 商详图detail_gallery
* -------------------------------------------------------------------------
* - 宽度文档要求宽度最小 480px width >= 480
* - 宽高比理解 A要求 / > 1/3严格大于
* 含义避免竖图过高例如宽 400 1200 400/1200 = 1/3不满足>不通过
* 高略减至 1199 400/1199 > 1/3通过在整数像素下
* 分母 height > 0否则视为无法校验
* - 文件大小单张不超过 1MB
*/
private static final long DETAIL_MAX_BYTES = 1024L * 1024L;
private static final int DETAIL_MIN_WIDTH = 480;
/** 商详图:宽/高 须严格大于 1/3 */
private static final double DETAIL_MIN_WIDTH_OVER_HEIGHT = 1.0 / 3.0;
/*
* -------------------------------------------------------------------------
* 商品编辑 SKU sku_list[].thumb_url
* -------------------------------------------------------------------------
* - 比例 1:1width == height
* - 边长文档宽和高均大于 480px按严格大于处理 width > 480 height > 480
* 整数像素下等价于至少 481×481
* - 文件大小单张不超过 1MB
*/
private static final long SKU_MAX_BYTES = 1024L * 1024L;
private static final int SKU_MIN_SIDE_EXCLUSIVE = 480;
private PddGoodsMaterialImageValidator() {
}
/**
* 对同一批字节按所承担的全部 {@link PddGoodsMaterialRole} 依次校验全部通过则返回
* 否则抛出 {@link IllegalStateException}信息中拼接所有违规说明
*
* @param imageBytes 已下载的完整图片文件内容用于文件大小与解码
* @param roles URL 在商品中出现的角色集合非空
* @param urlSnippet 用于错误信息中的 URL 摘要已脱敏/截断由调用方决定
*/
public static void validate(byte[] imageBytes, Set<PddGoodsMaterialRole> roles, String urlSnippet) {
if (roles == null || roles.isEmpty()) {
return;
}
if (imageBytes == null || imageBytes.length == 0) {
throw new IllegalStateException("素材校验失败:内容为空 url=" + urlSnippet);
}
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(imageBytes));
} catch (IOException e) {
throw new IllegalStateException("素材校验失败:无法解码图片(请使用 JPG/PNG 等常见格式) url=" + urlSnippet + " err=" + e.getMessage());
}
if (img == null) {
throw new IllegalStateException("素材校验失败ImageIO 无法识别图片格式 url=" + urlSnippet);
}
int w = img.getWidth();
int h = img.getHeight();
if (w <= 0 || h <= 0) {
throw new IllegalStateException("素材校验失败:图片尺寸无效 width=" + w + " height=" + h + " url=" + urlSnippet);
}
List<String> violations = new ArrayList<>();
for (PddGoodsMaterialRole role : roles) {
switch (role) {
case CAROUSEL -> validateCarousel(imageBytes.length, w, h, violations);
case DETAIL -> validateDetail(imageBytes.length, w, h, violations);
case SKU_THUMB -> validateSku(imageBytes.length, w, h, violations);
}
}
if (!violations.isEmpty()) {
throw new IllegalStateException("素材不符合拼多多商品编辑规则 url=" + urlSnippet + "" + String.join("", violations));
}
}
private static void validateCarousel(long fileBytes, int w, int h, List<String> out) {
String tag = "[轮播图]";
if (fileBytes > CAROUSEL_MAX_BYTES) {
out.add(tag + "文件须≤1MB当前约 " + formatBytes(fileBytes));
}
if (w != h) {
out.add(tag + "须等宽高1:1当前 " + w + "×" + h);
}
if (w < CAROUSEL_MIN_SIDE || h < CAROUSEL_MIN_SIDE) {
out.add(tag + "边长须≥480px当前 " + w + "×" + h);
}
}
private static void validateDetail(long fileBytes, int w, int h, List<String> out) {
String tag = "[商详图]";
if (fileBytes > DETAIL_MAX_BYTES) {
out.add(tag + "文件须≤1MB当前约 " + formatBytes(fileBytes));
}
if (w < DETAIL_MIN_WIDTH) {
out.add(tag + "宽度须≥480px当前 width=" + w);
}
if (h <= 0) {
out.add(tag + "高度无效");
return;
}
double ratio = w / (double) h;
if (!(ratio > DETAIL_MIN_WIDTH_OVER_HEIGHT)) {
out.add(tag + "须满足 宽÷高>1/3避免过竖长图等价 高<3×当前 " + w + "×" + h
+ "(宽÷高=" + String.format(Locale.ROOT, "%.4f", ratio) + "");
}
}
private static void validateSku(long fileBytes, int w, int h, List<String> out) {
String tag = "[SKU图]";
if (fileBytes > SKU_MAX_BYTES) {
out.add(tag + "文件须≤1MB当前约 " + formatBytes(fileBytes));
}
if (w != h) {
out.add(tag + "须 1:1等宽高当前 " + w + "×" + h);
}
if (w <= SKU_MIN_SIDE_EXCLUSIVE || h <= SKU_MIN_SIDE_EXCLUSIVE) {
out.add(tag + "宽、高均须>480px严格大于当前 " + w + "×" + h);
}
}
private static String formatBytes(long b) {
if (b < 1024) {
return b + "B";
}
if (b < 1024 * 1024) {
return String.format(Locale.ROOT, "%.1fKB", b / 1024.0);
}
return String.format(Locale.ROOT, "%.2fMB", b / (1024.0 * 1024.0));
}
}

View File

@ -0,0 +1,49 @@
package cn.qihangerp.service.external.pdd;
/**
* 拼多多商品编辑侧图片素材角色用于在发品前按角色套用不同校验规则
* <p>
* 对应业务文档中的素材分类轮播 / 商详 / SKU {@code pdd.goods.add} 根字段的映射关系
* <ul>
* <li>{@link #CAROUSEL} {@code carousel_gallery[]}主轮播图</li>
* <li>{@link #DETAIL} {@code detail_gallery[]}商品详情图</li>
* <li>{@link #SKU_THUMB} {@code sku_list[].thumb_url}SKU 缩略图</li>
* </ul>
* 同一 URL 若同时出现在轮播与详情中<strong>同时满足</strong>两种角色的全部约束取交集
*/
public enum PddGoodsMaterialRole {
/**
* 轮播图商品编辑
* <p>规则摘要与内部常量一致详见 {@link PddGoodsMaterialImageValidator}</p>
* <ul>
* <li>尺寸<strong>等宽高</strong>=边长 <strong> 480px</strong></li>
* <li>文件大小<strong> 1MB</strong></li>
* </ul>
*/
CAROUSEL,
/**
* 商详图商品编辑
* <p>规则摘要</p>
* <ul>
* <li>宽高比业务约定 <strong>理解 A</strong><strong> ÷ &gt; 1/3</strong>严格大于
* 即禁止过竖长图等价于在正像素下 <strong> &lt; 3×</strong>
* 若取等号=3× <code>/=1/3</code>不满足&gt; 1/3判失败</li>
* <li>宽度<strong> 480px</strong></li>
* <li>文件大小<strong> 1MB</strong></li>
* </ul>
*/
DETAIL,
/**
* SKU 商品编辑
* <p>规则摘要</p>
* <ul>
* <li>比例<strong>1:1</strong>=</li>
* <li>边长高均 <strong>&gt; 480px</strong>严格大于即至少 481×481 像素</li>
* <li>文件大小<strong> 1MB</strong></li>
* </ul>
*/
SKU_THUMB
}