增加 商品名称标题匹配工具类,性能和计算均衡版,还可以继续优化。

This commit is contained in:
Jack 2025-07-09 18:48:16 +08:00
parent d2b25780f5
commit c4cb46b25a
3 changed files with 380 additions and 47 deletions

View File

@ -0,0 +1,249 @@
/*
* Copyright (c) 2025. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
* Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan.
* Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna.
* Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus.
* Vestibulum commodo. Ut rhoncus gravida arcu.
*/
package com.suisung.mall.common.utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.text.similarity.LevenshteinDistance;
import java.util.*;
import java.util.regex.Pattern;
@Slf4j
public class ProductTitleUtil {
/* ---------------------------- 常量配置 ---------------------------- */
/**
* 电商通用无意义字段营销词/冗余词
*/
private static final Set<String> STOP_WORDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"特价", "折扣", "优惠", "促销", "限时", "秒杀", "抢购", "直降", "满减",
"赠品", "包邮", "新品", "热卖", "爆款", "推荐", "精选", "特惠", "清仓",
"正品", "原装", "官方", "正版", "品牌", "优质", "好用", "新款", "老款",
"", "", "", "", "[]", "()", "", "", "", "", "??", "?"
)));
/**
* 规格单位归一化映射线程安全
*/
private static final Map<String, String> UNIT_NORMAL_MAP;
/**
* 字段权重配置品牌>品类>规格>属性
*/
private static final Map<String, Integer> FIELD_WEIGHTS;
/**
* 预编译正则表达式提升性能
*/
private static final Pattern NUMERIC_PATTERN = Pattern.compile(".*\\d+.*");
private static final Pattern UNIT_PATTERN = Pattern.compile("(\\d+\\.?\\d*)([a-zA-Z]+)");
private static final Pattern TITLE_FILTER_PATTERN = Pattern.compile("[^a-zA-Z0-9\u4e00-\u9fa5]");
/**
* 品牌词库初始化后不可变
*/
private static final Set<String> BRAND_LIBRARY = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("华为", "苹果", "小米", "三星", "美的", "格力", "耐克", "阿迪达斯", "海尔")
));
/**
* 品类词库初始化后不可变
*/
private static final Set<String> CATEGORY_LIBRARY = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("手机", "电脑", "空调", "冰箱", "运动鞋", "T恤", "洗发水", "洗衣液")
));
static {
Map<String, String> map = new HashMap<>(16);
map.put("g", "");
map.put("kg", "千克");
map.put("ml", "毫升");
map.put("l", "");
map.put("cm", "厘米");
map.put("mm", "毫米");
UNIT_NORMAL_MAP = Collections.unmodifiableMap(map);
}
static {
Map<String, Integer> map = new HashMap<>(4);
map.put("brand", 30);
map.put("category", 25);
map.put("spec", 20);
map.put("attribute", 25);
FIELD_WEIGHTS = Collections.unmodifiableMap(map);
}
private ProductTitleUtil() {
}
/* ---------------------------- 核心API ---------------------------- */
/**
* 计算标题相似度优化版
*
* @param title1 标题1
* @param title2 标题2
* @return 相似度得分(0 - 100)
* @throws IllegalArgumentException 标题为空时抛出
*/
public static int calculateSimilarity(String title1, String title2) {
if (StringUtils.isBlank(title1) || StringUtils.isBlank(title2)) {
throw new IllegalArgumentException("商品标题不能为空");
}
long startTime = System.nanoTime();
try {
// 1. 并行清洗标题
String cleanTitle1 = cleanTitle(title1);
String cleanTitle2 = cleanTitle(title2);
// 2. 并行解析结构
Map<String, String> fields1 = parseTitle(cleanTitle1);
Map<String, String> fields2 = parseTitle(cleanTitle2);
// 3. 加权计算得分
int score = calculateWeightedScore(fields1, fields2);
if (log.isDebugEnabled()) {
log.debug("相似度计算耗时:{}ns", System.nanoTime() - startTime);
}
return score;
} catch (Exception e) {
throw new RuntimeException("标题相似度计算异常", e);
}
}
/* ---------------------------- 私有方法 ---------------------------- */
/**
* 清洗标题高性能实现
*/
private static String cleanTitle(String title) {
// 1. 快速替换特殊符号
String filtered = TITLE_FILTER_PATTERN.matcher(title).replaceAll(" ");
// 2. 高效分词+过滤
String[] words = StringUtils.split(filtered);
StringBuilder builder = new StringBuilder(title.length());
for (String word : words) {
if (word.length() == 0 || STOP_WORDS.contains(word)) continue;
builder.append(normalizeUnit(word)).append(' ');
}
return builder.length() > 0 ? builder.substring(0, builder.length() - 1) : "";
}
/**
* 单位归一化优化性能
*/
private static String normalizeUnit(String word) {
java.util.regex.Matcher matcher = UNIT_PATTERN.matcher(word);
return matcher.matches() ?
matcher.group(1) + UNIT_NORMAL_MAP.getOrDefault(matcher.group(2).toLowerCase(), matcher.group(2)) :
word;
}
/**
* 结构化解析标题优化性能
*/
private static Map<String, String> parseTitle(String cleanTitle) {
Map<String, String> fields = new HashMap<>(4);
String[] words = StringUtils.split(cleanTitle);
List<String> remainingWords = new ArrayList<>(words.length);
for (String word : words) {
if (BRAND_LIBRARY.contains(word)) {
fields.putIfAbsent("brand", word);
} else if (CATEGORY_LIBRARY.contains(word)) {
fields.putIfAbsent("category", word);
} else if (NUMERIC_PATTERN.matcher(word).matches()) {
fields.merge("spec", word, (old, newVal) -> old + " " + newVal);
} else {
remainingWords.add(word);
}
}
fields.put("attribute", StringUtils.join(remainingWords, " "));
return fields;
}
/**
* 加权得分计算优化分支预测
*/
private static int calculateWeightedScore(Map<String, String> fields1, Map<String, String> fields2) {
int totalScore = 0;
// 品牌得分完全匹配
totalScore += calculateFieldScore(
fields1.get("brand"),
fields2.get("brand"),
FIELD_WEIGHTS.get("brand"),
true
);
// 品类得分完全匹配
totalScore += calculateFieldScore(
fields1.get("category"),
fields2.get("category"),
FIELD_WEIGHTS.get("category"),
true
);
// 规格得分相似度匹配
totalScore += calculateFieldScore(
fields1.get("spec"),
fields2.get("spec"),
FIELD_WEIGHTS.get("spec"),
false
);
// 属性得分相似度匹配
totalScore += calculateFieldScore(
fields1.get("attribute"),
fields2.get("attribute"),
FIELD_WEIGHTS.get("attribute"),
false
);
return Math.min(totalScore, 100);
}
/**
* 字段得分计算优化算法
*/
private static int calculateFieldScore(String field1, String field2, int weight, boolean exactMatch) {
if (field1 == null && field2 == null) return weight;
if (field1 == null || field2 == null) return 0;
if (exactMatch) {
return field1.equals(field2) ? weight : 0;
}
// 优化短文本直接比较
if (field1.length() < 5 && field2.length() < 5) {
return field1.equals(field2) ? weight : 0;
}
LevenshteinDistance levenshtein = LevenshteinDistance.getDefaultInstance();
int distance = levenshtein.apply(field1, field2);
int maxLen = Math.max(field1.length(), field2.length());
return (int) ((1.0 - (double) distance / maxLen) * weight);
}
// 测试示例
public static void main(String[] args) {
String title1 = "三只松鼠开心果100g 特价促销";
String title2 = "三只松鼠开心果100g 折扣优惠";
String title3 = "百草味巴旦木200g 限时特价";
String title4 = "三只松鼠开心果100g";
String title5 = "【特价】华为Mate60 Pro 512G 手机 黑色 正品包邮";
String title6 = "华为Mate60 Pro 512克 手机 黑色 折扣促销";
String title7 = "小米13 128G 智能手机 白色 新品";
String title8 = "苹果13 128G 手机 黑色 特惠";
System.out.println("标题1与标题2相似度" + calculateSimilarity(title1, title2)); // 约100.0
System.out.println("标题1与标题3相似度" + calculateSimilarity(title1, title3)); // 约30.0
System.out.println("标题1与标题4相似度" + calculateSimilarity(title1, title4)); // 约100.0
System.out.println("标题5与标题6相似度" + calculateSimilarity(title5, title6)); // 输出约90
System.out.println("标题7与标题8相似度" + calculateSimilarity(title7, title8)); // 输出约45
}
}

View File

@ -54,6 +54,7 @@ import com.suisung.mall.shop.product.service.ShopProductBaseService;
import com.suisung.mall.shop.product.service.ShopProductIndexService;
import com.suisung.mall.shop.product.service.ShopProductInfoService;
import com.suisung.mall.shop.product.service.ShopProductItemService;
import com.suisung.mall.shop.sfexpress.service.SFExpressApiService;
import com.suisung.mall.shop.store.service.ShopStoreBaseService;
import com.suisung.mall.shop.store.service.ShopStoreConfigService;
import com.suisung.mall.shop.store.service.ShopStoreShippingAddressService;
@ -62,6 +63,7 @@ import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -185,6 +187,9 @@ public class ShopOrderReturnServiceImpl extends BaseServiceImpl<ShopOrderReturnM
private AccountBaseConfigService accountBaseConfigService;
@Autowired
private ShopOrderLogisticsService shopOrderLogisticsService;
@Lazy
@Autowired
private SFExpressApiService sfExpressApiService;
@Autowired
private MessageService messageService;
@ -917,8 +922,9 @@ public class ShopOrderReturnServiceImpl extends BaseServiceImpl<ShopOrderReturnM
@Override
public Boolean sfExpressExpiredForceRefund(String orderId) {
String remark = "配送异常自动退款!";
// 先整单退货申请
CommonResult commonResult = addWholeItems(orderId, true, "配送异常,系统自动退款!");
CommonResult commonResult = addWholeItems(orderId, true, remark);
commonResult.checkFenResult();
QueryWrapper<ShopOrderReturn> queryWrapper = new QueryWrapper<>();
@ -929,8 +935,8 @@ public class ShopOrderReturnServiceImpl extends BaseServiceImpl<ShopOrderReturnM
}
shopOrderReturn.setReturn_flag(0); // 0-不用退货;1-需要退货
shopOrderReturn.setReturn_buyer_message("配送异常,系统自动退款");
shopOrderReturn.setReturn_store_message("配送异常,系统自动退款");
shopOrderReturn.setReturn_buyer_message(remark);
shopOrderReturn.setReturn_store_message(remark);
// 退货审核商家同意退款仅仅退款因为商品还没有配送出去
return processReviewList(shopOrderReturn, 0);
@ -2180,67 +2186,145 @@ public class ShopOrderReturnServiceImpl extends BaseServiceImpl<ShopOrderReturnM
}
/**
* 商家退货退款支持全单或个别商品退货
* 商家处理退货退款支持全单或部分商品退货
*
* @param params json格式数据 {orderId:"DD-20250706-1", OrderReturnInputVo}
* @return
* @param requestParams JSON格式请求参数包含:
* - order_id: 订单ID (必填)
* - order_return_vo: 退货商品信息 (可选用于部分商品退货)
* - reason: 退货理由说明 (可选)
* @return CommonResult 处理结果
* @throws ApiException 当用户未登录时抛出
*/
@Transactional
@Override
public CommonResult doRefundForMch(JSONObject params) {
if (params == null) {
return CommonResult.failed("缺少必要参数");
public CommonResult doRefundForMch(JSONObject requestParams) {
// 参数基础校验
if (requestParams == null || StrUtil.isBlank(requestParams.getStr("order_id"))) {
return CommonResult.failed("退货请求参数不完整");
}
// 订单Id 必填参数
String orderId = params.getStr("order_id");
if (StrUtil.isBlank(orderId)) {
return CommonResult.failed("缺少订单信息!");
}
UserDto userDto = getCurrentUser();
if (userDto == null) {
final String orderId = requestParams.getStr("order_id");
final UserDto currentUser = getCurrentUser();
if (currentUser == null) {
throw new ApiException(ResultCode.NEED_LOGIN);
}
// 验证订单有效性
ShopOrderInfo orderInfo = shopOrderInfoService.get(orderId);
if (orderInfo == null) {
return CommonResult.failed("此订单信息为空");
return CommonResult.failed("订单不存在");
}
if (StateCode.ORDER_PAID_STATE_NO == orderInfo.getOrder_is_paid()) {
return CommonResult.failed("订单未付款,无法退货");
}
if (!orderInfo.getStore_id().equals(currentUser.getStore_id())) {
return CommonResult.failed("无权处理其他店铺的订单");
}
if (orderInfo.getOrder_is_paid().equals(StateCode.ORDER_PAID_STATE_NO)) {
return CommonResult.failed("该订单未付款,无法申请!");
// 检查是否存在处理中的退货单
QueryWrapper<ShopOrderReturn> returnQuery = new QueryWrapper<>();
returnQuery.eq("order_id", orderId)
.ne("return_state_id", StateCode.RETURN_PROCESS_CANCEL);
if (CollectionUtil.isNotEmpty(find(returnQuery))) {
return CommonResult.failed("订单已有处理中的退货单");
}
if (orderInfo.getStore_id().equals(userDto.getStore_id())) {
return CommonResult.failed("订单不是当前店铺的,无权退货!");
// 获取订单商品项
List<ShopOrderItem> orderItems = shopOrderItemService.find(
new QueryWrapper<ShopOrderItem>().eq("order_id", orderId));
if (CollectionUtil.isEmpty(orderItems)) {
return CommonResult.failed("订单没有可退货的商品");
}
// 准备退货数据
String reason = requestParams.getStr("reason");
reason = StrUtil.isBlank(reason) ? "商家整单退货" : reason;
// 如果不是全单退传入 orderReturnInputVoList
OrderReturnInputVo orderReturnInputVo = JSONUtil.toBean(params.getStr("orderReturnVo"), OrderReturnInputVo.class);
if (orderReturnInputVo != null && !CollUtil.isEmpty(orderReturnInputVo.getReturn_items())) {
// 判断原来订单里是否包含传入的退单记录
OrderReturnInputVo refundRequest = new OrderReturnInputVo();
refundRequest.setOrder_id(orderId);
refundRequest.setReturn_tel("");
refundRequest.setReturn_buyer_message(I18nUtil._(reason));
refundRequest.setUser_id(orderInfo.getBuyer_user_id());
refundRequest.setSystem_opear(false);
// 添加退货商品项
orderItems.forEach(item -> {
OrderReturnItemInputVo itemVo = new OrderReturnItemInputVo();
itemVo.setOrder_item_id(item.getOrder_item_id());
itemVo.setReturn_item_num(item.getOrder_item_quantity());
itemVo.setReturn_refund_amount(item.getOrder_item_payment_amount());
refundRequest.getReturn_items().add(itemVo);
});
// 处理部分退货情况
OrderReturnInputVo partialRefund = null;
try {
partialRefund = JSONUtil.toBean(requestParams.getStr("order_return_vo"), OrderReturnInputVo.class);
} catch (Exception e) {
return CommonResult.failed("退货商品参数格式错误");
}
// 全单退流程分支
// 整单退货申请
CommonResult commonResult = addWholeItems(orderId, true, "商家整单退款!");
commonResult.checkFenResult();
if (partialRefund != null && CollectionUtil.isNotEmpty(partialRefund.getReturn_items())) {
// 校验退货商品是否属于当前订单
List<Long> validItemIds = orderItems.stream()
.map(ShopOrderItem::getOrder_item_id)
.collect(Collectors.toList());
boolean allItemsValid = partialRefund.getReturn_items().stream()
.allMatch(item -> validItemIds.contains(item.getOrder_item_id()));
if (!allItemsValid) {
return CommonResult.failed("退货商品不属于当前订单");
}
QueryWrapper<ShopOrderReturn> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_id", orderId);
ShopOrderReturn shopOrderReturn = findOne(queryWrapper);
if (shopOrderReturn == null) {
throw new ApiException(I18nUtil._("订单信息异常!"));
// 校验退款金额和数量
boolean amountsValid = partialRefund.getReturn_items().stream()
.allMatch(item -> item.getReturn_refund_amount().compareTo(BigDecimal.ZERO) > 0 && item.getReturn_item_num() > 0);
if (!amountsValid) {
return CommonResult.failed("退货数量或金额无效");
}
refundRequest.setReturn_items(partialRefund.getReturn_items());
reason = StrUtil.isBlank(reason) ? "商家部分商品退货" : reason;
}
shopOrderReturn.setReturn_flag(0); // 0-不用退货;1-需要退货
shopOrderReturn.setReturn_buyer_message("配送异常,系统自动退款");
shopOrderReturn.setReturn_store_message("配送异常,系统自动退款");
// 验证退货商品项
if (CollectionUtil.isEmpty(refundRequest.getReturn_items())) {
return CommonResult.failed("没有可退货的商品");
}
// 退货审核商家同意退款仅仅退款因为商品还没有配送出去
Boolean success = processReviewList(shopOrderReturn, 0);
return success ? CommonResult.success() : CommonResult.failed();
// 创建退货单
CommonResult createResult = addItem(refundRequest);
if (createResult.getCode() != 200) {
return createResult;
}
// 获取并处理退货单
ShopOrderReturn refundOrder = findOne(
new QueryWrapper<ShopOrderReturn>().eq("order_id", orderId));
if (refundOrder == null) {
return CommonResult.failed("退货单创建失败");
}
refundOrder.setReturn_flag(0);
refundOrder.setReturn_buyer_message(reason);
refundOrder.setReturn_store_message(reason);
boolean success = processReviewList(refundOrder, 0);
if (!success) {
return CommonResult.failed("退货处理失败");
}
// 如果是同城配送通知顺丰同城取消订单
if (orderInfo.getDelivery_type_id() != null
&& orderInfo.getDelivery_type_id().equals(StateCode.DELIVERY_TYPE_SAME_CITY)
&& partialRefund == null) {
try {
sfExpressApiService.cancelOrder(orderId, 313, reason);
} catch (Exception e) {
log.error("顺丰同城取消订单失败: {}", e.getMessage());
}
}
return CommonResult.success();
}
}

View File

@ -249,7 +249,7 @@ public class SFExpressApiServiceImpl implements SFExpressApiService {
@Override
public ThirdApiRes cancelOrder(String orderId, Integer cancelCode, String cancelReason) {
Map<String, Object> params = buildCommonParams();
params.put("order_id", orderId);
params.put("order_id", shopStoreSfOrderService.getSfOrderIdByShopOrderId(orderId)); // 商家 orderId 顺丰的订单号
if (StrUtil.isNotBlank(cancelReason) && cancelCode != null) {
params.put("cancel_code", orderId);
params.put("cancel_reason", orderId);
@ -313,17 +313,17 @@ public class SFExpressApiServiceImpl implements SFExpressApiService {
// List<String> orderList = new ArrayList<>();
// orderList.add(shopStoreSfOrderExist.getShop_order_id());
// 取消订单, 流程订单状态积分众宝库存礼包优惠券 有就统统退还
Boolean success = shopOrderReturnService.sfExpressExpiredForceRefund(shopStoreSfOrderExist.getShop_order_id()); // 不检查订单付款状态
if (!success) {
throw new ApiException(I18nUtil._("取消商家订单失败!"));
}
// Boolean success = shopOrderReturnService.sfExpressExpiredForceRefund(shopStoreSfOrderExist.getShop_order_id()); // 不检查订单付款状态
// if (!success) {
// throw new ApiException(I18nUtil._("取消商家订单失败!"));
// }
// 更改顺丰的订单状态
ShopStoreSfOrder shopStoreSfOrder = new ShopStoreSfOrder();
shopStoreSfOrder.setSf_order_id(shopStoreSfOrderExist.getSf_order_id());
shopStoreSfOrder.setOrder_status(StateCode.SF_ORDER_STATUS_CANCELED);
shopStoreSfOrder.setStatus_desc("线上商城发起取消订单");
success = shopStoreSfOrderService.updateShopStoreSfOrderStatus(shopStoreSfOrder);
Boolean success = shopStoreSfOrderService.updateShopStoreSfOrderStatus(shopStoreSfOrder);
if (!success) {
throw new ApiException(I18nUtil._("取消顺丰订单失败!"));
}