From de006a5d1d5e031ed3ff8ed0b9f0baf0e038cd75 Mon Sep 17 00:00:00 2001 From: huangyujie <27665451@qq.com> Date: Wed, 25 Mar 2026 20:42:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(pdd):=20=E5=8F=91=E5=93=81=E5=89=8D=20pdd.?= =?UTF-8?q?goods.img.upload=20=E5=A4=96=E9=93=BE=E5=9B=BE=E8=BD=AC?= =?UTF-8?q?=E6=8B=BC=E5=A4=9A=E5=A4=9A=E5=9B=BE=E5=BA=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/main/resources/application.yml | 9 + .../src/main/resources/nacos/erp-api.yaml | 8 + .../external/pdd/ExternalPddProperties.java | 23 ++ .../pdd/ExternalPddPublishService.java | 3 + .../pdd/PddGoodsImageRehostService.java | 309 ++++++++++++++++++ .../external/pdd/PddOpenApiSupport.java | 23 ++ .../service/external/pdd/PddPopClient.java | 97 ++++++ 7 files changed, 472 insertions(+) create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index a402920c..f5b242fd 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -53,6 +53,15 @@ 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) + 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 对齐) category-map: DEFAULT: "15693" 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 135039e1..ee77e7a9 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -51,6 +51,14 @@ external: single-buy-add-fen: 200 isbn-goods-property-ref-pid: 425 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: DEFAULT: "15693" cost-template-map: 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 caaae393..e9bb0e4f 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 @@ -22,6 +22,29 @@ public class ExternalPddProperties { */ 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 imageUploadAllowedHostSuffixes = new ArrayList<>(List.of("etjbooks.com.cn")); + + /** + * 命中后缀则视为已在拼多多侧,跳过重传(小写比较,支持子域)。 + */ + private List imageUploadSkipHostSuffixes = new ArrayList<>(List.of( + "pddpic.com", "pinduoduo.com", "yangkeduo.com")); + /** * POP 网关地址 */ diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java index 489e540a..a9ab06e1 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java @@ -28,6 +28,7 @@ public class ExternalPddPublishService { private final PddGoodsAddParamBuilder paramBuilder; private final PddPopClient pddPopClient; private final PddCatRuleSpecAutoResolver catRuleSpecAutoResolver; + private final PddGoodsImageRehostService pddGoodsImageRehostService; /** * 落库成功后尝试拼多多上架;返回链路诊断信息。 @@ -165,6 +166,8 @@ public class ExternalPddPublishService { String goodsAddRootJson = noSpecBookPublish ? paramBuilder.buildParamJsonNoSpecBook(goods, skus, req, props, 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(), goodsAddRootJson); boolean ok = !PddOpenApiSupport.isError(raw); 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 new file mode 100644 index 00000000..126a22db --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsImageRehostService.java @@ -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 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 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 out) { + Set 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 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 suffixes = props.getImageUploadAllowedHostSuffixes(); + if (suffixes == null || suffixes.isEmpty()) { + return false; + } + return hostMatchesAnySuffix(h, suffixes); + } + + private static boolean hostMatchesAnySuffix(String hostLower, List 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 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 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 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); + } + } + } +} 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 6d37b60b..a0ec502b 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 @@ -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}(取第一个)。 *

兼容:{@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 05508c1e..2028f6dd 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,6 +4,7 @@ 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; @@ -13,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.UUID; /** * 拼多多 POP HTTP 调用(application/x-www-form-urlencoded)。 @@ -22,6 +24,7 @@ import java.util.Map; *

  • {@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 #invoke} — 仅当接口要求整包 {@code param_json} 时使用(少数场景)
  • * */ @@ -94,6 +97,100 @@ public class PddPopClient { 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 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 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 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 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 buildBaseParams(String clientId, String accessToken, String type, boolean withVersion) { long ts = System.currentTimeMillis() / 1000L;