From 4dae378c9aab65d6f6d3df69cdc1d68108e38c9a Mon Sep 17 00:00:00 2001 From: gxwebsoft <170083662@qq.com> Date: Sun, 1 Mar 2026 00:43:28 +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 | 37 ++++++ .../ShopOrderGltRepairController.java | 72 ++++++++++++ .../dto/RefundedOrderGltRepairRequest.java | 41 +++++++ .../service/ShopOrderGltRepairService.java | 109 ++++++++++++++++++ .../service/GltTicketRevokeServiceTest.java | 10 +- 5 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/shop/controller/ShopOrderGltRepairController.java create mode 100644 src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/ShopOrderGltRepairService.java diff --git a/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java index a73b8b7..196be88 100644 --- a/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java +++ b/src/main/java/com/gxwebsoft/glt/service/GltTicketRevokeService.java @@ -6,13 +6,17 @@ 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 com.gxwebsoft.shop.entity.ShopOrderGoods; +import com.gxwebsoft.shop.service.ShopOrderGoodsService; 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.HashSet; import java.util.List; +import java.util.Set; /** * 水票撤销(订单取消/退款成功后的清理): @@ -35,6 +39,7 @@ public class GltTicketRevokeService { private final GltUserTicketService gltUserTicketService; private final GltUserTicketReleaseService gltUserTicketReleaseService; private final GltTicketOrderService gltTicketOrderService; + private final ShopOrderGoodsService shopOrderGoodsService; @Transactional(rollbackFor = Exception.class) public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) { @@ -58,6 +63,33 @@ public class GltTicketRevokeService { } List tickets = gltUserTicketService.list(qw); + + // 兼容历史数据:部分水票只记录了 orderGoodsId,未记录 orderId/orderNo + if ((tickets == null || tickets.isEmpty()) && shopOrderId != null) { + try { + List goodsList = shopOrderGoodsService.list( + new LambdaQueryWrapper() + .select(ShopOrderGoods::getId) + .eq(ShopOrderGoods::getTenantId, tenantId) + .eq(ShopOrderGoods::getOrderId, shopOrderId) + ); + List orderGoodsIds = goodsList == null ? List.of() : goodsList.stream() + .map(ShopOrderGoods::getId) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (!orderGoodsIds.isEmpty()) { + tickets = gltUserTicketService.list( + new LambdaQueryWrapper() + .eq(GltUserTicket::getTenantId, tenantId) + .eq(GltUserTicket::getDeleted, 0) + .in(GltUserTicket::getOrderGoodsId, orderGoodsIds) + ); + } + } catch (Exception e) { + log.warn("撤销水票:通过orderGoodsId兜底反查失败 - tenantId={}, shopOrderId={}", tenantId, shopOrderId, e); + } + } if (tickets == null || tickets.isEmpty()) { return 0; } @@ -67,10 +99,15 @@ public class GltTicketRevokeService { // 不强制覆盖 comments,避免影响后台人工备注;reason 仅用于日志。 String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim(); + // 去重(避免 orderId/orderNo 与 orderGoodsId 两种路径重复命中) + Set seen = new HashSet<>(); for (GltUserTicket t : tickets) { if (t == null || t.getId() == null) { continue; } + if (!seen.add(t.getId())) { + continue; + } Integer userTicketId = t.getId(); diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopOrderGltRepairController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderGltRepairController.java new file mode 100644 index 0000000..b717fb9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopOrderGltRepairController.java @@ -0,0 +1,72 @@ +package com.gxwebsoft.shop.controller; + +import cn.hutool.core.util.ObjectUtil; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.shop.dto.RefundedOrderGltRepairRequest; +import com.gxwebsoft.shop.service.ShopOrderGltRepairService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.Map; + +/** + * 历史订单修复:用于对“已退款的旧订单”补偿撤销水票相关数据。 + */ +@Tag(name = "订单修复") +@RestController +@RequestMapping("/api/shop/shop-order/repair") +public class ShopOrderGltRepairController extends BaseController { + + @Resource + private ShopOrderGltRepairService shopOrderGltRepairService; + + @PreAuthorize("hasAuthority('shop:shopOrder:manage')") + @Operation(summary = "修复:已退款旧订单补偿撤销水票/释放计划/送水单(支持dryRun预览)") + @PostMapping("/revoke-glt-after-refund") + public ApiResult revokeGltAfterRefund(@RequestBody RefundedOrderGltRepairRequest req) { + if (req == null) { + return fail("请求体不能为空"); + } + + Integer tenantId = ObjectUtil.defaultIfNull(req.getTenantId(), getTenantId()); + if (tenantId == null) { + return fail("tenantId不能为空"); + } + + boolean dryRun = req.getDryRun() == null || req.getDryRun(); + + // 防误操作:既未指定订单,也未指定时间窗口时,只允许 dryRun + boolean noTargets = (req.getOrderIds() == null || req.getOrderIds().isEmpty()) + && (req.getOrderNos() == null || req.getOrderNos().isEmpty()) + && req.getRefundTimeStart() == null + && req.getRefundTimeEnd() == null; + if (noTargets && !dryRun) { + return fail("请指定 orderIds/orderNos 或 refundTimeStart/refundTimeEnd;否则仅允许 dryRun=true 预览"); + } + + ShopOrderGltRepairService.RepairResult r = shopOrderGltRepairService.revokeTicketsForRefundedOrders( + tenantId, + req.getOrderIds(), + req.getOrderNos(), + req.getRefundTimeStart(), + req.getRefundTimeEnd(), + req.getBatchSize(), + dryRun + ); + + Map data = new HashMap<>(); + data.put("tenantId", tenantId); + data.put("dryRun", r.dryRun()); + data.put("scannedOrders", r.scannedOrders()); + data.put("ordersWithTicketsRevoked", r.ordersWithTicketsRevoked()); + data.put("revokedTicketCount", r.revokedTicketCount()); + data.put("processedOrderIds", r.processedOrderIds()); + return success(data); + } +} + diff --git a/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java b/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java new file mode 100644 index 0000000..8526864 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/dto/RefundedOrderGltRepairRequest.java @@ -0,0 +1,41 @@ +package com.gxwebsoft.shop.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 历史退款订单:补偿撤销水票/释放计划/送水单 的修复请求。 + */ +@Data +public class RefundedOrderGltRepairRequest implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "租户ID(不传则使用当前请求租户)") + private Integer tenantId; + + @Schema(description = "指定订单ID列表(优先使用;为空则走时间窗口扫描)") + private List orderIds; + + @Schema(description = "指定订单号列表(可选;为空则走时间窗口扫描)") + private List orderNos; + + @Schema(description = "退款时间起(yyyy-MM-dd HH:mm:ss)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime refundTimeStart; + + @Schema(description = "退款时间止(yyyy-MM-dd HH:mm:ss)") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime refundTimeEnd; + + @Schema(description = "每次处理条数上限(默认200)") + private Integer batchSize; + + @Schema(description = "是否仅预览(默认true,仅统计不落库)") + private Boolean dryRun; +} + diff --git a/src/main/java/com/gxwebsoft/shop/service/ShopOrderGltRepairService.java b/src/main/java/com/gxwebsoft/shop/service/ShopOrderGltRepairService.java new file mode 100644 index 0000000..a3951c2 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/ShopOrderGltRepairService.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.shop.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.glt.service.GltTicketRevokeService; +import com.gxwebsoft.shop.entity.ShopOrder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 历史订单修复:对已退款订单补偿撤销水票相关数据。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ShopOrderGltRepairService { + + /** shop_order.order_status:退款成功 */ + private static final int ORDER_STATUS_REFUND_SUCCESS = 6; + + private final ShopOrderService shopOrderService; + private final GltTicketRevokeService gltTicketRevokeService; + + public RepairResult revokeTicketsForRefundedOrders(Integer tenantId, + List orderIds, + List orderNos, + LocalDateTime refundTimeStart, + LocalDateTime refundTimeEnd, + Integer batchSize, + boolean dryRun) { + if (tenantId == null) { + throw new IllegalArgumentException("tenantId不能为空"); + } + + int limit = batchSize == null ? 200 : Math.max(1, Math.min(batchSize, 2000)); + List orders = new ArrayList<>(); + + // 1) 精准修复:指定 orderIds / orderNos + if (orderIds != null && !orderIds.isEmpty()) { + orders = shopOrderService.list(new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS) + .in(ShopOrder::getOrderId, orderIds) + .last("limit " + limit)); + } else if (orderNos != null && !orderNos.isEmpty()) { + orders = shopOrderService.list(new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS) + .in(ShopOrder::getOrderNo, orderNos) + .last("limit " + limit)); + } else { + // 2) 扫描修复:按 refundTime 窗口分页处理 + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .eq(ShopOrder::getTenantId, tenantId) + .eq(ShopOrder::getDeleted, 0) + .eq(ShopOrder::getOrderStatus, ORDER_STATUS_REFUND_SUCCESS); + if (refundTimeStart != null) { + qw.ge(ShopOrder::getRefundTime, refundTimeStart); + } + if (refundTimeEnd != null) { + qw.le(ShopOrder::getRefundTime, refundTimeEnd); + } + orders = shopOrderService.list(qw.orderByAsc(ShopOrder::getRefundTime).orderByAsc(ShopOrder::getOrderId).last("limit " + limit)); + } + + int scanned = orders == null ? 0 : orders.size(); + int revokedTickets = 0; + int revokedOrders = 0; + List processedOrderIds = new ArrayList<>(); + + if (orders != null) { + for (ShopOrder o : orders) { + if (o == null || o.getOrderId() == null) { + continue; + } + processedOrderIds.add(o.getOrderId()); + if (dryRun) { + continue; + } + int revoked = gltTicketRevokeService.revokeByShopOrder( + tenantId, + o.getOrderId(), + o.getOrderNo(), + "历史退款订单补偿撤销水票" + ); + if (revoked > 0) { + revokedTickets += revoked; + revokedOrders++; + } + } + } + + return new RepairResult(scanned, revokedOrders, revokedTickets, processedOrderIds, dryRun); + } + + public record RepairResult(int scannedOrders, + int ordersWithTicketsRevoked, + int revokedTicketCount, + List processedOrderIds, + boolean dryRun) { + } +} + diff --git a/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java b/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java index 39f42c4..74b2303 100644 --- a/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java +++ b/src/test/java/com/gxwebsoft/glt/service/GltTicketRevokeServiceTest.java @@ -23,6 +23,8 @@ class GltTicketRevokeServiceTest { private GltUserTicketReleaseService gltUserTicketReleaseService; @Mock private GltTicketOrderService gltTicketOrderService; + @Mock + private com.gxwebsoft.shop.service.ShopOrderGoodsService shopOrderGoodsService; @InjectMocks private GltTicketRevokeService gltTicketRevokeService; @@ -31,16 +33,18 @@ class GltTicketRevokeServiceTest { void revokeByShopOrder_noTenant_noop() { int revoked = gltTicketRevokeService.revokeByShopOrder(null, 1, "O1", "r"); assertEquals(0, revoked); - verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService); + verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService, shopOrderGoodsService); } @Test void revokeByShopOrder_noTickets_noop() { when(gltUserTicketService.list(any())).thenReturn(List.of()); + when(shopOrderGoodsService.list(any())).thenReturn(List.of()); int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "r"); assertEquals(0, revoked); verify(gltUserTicketService, times(1)).list(any()); - verifyNoMoreInteractions(gltUserTicketService); + verify(shopOrderGoodsService, times(1)).list(any()); + verifyNoMoreInteractions(gltUserTicketService, shopOrderGoodsService); verifyNoInteractions(gltUserTicketReleaseService, gltTicketOrderService); } @@ -60,6 +64,6 @@ class GltTicketRevokeServiceTest { verify(gltTicketOrderService, times(1)).update(isNull(), any()); verify(gltUserTicketReleaseService, times(1)).update(isNull(), any()); verify(gltUserTicketService, times(1)).update(isNull(), any()); + verifyNoInteractions(shopOrderGoodsService); } } -