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);
+ }
+}