feat(weixin): implement SPH product add/update with image upload and configurable OpenAPI endpoints

- Add WeixinShopEcHttpClient, image upload (URL + resp_type=1), and product add/update calls
- Extend ExternalSphProperties for URLs, fixed leaf cat, min head images, listing flag
- ExternalGoodsUpsertRequest: sphProductId for idempotent update path
- Align application.yml and nacos erp-api.yaml with external.sph defaults

Made-with: Cursor
This commit is contained in:
huangyujie 2026-04-20 09:52:34 +08:00
parent a4e3421a10
commit ec10c8eba8
8 changed files with 596 additions and 11 deletions

View File

@ -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.mdNacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md

View File

@ -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

View File

@ -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;
/**
* 商品标题
*/

View File

@ -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;
}

View File

@ -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()}
* <p>具体 HTTP/OpenAPI 接入在 {@code publishEnabled=true} 后实现关闭开关时不调用渠道与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 行为对称</p>
* 微信视频号小店发品先上传素材换 {@code img_url}再调 {@code channels.ec.product.add/update}
* <p>凭证来自 {@link ExternalGoodsUpsertRequest#getSphPopAuth()}与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 对称</p>
*
* @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<OGoodsSku> 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("微信视频号小店发品接口尚未接入(请关闭 external.sph.publish-enabled 或完成适配开发)")
.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(attempted)
.success(false)
.message(msg)
.responseSnippet(null)
.sphProductId(null)
.build();
}
private HeadAndDetailWxImages buildWxImages(String token, ExternalGoodsUpsertRequest req) throws Exception {
int minHead = Math.max(1, props.getMinHeadImages());
List<String> headSources = new ArrayList<>();
LinkedHashSet<String> 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<String> 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<String> detailSources = new ArrayList<>();
LinkedHashSet<String> 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<String> detailWx = imageUploadService.uploadDistinctInOrder(token, detailSources);
if (detailWx.isEmpty()) {
detailWx = List.of(headWx.get(0));
}
return new HeadAndDetailWxImages(headWx, detailWx);
}
private static void addUnique(List<String> out, Set<String> 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<OGoodsSku> 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<OGoodsSku> skuRows, ExternalGoodsUpsertRequest req, String defaultThumbWxUrl,
String accessToken) {
JSONArray arr = new JSONArray();
List<OGoodsSku> 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<String> headImgUrls, List<String> detailImgUrls) {
}
}

View File

@ -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;
/**
* 微信小店 OpenAPIHTTPS 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<String> 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);
}
}
}

View File

@ -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}
* <p>文档{@code https://developers.weixin.qq.com/doc/store/shop/API/apimgnt/resource/api_img_upload.html}</p>
*
* @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<String> uploadDistinctInOrder(String accessToken, java.util.List<String> sourceUrls) throws Exception {
if (sourceUrls == null || sourceUrls.isEmpty()) {
return java.util.List.of();
}
Set<String> done = new LinkedHashSet<>();
java.util.List<String> 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;
}
}

View File

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