feat(pdd): 发品前 pdd.goods.img.upload 外链图转拼多多图床

- PddPopClient multipart 调用 pdd.goods.img.upload
- PddGoodsImageRehostService 收集 carousel/detail/thumb,白名单域名下载后上传并替换 URL
- ExternalPddProperties:image-upload-enabled、max-bytes、allowed/skip host 后缀
- application.yml / nacos 模板同步配置

Made-with: Cursor
This commit is contained in:
huangyujie 2026-03-25 20:42:53 +08:00
parent eb5d00f998
commit de006a5d1d
7 changed files with 472 additions and 0 deletions

View File

@ -53,6 +53,15 @@ external:
isbn-goods-property-ref-pid: 425 isbn-goods-property-ref-pid: 425
# 按 pdd.goods.cat.rule.get 中 goods_properties_rule 必填项自动补全 goods_properties # 按 pdd.goods.cat.rule.get 中 goods_properties_rule 必填项自动补全 goods_properties
fill-goods-properties-from-cat-rule: true fill-goods-properties-from-cat-rule: true
# 发品前 pdd.goods.img.upload外链图上传至拼多多图床后再 goods.add域名须在 allowed-host-suffixes
image-upload-enabled: true
image-upload-max-bytes: 3145728
image-upload-allowed-host-suffixes:
- etjbooks.com.cn
image-upload-skip-host-suffixes:
- pddpic.com
- pinduoduo.com
- yangkeduo.com
# categoryCode请求体-> 拼多多叶子类目 cat_id与 maindata default-category-code=DEFAULT 对齐) # categoryCode请求体-> 拼多多叶子类目 cat_id与 maindata default-category-code=DEFAULT 对齐)
category-map: category-map:
DEFAULT: "15693" DEFAULT: "15693"

View File

@ -51,6 +51,14 @@ external:
single-buy-add-fen: 200 single-buy-add-fen: 200
isbn-goods-property-ref-pid: 425 isbn-goods-property-ref-pid: 425
fill-goods-properties-from-cat-rule: true fill-goods-properties-from-cat-rule: true
image-upload-enabled: true
image-upload-max-bytes: 3145728
image-upload-allowed-host-suffixes:
- etjbooks.com.cn
image-upload-skip-host-suffixes:
- pddpic.com
- pinduoduo.com
- yangkeduo.com
category-map: category-map:
DEFAULT: "15693" DEFAULT: "15693"
cost-template-map: cost-template-map:

View File

@ -22,6 +22,29 @@ public class ExternalPddProperties {
*/ */
private boolean publishEnabled = false; private boolean publishEnabled = false;
/**
* 发品前是否将外链图片经 {@code pdd.goods.img.upload} 上传到拼多多图床并用返回 URL 替换
* {@code carousel_gallery}/{@code detail_gallery}/{@code sku_list[].thumb_url}去重上传
*/
private boolean imageUploadEnabled = true;
/**
* 单张图片最大下载字节默认 3MB与常见平台主图上限一致
*/
private long imageUploadMaxBytes = 3145728L;
/**
* 允许下载的图片 URL 域名后缀SSRF 防护 {@code etjbooks.com.cn} 可匹配 {@code oss.etjbooks.com.cn}
* 为空则不允许下载任何外链须显式配置
*/
private List<String> imageUploadAllowedHostSuffixes = new ArrayList<>(List.of("etjbooks.com.cn"));
/**
* 命中后缀则视为已在拼多多侧跳过重传小写比较支持子域
*/
private List<String> imageUploadSkipHostSuffixes = new ArrayList<>(List.of(
"pddpic.com", "pinduoduo.com", "yangkeduo.com"));
/** /**
* POP 网关地址 * POP 网关地址
*/ */

View File

@ -28,6 +28,7 @@ public class ExternalPddPublishService {
private final PddGoodsAddParamBuilder paramBuilder; private final PddGoodsAddParamBuilder paramBuilder;
private final PddPopClient pddPopClient; private final PddPopClient pddPopClient;
private final PddCatRuleSpecAutoResolver catRuleSpecAutoResolver; private final PddCatRuleSpecAutoResolver catRuleSpecAutoResolver;
private final PddGoodsImageRehostService pddGoodsImageRehostService;
/** /**
* 落库成功后尝试拼多多上架返回链路诊断信息 * 落库成功后尝试拼多多上架返回链路诊断信息
@ -165,6 +166,8 @@ public class ExternalPddPublishService {
String goodsAddRootJson = noSpecBookPublish String goodsAddRootJson = noSpecBookPublish
? paramBuilder.buildParamJsonNoSpecBook(goods, skus, req, props, catRuleRawForGoodsProps) ? paramBuilder.buildParamJsonNoSpecBook(goods, skus, req, props, catRuleRawForGoodsProps)
: paramBuilder.buildParamJson(goods, skus, req, props, effectiveOverrides, catRuleRawForGoodsProps); : paramBuilder.buildParamJson(goods, skus, req, props, effectiveOverrides, catRuleRawForGoodsProps);
goodsAddRootJson = pddGoodsImageRehostService.rehostExternalImagesInGoodsAddJson(
goodsAddRootJson, gateway, cred.getAppKey(), cred.getAppSecret(), cred.getAccessToken());
String raw = pddPopClient.invokeGoodsAdd(gateway, cred.getAppKey(), cred.getAppSecret(), cred.getAccessToken(), String raw = pddPopClient.invokeGoodsAdd(gateway, cred.getAppKey(), cred.getAppSecret(), cred.getAccessToken(),
goodsAddRootJson); goodsAddRootJson);
boolean ok = !PddOpenApiSupport.isError(raw); boolean ok = !PddOpenApiSupport.isError(raw);

View File

@ -0,0 +1,309 @@
package cn.qihangerp.service.external.pdd;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 发品前将 {@code pdd.goods.add} JSON 中的外链图片经 {@code pdd.goods.img.upload} 上传到拼多多图床并替换 URL
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PddGoodsImageRehostService {
private final ExternalPddProperties props;
private final PddPopClient pddPopClient;
private final HttpClient downloadClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
/**
* {@link ExternalPddProperties#isImageUploadEnabled()} false原样返回
*/
public String rehostExternalImagesInGoodsAddJson(String goodsAddRootJson, String gatewayUrl,
String clientId, String clientSecret, String accessToken) throws Exception {
if (!props.isImageUploadEnabled()) {
return goodsAddRootJson;
}
if (!StringUtils.hasText(goodsAddRootJson)) {
return goodsAddRootJson;
}
JSONObject root = JSON.parseObject(goodsAddRootJson);
if (root == null || root.isEmpty()) {
return goodsAddRootJson;
}
List<String> toUpload = new ArrayList<>();
collectUrlsNeedingRehost(root, toUpload);
if (toUpload.isEmpty()) {
log.debug("[PDD] image rehost skipped (no URLs requiring upload)");
return goodsAddRootJson;
}
log.info("[PDD] image rehost begin distinctUrls={}", toUpload.size());
Map<String, String> sourceToPdd = new LinkedHashMap<>();
for (String src : toUpload) {
if (sourceToPdd.containsKey(src)) {
continue;
}
byte[] bytes = downloadImageBytes(src);
String filename = guessFilenameFromUrl(src);
String raw = pddPopClient.invokeGoodsImgUpload(gatewayUrl, clientId, clientSecret, accessToken, bytes, filename);
String pddUrl = PddOpenApiSupport.parseGoodsImgUploadUrl(raw);
if (!StringUtils.hasText(pddUrl)) {
throw new IllegalStateException("pdd.goods.img.upload 失败: " + PddOpenApiSupport.formatError(raw));
}
sourceToPdd.put(src, pddUrl);
log.info("[PDD] image rehost ok srcSnippet={} bytes={} destSnippet={}",
PddOpenApiSupport.snippet(src, 96), bytes.length, PddOpenApiSupport.snippet(pddUrl, 96));
}
applyImageMapping(root, sourceToPdd);
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);
JSONArray skuList = root.getJSONArray("sku_list");
if (skuList != null) {
for (int i = 0; i < skuList.size(); i++) {
JSONObject sku = skuList.getJSONObject(i);
if (sku != null) {
String u = sku.getString("thumb_url");
if (StringUtils.hasText(u)) {
seen.add(u.trim());
}
}
}
}
for (String u : seen) {
if (needsRehost(u)) {
out.add(u);
}
}
}
private static void addStringsFromJsonArray(JSONArray arr, Set<String> target) {
if (arr == null || arr.isEmpty()) {
return;
}
for (int i = 0; i < arr.size(); i++) {
Object el = arr.get(i);
if (el == null) {
continue;
}
String u = el instanceof String s ? s : el.toString();
if (StringUtils.hasText(u)) {
target.add(u.trim());
}
}
}
private boolean needsRehost(String url) {
if (!StringUtils.hasText(url)) {
return false;
}
String u = url.trim();
if (!u.startsWith("http://") && !u.startsWith("https://")) {
return false;
}
String host = hostOf(u);
if (!StringUtils.hasText(host)) {
return false;
}
if (hostMatchesAnySuffix(host, props.getImageUploadSkipHostSuffixes())) {
return false;
}
if (!hostMatchesAllowedSuffix(host)) {
throw new IllegalStateException("图片 URL 域名不在 external.pdd.image-upload-allowed-host-suffixes 白名单: "
+ host + " url=" + PddOpenApiSupport.snippet(u, 96));
}
return true;
}
private boolean hostMatchesAllowedSuffix(String host) {
String h = host.toLowerCase(Locale.ROOT);
List<String> suffixes = props.getImageUploadAllowedHostSuffixes();
if (suffixes == null || suffixes.isEmpty()) {
return false;
}
return hostMatchesAnySuffix(h, suffixes);
}
private static boolean hostMatchesAnySuffix(String hostLower, List<String> suffixes) {
if (suffixes == null) {
return false;
}
for (String suf : suffixes) {
if (suf == null || suf.isBlank()) {
continue;
}
String s = suf.trim().toLowerCase(Locale.ROOT);
if (hostLower.equals(s) || hostLower.endsWith("." + s)) {
return true;
}
}
return false;
}
private static String hostOf(String url) {
try {
URI uri = URI.create(url.trim());
String h = uri.getHost();
return h != null ? h.toLowerCase(Locale.ROOT) : null;
} catch (Exception e) {
return null;
}
}
private byte[] downloadImageBytes(String urlString) throws Exception {
long max = props.getImageUploadMaxBytes();
URI uri = URI.create(urlString.trim());
HttpRequest req = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(90))
.header("User-Agent", "qihang-erp-open/pdd-image-rehost")
.GET()
.build();
HttpResponse<InputStream> resp = downloadClient.send(req, HttpResponse.BodyHandlers.ofInputStream());
int code = resp.statusCode();
try (InputStream in = resp.body()) {
if (code < 200 || code >= 300) {
drainQuietly(in);
throw new IllegalStateException("下载图片 HTTP " + code + ": " + PddOpenApiSupport.snippet(urlString, 100));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream(
(int) Math.min(max, Math.min(Integer.MAX_VALUE, 256L * 1024)));
byte[] buf = new byte[8192];
long total = 0;
int n;
while ((n = in.read(buf)) >= 0) {
total += n;
if (total > max) {
throw new IllegalStateException("图片超过 image-upload-max-bytes=" + max + "url="
+ PddOpenApiSupport.snippet(urlString, 96));
}
baos.write(buf, 0, n);
}
byte[] body = baos.toByteArray();
if (body.length == 0) {
throw new IllegalStateException("下载图片内容为空: " + PddOpenApiSupport.snippet(urlString, 100));
}
if (!looksLikeImageMagic(body)) {
log.warn("[PDD] image download magic check inconclusive url={}", PddOpenApiSupport.snippet(urlString, 96));
}
return body;
}
}
private static void drainQuietly(InputStream in) {
try {
byte[] buf = new byte[8192];
while (in.read(buf) >= 0) {
// discard
}
} catch (Exception ignored) {
}
}
private static boolean looksLikeImageMagic(byte[] b) {
if (b == null || b.length < 12) {
return false;
}
if (b[0] == (byte) 0xFF && b[1] == (byte) 0xD8 && b[2] == (byte) 0xFF) {
return true;
}
if (b[0] == (byte) 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G') {
return true;
}
if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F') {
return true;
}
return b.length >= 12 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F'
&& 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<String, String> mapping) {
if (mapping.isEmpty()) {
return;
}
replaceInJsonArray(root.getJSONArray("carousel_gallery"), mapping);
replaceInJsonArray(root.getJSONArray("detail_gallery"), mapping);
JSONArray skuList = root.getJSONArray("sku_list");
if (skuList != null) {
for (int i = 0; i < skuList.size(); i++) {
JSONObject sku = skuList.getJSONObject(i);
if (sku == null) {
continue;
}
String t = sku.getString("thumb_url");
if (StringUtils.hasText(t)) {
String repl = mapping.get(t.trim());
if (repl != null) {
sku.put("thumb_url", repl);
}
}
}
}
}
private static void replaceInJsonArray(JSONArray arr, Map<String, String> mapping) {
if (arr == null || arr.isEmpty()) {
return;
}
for (int i = 0; i < arr.size(); i++) {
Object el = arr.get(i);
if (el == null) {
continue;
}
String u = el instanceof String s ? s : el.toString();
if (!StringUtils.hasText(u)) {
continue;
}
String repl = mapping.get(u.trim());
if (repl != null) {
arr.set(i, repl);
}
}
}
}

View File

@ -143,6 +143,29 @@ public final class PddOpenApiSupport {
} }
} }
/**
* 解析 {@code pdd.goods.img.upload} 成功体中的 {@code goods_img_upload_response.url}
*/
public static String parseGoodsImgUploadUrl(String body) {
if (!StringUtils.hasText(body)) {
return null;
}
try {
JSONObject o = JSON.parseObject(body);
if (o == null || o.containsKey("error_response")) {
return null;
}
JSONObject r = o.getJSONObject("goods_img_upload_response");
if (r == null) {
return null;
}
String u = r.getString("url");
return StringUtils.hasText(u) ? u.trim() : null;
} catch (Exception e) {
return null;
}
}
/** /**
* 在类目规则 JSON 中解析销售规格对应的 {@code parent_spec_id}取第一个 * 在类目规则 JSON 中解析销售规格对应的 {@code parent_spec_id}取第一个
* <p>兼容{@code is_sale}/{@code isSale}{@code parent_spec_id}/{@code parentSpecId}/字符串数值 * <p>兼容{@code is_sale}/{@code isSale}{@code parent_spec_id}/{@code parentSpecId}/字符串数值

View File

@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.io.ByteArrayOutputStream;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@ -13,6 +14,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 拼多多 POP HTTP 调用application/x-www-form-urlencoded * 拼多多 POP HTTP 调用application/x-www-form-urlencoded
@ -22,6 +24,7 @@ import java.util.Map;
* <li>{@link #invokeGoodsAdd} {@code pdd.goods.add}业务字段全部在表单顶层{@code sku_list}{@code carousel_gallery} 等为 JSON 字符串 * <li>{@link #invokeGoodsAdd} {@code pdd.goods.add}业务字段全部在表单顶层{@code sku_list}{@code carousel_gallery} 等为 JSON 字符串
* <b></b> {@code param_json}<b></b> {@code version}</li> * <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 #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 #invoke} 仅当接口要求整包 {@code param_json} 时使用少数场景</li> * <li>{@link #invoke} 仅当接口要求整包 {@code param_json} 时使用少数场景</li>
* </ul> * </ul>
*/ */
@ -94,6 +97,100 @@ public class PddPopClient {
return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload); return postSignedAndLog(gatewayUrl, clientId, clientSecret, type, params, logPayload);
} }
/**
* {@code pdd.goods.img.upload}与官方示例一致{@code multipart/form-data}文本字段参与 MD5 签名{@code file} 不参与
*/
public String invokeGoodsImgUpload(String gatewayUrl, String clientId, String clientSecret, String accessToken,
byte[] fileBytes, String filename) throws Exception {
if (!StringUtils.hasText(gatewayUrl)) {
throw new IllegalArgumentException("gatewayUrl 不能为空");
}
if (fileBytes == null || fileBytes.length == 0) {
throw new IllegalArgumentException("file 不能为空");
}
Map<String, String> params = buildBaseParams(clientId, accessToken, "pdd.goods.img.upload", 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);
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<String> 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=pdd.goods.img.upload host={} clientId={} httpStatus={} durationMs={} bytes={} fileName={} popBizError={} errSummary={} bodySnippet={}",
host, clientMasked, httpStatus, durationMs, fileBytes.length, safeName, 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);
}
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);
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<String, String> textFields, byte[] fileBytes,
String filename) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream(fileBytes.length + 2048);
String crlf = "\r\n";
byte[] crlfBytes = crlf.getBytes(StandardCharsets.UTF_8);
String fn = filename.replace("\"", "");
for (Map.Entry<String, String> 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=\"file\"; 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();
}
private static Map<String, String> buildBaseParams(String clientId, String accessToken, String type, private static Map<String, String> buildBaseParams(String clientId, String accessToken, String type,
boolean withVersion) { boolean withVersion) {
long ts = System.currentTimeMillis() / 1000L; long ts = System.currentTimeMillis() / 1000L;