feat(pdd): 商品编辑线图片素材校验(轮播/商详/SKU)
- PddGoodsMaterialRole + PddGoodsMaterialImageValidator,规则与商详宽/高>1/3(理解A)写清注释 - 图床路径下载后校验,material-validation-enabled 可关 - 同一 URL 多角色时须同时满足各角色规则 Made-with: Cursor
This commit is contained in:
parent
7b2d6b4894
commit
ec1af6c757
|
|
@ -55,6 +55,8 @@ external:
|
|||
fill-goods-properties-from-cat-rule: true
|
||||
# 发品前 pdd.goods.image.upload(urlencoded,image=Base64,与开放平台 curl/SDK 一致)
|
||||
image-upload-enabled: true
|
||||
# 下载后、上传拼多多前:按商品编辑规则校验轮播/商详/SKU 图(须开启图床上传才有下载字节)
|
||||
material-validation-enabled: true
|
||||
image-upload-max-bytes: 3145728
|
||||
image-upload-allowed-host-suffixes:
|
||||
- etjbooks.com.cn
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ external:
|
|||
isbn-goods-property-ref-pid: 425
|
||||
fill-goods-properties-from-cat-rule: true
|
||||
image-upload-enabled: true
|
||||
material-validation-enabled: true
|
||||
image-upload-max-bytes: 3145728
|
||||
image-upload-allowed-host-suffixes:
|
||||
- etjbooks.com.cn
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ public class ExternalPddProperties {
|
|||
private List<String> imageUploadSkipHostSuffixes = new ArrayList<>(List.of(
|
||||
"pddpic.com", "pinduoduo.com", "yangkeduo.com"));
|
||||
|
||||
/**
|
||||
* 是否在图床上传路径上对下载后的图片做「商品编辑」线素材规则校验(轮播/商详/SKU)。
|
||||
* 关闭后仅上传不校验;关闭图床上传({@link #imageUploadEnabled}=false)时本项不生效(无下载字节)。
|
||||
*/
|
||||
private boolean materialValidationEnabled = true;
|
||||
|
||||
/**
|
||||
* POP 网关地址
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@ import java.net.http.HttpClient;
|
|||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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。
|
||||
* <p>若 {@link ExternalPddProperties#isMaterialValidationEnabled()} 为 true,在每张图下载后、上传前按
|
||||
* {@link PddGoodsMaterialImageValidator} 做「商品编辑」线规则校验(轮播/商详/SKU,规则见各枚举与校验类注释)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
|
|
@ -54,19 +55,23 @@ public class PddGoodsImageRehostService {
|
|||
if (root == null || root.isEmpty()) {
|
||||
return goodsAddRootJson;
|
||||
}
|
||||
List<String> toUpload = new ArrayList<>();
|
||||
collectUrlsNeedingRehost(root, toUpload);
|
||||
if (toUpload.isEmpty()) {
|
||||
Map<String, Set<PddGoodsMaterialRole>> urlToRoles = collectUrlsWithRolesNeedingRehost(root);
|
||||
if (urlToRoles.isEmpty()) {
|
||||
log.debug("[PDD] image rehost skipped (no URLs requiring upload)");
|
||||
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<>();
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
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 pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw);
|
||||
if (!StringUtils.hasText(pddUrl)) {
|
||||
|
|
@ -80,10 +85,13 @@ public class PddGoodsImageRehostService {
|
|||
return root.toJSONString();
|
||||
}
|
||||
|
||||
private void collectUrlsNeedingRehost(JSONObject root, List<String> out) {
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
addStringsFromJsonArray(root.getJSONArray("carousel_gallery"), seen);
|
||||
addStringsFromJsonArray(root.getJSONArray("detail_gallery"), seen);
|
||||
/**
|
||||
* 收集需上传的外链 URL,并为每个 URL 标注其在商品中出现的素材角色(轮播/商详/SKU),供校验时套用对应规则。
|
||||
*/
|
||||
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");
|
||||
if (skuList != null) {
|
||||
for (int i = 0; i < skuList.size(); i++) {
|
||||
|
|
@ -91,19 +99,21 @@ public class PddGoodsImageRehostService {
|
|||
if (sku != null) {
|
||||
String u = sku.getString("thumb_url");
|
||||
if (StringUtils.hasText(u)) {
|
||||
seen.add(u.trim());
|
||||
addRole(map, u.trim(), PddGoodsMaterialRole.SKU_THUMB);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String u : seen) {
|
||||
if (needsRehost(u)) {
|
||||
out.add(u);
|
||||
Map<String, Set<PddGoodsMaterialRole>> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<PddGoodsMaterialRole>> e : map.entrySet()) {
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -114,11 +124,15 @@ public class PddGoodsImageRehostService {
|
|||
}
|
||||
String u = el instanceof String s ? s : el.toString();
|
||||
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) {
|
||||
if (!StringUtils.hasText(url)) {
|
||||
return false;
|
||||
|
|
|
|||
164
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialImageValidator.java
vendored
Normal file
164
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialImageValidator.java
vendored
Normal 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。
|
||||
* - 文件大小:单张不超过 1MB(1024*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:1:width == 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));
|
||||
}
|
||||
}
|
||||
49
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialRole.java
vendored
Normal file
49
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialRole.java
vendored
Normal 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>宽 ÷ 高 > 1/3</strong>(严格大于),
|
||||
* 即禁止「过竖」长图:等价于在正像素下 <strong>高 < 3×宽</strong>。
|
||||
* 若取等号(高=3×宽)则 <code>宽/高=1/3</code>,不满足「> 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>> 480px</strong>(严格大于,即至少 481×481 像素)</li>
|
||||
* <li>文件大小:<strong>≤ 1MB</strong></li>
|
||||
* </ul>
|
||||
*/
|
||||
SKU_THUMB
|
||||
}
|
||||
Loading…
Reference in New Issue