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:
parent
eb5d00f998
commit
de006a5d1d
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 网关地址
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
309
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java
vendored
Normal file
309
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}/字符串数值;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue