feat(weixin): default to product classify for category selection

Use channels.ec.product.category.classify by default before add/update to reduce class mismatch failures, and remove the category-classify-enabled toggle from config.

Made-with: Cursor
This commit is contained in:
huangyujie 2026-04-21 16:31:13 +08:00
parent 2b576a0979
commit 864fd47907
4 changed files with 135 additions and 5 deletions

View File

@ -81,6 +81,7 @@ external:
sph: sph:
publish-enabled: true publish-enabled: true
image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload
product-classify-url: https://api.weixin.qq.com/channels/ec/product/category/classify
product-add-url: https://api.weixin.qq.com/channels/ec/product/add product-add-url: https://api.weixin.qq.com/channels/ec/product/add
product-update-url: https://api.weixin.qq.com/channels/ec/product/update product-update-url: https://api.weixin.qq.com/channels/ec/product/update
fixed-leaf-cat-id-v2: 537901187 fixed-leaf-cat-id-v2: 537901187

View File

@ -73,6 +73,7 @@ external:
sph: sph:
publish-enabled: false publish-enabled: false
image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload image-upload-url: https://api.weixin.qq.com/shop/ec/basics/img/upload
product-classify-url: https://api.weixin.qq.com/channels/ec/product/category/classify
product-add-url: https://api.weixin.qq.com/channels/ec/product/add product-add-url: https://api.weixin.qq.com/channels/ec/product/add
product-update-url: https://api.weixin.qq.com/channels/ec/product/update product-update-url: https://api.weixin.qq.com/channels/ec/product/update
fixed-leaf-cat-id-v2: 537901187 fixed-leaf-cat-id-v2: 537901187

View File

@ -32,6 +32,11 @@ public class ExternalSphProperties {
*/ */
private String productUpdateUrl = "https://api.weixin.qq.com/channels/ec/product/update"; private String productUpdateUrl = "https://api.weixin.qq.com/channels/ec/product/update";
/**
* 类目推荐product_classify{@code channels.ec.product.category.classify}
*/
private String productClassifyUrl = "https://api.weixin.qq.com/channels/ec/product/category/classify";
/** /**
* 新类目树叶子类目 ID本期固定对应请求体 {@code cats_v2} * 新类目树叶子类目 ID本期固定对应请求体 {@code cats_v2}
*/ */

View File

@ -77,7 +77,7 @@ public class ExternalSphPublishService {
String lane = update ? "PRODUCT_UPDATE" : "PRODUCT_ADD"; String lane = update ? "PRODUCT_UPDATE" : "PRODUCT_ADD";
try { try {
HeadAndDetailWxImages imgs = buildWxImages(token, req); HeadAndDetailWxImages imgs = buildWxImages(token, req);
JSONObject payload = buildProductPayload(goods, skus, req, imgs, update); JSONObject payload = buildProductPayload(goods, skus, req, imgs, update, token);
String url = (update ? props.getProductUpdateUrl() : props.getProductAddUrl()); String url = (update ? props.getProductUpdateUrl() : props.getProductAddUrl());
if (!StringUtils.hasText(url)) { if (!StringUtils.hasText(url)) {
return failVo(true, lane, "external.sph.product-add-url / product-update-url 未配置"); return failVo(true, lane, "external.sph.product-add-url / product-update-url 未配置");
@ -216,7 +216,7 @@ public class ExternalSphPublishService {
} }
private JSONObject buildProductPayload(OGoods goods, List<OGoodsSku> skuRows, ExternalGoodsUpsertRequest req, private JSONObject buildProductPayload(OGoods goods, List<OGoodsSku> skuRows, ExternalGoodsUpsertRequest req,
HeadAndDetailWxImages imgs, boolean update) { HeadAndDetailWxImages imgs, boolean update, String accessToken) throws Exception {
JSONObject root = new JSONObject(); JSONObject root = new JSONObject();
if (update) { if (update) {
root.put("product_id", req.getSphProductId()); root.put("product_id", req.getSphProductId());
@ -237,7 +237,8 @@ public class ExternalSphPublishService {
root.put("deliver_acct_type", new JSONArray()); root.put("deliver_acct_type", new JSONArray());
JSONArray catsV2 = new JSONArray(); JSONArray catsV2 = new JSONArray();
JSONObject leaf = new JSONObject(); JSONObject leaf = new JSONObject();
leaf.put("cat_id", String.valueOf(props.getFixedLeafCatIdV2())); long leafCatId = resolveLeafCatId(req, imgs.headImgUrls(), accessToken);
leaf.put("cat_id", String.valueOf(leafCatId));
catsV2.add(leaf); catsV2.add(leaf);
root.put("cats_v2", catsV2); root.put("cats_v2", catsV2);
JSONObject extra = new JSONObject(); JSONObject extra = new JSONObject();
@ -258,9 +259,9 @@ public class ExternalSphPublishService {
} }
root.put("brand_id", NO_BRAND_ID); root.put("brand_id", NO_BRAND_ID);
root.put("listing", props.getListingOnSave()); root.put("listing", props.getListingOnSave());
String accessToken = req.getSphPopAuth() != null && StringUtils.hasText(req.getSphPopAuth().getAccessToken()) String token = req.getSphPopAuth() != null && StringUtils.hasText(req.getSphPopAuth().getAccessToken())
? req.getSphPopAuth().getAccessToken().trim() : ""; ? req.getSphPopAuth().getAccessToken().trim() : "";
JSONArray skuArr = buildSkus(skuRows, req, imgs.headImgUrls().get(0), accessToken); JSONArray skuArr = buildSkus(skuRows, req, imgs.headImgUrls().get(0), token);
if (skuArr.isEmpty()) { if (skuArr.isEmpty()) {
throw new IllegalStateException("无可发品 SKU"); throw new IllegalStateException("无可发品 SKU");
} }
@ -268,6 +269,128 @@ public class ExternalSphPublishService {
return root; return root;
} }
private long resolveLeafCatId(ExternalGoodsUpsertRequest req, List<String> headImgUrls, String accessToken) {
long fixedCat = props.getFixedLeafCatIdV2();
if (!StringUtils.hasText(props.getProductClassifyUrl())) {
return fixedCat;
}
if (!StringUtils.hasText(accessToken) || CollectionUtils.isEmpty(headImgUrls)) {
return fixedCat;
}
try {
JSONObject body = new JSONObject();
body.put("req_type", 1);
if (StringUtils.hasText(req.getTitle())) {
body.put("title", req.getTitle().trim());
}
JSONArray head = new JSONArray();
for (String u : headImgUrls) {
if (StringUtils.hasText(u)) {
head.add(u);
}
}
if (head.isEmpty()) {
return fixedCat;
}
body.put("head_imgs", head);
String url = props.getProductClassifyUrl();
String fullUrl = url + (url.contains("?") ? "&" : "?")
+ "access_token=" + URLEncoder.encode(accessToken.trim(), StandardCharsets.UTF_8);
String raw = httpClient.postJson(fullUrl, body.toJSONString(),
Duration.ofSeconds(Math.max(5, props.getHttpReadTimeoutSeconds())));
if (!WeixinShopEcHttpClient.isBizOk(raw)) {
log.warn("[SPH] classify failed shopId={} outGoodsId={} err={} snippet={}",
req.getShopId(), req.getOutGoodsId(), WeixinShopEcHttpClient.formatWxError(raw),
WeixinShopEcLogSupport.snippet(raw, 500));
return fixedCat;
}
Long recommended = parseRecommendedLeafCatId(raw);
if (recommended == null || recommended <= 0) {
return fixedCat;
}
log.info("[SPH] classify ok shopId={} outGoodsId={} recommendedLeafCatId={} fixedLeafCatId={}",
req.getShopId(), req.getOutGoodsId(), recommended, fixedCat);
return recommended;
} catch (Exception e) {
log.warn("[SPH] classify exception shopId={} outGoodsId={} err={}",
req.getShopId(), req.getOutGoodsId(), e.getMessage());
return fixedCat;
}
}
private static Long parseRecommendedLeafCatId(String raw) {
JSONObject root = JSON.parseObject(raw);
JSONArray categories = root.getJSONArray("categories");
if (categories == null || categories.isEmpty()) {
return null;
}
Long fallback = null;
for (Object obj : categories) {
if (!(obj instanceof JSONObject item)) {
continue;
}
JSONObject cats = item.getJSONObject("cats");
Long leaf = extractLeafCatId(cats);
if (leaf == null || leaf <= 0) {
continue;
}
if (fallback == null) {
fallback = leaf;
}
if (hasCategoryAuth(item) || hasCategoryAuth(cats)) {
return leaf;
}
}
return fallback;
}
private static boolean hasCategoryAuth(JSONObject o) {
if (o == null) {
return false;
}
if (o.containsKey("has_auth")) {
return o.getBooleanValue("has_auth");
}
if (o.containsKey("has_permission")) {
return o.getBooleanValue("has_permission");
}
if (o.containsKey("can_publish")) {
return o.getBooleanValue("can_publish");
}
return false;
}
private static Long extractLeafCatId(JSONObject cats) {
if (cats == null) {
return null;
}
JSONArray catInfo = cats.getJSONArray("cat_info");
if (catInfo == null || catInfo.isEmpty()) {
return null;
}
for (int i = catInfo.size() - 1; i >= 0; i--) {
Object c = catInfo.get(i);
if (c instanceof JSONObject cObj) {
String catId = cObj.getString("cat_id");
if (StringUtils.hasText(catId)) {
try {
return Long.parseLong(catId.trim());
} catch (NumberFormatException ignore) {
// ignore and continue
}
}
if (cObj.containsKey("cat_id")) {
try {
return cObj.getLong("cat_id");
} catch (Exception ignore) {
// ignore and continue
}
}
}
}
return null;
}
private JSONArray buildSkus(List<OGoodsSku> skuRows, ExternalGoodsUpsertRequest req, String defaultThumbWxUrl, private JSONArray buildSkus(List<OGoodsSku> skuRows, ExternalGoodsUpsertRequest req, String defaultThumbWxUrl,
String accessToken) { String accessToken) {
JSONArray arr = new JSONArray(); JSONArray arr = new JSONArray();