diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index 6cbbbc1c..6681de59 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -77,8 +77,16 @@ external: DEFAULT: "303351846671360" sku-overrides: [] - # 微信视频号小店(platform=WEIXIN):默认不调用渠道发品;接入 OpenAPI 后再开启 + # 微信视频号小店(platform=WEIXIN):生产环境通过 Nacos 开启并校对类目/运费模板 sph: publish-enabled: false + image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload + product-add-url: https://api.weixin.qq.com/channels/ec/product/add + product-update-url: https://api.weixin.qq.com/channels/ec/product/update + fixed-leaf-cat-id-v2: 537901187 + min-head-images: 3 + listing-on-save: 1 + http-read-timeout-seconds: 90 + # express-template-id: 按需配置运费模板 ID # 说明:对外商品接口见 temp/yunxi-erp-open-goods-upsert-api.md;Nacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md 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 c01515ac..f2a58a47 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -68,3 +68,14 @@ external: cost-template-map: DEFAULT: "303351846671360" sku-overrides: [] + + # 微信视频号小店(platform=WEIXIN);联调时开启 publish-enabled + sph: + publish-enabled: false + image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload + product-add-url: https://api.weixin.qq.com/channels/ec/product/add + product-update-url: https://api.weixin.qq.com/channels/ec/product/update + fixed-leaf-cat-id-v2: 537901187 + min-head-images: 3 + listing-on-save: 1 + http-read-timeout-seconds: 90 diff --git a/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java index fc20820c..d1545e19 100644 --- a/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java +++ b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java @@ -41,6 +41,13 @@ public class ExternalGoodsUpsertRequest { @JsonAlias({"pdd_goods_id"}) private Long pddGoodsId; + /** + * 微信小店侧商品 ID({@code product_id})。二次上架/更新时由调用方从货架 extension 等来源传入,走 {@code channels.ec.product.update}。 + */ + @JsonProperty("sphProductId") + @JsonAlias({"sph_product_id", "productId"}) + private Long sphProductId; + /** * 商品标题 */ diff --git a/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphProperties.java b/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphProperties.java index 92f3745b..e1b365a2 100644 --- a/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphProperties.java +++ b/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphProperties.java @@ -4,7 +4,7 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * 微信视频号小店(platform=WEIXIN)发品相关开关;应用级密钥由调用方经 {@code sphPopAuth} 传入,与拼多多一致。 + * 微信视频号小店(platform=WEIXIN)发品相关开关与端点;应用级密钥由调用方经 {@code sphPopAuth} 传入,与拼多多一致。 * * @author guochengyu */ @@ -13,7 +13,47 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class ExternalSphProperties { /** - * 是否在 upsert 落库后尝试调用微信小店发品适配(未接入前请保持 false,避免误报失败)。 + * 是否在 upsert 落库后尝试调用微信小店发品适配。 */ private boolean publishEnabled = false; + + /** + * 图片上传接口(不含 query);文档默认 {@code https://api.weixin.qq.com/shop/ec/basics/img/upload}。 + */ + private String imageUploadUrl = "https://api.weixin.qq.com/shop/ec/basics/img/upload"; + + /** + * 添加商品 {@code channels.ec.product.add}。 + */ + private String productAddUrl = "https://api.weixin.qq.com/channels/ec/product/add"; + + /** + * 更新商品 {@code channels.ec.product.update}。 + */ + private String productUpdateUrl = "https://api.weixin.qq.com/channels/ec/product/update"; + + /** + * 新类目树叶子类目 ID(本期固定);对应请求体 {@code cats_v2}。 + */ + private long fixedLeafCatIdV2 = 537901187L; + + /** + * 主图最少张数(普通类目文档为 3;食品饮料等为 4,可按联调调整)。 + */ + private int minHeadImages = 3; + + /** + * 添加/更新后是否 {@code listing=1}(提审并上架流程,减少分步竞态)。 + */ + private int listingOnSave = 1; + + /** + * HTTP 读超时(秒)。 + */ + private int httpReadTimeoutSeconds = 90; + + /** + * 运费模板 ID(可选);非空则写入 {@code express_info.template_id}。 + */ + private String expressTemplateId; } diff --git a/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphPublishService.java b/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphPublishService.java index 2565b2cf..1f7b405a 100644 --- a/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphPublishService.java +++ b/service/src/main/java/cn/qihangerp/service/external/sph/ExternalSphPublishService.java @@ -4,16 +4,28 @@ import cn.qihangerp.model.entity.OGoods; import cn.qihangerp.model.entity.OGoodsSku; import cn.qihangerp.model.request.ExternalGoodsUpsertRequest; import cn.qihangerp.model.vo.SphPublishLaneResultVo; +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.CollectionUtils; import org.springframework.util.StringUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** - * 微信视频号小店发品:凭证仅来自本次请求的 {@link ExternalGoodsUpsertRequest#getSphPopAuth()}。 - *

具体 HTTP/OpenAPI 接入在 {@code publishEnabled=true} 后实现;关闭开关时不调用渠道,与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 行为对称。

+ * 微信视频号小店发品:先上传素材换 {@code img_url},再调 {@code channels.ec.product.add/update}。 + *

凭证来自 {@link ExternalGoodsUpsertRequest#getSphPopAuth()};与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 对称。

* * @author guochengyu */ @@ -22,10 +34,14 @@ import java.util.List; @RequiredArgsConstructor public class ExternalSphPublishService { + private static final String NO_BRAND_ID = "2100000000"; + private final ExternalSphProperties props; + private final WeixinShopEcImageUploadService imageUploadService; + private final WeixinShopEcHttpClient httpClient; /** - * 落库成功后可选调用微信侧发品;未开启或未实现时返回 {@code attempted=false},不阻断 ERP 本地 upsert。 + * 落库成功后尝试微信侧发品;失败返回 {@code success=false}(不抛异常),由编排层决定是否阻断。 */ public SphPublishLaneResultVo publish(OGoods goods, List skus, ExternalGoodsUpsertRequest req) { if (!props.isPublishEnabled()) { @@ -56,15 +72,329 @@ public class ExternalSphPublishService { .message("微信小店凭证不完整:请在请求体 sphPopAuth 中传入 appKey、appSecret、accessToken") .build(); } - // TODO: 接入微信视频号小店商品发布 OpenAPI,填充 sphProductId/responseSnippet - log.warn("[SPH] publish-enabled=true 但发品适配尚未实现 shopId={} outGoodsId={} skuCount={}", - req.getShopId(), req.getOutGoodsId(), skus == null ? 0 : skus.size()); + String token = auth.getAccessToken().trim(); + boolean update = req.getSphProductId() != null && req.getSphProductId() > 0; + String lane = update ? "PRODUCT_UPDATE" : "PRODUCT_ADD"; + try { + HeadAndDetailWxImages imgs = buildWxImages(token, req); + JSONObject payload = buildProductPayload(goods, skus, req, imgs, update); + String url = (update ? props.getProductUpdateUrl() : props.getProductAddUrl()); + if (!StringUtils.hasText(url)) { + return failVo(true, lane, "external.sph.product-add-url / product-update-url 未配置"); + } + String fullUrl = url + (url.contains("?") ? "&" : "?") + + "access_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8); + Duration readTimeout = Duration.ofSeconds(Math.max(5, props.getHttpReadTimeoutSeconds())); + String raw = httpClient.postJson(fullUrl, payload.toJSONString(), readTimeout); + if (!WeixinShopEcHttpClient.isBizOk(raw)) { + log.warn("[SPH] {} fail shopId={} outGoodsId={} err={} snippet={}", + lane, req.getShopId(), req.getOutGoodsId(), + WeixinShopEcHttpClient.formatWxError(raw), + WeixinShopEcLogSupport.snippet(raw, 800)); + return SphPublishLaneResultVo.builder() + .attempted(true) + .success(false) + .message(lane + " 失败: " + WeixinShopEcHttpClient.formatWxError(raw)) + .responseSnippet(WeixinShopEcLogSupport.snippet(raw, 2000)) + .sphProductId(null) + .build(); + } + String productIdStr = parseProductIdString(raw); + if (!StringUtils.hasText(productIdStr) && !update) { + return SphPublishLaneResultVo.builder() + .attempted(true) + .success(false) + .message(lane + " 成功但未解析到 data.product_id") + .responseSnippet(WeixinShopEcLogSupport.snippet(raw, 2000)) + .sphProductId(null) + .build(); + } + String outPid = StringUtils.hasText(productIdStr) ? productIdStr : String.valueOf(req.getSphProductId()); + log.info("[SPH] {} ok shopId={} outGoodsId={} sphProductId={} snippet={}", + lane, req.getShopId(), req.getOutGoodsId(), outPid, + WeixinShopEcLogSupport.snippet(raw, 500)); + return SphPublishLaneResultVo.builder() + .attempted(true) + .success(true) + .message(lane + " 成功") + .responseSnippet(WeixinShopEcLogSupport.snippet(raw, 2000)) + .sphProductId(outPid) + .build(); + } catch (Exception e) { + log.warn("[SPH] publish exception shopId={} outGoodsId={} lane={} err={}", + req.getShopId(), req.getOutGoodsId(), lane, e.getMessage(), e); + return SphPublishLaneResultVo.builder() + .attempted(true) + .success(false) + .message(lane + " 异常: " + e.getMessage()) + .responseSnippet(null) + .sphProductId(null) + .build(); + } + } + + private static SphPublishLaneResultVo failVo(boolean attempted, String lane, String msg) { return SphPublishLaneResultVo.builder() - .attempted(true) + .attempted(attempted) .success(false) - .message("微信视频号小店发品接口尚未接入(请关闭 external.sph.publish-enabled 或完成适配开发)") + .message(msg) .responseSnippet(null) .sphProductId(null) .build(); } + + private HeadAndDetailWxImages buildWxImages(String token, ExternalGoodsUpsertRequest req) throws Exception { + int minHead = Math.max(1, props.getMinHeadImages()); + List headSources = new ArrayList<>(); + LinkedHashSet seen = new LinkedHashSet<>(); + if (StringUtils.hasText(req.getMainImage())) { + addUnique(headSources, seen, req.getMainImage().trim()); + } + if (!CollectionUtils.isEmpty(req.getImages())) { + for (String u : req.getImages()) { + addUnique(headSources, seen, u); + } + } + List headWx = new ArrayList<>(); + for (String s : headSources) { + headWx.add(imageUploadService.ensureImgUrl(token, s)); + if (headWx.size() >= minHead) { + break; + } + } + if (headWx.size() < minHead && !CollectionUtils.isEmpty(req.getDetailImages())) { + for (String u : req.getDetailImages()) { + if (!StringUtils.hasText(u)) { + continue; + } + String t = u.trim(); + if (!seen.add(t)) { + continue; + } + headWx.add(imageUploadService.ensureImgUrl(token, t)); + if (headWx.size() >= minHead) { + break; + } + } + } + if (headWx.size() < minHead) { + throw new IllegalStateException("微信小店主图至少需要 " + minHead + " 张不同素材(上传后仍不足),请补充 mainImage/images/detailImages"); + } + + List detailSources = new ArrayList<>(); + LinkedHashSet dSeen = new LinkedHashSet<>(); + if (!CollectionUtils.isEmpty(req.getDetailImages())) { + for (String u : req.getDetailImages()) { + if (!StringUtils.hasText(u)) { + continue; + } + String t = u.trim(); + if (!dSeen.add(t)) { + continue; + } + detailSources.add(t); + } + } + if (detailSources.isEmpty() && !headSources.isEmpty()) { + detailSources.add(headSources.get(0)); + } + List detailWx = imageUploadService.uploadDistinctInOrder(token, detailSources); + if (detailWx.isEmpty()) { + detailWx = List.of(headWx.get(0)); + } + return new HeadAndDetailWxImages(headWx, detailWx); + } + + private static void addUnique(List out, Set seen, String u) { + if (!StringUtils.hasText(u)) { + return; + } + String t = u.trim(); + if (seen.add(t)) { + out.add(t); + } + } + + private JSONObject buildProductPayload(OGoods goods, List skuRows, ExternalGoodsUpsertRequest req, + HeadAndDetailWxImages imgs, boolean update) { + JSONObject root = new JSONObject(); + if (update) { + root.put("product_id", req.getSphProductId()); + } + root.put("title", normalizeTitle(req, goods)); + if (StringUtils.hasText(req.getOutGoodsId())) { + String outPid = req.getOutGoodsId().trim(); + if (outPid.length() <= 128) { + root.put("out_product_id", outPid); + } + } + JSONArray head = new JSONArray(); + for (String u : imgs.headImgUrls()) { + head.add(u); + } + root.put("head_imgs", head); + root.put("deliver_method", 0); + root.put("deliver_acct_type", new JSONArray()); + JSONArray catsV2 = new JSONArray(); + JSONObject leaf = new JSONObject(); + leaf.put("cat_id", String.valueOf(props.getFixedLeafCatIdV2())); + catsV2.add(leaf); + root.put("cats_v2", catsV2); + JSONObject extra = new JSONObject(); + extra.put("seven_day_return", 0); + extra.put("freight_insurance", 0); + root.put("extra_service", extra); + JSONObject desc = new JSONObject(); + JSONArray dimgs = new JSONArray(); + for (String u : imgs.detailImgUrls()) { + dimgs.add(u); + } + desc.put("imgs", dimgs); + root.put("desc_info", desc); + if (StringUtils.hasText(props.getExpressTemplateId())) { + JSONObject express = new JSONObject(); + express.put("template_id", props.getExpressTemplateId().trim()); + root.put("express_info", express); + } + root.put("brand_id", NO_BRAND_ID); + root.put("listing", props.getListingOnSave()); + String accessToken = req.getSphPopAuth() != null && StringUtils.hasText(req.getSphPopAuth().getAccessToken()) + ? req.getSphPopAuth().getAccessToken().trim() : ""; + JSONArray skuArr = buildSkus(skuRows, req, imgs.headImgUrls().get(0), accessToken); + if (skuArr.isEmpty()) { + throw new IllegalStateException("无可发品 SKU"); + } + root.put("skus", skuArr); + return root; + } + + private JSONArray buildSkus(List skuRows, ExternalGoodsUpsertRequest req, String defaultThumbWxUrl, + String accessToken) { + JSONArray arr = new JSONArray(); + List rows = skuRows == null ? List.of() : skuRows; + for (OGoodsSku row : rows) { + if (row == null || !StringUtils.hasText(row.getOuterErpSkuId())) { + continue; + } + try { + JSONObject sku = new JSONObject(); + String outSku = row.getOuterErpSkuId().trim(); + if (outSku.length() <= 128) { + sku.put("out_sku_id", outSku); + } + if (StringUtils.hasText(row.getSkuCode())) { + sku.put("sku_code", truncate(row.getSkuCode().trim(), 100)); + } + BigDecimal priceYuan = resolveSalePriceYuan(req, outSku); + if (priceYuan != null) { + long fen = priceYuan.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).longValue(); + sku.put("sale_price", fen); + } + int stock = resolveStock(req, outSku); + sku.put("stock_num", stock); + String srcThumb = findSkuImageSource(req, outSku); + String thumbWx = defaultThumbWxUrl; + if (StringUtils.hasText(srcThumb) && StringUtils.hasText(accessToken)) { + thumbWx = imageUploadService.ensureImgUrl(accessToken, srcThumb); + } + sku.put("thumb_img", thumbWx); + JSONArray skuAttrs = new JSONArray(); + JSONObject one = new JSONObject(); + one.put("attr_key", "规格"); + one.put("attr_value", truncate(StringUtils.hasText(row.getSkuName()) ? row.getSkuName() : outSku, 40)); + skuAttrs.add(one); + sku.put("sku_attrs", skuAttrs); + arr.add(sku); + } catch (Exception e) { + throw new IllegalStateException("组装微信 SKU 失败 outSkuId=" + row.getOuterErpSkuId() + ": " + e.getMessage(), e); + } + } + return arr; + } + + private BigDecimal resolveSalePriceYuan(ExternalGoodsUpsertRequest req, String outSkuId) { + if (req.getSkus() == null) { + return null; + } + for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) { + if (s == null || !outSkuId.equals(s.getOutSkuId())) { + continue; + } + if (s.getSalePrice() != null) { + return s.getSalePrice(); + } + if (s.getRetailPrice() != null) { + return s.getRetailPrice(); + } + } + return null; + } + + private int resolveStock(ExternalGoodsUpsertRequest req, String outSkuId) { + if (req.getSkus() == null) { + return 0; + } + for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) { + if (s == null || !outSkuId.equals(s.getOutSkuId())) { + continue; + } + if (s.getStockQty() == null) { + return 0; + } + return Math.max(0, s.getStockQty()); + } + return 0; + } + + private String findSkuImageSource(ExternalGoodsUpsertRequest req, String outSkuId) { + if (req.getSkus() == null) { + return null; + } + for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) { + if (s != null && outSkuId.equals(s.getOutSkuId()) && StringUtils.hasText(s.getImageUrl())) { + return s.getImageUrl().trim(); + } + } + return null; + } + + private String normalizeTitle(ExternalGoodsUpsertRequest req, OGoods goods) { + String t = StringUtils.hasText(req.getTitle()) ? req.getTitle().trim() : ""; + if (!StringUtils.hasText(t) && goods != null && StringUtils.hasText(goods.getName())) { + t = goods.getName().trim(); + } + if (!StringUtils.hasText(t)) { + t = "商品"; + } + while (t.length() < 5) { + t = t + " "; + } + return truncate(t, 60); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max); + } + + private static String parseProductIdString(String raw) { + try { + JSONObject o = JSON.parseObject(raw); + JSONObject data = o.getJSONObject("data"); + if (data == null) { + return null; + } + if (data.get("product_id") == null) { + return null; + } + return data.get("product_id").toString(); + } catch (Exception e) { + return null; + } + } + + private record HeadAndDetailWxImages(List headImgUrls, List detailImgUrls) { + } } diff --git a/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcHttpClient.java b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcHttpClient.java new file mode 100644 index 00000000..536ab432 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcHttpClient.java @@ -0,0 +1,77 @@ +package cn.qihangerp.service.external.sph; + +import com.alibaba.fastjson2.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * 微信小店 OpenAPI:HTTPS JSON POST(与拼多多 {@link cn.qihangerp.service.external.pdd.PddPopClient} 对称,无 POP 签名)。 + * + * @author guochengyu + */ +@Slf4j +@Component +public class WeixinShopEcHttpClient { + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + + /** + * @param urlWithQuery 已拼接 {@code access_token} 等 query 的完整 URL + * @param jsonBody 请求体 JSON;允许为空则发空对象 {@code {}} + */ + public String postJson(String urlWithQuery, String jsonBody, Duration readTimeout) throws Exception { + String body = jsonBody == null || jsonBody.isBlank() ? "{}" : jsonBody; + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(urlWithQuery)) + .timeout(readTimeout != null ? readTimeout : Duration.ofSeconds(60)) + .header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + long t0 = System.currentTimeMillis(); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + long ms = System.currentTimeMillis() - t0; + if (log.isDebugEnabled()) { + log.debug("weixin-ec POST url={} http={} costMs={} snippet={}", + WeixinShopEcLogSupport.redactAccessTokenInUrl(urlWithQuery), + resp.statusCode(), + ms, + WeixinShopEcLogSupport.snippet(resp.body(), 400)); + } + return resp.body(); + } + + /** + * 解析微信通用响应中的 {@code errcode};{@code errcode==0} 视为业务成功。 + */ + public static boolean isBizOk(String raw) { + if (raw == null || raw.isBlank()) { + return false; + } + try { + return JSON.parseObject(raw).getIntValue("errcode") == 0; + } catch (Exception e) { + return false; + } + } + + public static String formatWxError(String raw) { + if (raw == null || raw.isBlank()) { + return "empty response"; + } + try { + var o = JSON.parseObject(raw); + return "errcode=" + o.getIntValue("errcode") + ", errmsg=" + o.getString("errmsg"); + } catch (Exception e) { + return WeixinShopEcLogSupport.snippet(raw, 500); + } + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcImageUploadService.java b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcImageUploadService.java new file mode 100644 index 00000000..950119ea --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcImageUploadService.java @@ -0,0 +1,86 @@ +package cn.qihangerp.service.external.sph; + +import com.alibaba.fastjson2.JSON; +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.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 微信小店「上传图片」:{@code upload_type=1}(URL)、{@code resp_type=1}(返回 {@code img_url})。 + *

文档:{@code https://developers.weixin.qq.com/doc/store/shop/API/apimgnt/resource/api_img_upload.html}

+ * + * @author guochengyu + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WeixinShopEcImageUploadService { + + private final ExternalSphProperties props; + private final WeixinShopEcHttpClient httpClient; + + /** + * 将业务侧 HTTPS 图链转为微信侧 {@code img_url};已是 {@code mmecimage.cn/p/} 前缀则原样返回。 + */ + public String ensureImgUrl(String accessToken, String sourceUrl) throws Exception { + if (!StringUtils.hasText(sourceUrl)) { + throw new IllegalArgumentException("图片 URL 为空"); + } + String u = sourceUrl.trim(); + if (u.startsWith("https://mmecimage.cn/p/") || u.startsWith("http://mmecimage.cn/p/")) { + return u.startsWith("http://") ? u.replace("http://", "https://") : u; + } + String base = props.getImageUploadUrl(); + if (!StringUtils.hasText(base)) { + throw new IllegalStateException("external.sph.image-upload-url 未配置"); + } + String token = accessToken.trim(); + String q = base + + (base.contains("?") ? "&" : "?") + + "access_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8) + + "&upload_type=1&resp_type=1"; + JSONObject body = new JSONObject(); + body.put("img_url", u); + String raw = httpClient.postJson(q, body.toJSONString(), Duration.ofSeconds(props.getHttpReadTimeoutSeconds())); + if (!WeixinShopEcHttpClient.isBizOk(raw)) { + throw new IllegalStateException("微信上传图片失败: " + WeixinShopEcHttpClient.formatWxError(raw)); + } + JSONObject root = JSON.parseObject(raw); + JSONObject pic = root.getJSONObject("pic_file"); + if (pic == null || !StringUtils.hasText(pic.getString("img_url"))) { + throw new IllegalStateException("微信上传图片未返回 img_url: " + WeixinShopEcLogSupport.snippet(raw, 400)); + } + return pic.getString("img_url").trim(); + } + + /** + * 按出现顺序上传并去重(同一源 URL 只上传一次)。 + */ + public java.util.List uploadDistinctInOrder(String accessToken, java.util.List sourceUrls) throws Exception { + if (sourceUrls == null || sourceUrls.isEmpty()) { + return java.util.List.of(); + } + Set done = new LinkedHashSet<>(); + java.util.List out = new java.util.ArrayList<>(); + for (String s : sourceUrls) { + if (!StringUtils.hasText(s)) { + continue; + } + String key = s.trim(); + if (done.contains(key)) { + continue; + } + done.add(key); + out.add(ensureImgUrl(accessToken, key)); + } + return out; + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcLogSupport.java b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcLogSupport.java new file mode 100644 index 00000000..5281270b --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcLogSupport.java @@ -0,0 +1,26 @@ +package cn.qihangerp.service.external.sph; + +/** + * 微信小店调用日志:URL 脱敏与片段截断。 + * + * @author guochengyu + */ +final class WeixinShopEcLogSupport { + + private WeixinShopEcLogSupport() { + } + + static String redactAccessTokenInUrl(String url) { + if (url == null || url.isBlank()) { + return url; + } + return url.replaceAll("access_token=[^&]+", "access_token=***"); + } + + static String snippet(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } +}