From 349dbca470ed986358b51533632a086b3d52b2e3 Mon Sep 17 00:00:00 2001 From: huangyujie <27665451@qq.com> Date: Thu, 26 Mar 2026 09:29:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(pdd):=20=E5=9B=BE=E5=BA=8A=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20type=20=E9=BB=98=E8=AE=A4=E6=94=B9=E4=B8=BA=20pdd.g?= =?UTF-8?q?oods.image.upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解决 pdd.goods.img.upload 报「接口不属于当前网关」 - 可配 image-upload-api-type / gateway-url / multipart 字段名 - 解析响应兼容 goods_image_upload_response 与 image_url Made-with: Cursor --- .../src/main/resources/application.yml | 5 ++- .../src/main/resources/nacos/erp-api.yaml | 3 ++ .../external/pdd/ExternalPddProperties.java | 18 +++++++- .../pdd/PddGoodsImageRehostService.java | 28 ++++++++++-- .../external/pdd/PddOpenApiSupport.java | 45 ++++++++++++++++--- .../service/external/pdd/PddPopClient.java | 34 ++++++++------ 6 files changed, 108 insertions(+), 25 deletions(-) diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index f5b242fd..bfaab354 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -53,8 +53,11 @@ external: isbn-goods-property-ref-pid: 425 # 按 pdd.goods.cat.rule.get 中 goods_properties_rule 必填项自动补全 goods_properties fill-goods-properties-from-cat-rule: true - # 发品前 pdd.goods.img.upload:外链图上传至拼多多图床后再 goods.add(域名须在 allowed-host-suffixes) + # 发品前 POP 图床上传(默认 pdd.goods.image.upload;img.upload 易报「接口不属于当前网关」) image-upload-enabled: true + image-upload-api-type: pdd.goods.image.upload + image-upload-gateway-url: "" + image-upload-multipart-field: file 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..79aeb1c1 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,9 @@ external: isbn-goods-property-ref-pid: 425 fill-goods-properties-from-cat-rule: true image-upload-enabled: true + image-upload-api-type: pdd.goods.image.upload + image-upload-gateway-url: "" + image-upload-multipart-field: file 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 e9bb0e4f..58351ceb 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 @@ -23,11 +23,27 @@ public class ExternalPddProperties { private boolean publishEnabled = false; /** - * 发品前是否将外链图片经 {@code pdd.goods.img.upload} 上传到拼多多图床,并用返回 URL 替换 + * 发品前是否将外链图片经 POP 图片上传接口上传到拼多多图床,并用返回 URL 替换 * {@code carousel_gallery}/{@code detail_gallery}/{@code sku_list[].thumb_url}(去重上传)。 */ private boolean imageUploadEnabled = true; + /** + * 图片上传接口 {@code type}。{@code pdd.goods.img.upload} 在部分网关会报「接口不属于当前网关」, + * 默认使用 {@code pdd.goods.image.upload};若应用仅授权文件空间接口可改为 {@code pdd.goods.filespace.image.upload}。 + */ + private String imageUploadApiType = "pdd.goods.image.upload"; + + /** + * 图片上传专用网关;为空则与发品使用同一 {@link #gatewayUrl}(或请求里传入的 gateway)。 + */ + private String imageUploadGatewayUrl = ""; + + /** + * multipart 中二进制字段名,一般为 {@code file};若对接文档要求 {@code image} 可改此项。 + */ + private String imageUploadMultipartField = "file"; + /** * 单张图片最大下载字节(默认 3MB,与常见平台主图上限一致) */ 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 126a22db..da3dc83e 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 @@ -24,7 +24,7 @@ import java.util.Map; import java.util.Set; /** - * 发品前将 {@code pdd.goods.add} 根 JSON 中的外链图片经 {@code pdd.goods.img.upload} 上传到拼多多图床并替换 URL。 + * 发品前将 {@code pdd.goods.add} 根 JSON 中的外链图片经 POP 图床上传接口(默认 {@code pdd.goods.image.upload})上传并替换 URL。 */ @Slf4j @Service @@ -60,7 +60,14 @@ public class PddGoodsImageRehostService { log.debug("[PDD] image rehost skipped (no URLs requiring upload)"); return goodsAddRootJson; } - log.info("[PDD] image rehost begin distinctUrls={}", toUpload.size()); + String uploadGateway = StringUtils.hasText(props.getImageUploadGatewayUrl()) + ? props.getImageUploadGatewayUrl().trim() : gatewayUrl; + String apiType = StringUtils.hasText(props.getImageUploadApiType()) + ? props.getImageUploadApiType().trim() : "pdd.goods.image.upload"; + String fileField = StringUtils.hasText(props.getImageUploadMultipartField()) + ? props.getImageUploadMultipartField().trim() : "file"; + log.info("[PDD] image rehost begin distinctUrls={} apiType={} uploadGatewayHost={}", + toUpload.size(), apiType, hostForLog(uploadGateway)); Map sourceToPdd = new LinkedHashMap<>(); for (String src : toUpload) { if (sourceToPdd.containsKey(src)) { @@ -68,10 +75,11 @@ public class PddGoodsImageRehostService { } byte[] bytes = downloadImageBytes(src); String filename = guessFilenameFromUrl(src); - String raw = pddPopClient.invokeGoodsImgUpload(gatewayUrl, clientId, clientSecret, accessToken, bytes, filename); + String raw = pddPopClient.invokeGoodsImgUpload(uploadGateway, clientId, clientSecret, accessToken, bytes, filename, + apiType, fileField); String pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw); if (!StringUtils.hasText(pddUrl)) { - throw new IllegalStateException("pdd.goods.img.upload 失败: " + PddOpenApiSupport.formatError(raw)); + throw new IllegalStateException(apiType + " 失败: " + PddOpenApiSupport.formatError(raw)); } sourceToPdd.put(src, pddUrl); log.info("[PDD] image rehost ok srcSnippet={} bytes={} destSnippet={}", @@ -177,6 +185,18 @@ public class PddGoodsImageRehostService { } } + private static String hostForLog(String gatewayUrl) { + if (!StringUtils.hasText(gatewayUrl)) { + return ""; + } + try { + String h = URI.create(gatewayUrl.trim()).getHost(); + return h != null ? h : gatewayUrl.trim(); + } catch (Exception e) { + return gatewayUrl.trim(); + } + } + private byte[] downloadImageBytes(String urlString) throws Exception { long max = props.getImageUploadMaxBytes(); URI uri = URI.create(urlString.trim()); diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java index a0ec502b..b1f0f3a9 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java @@ -144,7 +144,7 @@ public final class PddOpenApiSupport { } /** - * 解析 {@code pdd.goods.img.upload} 成功体中的 {@code goods_img_upload_response.url}。 + * 解析商品图上传成功体中的 URL(兼容 {@code goods_img_upload_response} / {@code goods_image_upload_response} / 文件空间等命名)。 */ public static String parseGoodsImgUploadUrl(String body) { if (!StringUtils.hasText(body)) { @@ -155,17 +155,50 @@ public final class PddOpenApiSupport { if (o == null || o.containsKey("error_response")) { return null; } - JSONObject r = o.getJSONObject("goods_img_upload_response"); - if (r == null) { - return null; + String u = extractUrlFromPddImageUploadRoot(o); + if (StringUtils.hasText(u)) { + return u.trim(); } - String u = r.getString("url"); - return StringUtils.hasText(u) ? u.trim() : null; + JSONObject wrapped = o.getJSONObject("response"); + if (wrapped != null && !wrapped.containsKey("error_response")) { + u = extractUrlFromPddImageUploadRoot(wrapped); + if (StringUtils.hasText(u)) { + return u.trim(); + } + } + return null; } catch (Exception e) { return null; } } + private static String extractUrlFromPddImageUploadRoot(JSONObject o) { + if (o == null) { + return null; + } + String[] groups = { + "goods_img_upload_response", + "goods_image_upload_response", + "goods_filespace_image_upload_response", + "filespace_image_upload_response", + "pdd_goods_image_upload_response", + }; + for (String g : groups) { + JSONObject sub = o.getJSONObject(g); + if (sub == null) { + continue; + } + String u = sub.getString("url"); + if (!StringUtils.hasText(u)) { + u = sub.getString("image_url"); + } + if (StringUtils.hasText(u)) { + return u; + } + } + return null; + } + /** * 在类目规则 JSON 中解析「销售规格」对应的 {@code parent_spec_id}(取第一个)。 *

兼容:{@code is_sale}/{@code isSale}、{@code parent_spec_id}/{@code parentSpecId}/字符串数值; diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java index 2028f6dd..702ba83e 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java @@ -24,7 +24,7 @@ import java.util.UUID; *

  • {@link #invokeGoodsAdd} — {@code pdd.goods.add}:业务字段全部在表单顶层({@code sku_list}、{@code carousel_gallery} 等为 JSON 字符串), * {@code param_json}、 {@code version}
  • *
  • {@link #invokeTopLevelBiz} — 如 {@code pdd.goods.cat.rule.get} 的 {@code cat_id}/{@code goods_id} 等顶层字段
  • - *
  • {@link #invokeGoodsImgUpload} — {@code pdd.goods.img.upload},{@code multipart/form-data}
  • + *
  • {@link #invokeGoodsImgUpload} — 商品图上传(如 {@code pdd.goods.image.upload}),{@code multipart/form-data}
  • *
  • {@link #invoke} — 仅当接口要求整包 {@code param_json} 时使用(少数场景)
  • * */ @@ -98,23 +98,30 @@ public class PddPopClient { } /** - * {@code pdd.goods.img.upload}:与官方示例一致,{@code multipart/form-data},文本字段参与 MD5 签名,{@code file} 不参与。 + * 商品图片上传:{@code multipart/form-data},文本字段参与 MD5 签名,二进制字段不参与。 + * + * @param apiType POP {@code type},如 {@code pdd.goods.image.upload} + * @param multipartFileField 文件 part 的 name,常见 {@code file} */ public String invokeGoodsImgUpload(String gatewayUrl, String clientId, String clientSecret, String accessToken, - byte[] fileBytes, String filename) throws Exception { + byte[] fileBytes, String filename, String apiType, String multipartFileField) throws Exception { if (!StringUtils.hasText(gatewayUrl)) { throw new IllegalArgumentException("gatewayUrl 不能为空"); } + if (!StringUtils.hasText(apiType)) { + throw new IllegalArgumentException("apiType 不能为空"); + } if (fileBytes == null || fileBytes.length == 0) { throw new IllegalArgumentException("file 不能为空"); } - Map params = buildBaseParams(clientId, accessToken, "pdd.goods.img.upload", false); + String fileField = StringUtils.hasText(multipartFileField) ? multipartFileField.trim() : "file"; + Map params = buildBaseParams(clientId, accessToken, apiType, false); String sign = PddPopSignUtil.sign(params, clientSecret); params.put("sign", sign); String boundary = "----PddPopFormBoundary" + UUID.randomUUID().toString().replace("-", ""); String safeName = safeMultipartFilename(filename); - byte[] body = buildMultipartFormData(boundary, params, fileBytes, safeName); + byte[] body = buildMultipartFormData(boundary, params, fileBytes, safeName, fileField); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(gatewayUrl)) @@ -135,17 +142,17 @@ public class PddPopClient { String snippet = PddOpenApiSupport.snippet(raw, 400); if (!httpOk || popBizError) { String errSummary = popBizError ? PddOpenApiSupport.formatError(raw) : ""; - log.warn("PDD_POP api=pdd.goods.img.upload host={} clientId={} httpStatus={} durationMs={} bytes={} fileName={} popBizError={} errSummary={} bodySnippet={}", - host, clientMasked, httpStatus, durationMs, fileBytes.length, safeName, popBizError, errSummary, snippet); + log.warn("PDD_POP api={} host={} clientId={} httpStatus={} durationMs={} bytes={} fileName={} fileField={} popBizError={} errSummary={} bodySnippet={}", + apiType, host, clientMasked, httpStatus, durationMs, fileBytes.length, safeName, fileField, popBizError, errSummary, snippet); } else { - log.info("PDD_POP api=pdd.goods.img.upload host={} clientId={} httpStatus={} durationMs={} bytes={} fileName={} bodySnippet={}", - host, clientMasked, httpStatus, durationMs, fileBytes.length, safeName, snippet); + log.info("PDD_POP api={} host={} clientId={} httpStatus={} durationMs={} bytes={} fileName={} fileField={} bodySnippet={}", + apiType, host, clientMasked, httpStatus, durationMs, fileBytes.length, safeName, fileField, snippet); } return raw; } catch (Exception e) { long durationMs = (System.nanoTime() - t0) / 1_000_000L; - log.warn("PDD_POP api=pdd.goods.img.upload host={} clientId={} durationMs={} bytes={} invokeFailed={}", - host, clientMasked, durationMs, fileBytes.length, e.toString(), e); + log.warn("PDD_POP api={} host={} clientId={} durationMs={} bytes={} invokeFailed={}", + apiType, host, clientMasked, durationMs, fileBytes.length, e.toString(), e); throw e; } } @@ -166,11 +173,12 @@ public class PddPopClient { } private static byte[] buildMultipartFormData(String boundary, Map textFields, byte[] fileBytes, - String filename) throws Exception { + String filename, String filePartName) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(fileBytes.length + 2048); String crlf = "\r\n"; byte[] crlfBytes = crlf.getBytes(StandardCharsets.UTF_8); String fn = filename.replace("\"", ""); + String part = (filePartName == null || filePartName.isBlank()) ? "file" : filePartName.replace("\"", "").trim(); for (Map.Entry e : textFields.entrySet()) { if (e.getKey() == null || e.getValue() == null) { continue; @@ -183,7 +191,7 @@ public class PddPopClient { baos.write(crlfBytes); } baos.write(("--" + boundary + crlf).getBytes(StandardCharsets.UTF_8)); - baos.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + fn + "\"" + crlf).getBytes(StandardCharsets.UTF_8)); + baos.write(("Content-Disposition: form-data; name=\"" + part + "\"; filename=\"" + fn + "\"" + crlf).getBytes(StandardCharsets.UTF_8)); baos.write(("Content-Type: application/octet-stream" + crlf + crlf).getBytes(StandardCharsets.UTF_8)); baos.write(fileBytes); baos.write(crlfBytes);