From a8af20bcde47da94b2ef1c5951bf3ca1b71fcc30 Mon Sep 17 00:00:00 2001 From: gxwebsoft <170083662@qq.com> Date: Sun, 1 Mar 2026 00:30:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(shop):=20=E6=B7=BB=E5=8A=A0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=8F=96=E6=B6=88=E5=92=8C=E9=80=80=E6=AC=BE=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E6=B0=B4=E7=A5=A8=E6=92=A4=E9=94=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在ShopOrderController中注入GltTicketRevokeService服务 - 实现订单状态改为已取消时同步撤销相关水票、释放计划和送水订单 - 实现退款成功后自动撤销水票相关数据的功能 - 新增GltTicketRevokeService服务处理水票撤销逻辑 - 添加批量订单取消时的水票撤销支持 - 实现撤销操作的幂等性确保无副作用 - 添加单元测试验证水票撤销功能的正确性 --- .../glt/service/GltTicketRevokeService.java | 130 ++++++++++++++++++ .../shop/controller/ShopOrderController.java | 69 +++++++++- .../service/GltTicketRevokeServiceTest.java | 65 +++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java create mode 100644 src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java new file mode 100644 index 0000000..a73b8b7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java @@ -0,0 +1,130 @@ +package com.gxwebsoft.glt.service; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.glt.entity.GltTicketOrder; +import com.gxwebsoft.glt.entity.GltUserTicket; +import com.gxwebsoft.glt.entity.GltUserTicketRelease; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 水票撤销(订单取消/退款成功后的清理): + * - 取消/隐藏用户水票(glt_user_ticket.deleted=1) + * - 删除未完成的释放计划(glt_user_ticket_release.deleted=1, status!=1) + * - 删除未完成的送水订单(glt_ticket_order.deleted=1, delivery_status!=40) + * + *

说明:该操作需保证幂等;若无关联水票则无任何副作用。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GltTicketRevokeService { + + /** release.status:已释放 */ + private static final int RELEASE_STATUS_DONE = 1; + /** ticketOrder.deliveryStatus:已完成 */ + private static final int TICKET_ORDER_DELIVERY_STATUS_FINISHED = 40; + + private final GltUserTicketService gltUserTicketService; + private final GltUserTicketReleaseService gltUserTicketReleaseService; + private final GltTicketOrderService gltTicketOrderService; + + @Transactional(rollbackFor = Exception.class) + public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) { + if (tenantId == null) { + return 0; + } + if (shopOrderId == null && StrUtil.isBlank(shopOrderNo)) { + return 0; + } + + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0); + + if (shopOrderId != null && StrUtil.isNotBlank(shopOrderNo)) { + qw.and(w -> w.eq(GltUserTicket::getOrderId, shopOrderId).or().eq(GltUserTicket::getOrderNo, shopOrderNo)); + } else if (shopOrderId != null) { + qw.eq(GltUserTicket::getOrderId, shopOrderId); + } else { + qw.eq(GltUserTicket::getOrderNo, shopOrderNo); + } + + List tickets = gltUserTicketService.list(qw); + if (tickets == null || tickets.isEmpty()) { + return 0; + } + + LocalDateTime now = LocalDateTime.now(); + int revoked = 0; + // 不强制覆盖 comments,避免影响后台人工备注;reason 仅用于日志。 + String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim(); + + for (GltUserTicket t : tickets) { + if (t == null || t.getId() == null) { + continue; + } + + Integer userTicketId = t.getId(); + + // 1) 删除未完成的送水订单(避免继续配送/接单/确认) + try { + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(GltTicketOrder::getTenantId, tenantId) + .eq(GltTicketOrder::getDeleted, 0) + .eq(GltTicketOrder::getUserTicketId, userTicketId) + // 兼容历史/脏数据:deliveryStatus 为空时也按“未完成”处理 + .and(w -> w.ne(GltTicketOrder::getDeliveryStatus, TICKET_ORDER_DELIVERY_STATUS_FINISHED) + .or().isNull(GltTicketOrder::getDeliveryStatus)) + .set(GltTicketOrder::getDeleted, 1) + .set(GltTicketOrder::getUpdateTime, now); + gltTicketOrderService.update(null, uw); + } catch (Exception e) { + log.warn("撤销送水订单失败(继续尝试撤销水票/释放计划) - tenantId={}, shopOrderId={}, userTicketId={}", + tenantId, shopOrderId, userTicketId, e); + } + + // 2) 删除未完成的释放计划(防止后续继续自动释放) + try { + LambdaUpdateWrapper uw = new LambdaUpdateWrapper() + .eq(GltUserTicketRelease::getTenantId, tenantId) + .eq(GltUserTicketRelease::getDeleted, 0) + .eq(GltUserTicketRelease::getUserTicketId, userTicketId.longValue()) + // status 为空时也视为“未完成” + .and(w -> w.ne(GltUserTicketRelease::getStatus, RELEASE_STATUS_DONE) + .or().isNull(GltUserTicketRelease::getStatus)) + .set(GltUserTicketRelease::getDeleted, 1) + .set(GltUserTicketRelease::getUpdateTime, now); + gltUserTicketReleaseService.update(null, uw); + } catch (Exception e) { + log.warn("撤销水票释放计划失败(继续尝试撤销水票) - tenantId={}, shopOrderId={}, userTicketId={}", + tenantId, shopOrderId, userTicketId, e); + } + + // 3) 撤销水票本身(软删除;幂等) + boolean ok = gltUserTicketService.update( + null, + new LambdaUpdateWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .eq(GltUserTicket::getId, userTicketId) + .set(GltUserTicket::getDeleted, 1) + .set(GltUserTicket::getUpdateTime, now) + ); + if (ok) { + revoked++; + } + } + + log.info("撤销水票完成 - tenantId={}, shopOrderId={}, shopOrderNo={}, tickets={}, reason={}", + tenantId, shopOrderId, shopOrderNo, revoked, reasonForLog); + return revoked; + } +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java index 6aafdbb..508ec67 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderController.java @@ -16,6 +16,7 @@ import com.gxwebsoft.common.system.entity.Payment; import com.gxwebsoft.shop.entity.ShopOrderDelivery; import com.gxwebsoft.shop.entity.ShopUserAddress; import com.gxwebsoft.shop.service.*; +import com.gxwebsoft.glt.service.GltTicketRevokeService; import com.gxwebsoft.shop.service.impl.KuaiDi100Impl; import com.gxwebsoft.shop.task.OrderAutoCancelTask; import com.gxwebsoft.shop.entity.ShopOrder; @@ -102,6 +103,8 @@ public class ShopOrderController extends BaseController { private PaymentService paymentService; @Resource private ShopStoreFenceService shopStoreFenceService; + @Resource + private GltTicketRevokeService gltTicketRevokeService; @Operation(summary = "分页查询订单") @GetMapping("/page") @@ -373,6 +376,21 @@ public class ShopOrderController extends BaseController { } if (shopOrderService.updateById(shopOrder)) { + // 后台手工将订单改为“已取消”(2)时,需同步撤销可能已发放的水票/释放计划/送水订单 + try { + if (Objects.equals(shopOrder.getOrderStatus(), 2) && !Objects.equals(shopOrderNow.getOrderStatus(), 2)) { + gltTicketRevokeService.revokeByShopOrder( + ObjectUtil.defaultIfNull(shopOrder.getTenantId(), shopOrderNow.getTenantId()), + shopOrderNow.getOrderId(), + shopOrderNow.getOrderNo(), + "订单取消撤销水票" + ); + } + } catch (Exception e) { + logger.error("订单更新为取消后撤销水票失败 - orderId={}, orderNo={}", + shopOrderNow.getOrderId(), shopOrderNow.getOrderNo(), e); + } + // 如果订单上带了快递单号(常见于后台手工修正/补录),同步到发货单表,避免发货单还是旧单号 if (StrUtil.isNotBlank(shopOrder.getExpressNo()) && shopOrder.getOrderId() != null) { try { @@ -527,6 +545,20 @@ public class ShopOrderController extends BaseController { return fail("退款成功,但订单状态更新失败,请联系管理员"); } + // 退款成功后撤销水票相关数据(幂等;无水票则无副作用) + try { + gltTicketRevokeService.revokeByShopOrder( + current.getTenantId(), + current.getOrderId(), + current.getOrderNo(), + "订单退款成功撤销水票" + ); + } catch (Exception e) { + // 退款已完成,不能因为撤销失败而回滚;记录日志以便人工补偿 + logger.error("退款成功但撤销水票失败 - tenantId={}, orderId={}, orderNo={}", + current.getTenantId(), current.getOrderId(), current.getOrderNo(), e); + } + logger.info("订单退款请求成功 - 订单号: {}, 退款单号: {}, 微信退款单号: {}", current.getOrderNo(), refundNo, refundResponse.getTransactionId()); return success("退款成功"); @@ -568,7 +600,42 @@ public class ShopOrderController extends BaseController { return fail("退款相关操作请使用退款接口: PUT /api/shop/shop-order/refund"); } } - if (batchParam.update(shopOrderService, "order_id")) { + boolean ok = batchParam.update(shopOrderService, "order_id"); + if (ok) { + // 兼容后台直接将订单改为“已取消”(2)的场景:同步撤销可能已发放的水票/释放计划/送水订单 + try { + if (batchParam != null && batchParam.getData() != null + && Objects.equals(batchParam.getData().getOrderStatus(), 2) + && batchParam.getIds() != null && !batchParam.getIds().isEmpty()) { + for (Object rawId : batchParam.getIds()) { + Integer orderId = null; + if (rawId instanceof Integer) { + orderId = (Integer) rawId; + } else if (rawId != null) { + try { + orderId = Integer.valueOf(rawId.toString()); + } catch (Exception ignore) { + // ignore malformed id + } + } + if (orderId == null) { + continue; + } + ShopOrder order = shopOrderService.getById(orderId); + if (order == null) { + continue; + } + gltTicketRevokeService.revokeByShopOrder( + order.getTenantId(), + order.getOrderId(), + order.getOrderNo(), + "订单取消撤销水票" + ); + } + } + } catch (Exception e) { + logger.error("批量取消订单后撤销水票失败", e); + } return success("修改成功"); } return fail("修改失败"); diff --git a/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java b/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java new file mode 100644 index 0000000..39f42c4 --- /dev/null +++ b/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.glt.service; + +import com.gxwebsoft.glt.entity.GltUserTicket; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GltTicketRevokeServiceTest { + + @Mock + private GltUserTicketService gltUserTicketService; + @Mock + private GltUserTicketReleaseService gltUserTicketReleaseService; + @Mock + private GltTicketOrderService gltTicketOrderService; + + @InjectMocks + private GltTicketRevokeService gltTicketRevokeService; + + @Test + void revokeByShopOrder_noTenant_noop() { + int revoked = gltTicketRevokeService.revokeByShopOrder(null, 1, "O1", "r"); + assertEquals(0, revoked); + verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService); + } + + @Test + void revokeByShopOrder_noTickets_noop() { + when(gltUserTicketService.list(any())).thenReturn(List.of()); + int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "r"); + assertEquals(0, revoked); + verify(gltUserTicketService, times(1)).list(any()); + verifyNoMoreInteractions(gltUserTicketService); + verifyNoInteractions(gltUserTicketReleaseService, gltTicketOrderService); + } + + @Test + void revokeByShopOrder_hasTickets_revokesAll() { + GltUserTicket t = new GltUserTicket(); + t.setId(123); + t.setTenantId(10584); + when(gltUserTicketService.list(any())).thenReturn(List.of(t)); + when(gltUserTicketService.update(isNull(), any())).thenReturn(true); + when(gltTicketOrderService.update(isNull(), any())).thenReturn(true); + when(gltUserTicketReleaseService.update(isNull(), any())).thenReturn(true); + + int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "退款撤销"); + + assertEquals(1, revoked); + verify(gltTicketOrderService, times(1)).update(isNull(), any()); + verify(gltUserTicketReleaseService, times(1)).update(isNull(), any()); + verify(gltUserTicketService, times(1)).update(isNull(), any()); + } +} +