|
|
@@ -0,0 +1,167 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Activity;
|
|
|
+import com.etotem.cfc.entity.ActivityOrder;
|
|
|
+import com.etotem.cfc.entity.ActivityRegistration;
|
|
|
+import com.etotem.cfc.mapper.ActivityMapper;
|
|
|
+import com.etotem.cfc.mapper.ActivityOrderMapper;
|
|
|
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * W1 活动报名名额并发超卖 + 取消/退款回滚验证用例。
|
|
|
+ *
|
|
|
+ * 验证点:
|
|
|
+ * 1. occupyQuota 是有条件原子 UPDATE(守卫 approved_count < max_participants),
|
|
|
+ * 以影响行数判断是否抢到名额 —— 杜绝「先查剩余名额再插入」在并发下的超卖;
|
|
|
+ * 2. releaseQuota 带 approved_count > 0 守卫,回滚不越界;
|
|
|
+ * 3. cancel(approved) 退款失败立即返回错误、不回滚名额、不改报名状态;
|
|
|
+ * 退款成功才在同一事务内回滚名额(@Transactional 由 Spring 代理保证,单测验证方法内逻辑顺序)。
|
|
|
+ */
|
|
|
+public class ActivityRegistrationQuotaConcurrencyTest {
|
|
|
+
|
|
|
+ private ActivityRegistrationService service;
|
|
|
+ private ActivityRegistrationMapper registrationMapper;
|
|
|
+ private ActivityMapper activityMapper;
|
|
|
+ private ActivityOrderMapper orderMapper;
|
|
|
+ private PaymentService paymentService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ public void setUp() {
|
|
|
+ service = new ActivityRegistrationService();
|
|
|
+ registrationMapper = mock(ActivityRegistrationMapper.class);
|
|
|
+ activityMapper = mock(ActivityMapper.class);
|
|
|
+ orderMapper = mock(ActivityOrderMapper.class);
|
|
|
+ paymentService = mock(PaymentService.class);
|
|
|
+ ReflectionTestUtils.setField(service, "registrationMapper", registrationMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "activityMapper", activityMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
|
|
|
+ ReflectionTestUtils.setField(service, "paymentService", paymentService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== occupyQuota:原子扣减守卫(防止并发超卖) ==========
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void occupyQuota_limited_updateCarriesApprovedCountLtMaxGuard() {
|
|
|
+ Activity activity = new Activity();
|
|
|
+ activity.setId(1L);
|
|
|
+ activity.setMaxParticipants(5);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ assertTrue(service.occupyQuota(1L));
|
|
|
+
|
|
|
+ ArgumentCaptor<UpdateWrapper<Activity>> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(activityMapper).update(isNull(), captor.capture());
|
|
|
+ UpdateWrapper<Activity> uw = captor.getValue();
|
|
|
+ // 形如: UPDATE activities SET approved_count = approved_count + 1 WHERE id = ? AND approved_count < ?
|
|
|
+ String where = uw.getSqlSegment();
|
|
|
+ assertTrue(where.contains("approved_count"), "UPDATE 必须带 approved_count 守卫条件: " + where);
|
|
|
+ assertTrue(where.contains("<"), "守卫必须是 approved_count < 上限: " + where);
|
|
|
+ assertTrue(uw.getSqlSet().contains("approved_count = approved_count + 1"),
|
|
|
+ "setSql 必须为原子自增: " + uw.getSqlSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void occupyQuota_soldOut_zeroRowsReturnsFalse() {
|
|
|
+ Activity activity = new Activity();
|
|
|
+ activity.setId(1L);
|
|
|
+ activity.setMaxParticipants(5);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ // 并发下名额已被抢完:条件不满足,0 行受影响
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(0);
|
|
|
+
|
|
|
+ assertFalse(service.occupyQuota(1L), "影响行数为 0 时必须判定为抢不到名额(名额已满)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void occupyQuota_unlimited_noGuardIncrementsAlways() {
|
|
|
+ Activity activity = new Activity();
|
|
|
+ activity.setId(1L);
|
|
|
+ activity.setMaxParticipants(null);
|
|
|
+ when(activityMapper.selectById(1L)).thenReturn(activity);
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ assertTrue(service.occupyQuota(1L));
|
|
|
+ ArgumentCaptor<UpdateWrapper<Activity>> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(activityMapper).update(isNull(), captor.capture());
|
|
|
+ assertFalse(captor.getValue().getSqlSegment().contains("approved_count"),
|
|
|
+ "无人数限制时不带上限守卫,直接自增");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== releaseQuota:回滚守卫(名额只减不增的下界保护) ==========
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void releaseQuota_updateCarriesApprovedCountGtZeroGuard() {
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ service.releaseQuota(1L);
|
|
|
+
|
|
|
+ ArgumentCaptor<UpdateWrapper<Activity>> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(activityMapper).update(isNull(), captor.capture());
|
|
|
+ UpdateWrapper<Activity> uw = captor.getValue();
|
|
|
+ assertTrue(uw.getSqlSegment().contains("approved_count"), uw.getSqlSegment());
|
|
|
+ assertTrue(uw.getSqlSegment().contains(">"), "回滚必须带 approved_count > 0 守卫: " + uw.getSqlSegment());
|
|
|
+ assertTrue(uw.getSqlSet().contains("approved_count = approved_count - 1"), uw.getSqlSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== cancel:退款成功才回滚名额,退款失败不生效 ==========
|
|
|
+
|
|
|
+ private ActivityRegistration approvedReg() {
|
|
|
+ ActivityRegistration reg = new ActivityRegistration();
|
|
|
+ reg.setId(10L);
|
|
|
+ reg.setActivityId(1L);
|
|
|
+ reg.setUserId(100L);
|
|
|
+ reg.setChildId(200L);
|
|
|
+ reg.setStatus("approved");
|
|
|
+ return reg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ActivityOrder paidOrder() {
|
|
|
+ ActivityOrder order = new ActivityOrder();
|
|
|
+ order.setOrderNo("ACT123");
|
|
|
+ order.setTotalAmount(100);
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void cancel_refundFails_noQuotaRelease_noStatusChange() {
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(approvedReg());
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(paidOrder());
|
|
|
+ doThrow(new RuntimeException("微信退款失败")).when(paymentService).refund(anyString(), anyInt(), anyString());
|
|
|
+
|
|
|
+ Result<Void> result = service.cancel(1L, 100L, 200L);
|
|
|
+
|
|
|
+ assertEquals(500, result.getCode(), "退款失败必须返回错误");
|
|
|
+ // 退款失败 → 名额不回滚
|
|
|
+ verify(activityMapper, never()).update(any(), any());
|
|
|
+ // 报名状态也不得变更
|
|
|
+ verify(registrationMapper, never()).updateById(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ public void cancel_refundSucceeds_releaseQuotaThenMarkCancelled() {
|
|
|
+ ActivityRegistration reg = approvedReg();
|
|
|
+ when(registrationMapper.selectOne(any())).thenReturn(reg);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(paidOrder());
|
|
|
+ when(activityMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1);
|
|
|
+
|
|
|
+ Result<Void> result = service.cancel(1L, 100L, 200L);
|
|
|
+
|
|
|
+ assertEquals(200, result.getCode());
|
|
|
+ // 退款成功后:回滚名额
|
|
|
+ verify(activityMapper).update(isNull(), any(UpdateWrapper.class));
|
|
|
+ // 报名状态置为 cancelled
|
|
|
+ verify(registrationMapper).updateById(reg);
|
|
|
+ assertEquals("cancelled", reg.getStatus());
|
|
|
+ }
|
|
|
+}
|