From 757e169ad5809741c90eaef288e76b95608e215a Mon Sep 17 00:00:00 2001 From: huangyujie <27665451@qq.com> Date: Thu, 26 Mar 2026 09:34:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pdd):=20pdd.goods.image.upload=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AE=98=E6=96=B9=20urlencoded=20+=20Base64?= =?UTF-8?q?=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 与开放平台 curl/SDK 一致,不再 multipart - 移除 image-upload-api-type/gateway/multipart-field 配置项 Made-with: Cursor --- .../src/main/resources/application.yml | 5 +- .../src/main/resources/nacos/erp-api.yaml | 3 - .../external/pdd/ExternalPddProperties.java | 16 --- .../pdd/PddGoodsImageRehostService.java | 37 +----- .../external/pdd/PddOpenApiSupport.java | 2 +- .../service/external/pdd/PddPopClient.java | 121 +++--------------- 6 files changed, 25 insertions(+), 159 deletions(-) diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index bfaab354..fcce9490 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -53,11 +53,8 @@ external: isbn-goods-property-ref-pid: 425 # 按 pdd.goods.cat.rule.get 中 goods_properties_rule 必填项自动补全 goods_properties fill-goods-properties-from-cat-rule: true - # 发品前 POP 图床上传(默认 pdd.goods.image.upload;img.upload 易报「接口不属于当前网关」) + # 发品前 pdd.goods.image.upload(urlencoded,image=Base64,与开放平台 curl/SDK 一致) 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 79aeb1c1..ee77e7a9 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -52,9 +52,6 @@ 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 58351ceb..cd19c6a1 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 @@ -28,22 +28,6 @@ public class ExternalPddProperties { */ 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 da3dc83e..2e6763f5 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 中的外链图片经 POP 图床上传接口(默认 {@code pdd.goods.image.upload})上传并替换 URL。 + * 发品前将 {@code pdd.goods.add} 根 JSON 中的外链图片经 {@code pdd.goods.image.upload}(urlencoded + Base64 {@code image})上传并替换 URL。 */ @Slf4j @Service @@ -60,26 +60,17 @@ public class PddGoodsImageRehostService { log.debug("[PDD] image rehost skipped (no URLs requiring upload)"); return goodsAddRootJson; } - 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)); + log.info("[PDD] image rehost begin distinctUrls={} gatewayHost={}", toUpload.size(), hostForLog(gatewayUrl)); Map sourceToPdd = new LinkedHashMap<>(); for (String src : toUpload) { if (sourceToPdd.containsKey(src)) { continue; } byte[] bytes = downloadImageBytes(src); - String filename = guessFilenameFromUrl(src); - String raw = pddPopClient.invokeGoodsImgUpload(uploadGateway, clientId, clientSecret, accessToken, bytes, filename, - apiType, fileField); + String raw = pddPopClient.invokeGoodsImageUpload(gatewayUrl, clientId, clientSecret, accessToken, bytes); String pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw); if (!StringUtils.hasText(pddUrl)) { - throw new IllegalStateException(apiType + " 失败: " + PddOpenApiSupport.formatError(raw)); + throw new IllegalStateException("pdd.goods.image.upload 失败: " + PddOpenApiSupport.formatError(raw)); } sourceToPdd.put(src, pddUrl); log.info("[PDD] image rehost ok srcSnippet={} bytes={} destSnippet={}", @@ -263,26 +254,6 @@ public class PddGoodsImageRehostService { && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P'; } - private static String guessFilenameFromUrl(String url) { - try { - String path = URI.create(url.trim()).getPath(); - if (!StringUtils.hasText(path)) { - return "image.jpg"; - } - int slash = path.lastIndexOf('/'); - String name = slash >= 0 ? path.substring(slash + 1) : path; - if (!StringUtils.hasText(name) || name.length() > 200) { - return "image.jpg"; - } - if (!name.contains(".")) { - return name + ".jpg"; - } - return name; - } catch (Exception e) { - return "image.jpg"; - } - } - private void applyImageMapping(JSONObject root, Map mapping) { if (mapping.isEmpty()) { return; 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 b1f0f3a9..48c97928 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 { } /** - * 解析商品图上传成功体中的 URL(兼容 {@code goods_img_upload_response} / {@code goods_image_upload_response} / 文件空间等命名)。 + * 解析 {@code pdd.goods.image.upload} 成功体中的 URL(兼容 {@code goods_image_upload_response} / {@code goods_img_upload_response} 等命名)。 */ public static String parseGoodsImgUploadUrl(String body) { if (!StringUtils.hasText(body)) { 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 702ba83e..43eae51c 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 @@ -4,7 +4,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import java.io.ByteArrayOutputStream; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -12,9 +11,9 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; import java.util.HashMap; import java.util.Map; -import java.util.UUID; /** * 拼多多 POP HTTP 调用(application/x-www-form-urlencoded)。 @@ -24,7 +23,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.image.upload}),{@code multipart/form-data}
  • + *
  • {@link #invokeGoodsImageUpload} — {@code pdd.goods.image.upload}:{@code application/x-www-form-urlencoded},{@code image} 为 Base64
  • *
  • {@link #invoke} — 仅当接口要求整包 {@code param_json} 时使用(少数场景)
  • * */ @@ -55,7 +54,7 @@ public class PddPopClient { params.put(e.getKey(), e.getValue()); } } - return postSignedAndLog(gatewayUrl, clientId, clientSecret, "pdd.goods.add", params, logPayload); + return postSignedAndLog(gatewayUrl, clientId, clientSecret, "pdd.goods.add", params, logPayload, Duration.ofSeconds(60)); } /** @@ -71,7 +70,7 @@ public class PddPopClient { params.put("param_json", paramJson); } String logPayload = StringUtils.hasText(paramJson) ? PddOpenApiSupport.snippet(paramJson, 400) : ""; - return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload); + return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload, Duration.ofSeconds(60)); } /** @@ -94,109 +93,27 @@ public class PddPopClient { } } String logPayload = topLevelBizParams == null ? "" : PddOpenApiSupport.snippet(topLevelBizParams.toString(), 400); - return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload); + return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload, Duration.ofSeconds(60)); } /** - * 商品图片上传:{@code multipart/form-data},文本字段参与 MD5 签名,二进制字段不参与。 - * - * @param apiType POP {@code type},如 {@code pdd.goods.image.upload} - * @param multipartFileField 文件 part 的 name,常见 {@code file} + * {@code pdd.goods.image.upload}:与官方 curl/SDK 一致,{@code Content-Type: application/x-www-form-urlencoded}, + * 表单字段含 {@code image}(图片字节的 Base64,无 {@code data:} 前缀)。 */ - public String invokeGoodsImgUpload(String gatewayUrl, String clientId, String clientSecret, String accessToken, - byte[] fileBytes, String filename, String apiType, String multipartFileField) throws Exception { + public String invokeGoodsImageUpload(String gatewayUrl, String clientId, String clientSecret, String accessToken, + byte[] imageBytes) throws Exception { if (!StringUtils.hasText(gatewayUrl)) { throw new IllegalArgumentException("gatewayUrl 不能为空"); } - if (!StringUtils.hasText(apiType)) { - throw new IllegalArgumentException("apiType 不能为空"); + if (imageBytes == null || imageBytes.length == 0) { + throw new IllegalArgumentException("image 不能为空"); } - if (fileBytes == null || fileBytes.length == 0) { - throw new IllegalArgumentException("file 不能为空"); - } - 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, fileField); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(gatewayUrl)) - .timeout(Duration.ofSeconds(120)) - .header("Content-Type", "multipart/form-data; boundary=" + boundary) - .POST(HttpRequest.BodyPublishers.ofByteArray(body)) - .build(); - String host = safeHostForLog(gatewayUrl); - String clientMasked = PddSensitiveLogUtil.maskForLog(clientId); - long t0 = System.nanoTime(); - try { - HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - long durationMs = (System.nanoTime() - t0) / 1_000_000L; - int httpStatus = resp.statusCode(); - String raw = resp.body(); - boolean httpOk = httpStatus >= 200 && httpStatus < 300; - boolean popBizError = PddOpenApiSupport.isError(raw); - String snippet = PddOpenApiSupport.snippet(raw, 400); - if (!httpOk || popBizError) { - String errSummary = popBizError ? PddOpenApiSupport.formatError(raw) : ""; - 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={} 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={} host={} clientId={} durationMs={} bytes={} invokeFailed={}", - apiType, host, clientMasked, durationMs, fileBytes.length, e.toString(), e); - throw e; - } - } - - private static String safeMultipartFilename(String filename) { - if (!StringUtils.hasText(filename)) { - return "image.jpg"; - } - String t = filename.trim().replace("\"", "").replace("\r", "").replace("\n", ""); - int slash = Math.max(t.lastIndexOf('/'), t.lastIndexOf('\\')); - if (slash >= 0) { - t = t.substring(slash + 1); - } - if (!StringUtils.hasText(t)) { - return "image.jpg"; - } - return t.length() > 120 ? t.substring(t.length() - 120) : t; - } - - private static byte[] buildMultipartFormData(String boundary, Map textFields, byte[] fileBytes, - 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; - } - String key = e.getKey().replace("\"", ""); - baos.write(("--" + boundary + crlf).getBytes(StandardCharsets.UTF_8)); - baos.write(("Content-Disposition: form-data; name=\"" + key + "\"" + crlf).getBytes(StandardCharsets.UTF_8)); - baos.write(crlfBytes); - baos.write(e.getValue().getBytes(StandardCharsets.UTF_8)); - baos.write(crlfBytes); - } - baos.write(("--" + boundary + 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); - baos.write(("--" + boundary + "--" + crlf).getBytes(StandardCharsets.UTF_8)); - return baos.toByteArray(); + String b64 = Base64.getEncoder().encodeToString(imageBytes); + Map params = buildBaseParams(clientId, accessToken, "pdd.goods.image.upload", false); + params.put("image", b64); + String logPayload = "pdd.goods.image.upload image=base64(len=" + b64.length() + ") rawBytes=" + imageBytes.length; + return postSignedAndLog(gatewayUrl, clientId, clientSecret, "pdd.goods.image.upload", params, logPayload, + Duration.ofSeconds(180)); } private static Map buildBaseParams(String clientId, String accessToken, String type, @@ -217,14 +134,14 @@ public class PddPopClient { } private String postSignedAndLog(String gatewayUrl, String clientId, String clientSecret, String type, - Map params, String paramLogSnippet) throws Exception { + Map params, String paramLogSnippet, Duration requestTimeout) throws Exception { String sign = PddPopSignUtil.sign(params, clientSecret); params.put("sign", sign); String body = formEncode(params); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(gatewayUrl)) - .timeout(Duration.ofSeconds(60)) + .timeout(requestTimeout != null ? requestTimeout : Duration.ofSeconds(60)) .header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build();