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:
parent
a4e3421a10
commit
ec10c8eba8
|
|
@ -77,8 +77,16 @@ external:
|
||||||
DEFAULT: "303351846671360"
|
DEFAULT: "303351846671360"
|
||||||
sku-overrides: []
|
sku-overrides: []
|
||||||
|
|
||||||
# 微信视频号小店(platform=WEIXIN):默认不调用渠道发品;接入 OpenAPI 后再开启
|
# 微信视频号小店(platform=WEIXIN):生产环境通过 Nacos 开启并校对类目/运费模板
|
||||||
sph:
|
sph:
|
||||||
publish-enabled: false
|
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
|
# 说明:对外商品接口见 temp/yunxi-erp-open-goods-upsert-api.md;Nacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,14 @@ external:
|
||||||
cost-template-map:
|
cost-template-map:
|
||||||
DEFAULT: "303351846671360"
|
DEFAULT: "303351846671360"
|
||||||
sku-overrides: []
|
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
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ public class ExternalGoodsUpsertRequest {
|
||||||
@JsonAlias({"pdd_goods_id"})
|
@JsonAlias({"pdd_goods_id"})
|
||||||
private Long pddGoodsId;
|
private Long pddGoodsId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小店侧商品 ID({@code product_id})。二次上架/更新时由调用方从货架 extension 等来源传入,走 {@code channels.ec.product.update}。
|
||||||
|
*/
|
||||||
|
@JsonProperty("sphProductId")
|
||||||
|
@JsonAlias({"sph_product_id", "productId"})
|
||||||
|
private Long sphProductId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品标题
|
* 商品标题
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信视频号小店(platform=WEIXIN)发品相关开关;应用级密钥由调用方经 {@code sphPopAuth} 传入,与拼多多一致。
|
* 微信视频号小店(platform=WEIXIN)发品相关开关与端点;应用级密钥由调用方经 {@code sphPopAuth} 传入,与拼多多一致。
|
||||||
*
|
*
|
||||||
* @author guochengyu
|
* @author guochengyu
|
||||||
*/
|
*/
|
||||||
|
|
@ -13,7 +13,47 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
public class ExternalSphProperties {
|
public class ExternalSphProperties {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否在 upsert 落库后尝试调用微信小店发品适配(未接入前请保持 false,避免误报失败)。
|
* 是否在 upsert 落库后尝试调用微信小店发品适配。
|
||||||
*/
|
*/
|
||||||
private boolean publishEnabled = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,28 @@ import cn.qihangerp.model.entity.OGoods;
|
||||||
import cn.qihangerp.model.entity.OGoodsSku;
|
import cn.qihangerp.model.entity.OGoodsSku;
|
||||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||||
import cn.qihangerp.model.vo.SphPublishLaneResultVo;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.util.StringUtils;
|
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.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信视频号小店发品:凭证仅来自本次请求的 {@link ExternalGoodsUpsertRequest#getSphPopAuth()}。
|
* 微信视频号小店发品:先上传素材换 {@code img_url},再调 {@code channels.ec.product.add/update}。
|
||||||
* <p>具体 HTTP/OpenAPI 接入在 {@code publishEnabled=true} 后实现;关闭开关时不调用渠道,与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 行为对称。</p>
|
* <p>凭证来自 {@link ExternalGoodsUpsertRequest#getSphPopAuth()};与拼多多 {@link cn.qihangerp.service.external.pdd.ExternalPddPublishService} 对称。</p>
|
||||||
*
|
*
|
||||||
* @author guochengyu
|
* @author guochengyu
|
||||||
*/
|
*/
|
||||||
|
|
@ -22,10 +34,14 @@ import java.util.List;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ExternalSphPublishService {
|
public class ExternalSphPublishService {
|
||||||
|
|
||||||
|
private static final String NO_BRAND_ID = "2100000000";
|
||||||
|
|
||||||
private final ExternalSphProperties props;
|
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) {
|
public SphPublishLaneResultVo publish(OGoods goods, List<OGoodsSku> skus, ExternalGoodsUpsertRequest req) {
|
||||||
if (!props.isPublishEnabled()) {
|
if (!props.isPublishEnabled()) {
|
||||||
|
|
@ -56,15 +72,329 @@ public class ExternalSphPublishService {
|
||||||
.message("微信小店凭证不完整:请在请求体 sphPopAuth 中传入 appKey、appSecret、accessToken")
|
.message("微信小店凭证不完整:请在请求体 sphPopAuth 中传入 appKey、appSecret、accessToken")
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
// TODO: 接入微信视频号小店商品发布 OpenAPI,填充 sphProductId/responseSnippet
|
String token = auth.getAccessToken().trim();
|
||||||
log.warn("[SPH] publish-enabled=true 但发品适配尚未实现 shopId={} outGoodsId={} skuCount={}",
|
boolean update = req.getSphProductId() != null && req.getSphProductId() > 0;
|
||||||
req.getShopId(), req.getOutGoodsId(), skus == null ? 0 : skus.size());
|
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()
|
return SphPublishLaneResultVo.builder()
|
||||||
.attempted(true)
|
.attempted(attempted)
|
||||||
.success(false)
|
.success(false)
|
||||||
.message("微信视频号小店发品接口尚未接入(请关闭 external.sph.publish-enabled 或完成适配开发)")
|
.message(msg)
|
||||||
.responseSnippet(null)
|
.responseSnippet(null)
|
||||||
.sphProductId(null)
|
.sphProductId(null)
|
||||||
.build();
|
.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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcHttpClient.java
vendored
Normal file
77
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcHttpClient.java
vendored
Normal 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小店 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcImageUploadService.java
vendored
Normal file
86
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcImageUploadService.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcLogSupport.java
vendored
Normal file
26
service/src/main/java/cn/qihangerp/service/external/sph/WeixinShopEcLogSupport.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue