feat(pdd): ISBN 规范化与校验位校验;external upsert 请求日志脱敏 pddPopAuth

Made-with: Cursor
This commit is contained in:
huangyujie 2026-03-26 11:12:14 +08:00
parent ec1af6c757
commit e2cc135ce3
6 changed files with 230 additions and 11 deletions

View File

@ -11,7 +11,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
public class ExternalGoodsApiLogProperties { public class ExternalGoodsApiLogProperties {
/** /**
* 是否打印 upsert/delist 的完整请求体JSON pddPopAuth 等敏感字段生产务必关闭 * 是否打印 upsert 的请求体 JSON{@code pddPopAuth} appSecret 全掩appKey/accessToken 首尾各保留若干字符便于排障
* delist 请求体无凭证字段仍为完整 JSON
*/ */
private boolean logFullRequest = false; private boolean logFullRequest = false;

View File

@ -7,6 +7,7 @@ import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo; import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo;
import cn.qihangerp.security.common.BaseController; import cn.qihangerp.security.common.BaseController;
import cn.qihangerp.erp.config.ExternalGoodsApiLogProperties; import cn.qihangerp.erp.config.ExternalGoodsApiLogProperties;
import cn.qihangerp.erp.support.ExternalGoodsRequestLogSupport;
import cn.qihangerp.service.external.ExternalGoodsAppService; import cn.qihangerp.service.external.ExternalGoodsAppService;
import cn.qihangerp.service.external.pdd.ExternalPddProperties; import cn.qihangerp.service.external.pdd.ExternalPddProperties;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
@ -42,7 +43,7 @@ public class ExternalGoodsController extends BaseController {
@PostMapping("/upsert") @PostMapping("/upsert")
public AjaxResult upsert(@RequestBody ExternalGoodsUpsertRequest req) { public AjaxResult upsert(@RequestBody ExternalGoodsUpsertRequest req) {
if (goodsApiLogProperties.isLogFullRequest()) { 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) { if (req == null || req.getShopId() == null || req.getShopId() <= 0) {
return AjaxResult.error("参数错误shopId不能为空"); return AjaxResult.error("参数错误shopId不能为空");

View File

@ -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);
}
}

View File

@ -31,7 +31,7 @@ external:
api-key: xh-uat-erp-api-ak api-key: xh-uat-erp-api-ak
secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c
timestamp-skew-ms: 300000 timestamp-skew-ms: 300000
# 上下架接口全量请求体日志;生产上线后改为 false # 上下架接口请求体日志upsert 时 pddPopAuth 已脱敏);生产可关
goods: goods:
log-full-request: true log-full-request: true

View File

@ -37,7 +37,7 @@ external:
secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c secret-key: xh-uat-erp-api-sk-9f2d3c4b5a6e7d8c
timestamp-skew-ms: 300000 timestamp-skew-ms: 300000
goods: goods:
# 生产上线后改为 false # upsert 请求体日志pddPopAuth 已脱敏);生产可关
log-full-request: true log-full-request: true
# 拼多多 POP与 jar 内 application.yml 对齐Nacos 下发会覆盖本地 # 拼多多 POP与 jar 内 application.yml 对齐Nacos 下发会覆盖本地

View File

@ -275,7 +275,15 @@ public class PddGoodsAddParamBuilder {
if (a.getVid() != null) { if (a.getVid() != null) {
o.put("vid", a.getVid()); 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() : ""); o.put("punit", StringUtils.hasText(a.getUnit()) ? a.getUnit().trim() : "");
arr.add(o); arr.add(o);
} }
@ -283,9 +291,13 @@ public class PddGoodsAddParamBuilder {
String isbn = resolveIsbnFromExt(req, goods); String isbn = resolveIsbnFromExt(req, goods);
long isbnPid = props.getIsbnGoodsPropertyRefPid(); long isbnPid = props.getIsbnGoodsPropertyRefPid();
if (StringUtils.hasText(isbn) && isbnPid > 0 && seenRefPid.add(isbnPid)) { if (StringUtils.hasText(isbn) && isbnPid > 0 && seenRefPid.add(isbnPid)) {
String isbnVal = normalizeIsbnForPddGoodsProperty(isbn);
if (!StringUtils.hasText(isbnVal)) {
isbnVal = isbn.trim();
}
JSONObject o = new JSONObject(); JSONObject o = new JSONObject();
o.put("ref_pid", isbnPid); o.put("ref_pid", isbnPid);
o.put("value", isbn.trim()); o.put("value", isbnVal);
o.put("punit", ""); o.put("punit", "");
arr.add(o); arr.add(o);
} }
@ -350,8 +362,15 @@ public class PddGoodsAddParamBuilder {
continue; continue;
} }
if (StringUtils.hasText(a.getValue()) || a.getVid() != null) { if (StringUtils.hasText(a.getValue()) || a.getVid() != null) {
return new ResolvedGoodsPropValue(a.getVid(), String val = a.getValue() == null ? "" : a.getValue().trim();
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()) { if (props.getIsbnGoodsPropertyRefPid() == rule.getRefPid()) {
String isbn = resolveIsbnFromExt(req, goods); String isbn = resolveIsbnFromExt(req, goods);
if (StringUtils.hasText(isbn)) { 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) { if (m != null) {
return m; return m;
} }
@ -386,6 +409,7 @@ public class PddGoodsAddParamBuilder {
return null; return null;
} }
v = applyMaxValueLen(v, rule.getMaxValueHint()); v = applyMaxValueLen(v, rule.getMaxValueHint());
v = normalizeIsbnGoodsPropertyValueIfApplicable(rule, props, v);
return new ResolvedGoodsPropValue(null, v); return new ResolvedGoodsPropValue(null, v);
} }
@ -457,7 +481,12 @@ public class PddGoodsAddParamBuilder {
String name = rule.getName(); String name = rule.getName();
if (StringUtils.hasText(name)) { if (StringUtils.hasText(name)) {
if (name.contains("ISBN") || name.contains("isbn")) { 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 (name.contains("书名")) {
if (goods != null && StringUtils.hasText(goods.getName())) { if (goods != null && StringUtils.hasText(goods.getName())) {
@ -469,7 +498,12 @@ public class PddGoodsAddParamBuilder {
} }
} }
if (props.getIsbnGoodsPropertyRefPid() == rule.getRefPid()) { 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; return null;
} }
@ -526,6 +560,110 @@ public class PddGoodsAddParamBuilder {
return extFromCanonicalOrReq(req, goods, "ISBN"); return extFromCanonicalOrReq(req, goods, "ISBN");
} }
/**
* 拼多多ISBN编号属性通常校验为 13 位纯数字对横杠/空格全角数字做清洗并将合法 ISBN-10 转为 ISBN-13978 前缀
* <p>13 位时必须满足 ISBN-13EAN-13校验位否则平台会报格式不正确</p>
*/
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 >= '' && c <= '') {
sb.append((char) ('0' + (c - '')));
} 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 位校验码09。 */
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) { private static String resolveGoodsDescForPdd(OGoods goods, ExternalGoodsUpsertRequest req) {
if (goods != null && StringUtils.hasText(goods.getCanonicalExt())) { if (goods != null && StringUtils.hasText(goods.getCanonicalExt())) {
try { try {