Selaa lähdekoodia

fix(统一支付): 订单5退款成功重算金额并补租户/幂等/额度测试

- 订单早退集合收窄到 3/4/6;订单 5 与 2 同路按退款表重算 refunded_amount,
  2→5 条件 UPDATE 对已 5 的订单自然 0 行,不重复发 ORDER(评审 I1)
- 补 DuplicateKeyException 回查命中的同参返回/异参 409 两支测试(I2)
- 补跨租户同 out_refund_no 不可见测试(Minor 1)
- 补 R21 不再重复扣减 refunded_amount 的额度测试(Minor 2)
alphaH 2 viikkoa sitten
vanhempi
sitoutus
08604dc0e6

+ 11 - 5
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedRefundTxService.java

@@ -32,11 +32,13 @@ import java.util.Objects;
  * 因此这里**不再**减 {@code refunded_amount}(R21:那会重复扣减)。
  *
  * <p><b>事务 B {@link #apply}</b>:只有退款行条件 UPDATE 真的写了行才算变化;订单侧
- * 同样只走条件 UPDATE。三条刻意的规则:
+ * 同样只走条件 UPDATE。四条刻意的规则:
  *
  * <ol>
- *   <li><b>订单 3/4/6/5 早退</b>(R3):终止态订单不写 {@code refunded_amount}、
+ *   <li><b>订单 3/4/6 早退</b>(R3):从未成功收款的终止态订单不写 {@code refunded_amount}、
  *       不发 ORDER 事件 —— 退款行自己照常收敛并推 REFUND。</li>
+ *   <li><b>订单 2/5 重算</b>(spec §7.4):两者都按退款表汇总 {@code refunded_amount};
+ *       {@code 2→5} 的条件 UPDATE 对已 5 的订单自然 0 行,因此不会重复发 ORDER。</li>
  *   <li><b>订单 0/1 且支付金额未知</b>(R25①):{@code payAmount} 缺失或与订单金额不等时
  *       只记 ERROR,不把订单标成已支付;未知不等于相等。</li>
  *   <li><b>一次 apply 至多一个 ORDER 事件</b>(R25②):0/1→2→5 的全额退款在同一个事务里
@@ -270,15 +272,19 @@ public class UnifiedRefundTxService {
             order.setTradeStatus(UnifiedOrderStatus.SUCCESS);
             order.setUpdatedTime(paidAt);
             orderPaidChanged = true;
-        } else if (UnifiedOrderStatus.FINAL.contains(tradeStatus)) {
-            // R3:终止态订单早退 —— 不写 refunded_amount、不发 ORDER
+        } else if (UnifiedOrderStatus.FAILED.equals(tradeStatus)
+                || UnifiedOrderStatus.REVOKED.equals(tradeStatus)
+                || UnifiedOrderStatus.CLOSED.equals(tradeStatus)) {
+            // R3:3/4/6 早退 —— 订单从未成功收款,不写 refunded_amount、不发 ORDER。
+            // 5 不在此列:spec §7.4 要求 2/5 同路重算(2→5 的条件 UPDATE 对 5 自然 0 行)。
             log.error("[统一支付] 退款成功但订单已处于终止态,订单不做联动: "
                             + "order_no={}, refund_no={}, trade_status={}",
                     order.getOrderNo(), refund.getRefundNo(), tradeStatus);
             return new ApplyOutcome(true, orderPaidChanged, false, refund, order);
         }
 
-        if (!UnifiedOrderStatus.SUCCESS.equals(order.getTradeStatus())) {
+        if (!UnifiedOrderStatus.SUCCESS.equals(order.getTradeStatus())
+                && !UnifiedOrderStatus.REFUNDED.equals(order.getTradeStatus())) {
             return new ApplyOutcome(true, orderPaidChanged, false, refund, order);
         }
 

+ 141 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedRefundServiceTest.java

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