diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java index 2c6553bd..b738dd89 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java @@ -51,7 +51,8 @@ public class PddCatRuleSpecAutoResolver { } Long parentSpecId = PddOpenApiSupport.findFirstSaleParentSpecId(raw); if (parentSpecId == null || parentSpecId <= 0) { - r.setDetail("类目规则中未解析到销售 parent_spec_id"); + r.setDetail("类目规则中未解析到销售 parent_spec_id" + + PddOpenApiSupport.suffixHintForMissingSaleParentSpec(raw)); return r; } List ovs = buildSkuOverridesBySpecName( diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java index a7d5080d..ae0f9314 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java @@ -8,7 +8,9 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.math.BigInteger; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; /** * 解析拼多多 POP 返回 JSON 的通用工具。 @@ -139,7 +141,10 @@ public final class PddOpenApiSupport { } /** - * 在类目规则 JSON 中递归查找 is_sale 为 true 的节点上的 parent_spec_id(取第一个)。 + * 在类目规则 JSON 中解析「销售规格」对应的 {@code parent_spec_id}(取第一个)。 + *

兼容:{@code is_sale}/{@code isSale}、{@code parent_spec_id}/{@code parentSpecId}/字符串数值; + * 若整树未命中,再尝试 {@code goods_properties_rule.properties}、常见 {@code goods_spec_rule} 等数组; + * 若全树仅出现一个不同的 {@code parent_spec_id},则采用(单规格维度类目)。

*/ public static Long findFirstSaleParentSpecId(String catRuleBody) { if (!StringUtils.hasText(catRuleBody)) { @@ -150,30 +155,182 @@ public final class PddOpenApiSupport { if (root == null) { return null; } - JSONObject inner = root.getJSONObject("goods_cat_rule_get_response"); + JSONObject inner = unwrapCatRulePayload(root); if (inner == null) { - inner = root; + return null; } long[] holder = new long[]{0L}; walkForSaleParent(inner, holder); - return holder[0] > 0 ? holder[0] : null; + if (holder[0] > 0) { + return holder[0]; + } + Long fromProps = extractParentSpecFromGoodsPropertiesRule(inner); + if (fromProps != null && fromProps > 0) { + return fromProps; + } + Long fb = fallbackParentSpecFromSpecRuleArrays(inner); + if (fb != null && fb > 0) { + return fb; + } + return uniqueParentSpecIdIfSingle(inner); } catch (Exception e) { return null; } } + /** + * 当 {@link #findFirstSaleParentSpecId} 为空时,根据类目规则返回体补充说明与处置建议(例如 {@code input_max_spec_num=0} 的图书属性类目)。 + */ + public static String suffixHintForMissingSaleParentSpec(String catRuleBody) { + if (!StringUtils.hasText(catRuleBody)) { + return ""; + } + try { + JSONObject root = JSON.parseObject(catRuleBody); + if (root == null) { + return ""; + } + JSONObject inner = unwrapCatRulePayload(root); + if (inner == null) { + return ""; + } + JSONObject gpr = inner.getJSONObject("goods_properties_rule"); + if (gpr == null) { + return "。返回体中无 goods_properties_rule,无法自动解析销售规格;请配置 external.pdd.sku-overrides 或核对类目。"; + } + int maxSpec = gpr.getIntValue("input_max_spec_num"); + JSONArray props = gpr.getJSONArray("properties"); + boolean anySale = false; + if (props != null) { + for (int i = 0; i < props.size(); i++) { + Object el = props.get(i); + if (el instanceof JSONObject p) { + if (isTruthySale(p.get("is_sale")) || isTruthySale(p.get("isSale"))) { + anySale = true; + break; + } + } + } + } + StringBuilder sb = new StringBuilder("。"); + if (maxSpec == 0) { + sb.append(" 拼多多返回 goods_properties_rule.input_max_spec_num=0(不允许商家再增自定义规格维),"); + } + if (!anySale && props != null && !props.isEmpty()) { + sb.append(" properties 中无 is_sale=true 的销售规格行(多为书名、ISBN 等普通属性),"); + } + sb.append("无法通过 pdd.goods.spec.id.get 自动生成 SKU 的 spec_id_list。"); + sb.append(" 请在 external.pdd.sku-overrides 中为每条 SKU 配置 spec-id-list,或改用拼多多侧支持「销售规格」的叶子类目并同步调整 category-map。"); + return sb.toString(); + } catch (Exception e) { + return "。解析类目规则失败;请配置 external.pdd.sku-overrides 或开启全量日志核对 cat_rule 返回结构。"; + } + } + + /** + * POP 成功体多为 {@code goods_cat_rule_get_response};部分环境为 {@code cat_rule_get_response}(与日志一致)。 + */ + private static JSONObject unwrapCatRulePayload(JSONObject root) { + if (root == null) { + return null; + } + JSONObject a = root.getJSONObject("goods_cat_rule_get_response"); + if (a != null) { + return a; + } + JSONObject b = root.getJSONObject("cat_rule_get_response"); + if (b != null) { + return b; + } + return root; + } + + /** + * 从 {@code goods_properties_rule.properties} 里找销售属性行上的 {@code parent_spec_id}。 + */ + private static Long extractParentSpecFromGoodsPropertiesRule(JSONObject inner) { + if (inner == null) { + return null; + } + JSONObject gpr = inner.getJSONObject("goods_properties_rule"); + if (gpr == null) { + return null; + } + JSONArray props = gpr.getJSONArray("properties"); + if (props == null || props.isEmpty()) { + return null; + } + for (int i = 0; i < props.size(); i++) { + Object el = props.get(i); + if (!(el instanceof JSONObject p)) { + continue; + } + boolean sale = isTruthySale(p.get("is_sale")) || isTruthySale(p.get("isSale")); + boolean skuRow = p.getBooleanValue("is_sku") || p.getBooleanValue("isSku"); + if (!sale && !skuRow) { + continue; + } + Long pid = readParentSpecId(p); + if (pid != null && pid > 0) { + return pid; + } + JSONObject spec = p.getJSONObject("spec"); + if (spec != null) { + pid = readParentSpecId(spec); + if (pid != null && pid > 0) { + return pid; + } + } + } + return null; + } + + /** + * 全树收集 {@code parent_spec_id},仅当恰好一个不同取值时返回(避免多规格维度误选)。 + */ + private static Long uniqueParentSpecIdIfSingle(JSONObject inner) { + Set ids = new LinkedHashSet<>(); + collectAllParentSpecIds(inner, ids); + if (ids.size() == 1) { + return ids.iterator().next(); + } + return null; + } + + private static void collectAllParentSpecIds(Object node, Set out) { + if (node instanceof JSONObject jo) { + Long p = readParentSpecId(jo); + if (p != null && p > 0) { + out.add(p); + } + for (String k : jo.keySet()) { + collectAllParentSpecIds(jo.get(k), out); + } + } else if (node instanceof JSONArray ja) { + for (int i = 0; i < ja.size(); i++) { + collectAllParentSpecIds(ja.get(i), out); + } + } + } + private static void walkForSaleParent(Object node, long[] holder) { if (holder[0] > 0) { return; } if (node instanceof JSONObject jo) { - if (isTruthySale(jo.get("is_sale"))) { - Long p = jo.getLong("parent_spec_id"); + boolean sale = isTruthySale(jo.get("is_sale")) || isTruthySale(jo.get("isSale")); + if (sale) { + Long p = readParentSpecId(jo); if (p != null && p > 0) { holder[0] = p; return; } } + Long pid = readParentSpecId(jo); + if (pid != null && pid > 0 && (isTruthySale(jo.get("sale")) || isOne(jo.get("spec_type")))) { + holder[0] = pid; + return; + } for (String k : jo.keySet()) { walkForSaleParent(jo.get(k), holder); if (holder[0] > 0) { @@ -190,6 +347,106 @@ public final class PddOpenApiSupport { } } + /** + * 部分类目返回的销售规格列表在固定 key 下,且字段名与递归路径不一致时在此补解析。 + */ + private static Long fallbackParentSpecFromSpecRuleArrays(JSONObject inner) { + if (inner == null) { + return null; + } + String[] arrayKeys = { + "goods_spec_rule", + "goods_spec_rules", + "spec_rule_list", + "spec_rules", + "cat_rule", + }; + for (String key : arrayKeys) { + JSONArray arr = findFirstArrayByKey(inner, key); + if (arr == null || arr.isEmpty()) { + continue; + } + for (int i = 0; i < arr.size(); i++) { + Object el = arr.get(i); + if (!(el instanceof JSONObject row)) { + continue; + } + Long p = readParentSpecId(row); + if (p == null || p <= 0) { + continue; + } + if (isTruthySale(row.get("is_sale")) || isTruthySale(row.get("isSale"))) { + return p; + } + } + JSONObject first = arr.getJSONObject(0); + if (first != null) { + Long p = readParentSpecId(first); + if (p != null && p > 0 && arr.size() == 1) { + return p; + } + } + } + return null; + } + + private static JSONArray findFirstArrayByKey(JSONObject root, String key) { + if (root == null || !StringUtils.hasText(key)) { + return null; + } + JSONArray direct = root.getJSONArray(key); + if (direct != null && !direct.isEmpty()) { + return direct; + } + for (String k : root.keySet()) { + Object v = root.get(k); + if (v instanceof JSONObject child) { + JSONArray nested = findFirstArrayByKey(child, key); + if (nested != null && !nested.isEmpty()) { + return nested; + } + } + } + return null; + } + + private static Long readParentSpecId(JSONObject jo) { + if (jo == null) { + return null; + } + Long p = jo.getLong("parent_spec_id"); + if (p != null && p > 0) { + return p; + } + p = jo.getLong("parentSpecId"); + if (p != null && p > 0) { + return p; + } + String s = jo.getString("parent_spec_id"); + if (!StringUtils.hasText(s)) { + s = jo.getString("parentSpecId"); + } + if (StringUtils.hasText(s)) { + try { + long v = Long.parseLong(s.trim()); + return v > 0 ? v : null; + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private static boolean isOne(Object v) { + if (v == null) { + return false; + } + if (v instanceof Number n) { + return n.intValue() == 1; + } + return "1".equals(String.valueOf(v).trim()); + } + private static boolean isTruthySale(Object v) { if (v == null) { return false;