diff --git a/mall-common/src/main/java/com/suisung/mall/common/utils/ProductTitleUtil.java b/mall-common/src/main/java/com/suisung/mall/common/utils/ProductTitleUtil.java new file mode 100644 index 00000000..7027541d --- /dev/null +++ b/mall-common/src/main/java/com/suisung/mall/common/utils/ProductTitleUtil.java @@ -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 STOP_WORDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "特价", "折扣", "优惠", "促销", "限时", "秒杀", "抢购", "直降", "满减", + "赠品", "包邮", "新品", "热卖", "爆款", "推荐", "精选", "特惠", "清仓", + "正品", "原装", "官方", "正版", "品牌", "优质", "好用", "新款", "老款", + "【", "】", "(", ")", "[]", "()", "「", "」", "!", "!!", "??", "?" + ))); + + /** + * 规格单位归一化映射(线程安全) + */ + private static final Map UNIT_NORMAL_MAP; + /** + * 字段权重配置(品牌>品类>规格>属性) + */ + private static final Map 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 BRAND_LIBRARY = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("华为", "苹果", "小米", "三星", "美的", "格力", "耐克", "阿迪达斯", "海尔") + )); + /** + * 品类词库(初始化后不可变) + */ + private static final Set CATEGORY_LIBRARY = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("手机", "电脑", "空调", "冰箱", "运动鞋", "T恤", "洗发水", "洗衣液") + )); + + static { + Map 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 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 fields1 = parseTitle(cleanTitle1); + Map 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 parseTitle(String cleanTitle) { + Map fields = new HashMap<>(4); + String[] words = StringUtils.split(cleanTitle); + + List 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 fields1, Map 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 + } +} diff --git a/mall-shop/src/main/java/com/suisung/mall/shop/order/service/impl/ShopOrderReturnServiceImpl.java b/mall-shop/src/main/java/com/suisung/mall/shop/order/service/impl/ShopOrderReturnServiceImpl.java index 7718b7ca..6d1a195a 100644 --- a/mall-shop/src/main/java/com/suisung/mall/shop/order/service/impl/ShopOrderReturnServiceImpl.java +++ b/mall-shop/src/main/java/com/suisung/mall/shop/order/service/impl/ShopOrderReturnServiceImpl.java @@ -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 queryWrapper = new QueryWrapper<>(); @@ -929,8 +935,8 @@ public class ShopOrderReturnServiceImpl extends BaseServiceImpl 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 orderItems = shopOrderItemService.find( + new QueryWrapper().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 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 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().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(); } } \ No newline at end of file diff --git a/mall-shop/src/main/java/com/suisung/mall/shop/sfexpress/service/impl/SFExpressApiServiceImpl.java b/mall-shop/src/main/java/com/suisung/mall/shop/sfexpress/service/impl/SFExpressApiServiceImpl.java index 9541a2be..8509e4ca 100644 --- a/mall-shop/src/main/java/com/suisung/mall/shop/sfexpress/service/impl/SFExpressApiServiceImpl.java +++ b/mall-shop/src/main/java/com/suisung/mall/shop/sfexpress/service/impl/SFExpressApiServiceImpl.java @@ -249,7 +249,7 @@ public class SFExpressApiServiceImpl implements SFExpressApiService { @Override public ThirdApiRes cancelOrder(String orderId, Integer cancelCode, String cancelReason) { Map 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 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._("取消顺丰订单失败!")); }