diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/config/ExternalGoodsApiLogProperties.java b/api/erp-api/src/main/java/cn/qihangerp/erp/config/ExternalGoodsApiLogProperties.java index e23c7db4..9806437c 100644 --- a/api/erp-api/src/main/java/cn/qihangerp/erp/config/ExternalGoodsApiLogProperties.java +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/config/ExternalGoodsApiLogProperties.java @@ -11,7 +11,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class ExternalGoodsApiLogProperties { /** - * 是否打印 upsert/delist 的完整请求体(JSON,含 pddPopAuth 等敏感字段;生产务必关闭)。 + * 是否打印 upsert 的请求体 JSON({@code pddPopAuth} 中 appSecret 全掩、appKey/accessToken 首尾各保留若干字符便于排障)。 + * delist 请求体无凭证字段,仍为完整 JSON。 */ private boolean logFullRequest = false; diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java index bb7fb66d..5028c835 100644 --- a/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java @@ -7,6 +7,7 @@ import cn.qihangerp.model.request.ExternalGoodsUpsertRequest; import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo; import cn.qihangerp.security.common.BaseController; import cn.qihangerp.erp.config.ExternalGoodsApiLogProperties; +import cn.qihangerp.erp.support.ExternalGoodsRequestLogSupport; import cn.qihangerp.service.external.ExternalGoodsAppService; import cn.qihangerp.service.external.pdd.ExternalPddProperties; import com.alibaba.fastjson2.JSON; @@ -42,7 +43,7 @@ public class ExternalGoodsController extends BaseController { @PostMapping("/upsert") public AjaxResult upsert(@RequestBody ExternalGoodsUpsertRequest req) { if (goodsApiLogProperties.isLogFullRequest()) { - log.info("[external/goods/upsert] request={}", req == null ? "null" : JSON.toJSONString(req)); + log.info("[external/goods/upsert] request={}", ExternalGoodsRequestLogSupport.upsertRequestJsonForLog(req)); } if (req == null || req.getShopId() == null || req.getShopId() <= 0) { return AjaxResult.error("参数错误:shopId不能为空"); diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/support/ExternalGoodsRequestLogSupport.java b/api/erp-api/src/main/java/cn/qihangerp/erp/support/ExternalGoodsRequestLogSupport.java new file mode 100644 index 00000000..00d60642 --- /dev/null +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/support/ExternalGoodsRequestLogSupport.java @@ -0,0 +1,79 @@ +package cn.qihangerp.erp.support; + +import cn.qihangerp.model.request.ExternalGoodsUpsertRequest; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import org.springframework.util.StringUtils; + +/** + * 对外商品接口请求日志脱敏:{@code pddPopAuth} 中 appSecret 全掩、appKey/accessToken 首尾保留便于排障。 + * + * @author guochengyu + */ +public final class ExternalGoodsRequestLogSupport { + + private ExternalGoodsRequestLogSupport() { + } + + /** + * 序列化为 JSON 并对 {@link ExternalGoodsUpsertRequest#getPddPopAuth()} 脱敏(不修改入参对象)。 + */ + public static String upsertRequestJsonForLog(ExternalGoodsUpsertRequest req) { + if (req == null) { + return "null"; + } + try { + JSONObject root = JSON.parseObject(JSON.toJSONString(req)); + if (root == null) { + return "{}"; + } + JSONObject auth = root.getJSONObject("pddPopAuth"); + if (auth != null && !auth.isEmpty()) { + maskPddPopAuth(auth); + } + return root.toJSONString(); + } catch (Exception e) { + return "{\"_logSanitizeError\":true}"; + } + } + + private static void maskPddPopAuth(JSONObject auth) { + replaceSecret(auth, "appKey", 4, 4); + replaceSecret(auth, "appSecret", 0, 0); + replaceSecret(auth, "accessToken", 4, 4); + } + + /** + * @param keepHead keepTail 均为 0 时表示整段替换为 {@code ***}(用于 appSecret) + */ + private static void replaceSecret(JSONObject auth, String field, int keepHead, int keepTail) { + if (!auth.containsKey(field)) { + return; + } + String raw = auth.getString(field); + if (!StringUtils.hasText(raw)) { + return; + } + if (keepHead == 0 && keepTail == 0) { + auth.put(field, "***"); + return; + } + auth.put(field, maskKeepEnds(raw, keepHead, keepTail)); + } + + private static String maskKeepEnds(String s, int head, int tail) { + if (!StringUtils.hasText(s)) { + return s; + } + if (head < 0) { + head = 0; + } + if (tail < 0) { + tail = 0; + } + if (s.length() <= head + tail) { + return "***"; + } + return s.substring(0, head) + "***" + s.substring(s.length() - tail); + } +} diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index 11272cbd..edd0df87 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -31,7 +31,7 @@ external: api-key: xh-uat-erp-api-ak secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c timestamp-skew-ms: 300000 - # 上下架接口全量请求体日志;生产上线后改为 false + # 上下架接口请求体日志(upsert 时 pddPopAuth 已脱敏);生产可关 goods: log-full-request: true 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 09d928af..abd63ea8 100644 --- a/api/erp-api/src/main/resources/nacos/erp-api.yaml +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -37,7 +37,7 @@ external: secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c timestamp-skew-ms: 300000 goods: - # 生产上线后改为 false + # upsert 请求体日志(pddPopAuth 已脱敏);生产可关 log-full-request: true # 拼多多 POP:与 jar 内 application.yml 对齐;Nacos 下发会覆盖本地 diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java index 188971d1..2b715f8a 100644 --- a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java @@ -275,7 +275,15 @@ public class PddGoodsAddParamBuilder { if (a.getVid() != null) { o.put("vid", a.getVid()); } - o.put("value", a.getValue() == null ? "" : a.getValue().trim()); + String attrVal = a.getValue() == null ? "" : a.getValue().trim(); + if (a.getVid() == null && StringUtils.hasText(attrVal) && props.getIsbnGoodsPropertyRefPid() > 0 + && rp == props.getIsbnGoodsPropertyRefPid()) { + String n = normalizeIsbnForPddGoodsProperty(attrVal); + if (StringUtils.hasText(n)) { + attrVal = n; + } + } + o.put("value", attrVal); o.put("punit", StringUtils.hasText(a.getUnit()) ? a.getUnit().trim() : ""); arr.add(o); } @@ -283,9 +291,13 @@ public class PddGoodsAddParamBuilder { String isbn = resolveIsbnFromExt(req, goods); long isbnPid = props.getIsbnGoodsPropertyRefPid(); if (StringUtils.hasText(isbn) && isbnPid > 0 && seenRefPid.add(isbnPid)) { + String isbnVal = normalizeIsbnForPddGoodsProperty(isbn); + if (!StringUtils.hasText(isbnVal)) { + isbnVal = isbn.trim(); + } JSONObject o = new JSONObject(); o.put("ref_pid", isbnPid); - o.put("value", isbn.trim()); + o.put("value", isbnVal); o.put("punit", ""); arr.add(o); } @@ -350,8 +362,15 @@ public class PddGoodsAddParamBuilder { continue; } if (StringUtils.hasText(a.getValue()) || a.getVid() != null) { - return new ResolvedGoodsPropValue(a.getVid(), - a.getValue() == null ? "" : a.getValue().trim()); + String val = a.getValue() == null ? "" : a.getValue().trim(); + if (a.getVid() == null && StringUtils.hasText(val) && props.getIsbnGoodsPropertyRefPid() > 0 + && rule.getRefPid() == props.getIsbnGoodsPropertyRefPid()) { + String n = normalizeIsbnForPddGoodsProperty(val); + if (StringUtils.hasText(n)) { + val = n; + } + } + return new ResolvedGoodsPropValue(a.getVid(), val); } } } @@ -370,7 +389,11 @@ public class PddGoodsAddParamBuilder { if (props.getIsbnGoodsPropertyRefPid() == rule.getRefPid()) { String isbn = resolveIsbnFromExt(req, goods); if (StringUtils.hasText(isbn)) { - ResolvedGoodsPropValue m = matchEnumOption(opts, isbn.trim()); + String isbnMatchHint = normalizeIsbnForPddGoodsProperty(isbn); + if (!StringUtils.hasText(isbnMatchHint)) { + isbnMatchHint = isbn.trim(); + } + ResolvedGoodsPropValue m = matchEnumOption(opts, isbnMatchHint); if (m != null) { return m; } @@ -386,6 +409,7 @@ public class PddGoodsAddParamBuilder { return null; } v = applyMaxValueLen(v, rule.getMaxValueHint()); + v = normalizeIsbnGoodsPropertyValueIfApplicable(rule, props, v); return new ResolvedGoodsPropValue(null, v); } @@ -457,7 +481,12 @@ public class PddGoodsAddParamBuilder { String name = rule.getName(); if (StringUtils.hasText(name)) { if (name.contains("ISBN") || name.contains("isbn")) { - return resolveIsbnFromExt(req, goods); + String raw = resolveIsbnFromExt(req, goods); + if (!StringUtils.hasText(raw)) { + return null; + } + String n = normalizeIsbnForPddGoodsProperty(raw); + return StringUtils.hasText(n) ? n : raw.trim(); } if (name.contains("书名")) { if (goods != null && StringUtils.hasText(goods.getName())) { @@ -469,7 +498,12 @@ public class PddGoodsAddParamBuilder { } } if (props.getIsbnGoodsPropertyRefPid() == rule.getRefPid()) { - return resolveIsbnFromExt(req, goods); + String raw = resolveIsbnFromExt(req, goods); + if (!StringUtils.hasText(raw)) { + return null; + } + String n = normalizeIsbnForPddGoodsProperty(raw); + return StringUtils.hasText(n) ? n : raw.trim(); } return null; } @@ -526,6 +560,110 @@ public class PddGoodsAddParamBuilder { return extFromCanonicalOrReq(req, goods, "ISBN"); } + /** + * 拼多多「ISBN编号」属性通常校验为 13 位纯数字;对横杠/空格、全角数字做清洗,并将合法 ISBN-10 转为 ISBN-13(978 前缀)。 + *

13 位时必须满足 ISBN-13(EAN-13)校验位,否则平台会报「格式不正确」。

+ */ + private static String normalizeIsbnForPddGoodsProperty(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + String s = normalizeFullWidthAsciiDigits(raw.trim()); + StringBuilder digits = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= '0' && c <= '9') { + digits.append(c); + } else if ((c == 'X' || c == 'x') && digits.length() == 9) { + digits.append('X'); + } + } + String compact = digits.toString(); + if (compact.length() == 13 && compact.chars().allMatch(Character::isDigit)) { + if (!isIsbn13CheckDigitValid(compact)) { + int expect = computeIsbn13CheckDigit(compact.substring(0, 12)); + throw new IllegalStateException("ISBN-13 校验位错误(拼多多会拒收):当前=" + compact + + ",末位应为 " + expect + ",请核对主档或 ext.isbn 是否为笔误"); + } + return compact; + } + if (compact.length() == 10) { + String first9 = compact.substring(0, 9); + if (!first9.chars().allMatch(Character::isDigit)) { + return null; + } + char last = compact.charAt(9); + if (!(Character.isDigit(last) || last == 'X')) { + return null; + } + return isbn10CompactToIsbn13(first9); + } + return null; + } + + private static String normalizeFullWidthAsciiDigits(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= '0' && c <= '9') { + sb.append((char) ('0' + (c - '0'))); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** @param nineDigits ISBN-10 前 9 位(均为数字) */ + private static String isbn10CompactToIsbn13(String nineDigits) { + String core = "978" + nineDigits; + return core + computeIsbn13CheckDigit(core); + } + + /** 前 12 位均为数字时,返回 ISBN-13 第 13 位校验码(0–9)。 */ + private static int computeIsbn13CheckDigit(String twelveDigits) { + if (twelveDigits == null || twelveDigits.length() != 12) { + throw new IllegalArgumentException("ISBN-13 校验位计算需要恰好 12 位数字"); + } + int sum = 0; + for (int i = 0; i < 12; i++) { + int d = twelveDigits.charAt(i) - '0'; + if (d < 0 || d > 9) { + throw new IllegalArgumentException("ISBN-13 校验位计算仅允许数字"); + } + sum += (i % 2 == 0) ? d : d * 3; + } + return (10 - (sum % 10)) % 10; + } + + private static boolean isIsbn13CheckDigitValid(String isbn13) { + if (isbn13 == null || isbn13.length() != 13 || !isbn13.chars().allMatch(Character::isDigit)) { + return false; + } + try { + return computeIsbn13CheckDigit(isbn13.substring(0, 12)) == (isbn13.charAt(12) - '0'); + } catch (IllegalArgumentException e) { + return false; + } + } + + private static String normalizeIsbnGoodsPropertyValueIfApplicable(PddOpenApiSupport.CatGoodsPropertyRuleRow rule, + ExternalPddProperties props, String value) { + if (!StringUtils.hasText(value)) { + return value; + } + boolean isbnRule = props.getIsbnGoodsPropertyRefPid() > 0 && rule.getRefPid() == props.getIsbnGoodsPropertyRefPid(); + if (!isbnRule && StringUtils.hasText(rule.getName())) { + String n = rule.getName(); + isbnRule = n.contains("ISBN") || n.contains("isbn"); + } + if (!isbnRule) { + return value; + } + String n = normalizeIsbnForPddGoodsProperty(value); + return StringUtils.hasText(n) ? n : value; + } + private static String resolveGoodsDescForPdd(OGoods goods, ExternalGoodsUpsertRequest req) { if (goods != null && StringUtils.hasText(goods.getCanonicalExt())) { try {