From 864fd479079ae36853ca4288c7dacaaf442f7ece Mon Sep 17 00:00:00 2001 From: huangyujie <27665451@qq.com> Date: Tue, 21 Apr 2026 16:31:13 +0800 Subject: [PATCH] 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 --- .../src/main/resources/application.yml | 1 + .../src/main/resources/nacos/erp-api.yaml | 1 + .../external/sph/ExternalSphProperties.java | 5 + .../sph/ExternalSphPublishService.java | 133 +++++++++++++++++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index 7924ac64..0ac2d62f 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -81,6 +81,7 @@ external: sph: publish-enabled: true 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-update-url: https://api.weixin.qq.com/channels/ec/product/update fixed-leaf-cat-id-v2: 537901187 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 f2a58a47..f6c48fcf 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -73,6 +73,7 @@ external: sph: publish-enabled: false 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-update-url: https://api.weixin.qq.com/channels/ec/product/update fixed-leaf-cat-id-v2: 537901187 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 e1b365a2..c307ac24 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 @@ -32,6 +32,11 @@ public class ExternalSphProperties { */ 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}。 */ 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 1f7b405a..396f0bf7 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 @@ -77,7 +77,7 @@ public class ExternalSphPublishService { String lane = update ? "PRODUCT_UPDATE" : "PRODUCT_ADD"; try { 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()); if (!StringUtils.hasText(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 skuRows, ExternalGoodsUpsertRequest req, - HeadAndDetailWxImages imgs, boolean update) { + HeadAndDetailWxImages imgs, boolean update, String accessToken) throws Exception { JSONObject root = new JSONObject(); if (update) { root.put("product_id", req.getSphProductId()); @@ -237,7 +237,8 @@ public class ExternalSphPublishService { root.put("deliver_acct_type", new JSONArray()); JSONArray catsV2 = new JSONArray(); 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); root.put("cats_v2", catsV2); JSONObject extra = new JSONObject(); @@ -258,9 +259,9 @@ public class ExternalSphPublishService { } root.put("brand_id", NO_BRAND_ID); 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() : ""; - JSONArray skuArr = buildSkus(skuRows, req, imgs.headImgUrls().get(0), accessToken); + JSONArray skuArr = buildSkus(skuRows, req, imgs.headImgUrls().get(0), token); if (skuArr.isEmpty()) { throw new IllegalStateException("无可发品 SKU"); } @@ -268,6 +269,128 @@ public class ExternalSphPublishService { return root; } + private long resolveLeafCatId(ExternalGoodsUpsertRequest req, List 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 skuRows, ExternalGoodsUpsertRequest req, String defaultThumbWxUrl, String accessToken) { JSONArray arr = new JSONArray();