feat(shop): 添加订单取消和退款时的水票撤销功能

- 在ShopOrderController中注入GltTicketRevokeService服务
- 实现订单状态改为已取消时同步撤销相关水票、释放计划和送水订单
- 实现退款成功后自动撤销水票相关数据的功能
- 新增GltTicketRevokeService服务处理水票撤销逻辑
- 添加批量订单取消时的水票撤销支持
- 实现撤销操作的幂等性确保无副作用
- 添加单元测试验证水票撤销功能的正确性
This commit is contained in:
2026-03-01 00:43:28 +08:00
parent a8af20bcde
commit 4dae378c9a
5 changed files with 266 additions and 3 deletions

View File

@@ -6,13 +6,17 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.glt.entity.GltTicketOrder; import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltUserTicket; import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketRelease; import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* 水票撤销(订单取消/退款成功后的清理): * 水票撤销(订单取消/退款成功后的清理):
@@ -35,6 +39,7 @@ public class GltTicketRevokeService {
private final GltUserTicketService gltUserTicketService; private final GltUserTicketService gltUserTicketService;
private final GltUserTicketReleaseService gltUserTicketReleaseService; private final GltUserTicketReleaseService gltUserTicketReleaseService;
private final GltTicketOrderService gltTicketOrderService; private final GltTicketOrderService gltTicketOrderService;
private final ShopOrderGoodsService shopOrderGoodsService;
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) { public int revokeByShopOrder(Integer tenantId, Integer shopOrderId, String shopOrderNo, String reason) {
@@ -58,6 +63,33 @@ public class GltTicketRevokeService {
} }
List<GltUserTicket> tickets = gltUserTicketService.list(qw); List<GltUserTicket> tickets = gltUserTicketService.list(qw);
// 兼容历史数据:部分水票只记录了 orderGoodsId未记录 orderId/orderNo
if ((tickets == null || tickets.isEmpty()) && shopOrderId != null) {
try {
List<ShopOrderGoods> goodsList = shopOrderGoodsService.list(
new LambdaQueryWrapper<ShopOrderGoods>()
.select(ShopOrderGoods::getId)
.eq(ShopOrderGoods::getTenantId, tenantId)
.eq(ShopOrderGoods::getOrderId, shopOrderId)
);
List<Integer> 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<GltUserTicket>()
.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()) { if (tickets == null || tickets.isEmpty()) {
return 0; return 0;
} }
@@ -67,10 +99,15 @@ public class GltTicketRevokeService {
// 不强制覆盖 comments避免影响后台人工备注reason 仅用于日志。 // 不强制覆盖 comments避免影响后台人工备注reason 仅用于日志。
String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim(); String reasonForLog = StrUtil.isBlank(reason) ? "订单取消/退款撤销水票" : reason.trim();
// 去重(避免 orderId/orderNo 与 orderGoodsId 两种路径重复命中)
Set<Integer> seen = new HashSet<>();
for (GltUserTicket t : tickets) { for (GltUserTicket t : tickets) {
if (t == null || t.getId() == null) { if (t == null || t.getId() == null) {
continue; continue;
} }
if (!seen.add(t.getId())) {
continue;
}
Integer userTicketId = t.getId(); Integer userTicketId = t.getId();

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<Integer> orderIds;
@Schema(description = "指定订单号列表(可选;为空则走时间窗口扫描)")
private List<String> 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;
}

View File

@@ -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<Integer> orderIds,
List<String> 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<ShopOrder> orders = new ArrayList<>();
// 1) 精准修复:指定 orderIds / orderNos
if (orderIds != null && !orderIds.isEmpty()) {
orders = shopOrderService.list(new LambdaQueryWrapper<ShopOrder>()
.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<ShopOrder>()
.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<ShopOrder> qw = new LambdaQueryWrapper<ShopOrder>()
.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<Integer> 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<Integer> processedOrderIds,
boolean dryRun) {
}
}

View File

@@ -23,6 +23,8 @@ class GltTicketRevokeServiceTest {
private GltUserTicketReleaseService gltUserTicketReleaseService; private GltUserTicketReleaseService gltUserTicketReleaseService;
@Mock @Mock
private GltTicketOrderService gltTicketOrderService; private GltTicketOrderService gltTicketOrderService;
@Mock
private com.gxwebsoft.shop.service.ShopOrderGoodsService shopOrderGoodsService;
@InjectMocks @InjectMocks
private GltTicketRevokeService gltTicketRevokeService; private GltTicketRevokeService gltTicketRevokeService;
@@ -31,16 +33,18 @@ class GltTicketRevokeServiceTest {
void revokeByShopOrder_noTenant_noop() { void revokeByShopOrder_noTenant_noop() {
int revoked = gltTicketRevokeService.revokeByShopOrder(null, 1, "O1", "r"); int revoked = gltTicketRevokeService.revokeByShopOrder(null, 1, "O1", "r");
assertEquals(0, revoked); assertEquals(0, revoked);
verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService); verifyNoInteractions(gltUserTicketService, gltUserTicketReleaseService, gltTicketOrderService, shopOrderGoodsService);
} }
@Test @Test
void revokeByShopOrder_noTickets_noop() { void revokeByShopOrder_noTickets_noop() {
when(gltUserTicketService.list(any())).thenReturn(List.of()); when(gltUserTicketService.list(any())).thenReturn(List.of());
when(shopOrderGoodsService.list(any())).thenReturn(List.of());
int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "r"); int revoked = gltTicketRevokeService.revokeByShopOrder(10584, 1, "O1", "r");
assertEquals(0, revoked); assertEquals(0, revoked);
verify(gltUserTicketService, times(1)).list(any()); verify(gltUserTicketService, times(1)).list(any());
verifyNoMoreInteractions(gltUserTicketService); verify(shopOrderGoodsService, times(1)).list(any());
verifyNoMoreInteractions(gltUserTicketService, shopOrderGoodsService);
verifyNoInteractions(gltUserTicketReleaseService, gltTicketOrderService); verifyNoInteractions(gltUserTicketReleaseService, gltTicketOrderService);
} }
@@ -60,6 +64,6 @@ class GltTicketRevokeServiceTest {
verify(gltTicketOrderService, times(1)).update(isNull(), any()); verify(gltTicketOrderService, times(1)).update(isNull(), any());
verify(gltUserTicketReleaseService, times(1)).update(isNull(), any()); verify(gltUserTicketReleaseService, times(1)).update(isNull(), any());
verify(gltUserTicketService, times(1)).update(isNull(), any()); verify(gltUserTicketService, times(1)).update(isNull(), any());
verifyNoInteractions(shopOrderGoodsService);
} }
} }