feat(pdd): ISBN 规范化与校验位校验;external upsert 请求日志脱敏 pddPopAuth
Made-with: Cursor
This commit is contained in:
parent
ec1af6c757
commit
e2cc135ce3
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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不能为空");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 下发会覆盖本地
|
||||||
|
|
|
||||||
|
|
@ -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-13(978 前缀)。
|
||||||
|
* <p>13 位时必须满足 ISBN-13(EAN-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 >= '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) {
|
private static String resolveGoodsDescForPdd(OGoods goods, ExternalGoodsUpsertRequest req) {
|
||||||
if (goods != null && StringUtils.hasText(goods.getCanonicalExt())) {
|
if (goods != null && StringUtils.hasText(goods.getCanonicalExt())) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue