|
|
@@ -296,6 +296,30 @@ class UnifiedRefundServiceTest {
|
|
|
verify(client, times(1)).refundOrder(any(), anyString(), anyString(), anyLong(), anyString());
|
|
|
}
|
|
|
|
|
|
+ /** R21:占用口径已含成功退款,可退额度不得再减一次 refunded_amount。 */
|
|
|
+ @Test
|
|
|
+ void refundableQuota_doesNotSubtractRefundedAmountAgain() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ o.setRefundedAmount(40L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ // 已成功退款 40,占用口径也是 40;可退 = 100 - 40 = 60
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(40L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), eq(60L), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":0}"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(
|
|
|
+ command(60L, "R1"), TENANT, null, UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("0", result.getState(), "再退 60 必须放行(双减会误判只剩 20)");
|
|
|
+ verify(refundMapper).insert(any());
|
|
|
+ verify(client).refundOrder(any(), eq("UP5"), anyString(), eq(60L), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
// ────────────────────────────── 4. 幂等 ──────────────────────────────
|
|
|
|
|
|
@Test
|
|
|
@@ -346,6 +370,47 @@ class UnifiedRefundServiceTest {
|
|
|
verifyNoInteractions(client);
|
|
|
}
|
|
|
|
|
|
+ @Test
|
|
|
+ void duplicateInsertRace_hitSameParams_returnsExistingWithoutUpstream() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity existing = refund("0", 100L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ // 第一次幂等检查未命中;insert 撞唯一键后回查命中
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, existing);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenThrow(
|
|
|
+ new org.springframework.dao.DuplicateKeyException("uk_unified_refund_tenant_out_refund_no"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertSame(existing, result, "并发重试必须返回先落库的那一行");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateInsertRace_hitDifferentAmount_409() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity existing = refund("0", 60L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, existing);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenThrow(
|
|
|
+ new org.springframework.dao.DuplicateKeyException("uk_unified_refund_tenant_out_refund_no"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> realService.createRefund(command(100L, "R1"), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("out_refund_no"), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
// ────────────────────────────── 5. 上游明确拒绝 ──────────────────────────────
|
|
|
|
|
|
@Test
|
|
|
@@ -621,6 +686,41 @@ class UnifiedRefundServiceTest {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /** spec §7.4:订单已是 5 仍要继续按退款表重算 refunded_amount;5→5 不是 2→5,不推 ORDER。 */
|
|
|
+ @Test
|
|
|
+ void refundSuccess_alreadyRefundedOrder_recomputesRefundedAmountWithoutOrderEvent() {
|
|
|
+ UnifiedOrderEntity o = order("5");
|
|
|
+ o.setRefundedAmount(60L);
|
|
|
+ UnifiedRefundEntity r = refund("1", 40L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(100L);
|
|
|
+ // 2→5 的条件 UPDATE 带 trade_status='2',对已 5 的订单自然 0 行
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundTxService.class);
|
|
|
+ try {
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
|
|
|
+ 100L, null, null);
|
|
|
+ } finally {
|
|
|
+ ((Logger) LoggerFactory.getLogger(UnifiedRefundTxService.class))
|
|
|
+ .detachAppender(appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals("5", o.getTradeStatus());
|
|
|
+ assertEquals(100L, o.getRefundedAmount(), "订单 5 也必须按退款表重算 refunded_amount");
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
|
|
|
+ verify(callbackService).notifyRefund(any(), any(), any());
|
|
|
+ verify(callbackService, never()).notifyOrder(any(), any());
|
|
|
+ assertFalse(appender.list.stream().anyMatch(ev ->
|
|
|
+ ev.getLevel() == Level.ERROR
|
|
|
+ && ev.getFormattedMessage().contains("终止态")),
|
|
|
+ "订单 5 是合法路径,不得记「终止态」ERROR,实测: "
|
|
|
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
|
|
|
+ }
|
|
|
+
|
|
|
// ────────────────────────────── 12. 重复收敛 ──────────────────────────────
|
|
|
|
|
|
@Test
|
|
|
@@ -793,6 +893,47 @@ class UnifiedRefundServiceTest {
|
|
|
verifyNoInteractions(refundMapper);
|
|
|
}
|
|
|
|
|
|
+ /** 幂等键只是租户内唯一:同 out_refund_no 的别家行不能被看见/复用。 */
|
|
|
+ @Test
|
|
|
+ void crossTenantSameOutRefundNo_isInvisible() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity foreign = refund("2", 100L);
|
|
|
+ foreign.setTenantId(99L);
|
|
|
+ foreign.setOrderId(77L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":0}"));
|
|
|
+ when(refundMapper.selectOne(any())).thenAnswer(inv -> {
|
|
|
+ LambdaQueryWrapper<UnifiedRefundEntity> w = inv.getArgument(0);
|
|
|
+ String sql = w.getSqlSegment();
|
|
|
+ if (!sql.contains("out_refund_no")) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ // 模拟数据库唯一键 (tenant_id, out_refund_no):没有租户谓词就会看见别家的行
|
|
|
+ return sql.contains("tenant_id") ? null : foreign;
|
|
|
+ });
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("0", result.getState(), "本租户无该 out_refund_no,必须新建而不是复用别家行");
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> q =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(refundMapper, org.mockito.Mockito.atLeastOnce()).selectOne(q.capture());
|
|
|
+ assertTrue(q.getAllValues().stream().anyMatch(w -> {
|
|
|
+ String sql = w.getSqlSegment();
|
|
|
+ return sql.contains("out_refund_no") && sql.contains("tenant_id");
|
|
|
+ }),
|
|
|
+ "out_refund_no 幂等查询必须带 tenant_id,实测: "
|
|
|
+ + q.getAllValues().stream().map(w -> w.getSqlSegment()).toList());
|
|
|
+ verify(refundMapper).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
@Test
|
|
|
void findPendingForPoll_filtersAndClampsLimit() {
|
|
|
when(refundMapper.selectList(any())).thenReturn(List.of(refund("0", 100L)));
|