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

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

View File

@@ -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
*
* <p>说明:该操作需保证幂等;若无关联水票则无任何副作用。</p>
*/
@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<GltUserTicket> qw = new LambdaQueryWrapper<GltUserTicket>()
.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<GltUserTicket> 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<GltTicketOrder> uw = new LambdaUpdateWrapper<GltTicketOrder>()
.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<GltUserTicketRelease> uw = new LambdaUpdateWrapper<GltUserTicketRelease>()
.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<GltUserTicket>()
.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;
}
}

View File

@@ -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("修改失败");

View File

@@ -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());
}
}