fix(pdd): 图床上传 type 默认改为 pdd.goods.image.upload
- 解决 pdd.goods.img.upload 报「接口不属于当前网关」 - 可配 image-upload-api-type / gateway-url / multipart 字段名 - 解析响应兼容 goods_image_upload_response 与 image_url Made-with: Cursor
This commit is contained in:
parent
de006a5d1d
commit
349dbca470
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,与常见平台主图上限一致)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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());
|
||||
|
|
|
|||
|
|
@ -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}(取第一个)。
|
||||
* <p>兼容:{@code is_sale}/{@code isSale}、{@code parent_spec_id}/{@code parentSpecId}/字符串数值;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import java.util.UUID;
|
|||
* <li>{@link #invokeGoodsAdd} — {@code pdd.goods.add}:业务字段全部在表单顶层({@code sku_list}、{@code carousel_gallery} 等为 JSON 字符串),
|
||||
* <b>无</b> {@code param_json}、<b>无</b> {@code version}</li>
|
||||
* <li>{@link #invokeTopLevelBiz} — 如 {@code pdd.goods.cat.rule.get} 的 {@code cat_id}/{@code goods_id} 等顶层字段</li>
|
||||
* <li>{@link #invokeGoodsImgUpload} — {@code pdd.goods.img.upload},{@code multipart/form-data}</li>
|
||||
* <li>{@link #invokeGoodsImgUpload} — 商品图上传(如 {@code pdd.goods.image.upload}),{@code multipart/form-data}</li>
|
||||
* <li>{@link #invoke} — 仅当接口要求整包 {@code param_json} 时使用(少数场景)</li>
|
||||
* </ul>
|
||||
*/
|
||||
|
|
@ -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<String, String> params = buildBaseParams(clientId, accessToken, "pdd.goods.img.upload", false);
|
||||
String fileField = StringUtils.hasText(multipartFileField) ? multipartFileField.trim() : "file";
|
||||
Map<String, String> 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<String, String> 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<String, String> 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue