diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index fcce9490..11272cbd 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -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 diff --git a/api/erp-api/src/main/resources/nacos/erp-api.yaml b/api/erp-api/src/main/resources/nacos/erp-api.yaml index ee77e7a9..09d928af 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -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 diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java index cd19c6a1..68cb3e24 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java @@ -45,6 +45,12 @@ public class ExternalPddProperties { private List imageUploadSkipHostSuffixes = new ArrayList<>(List.of( "pddpic.com", "pinduoduo.com", "yangkeduo.com")); + /** + * 是否在图床上传路径上对下载后的图片做「商品编辑」线素材规则校验(轮播/商详/SKU)。 + * 关闭后仅上传不校验;关闭图床上传({@link #imageUploadEnabled}=false)时本项不生效(无下载字节)。 + */ + private boolean materialValidationEnabled = true; + /** * POP 网关地址 */ diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java index 2e6763f5..6558ec73 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java @@ -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。 + *

若 {@link ExternalPddProperties#isMaterialValidationEnabled()} 为 true,在每张图下载后、上传前按 + * {@link PddGoodsMaterialImageValidator} 做「商品编辑」线规则校验(轮播/商详/SKU,规则见各枚举与校验类注释)。

*/ @Slf4j @Service @@ -54,19 +55,23 @@ public class PddGoodsImageRehostService { if (root == null || root.isEmpty()) { return goodsAddRootJson; } - List toUpload = new ArrayList<>(); - collectUrlsNeedingRehost(root, toUpload); - if (toUpload.isEmpty()) { + Map> 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 sourceToPdd = new LinkedHashMap<>(); - for (String src : toUpload) { + for (Map.Entry> e : urlToRoles.entrySet()) { + String src = e.getKey(); + Set 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 out) { - Set seen = new LinkedHashSet<>(); - addStringsFromJsonArray(root.getJSONArray("carousel_gallery"), seen); - addStringsFromJsonArray(root.getJSONArray("detail_gallery"), seen); + /** + * 收集需上传的外链 URL,并为每个 URL 标注其在商品中出现的素材角色(轮播/商详/SKU),供校验时套用对应规则。 + */ + private Map> collectUrlsWithRolesNeedingRehost(JSONObject root) { + Map> 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> out = new LinkedHashMap<>(); + for (Map.Entry> e : map.entrySet()) { + if (needsRehost(e.getKey())) { + out.put(e.getKey(), e.getValue()); } } + return out; } - private static void addStringsFromJsonArray(JSONArray arr, Set target) { + private static void addUrlsWithRole(JSONArray arr, Map> 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> 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; diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialImageValidator.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialImageValidator.java new file mode 100644 index 00000000..d5560a35 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialImageValidator.java @@ -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)。 + *

+ * 规则来源:运营/平台对「商品编辑」素材的要求整理;其中商详图宽高比采用业务确认的 + * 理解 A:{@code 宽/高 > 1/3}(见 {@link PddGoodsMaterialRole#DETAIL} 注释)。 + *

+ *

+ * 校验在已下载的图片字节上进行:文件大小取字节数组长度;像素尺寸依赖 {@link ImageIO}, + * 若环境无法解码(如部分 WEBP),将抛出带说明的异常,建议源站改为 JPG/PNG。 + *

+ */ +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 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 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 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 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 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)); + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialRole.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialRole.java new file mode 100644 index 00000000..02c60d05 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsMaterialRole.java @@ -0,0 +1,49 @@ +package cn.qihangerp.service.external.pdd; + +/** + * 拼多多「商品编辑」侧图片素材角色,用于在发品前按角色套用不同校验规则。 + *

+ * 对应业务文档中的素材分类(轮播 / 商详 / SKU),与 {@code pdd.goods.add} 根字段的映射关系: + *

    + *
  • {@link #CAROUSEL} — {@code carousel_gallery[]},主轮播图
  • + *
  • {@link #DETAIL} — {@code detail_gallery[]},商品详情图
  • + *
  • {@link #SKU_THUMB} — {@code sku_list[].thumb_url},SKU 缩略图
  • + *
+ * 同一 URL 若同时出现在轮播与详情中,须同时满足两种角色的全部约束(取交集)。 + */ +public enum PddGoodsMaterialRole { + + /** + * 轮播图(商品编辑)。 + *

规则摘要(与内部常量一致,详见 {@link PddGoodsMaterialImageValidator}):

+ *
    + *
  • 尺寸:等宽高(宽=高);边长 ≥ 480px
  • + *
  • 文件大小:≤ 1MB
  • + *
+ */ + CAROUSEL, + + /** + * 商详图(商品编辑)。 + *

规则摘要:

+ *
    + *
  • 宽高比(业务约定 理解 A):宽 ÷ 高 > 1/3(严格大于), + * 即禁止「过竖」长图:等价于在正像素下 高 < 3×宽。 + * 若取等号(高=3×宽)则 宽/高=1/3,不满足「> 1/3」,判失败。
  • + *
  • 宽度:≥ 480px
  • + *
  • 文件大小:≤ 1MB
  • + *
+ */ + DETAIL, + + /** + * SKU 图(商品编辑)。 + *

规则摘要:

+ *
    + *
  • 比例:1:1(宽=高)
  • + *
  • 边长:宽、高均 > 480px(严格大于,即至少 481×481 像素)
  • + *
  • 文件大小:≤ 1MB
  • + *
+ */ + SKU_THUMB +}