From a28763f0f6c3257ada00e8bc064805d604f8af4f Mon Sep 17 00:00:00 2001 From: Emrys <219520811+Emryswu@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:58:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(reservation):=20=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=85=A8=E9=A2=9D=E9=80=80=E6=AC=BE=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E9=A2=84=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../payment/PaymentCallbackService.java | 6 +- .../labops/reservation/Reservation.java | 2 +- ...RefundAutoCancellationIntegrationTest.java | 165 ++++++++++++++++++ ...fundBeforeCancellationIntegrationTest.java | 8 +- 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java diff --git a/README.md b/README.md index 861243c..2b81ff5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ **预约(免费设备):** 选设备与时段 → 进入 `PENDING` → 教师批准为 `APPROVED` → 超时未批自动 `EXPIRED` → 用完后 `COMPLETED`。重叠的待审批可以同时存在;真正占住时段的只有已批准预约。 -**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。取消已支付的预约时,订单已全额退款则直接变为 `CANCELLED`;仍有可退余额则进入 `REFUNDING`,申请退还剩余金额,等退款回调到账后变为 `CANCELLED`。 +**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。渠道侧直接全额退款后,回调把订单置为 `REFUNDED`,同时将 `PAID` 预约置为 `CANCELLED` 并释放时段;部分退款仍保持 `PAID`、继续占用时段,重复回调不再改状态。取消已支付的预约时,历史订单已全额退款则直接变为 `CANCELLED`;仍有可退余额则进入 `REFUNDING`,申请退还剩余金额,等退款回调到账后变为 `CANCELLED`。 **报修:** 提交工单 → 有权限的报修会取消冲突预约并让设备进入维护 → 管理员派单或维修员接单 → 处理、解决、关闭后设备恢复可预约。 diff --git a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java index 2edac18..c9fc3d3 100644 --- a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java +++ b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java @@ -210,7 +210,11 @@ private void applyToLedgerAndReservation(PaymentCallbackRequest request, "支付成功 订单号 " + order.getOrderNo() + ",金额 " + request.amountCents() + " 分"); } else { order.applyRefund(request.amountCents()); - if (reservation.getStatus() == ReservationStatus.REFUNDING && order.refundableCents() <= 0) { + // A full channel-side refund also cancels a still-paid reservation; + // partial refunds keep its slot. All three rows are already locked. + if (order.getStatus() == PaymentOrderStatus.REFUNDED + && (reservation.getStatus() == ReservationStatus.PAID + || reservation.getStatus() == ReservationStatus.REFUNDING)) { reservation.completeRefund(); } auditLogService.recordSystem("REFUND_SUCCEEDED", "RESERVATION", reservation.getId(), diff --git a/src/main/java/com/arthur/labops/reservation/Reservation.java b/src/main/java/com/arthur/labops/reservation/Reservation.java index 84da051..3016175 100644 --- a/src/main/java/com/arthur/labops/reservation/Reservation.java +++ b/src/main/java/com/arthur/labops/reservation/Reservation.java @@ -132,7 +132,7 @@ public void beginRefund() { paymentDeadline = null; } - /** The refund landed; the reservation is finally closed. */ + /** Full refund landed: close REFUNDING, or cancel PAID and release its slot. */ public void completeRefund() { status = ReservationStatus.CANCELLED; } diff --git a/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java new file mode 100644 index 0000000..975872e --- /dev/null +++ b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java @@ -0,0 +1,165 @@ +package com.arthur.labops; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import com.arthur.labops.payment.PaymentCallbackIngest; +import com.arthur.labops.payment.PaymentCallbackRequest; +import com.arthur.labops.payment.PaymentOrderRepository; +import com.arthur.labops.payment.PaymentOrderStatus; +import com.arthur.labops.payment.PaymentRequestRepository; +import com.arthur.labops.payment.PaymentService; +import com.arthur.labops.payment.PaymentTransactionRepository; +import com.arthur.labops.payment.PaymentTransactionType; +import com.arthur.labops.payment.channel.ChannelEntry; +import com.arthur.labops.payment.channel.SimulatedPaymentChannel; +import com.arthur.labops.reservation.Reservation; +import com.arthur.labops.reservation.ReservationRepository; +import com.arthur.labops.reservation.ReservationStatus; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest(properties = { + "labops.payment.channel.callback-mode=MANUAL", + "labops.payment.channel.bill-directory=target/test-channel-bills/refund-auto-cancel", + "spring.jpa.properties.hibernate.session_factory.statement_inspector=com.arthur.labops.SqlCaptureStatementInspector" +}) +@AutoConfigureMockMvc +class ChannelRefundAutoCancellationIntegrationTest { + + private static final long PRICE_CENTS = 5_000L; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private SimulatedPaymentChannel channel; + @Autowired private PaymentCallbackIngest callbackIngest; + @Autowired private ReservationRepository reservationRepository; + @Autowired private PaymentOrderRepository orderRepository; + @Autowired private PaymentRequestRepository requestRepository; + @Autowired private PaymentTransactionRepository transactionRepository; + + private PaymentScenario scenario; + + @BeforeEach + void setUp() { + TestAuth.clearCache(); + channel.reset(); + scenario = new PaymentScenario(mockMvc, objectMapper); + } + + @ParameterizedTest + @ValueSource(longs = {0L, 2_000L}) + void fullRefundCancelsAndReleasesSlotIncludingAfterAPartialRefund(long firstRefund) throws Exception { + Long equipmentId = scenario.createPricedEquipment("AUTO-CANCEL", PRICE_CENTS); + Long reservationId = pay(equipmentId); + String orderNo = PaymentService.orderNoFor(reservationId); + if (firstRefund > 0) { + refund(orderNo, firstRefund); + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.PAID); + } + + refund(orderNo, PRICE_CENTS - firstRefund); + + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.CANCELLED); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow()).satisfies(order -> { + assertThat(order.getStatus()).isEqualTo(PaymentOrderStatus.REFUNDED); + assertThat(order.getRefundedCents()).isEqualTo(PRICE_CENTS); + }); + assertThat(requestRepository.findByOrderNoOrderByIdAsc(orderNo)).isEmpty(); + // A new overlapping reservation can be approved without a manual cancellation. + Long replacement = scenario.createReservation(equipmentId, 1); + assertThat(scenario.approve(replacement).get("status")).isEqualTo("AWAITING_PAYMENT"); + } + + @Test + void partialRefundKeepsPaidReservationAndVersionUnchanged() throws Exception { + Long equipmentId = scenario.createPricedEquipment("PARTIAL-KEEP", PRICE_CENTS); + Long reservationId = pay(equipmentId); + long version = reservationRepository.findById(reservationId).orElseThrow().getVersion(); + String orderNo = PaymentService.orderNoFor(reservationId); + + refund(orderNo, 2_000L); + + assertThat(reservationRepository.findById(reservationId).orElseThrow()).satisfies(reservation -> { + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PAID); + assertThat(reservation.getVersion()).isEqualTo(version); + }); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) + .isEqualTo(PaymentOrderStatus.PARTIALLY_REFUNDED); + } + + @Test + void replayOfFullRefundDoesNotWriteReservationOrderOrLedgerAgain() throws Exception { + Long reservationId = pay(scenario.createPricedEquipment("REFUND-REPLAY", PRICE_CENTS)); + String orderNo = PaymentService.orderNoFor(reservationId); + ChannelEntry entry = refund(orderNo, PRICE_CENTS); + Reservation cancelled = reservationRepository.findById(reservationId).orElseThrow(); + assertThat(cancelled.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + var order = orderRepository.findByOrderNo(orderNo).orElseThrow(); + var request = new PaymentCallbackRequest(orderNo, entry.channelTxnId(), PaymentTransactionType.REFUND, + entry.amountCents(), entry.channelTxnId(), entry.status(), entry.occurredAt()); + + // Call the ingest directly so the simulator cannot swallow a replay error. + for (int attempt = 0; attempt < 2; attempt++) { + var result = callbackIngest.ingest(request); + assertThat(result.accepted()).isFalse(); + assertThat(result.orderStatus()).isEqualTo(PaymentOrderStatus.REFUNDED); + } + + assertThat(reservationRepository.findById(reservationId).orElseThrow()).satisfies(reservation -> { + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + assertThat(reservation.getVersion()).isEqualTo(cancelled.getVersion()); + }); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow()).satisfies(reloaded -> { + assertThat(reloaded.getStatus()).isEqualTo(PaymentOrderStatus.REFUNDED); + assertThat(reloaded.getRefundedCents()).isEqualTo(PRICE_CENTS); + assertThat(reloaded.getVersion()).isEqualTo(order.getVersion()); + assertThat(reloaded.getUpdatedAt()).isEqualTo(order.getUpdatedAt()); + }); + assertThat(transactionRepository.countByOrderNo(orderNo)).isEqualTo(2); + } + + @Test + void fullRefundLocksEquipmentThenReservationThenPaymentOrder() throws Exception { + Long reservationId = pay(scenario.createPricedEquipment("REFUND-LOCK", PRICE_CENTS)); + channel.refund(PaymentService.orderNoFor(reservationId), PRICE_CENTS); + + SqlCaptureStatementInspector.clear(); + assertThat(channel.deliverPending()).isEqualTo(1); + + List locks = SqlCaptureStatementInspector.snapshot().stream() + .filter(sql -> sql.contains(" for update")) + .toList(); + assertThat(locks).hasSizeGreaterThanOrEqualTo(3); + assertThat(locks.get(0)).contains(" from equipment "); + assertThat(locks.get(1)).contains(" from equipment_reservations "); + assertThat(locks.get(2)).contains(" from payment_orders "); + } + + private Long pay(Long equipmentId) throws Exception { + Long reservationId = scenario.createReservation(equipmentId, 1); + scenario.approve(reservationId); + channel.charge(PaymentService.orderNoFor(reservationId), PRICE_CENTS); + assertThat(channel.deliverPending()).isEqualTo(1); + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.PAID); + return reservationId; + } + + private ChannelEntry refund(String orderNo, long amount) { + ChannelEntry entry = channel.refund(orderNo, amount); + assertThat(channel.deliverPending()).isEqualTo(1); + return entry; + } +} diff --git a/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java b/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java index 0e9db5f..8b25c0b 100644 --- a/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java +++ b/src/test/java/com/arthur/labops/ChannelRefundBeforeCancellationIntegrationTest.java @@ -62,6 +62,12 @@ void fullyRefundedOrderClosesWithoutAnotherRefund(CancellationRoute route) throw Long reservationId = payAndRefund(equipmentId, PRICE_CENTS); String orderNo = PaymentService.orderNoFor(reservationId); + // Preserve coverage for legacy PAID reservations left behind before + // full channel refunds began cancelling reservations automatically. + var legacy = reservationRepository.findById(reservationId).orElseThrow(); + legacy.markPaid(); + reservationRepository.saveAndFlush(legacy); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) .isEqualTo(PaymentOrderStatus.REFUNDED); cancel(route, equipmentId, reservationId, "CANCELLED"); @@ -114,7 +120,7 @@ private Long payAndRefund(Long equipmentId, long refundCents) throws Exception { channel.refund(orderNo, refundCents); assertThat(channel.deliverPending()).isEqualTo(1); assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) - .isEqualTo(ReservationStatus.PAID); + .isEqualTo(refundCents == PRICE_CENTS ? ReservationStatus.CANCELLED : ReservationStatus.PAID); return reservationId; } From 3d1b8e6454ffb37797782157cf4fd1f66e604a22 Mon Sep 17 00:00:00 2001 From: Emrys <219520811+Emryswu@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:13:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(payment):=20=E4=BF=9D=E7=95=99=E5=B7=B2?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E9=A2=84=E7=BA=A6=E5=85=A8=E9=A2=9D=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=90=8E=E7=9A=84=E4=BD=BF=E7=94=A8=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- pom.xml | 14 ++-- .../payment/PaymentCallbackService.java | 10 ++- .../labops/reservation/Reservation.java | 5 +- ...RefundAutoCancellationIntegrationTest.java | 79 +++++++++++++++++++ 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2b81ff5..30650e3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ **预约(免费设备):** 选设备与时段 → 进入 `PENDING` → 教师批准为 `APPROVED` → 超时未批自动 `EXPIRED` → 用完后 `COMPLETED`。重叠的待审批可以同时存在;真正占住时段的只有已批准预约。 -**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。渠道侧直接全额退款后,回调把订单置为 `REFUNDED`,同时将 `PAID` 预约置为 `CANCELLED` 并释放时段;部分退款仍保持 `PAID`、继续占用时段,重复回调不再改状态。取消已支付的预约时,历史订单已全额退款则直接变为 `CANCELLED`;仍有可退余额则进入 `REFUNDING`,申请退还剩余金额,等退款回调到账后变为 `CANCELLED`。 +**预约(计价设备,`hourlyPriceCents > 0`):** 批准后不是 `APPROVED` 而是 `AWAITING_PAYMENT`——占住时段,但只给一个很短的支付窗口;付了变 `PAID`,窗口内没付就关单、时段归还。渠道侧直接全额退款后,回调把订单置为 `REFUNDED`;仅当处理回调时刻早于预约 `startTime`,才将 `PAID` 预约置为 `CANCELLED` 并释放时段。已开始(含恰好 `startTime`)和已结束的 `PAID` 预约只记退款,预约状态和版本号不变;部分退款仍保持 `PAID`、继续占用时段,重复回调不再改状态。取消已支付的预约时,历史订单已全额退款则直接变为 `CANCELLED`;仍有可退余额则进入 `REFUNDING`,申请退还剩余金额,等退款回调到账后变为 `CANCELLED`。 **报修:** 提交工单 → 有权限的报修会取消冲突预约并让设备进入维护 → 管理员派单或维修员接单 → 处理、解决、关闭后设备恢复可预约。 diff --git a/pom.xml b/pom.xml index 25f5c7d..7da2890 100644 --- a/pom.xml +++ b/pom.xml @@ -100,12 +100,6 @@ org.springframework.boot spring-boot-starter-test test - - - org.mockito - mockito-core - - org.springframework.boot @@ -126,6 +120,14 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + "-javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar" + + org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java index c9fc3d3..f73bac4 100644 --- a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java +++ b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java @@ -210,11 +210,13 @@ private void applyToLedgerAndReservation(PaymentCallbackRequest request, "支付成功 订单号 " + order.getOrderNo() + ",金额 " + request.amountCents() + " 分"); } else { order.applyRefund(request.amountCents()); - // A full channel-side refund also cancels a still-paid reservation; - // partial refunds keep its slot. All three rows are already locked. + // Full refunds cancel PAID only before its start, using callback processing + // time rather than occurredAt. Keep usage history once started; REFUNDING + // still closes at any time. All three rows are already locked. if (order.getStatus() == PaymentOrderStatus.REFUNDED - && (reservation.getStatus() == ReservationStatus.PAID - || reservation.getStatus() == ReservationStatus.REFUNDING)) { + && (reservation.getStatus() == ReservationStatus.REFUNDING + || (reservation.getStatus() == ReservationStatus.PAID + && Instant.now().isBefore(reservation.getStartTime())))) { reservation.completeRefund(); } auditLogService.recordSystem("REFUND_SUCCEEDED", "RESERVATION", reservation.getId(), diff --git a/src/main/java/com/arthur/labops/reservation/Reservation.java b/src/main/java/com/arthur/labops/reservation/Reservation.java index 3016175..6502f0c 100644 --- a/src/main/java/com/arthur/labops/reservation/Reservation.java +++ b/src/main/java/com/arthur/labops/reservation/Reservation.java @@ -132,7 +132,10 @@ public void beginRefund() { paymentDeadline = null; } - /** Full refund landed: close REFUNDING, or cancel PAID and release its slot. */ + /** + * Close after a full refund. In callbacks, PAID is eligible only before its + * start; REFUNDING is eligible at any time. Explicit cancellations also use this. + */ public void completeRefund() { status = ReservationStatus.CANCELLED; } diff --git a/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java index 975872e..2638d24 100644 --- a/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java +++ b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java @@ -1,16 +1,22 @@ package com.arthur.labops; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import com.arthur.labops.payment.PaymentCallbackIngest; @@ -46,6 +52,7 @@ class ChannelRefundAutoCancellationIntegrationTest { @Autowired private PaymentOrderRepository orderRepository; @Autowired private PaymentRequestRepository requestRepository; @Autowired private PaymentTransactionRepository transactionRepository; + @Autowired private JdbcTemplate jdbcTemplate; private PaymentScenario scenario; @@ -99,6 +106,66 @@ void partialRefundKeepsPaidReservationAndVersionUnchanged() throws Exception { .isEqualTo(PaymentOrderStatus.PARTIALLY_REFUNDED); } + @ParameterizedTest + @ValueSource(longs = {3_600L, -60L}) + void fullRefundKeepsStartedOrEndedPaidReservationUnchanged(long endOffsetSeconds) throws Exception { + Long reservationId = pay(scenario.createPricedEquipment("USED-REFUND", PRICE_CENTS)); + Instant now = Instant.now(); + Instant start = now.minusSeconds(7_200); + setWindow(reservationId, start, now.plusSeconds(endOffsetSeconds)); + Reservation paid = reservationRepository.findById(reservationId).orElseThrow(); + String orderNo = PaymentService.orderNoFor(reservationId); + + // The channel refunded before the session started, but its callback was + // delayed. Cancellation depends on processing time, not occurredAt. + var request = new PaymentCallbackRequest(orderNo, "USED-REFUND-" + reservationId, + PaymentTransactionType.REFUND, PRICE_CENTS, "CH-USED-" + reservationId, + PaymentCallbackRequest.STATUS_SUCCESS, start.minusSeconds(60)); + assertThat(callbackIngest.ingest(request).accepted()).isTrue(); + + assertPaidAndVersionUnchanged(reservationId, paid.getVersion()); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow()).satisfies(order -> { + assertThat(order.getStatus()).isEqualTo(PaymentOrderStatus.REFUNDED); + assertThat(order.getRefundedCents()).isEqualTo(PRICE_CENTS); + }); + assertThat(transactionRepository.countByOrderNo(orderNo)).isEqualTo(2); + } + + @Test + void fullRefundExactlyAtStartKeepsPaidReservationAndVersionUnchanged() throws Exception { + Long reservationId = pay(scenario.createPricedEquipment("START-REFUND", PRICE_CENTS)); + Instant start = Instant.parse("2030-01-15T10:00:00Z"); + setWindow(reservationId, start, start.plusSeconds(3_600)); + Reservation paid = reservationRepository.findById(reservationId).orElseThrow(); + assertThat(paid.getStartTime()).isEqualTo(start); + String orderNo = PaymentService.orderNoFor(reservationId); + + try (MockedStatic time = mockStatic(Instant.class, CALLS_REAL_METHODS)) { + time.when(Instant::now).thenReturn(start); + refund(orderNo, PRICE_CENTS); + } + + assertPaidAndVersionUnchanged(reservationId, paid.getVersion()); + assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) + .isEqualTo(PaymentOrderStatus.REFUNDED); + } + + @ParameterizedTest + @ValueSource(longs = {3_600L, -60L}) + void fullRefundStillClosesRefundingAfterStartOrEnd(long endOffsetSeconds) throws Exception { + Long reservationId = pay(scenario.createPricedEquipment("REFUNDING-USED", PRICE_CENTS)); + Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); + reservation.beginRefund(); + reservationRepository.saveAndFlush(reservation); + Instant now = Instant.now(); + setWindow(reservationId, now.minusSeconds(7_200), now.plusSeconds(endOffsetSeconds)); + + refund(PaymentService.orderNoFor(reservationId), PRICE_CENTS); + + assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) + .isEqualTo(ReservationStatus.CANCELLED); + } + @Test void replayOfFullRefundDoesNotWriteReservationOrderOrLedgerAgain() throws Exception { Long reservationId = pay(scenario.createPricedEquipment("REFUND-REPLAY", PRICE_CENTS)); @@ -162,4 +229,16 @@ private ChannelEntry refund(String orderNo, long amount) { assertThat(channel.deliverPending()).isEqualTo(1); return entry; } + + private void setWindow(Long reservationId, Instant start, Instant end) { + jdbcTemplate.update("update equipment_reservations set start_time = ?, end_time = ? where id = ?", + Timestamp.from(start), Timestamp.from(end), reservationId); + } + + private void assertPaidAndVersionUnchanged(Long reservationId, long version) { + assertThat(reservationRepository.findById(reservationId).orElseThrow()).satisfies(reservation -> { + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PAID); + assertThat(reservation.getVersion()).isEqualTo(version); + }); + } } From 0842c9f6ff8a3046f09dcc6e9883591080eba851 Mon Sep 17 00:00:00 2001 From: Emrys <219520811+Emryswu@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:22:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(reservation):=20=E5=B0=86=E9=80=80?= =?UTF-8?q?=E6=AC=BE=E5=8F=96=E6=B6=88=E6=97=B6=E9=97=B4=E8=A7=84=E5=88=99?= =?UTF-8?q?=E6=94=B6=E8=BF=9B=E5=AE=9E=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + pom.xml | 14 +++--- .../payment/PaymentCallbackService.java | 12 ++--- .../labops/reservation/Reservation.java | 17 ++++++- ...RefundAutoCancellationIntegrationTest.java | 38 --------------- .../labops/reservation/ReservationTest.java | 46 +++++++++++++++++++ 6 files changed, 72 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 989ae66..a62fb7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ CI(`ci.yml`)对每个指向 main 的 PR 跑以下全部检查。 ## 后端约定 +- 时间相关判断写成实体方法并显式传入 `now`,参照 `closeIfPaymentWindowElapsed(now)` / `expireIfPending(now)`;测试直接传入时间,不 mock 静态时间,不为测试修改 `pom.xml` 依赖。 - 数据库结构变更只加新的 Flyway 迁移,版本号接着最大的往下排,不改已经合入的迁移。 - 新增带截止时间的预约状态或 `ReservationDeadlineKind` 时,必须同时覆盖延迟消息和数据库补偿扫描两条路径;`ReservationDeadlineCompensationIntegrationTest` 遍历全部截止类型,断言消息投递失败且截止时间已过后,一次扫描即可完成超时处理。新增类型必须补齐测试中的状态与截止时间数据,不能过滤枚举值或跳过用例。 - 状态变更涉及设备和预约两张表时,加锁顺序固定为 Equipment → Reservation,批量锁按主键排序,避免 ABBA 死锁。细节见 `docs/concurrency-notes.md`。 diff --git a/pom.xml b/pom.xml index 7da2890..25f5c7d 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,12 @@ org.springframework.boot spring-boot-starter-test test + + + org.mockito + mockito-core + + org.springframework.boot @@ -120,14 +126,6 @@ - - org.apache.maven.plugins - maven-surefire-plugin - - - "-javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar" - - org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java index f73bac4..fffb8f5 100644 --- a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java +++ b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java @@ -210,14 +210,10 @@ private void applyToLedgerAndReservation(PaymentCallbackRequest request, "支付成功 订单号 " + order.getOrderNo() + ",金额 " + request.amountCents() + " 分"); } else { order.applyRefund(request.amountCents()); - // Full refunds cancel PAID only before its start, using callback processing - // time rather than occurredAt. Keep usage history once started; REFUNDING - // still closes at any time. All three rows are already locked. - if (order.getStatus() == PaymentOrderStatus.REFUNDED - && (reservation.getStatus() == ReservationStatus.REFUNDING - || (reservation.getStatus() == ReservationStatus.PAID - && Instant.now().isBefore(reservation.getStartTime())))) { - reservation.completeRefund(); + // The entity owns cancellation eligibility. Pass processing time rather + // than occurredAt; all three rows are already locked. + if (order.getStatus() == PaymentOrderStatus.REFUNDED) { + reservation.cancelOnFullRefundIfNotStarted(Instant.now()); } auditLogService.recordSystem("REFUND_SUCCEEDED", "RESERVATION", reservation.getId(), "退款到账 订单号 " + order.getOrderNo() + ",金额 " + request.amountCents() + " 分"); diff --git a/src/main/java/com/arthur/labops/reservation/Reservation.java b/src/main/java/com/arthur/labops/reservation/Reservation.java index 6502f0c..4d5128a 100644 --- a/src/main/java/com/arthur/labops/reservation/Reservation.java +++ b/src/main/java/com/arthur/labops/reservation/Reservation.java @@ -133,13 +133,26 @@ public void beginRefund() { } /** - * Close after a full refund. In callbacks, PAID is eligible only before its - * start; REFUNDING is eligible at any time. Explicit cancellations also use this. + * Close an explicitly cancelled reservation whose order is already fully refunded. */ public void completeRefund() { status = ReservationStatus.CANCELLED; } + /** + * Full refund callback: release an unstarted PAID slot, or close REFUNDING at + * any time. The caller holds the row lock and confirms the order is fully + * refunded; {@code now} is callback processing time, not the channel timestamp. + */ + public boolean cancelOnFullRefundIfNotStarted(Instant now) { + if (status == ReservationStatus.REFUNDING + || (status == ReservationStatus.PAID && now.isBefore(startTime))) { + status = ReservationStatus.CANCELLED; + return true; + } + return false; + } + /** * Closes an unpaid reservation whose payment window elapsed and returns the * slot. Same shape as {@link #expireIfPending(Instant)}: the caller holds the diff --git a/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java index 2638d24..b0b80e2 100644 --- a/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java +++ b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java @@ -1,8 +1,6 @@ package com.arthur.labops; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.CALLS_REAL_METHODS; -import static org.mockito.Mockito.mockStatic; import java.sql.Timestamp; import java.time.Instant; @@ -12,7 +10,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.MockedStatic; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -131,41 +128,6 @@ void fullRefundKeepsStartedOrEndedPaidReservationUnchanged(long endOffsetSeconds assertThat(transactionRepository.countByOrderNo(orderNo)).isEqualTo(2); } - @Test - void fullRefundExactlyAtStartKeepsPaidReservationAndVersionUnchanged() throws Exception { - Long reservationId = pay(scenario.createPricedEquipment("START-REFUND", PRICE_CENTS)); - Instant start = Instant.parse("2030-01-15T10:00:00Z"); - setWindow(reservationId, start, start.plusSeconds(3_600)); - Reservation paid = reservationRepository.findById(reservationId).orElseThrow(); - assertThat(paid.getStartTime()).isEqualTo(start); - String orderNo = PaymentService.orderNoFor(reservationId); - - try (MockedStatic time = mockStatic(Instant.class, CALLS_REAL_METHODS)) { - time.when(Instant::now).thenReturn(start); - refund(orderNo, PRICE_CENTS); - } - - assertPaidAndVersionUnchanged(reservationId, paid.getVersion()); - assertThat(orderRepository.findByOrderNo(orderNo).orElseThrow().getStatus()) - .isEqualTo(PaymentOrderStatus.REFUNDED); - } - - @ParameterizedTest - @ValueSource(longs = {3_600L, -60L}) - void fullRefundStillClosesRefundingAfterStartOrEnd(long endOffsetSeconds) throws Exception { - Long reservationId = pay(scenario.createPricedEquipment("REFUNDING-USED", PRICE_CENTS)); - Reservation reservation = reservationRepository.findById(reservationId).orElseThrow(); - reservation.beginRefund(); - reservationRepository.saveAndFlush(reservation); - Instant now = Instant.now(); - setWindow(reservationId, now.minusSeconds(7_200), now.plusSeconds(endOffsetSeconds)); - - refund(PaymentService.orderNoFor(reservationId), PRICE_CENTS); - - assertThat(reservationRepository.findById(reservationId).orElseThrow().getStatus()) - .isEqualTo(ReservationStatus.CANCELLED); - } - @Test void replayOfFullRefundDoesNotWriteReservationOrderOrLedgerAgain() throws Exception { Long reservationId = pay(scenario.createPricedEquipment("REFUND-REPLAY", PRICE_CENTS)); diff --git a/src/test/java/com/arthur/labops/reservation/ReservationTest.java b/src/test/java/com/arthur/labops/reservation/ReservationTest.java index 2adad58..07fdc52 100644 --- a/src/test/java/com/arthur/labops/reservation/ReservationTest.java +++ b/src/test/java/com/arthur/labops/reservation/ReservationTest.java @@ -6,11 +6,16 @@ import java.time.temporal.ChronoUnit; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import com.arthur.labops.equipment.Equipment; class ReservationTest { + private static final Instant START = Instant.parse("2030-01-15T10:00:00Z"); + @Test void approvalDeadlineNeverOutlivesRequestedWindow() { Instant start = Instant.now().plus(1, ChronoUnit.MINUTES); @@ -26,4 +31,45 @@ void approvalDeadlineNeverOutlivesRequestedWindow() { assertThat(reservation.getExpiresAt()).isEqualTo(end); } + + @ParameterizedTest + @CsvSource({"-1, true, CANCELLED", "0, false, PAID", "1, false, PAID"}) + void fullRefundCancelsPaidOnlyStrictlyBeforeStart(long nanosFromStart, boolean expectedChange, + ReservationStatus expectedStatus) { + Reservation reservation = paidReservation(); + + boolean changed = reservation.cancelOnFullRefundIfNotStarted(START.plusNanos(nanosFromStart)); + + assertThat(changed).isEqualTo(expectedChange); + assertThat(reservation.getStatus()).isEqualTo(expectedStatus); + } + + @ParameterizedTest + @ValueSource(longs = {-1L, 0L, 3_601L}) + void fullRefundClosesRefundingBeforeAtOrAfterStart(long secondsFromStart) { + Reservation reservation = paidReservation(); + reservation.beginRefund(); + + assertThat(reservation.cancelOnFullRefundIfNotStarted(START.plusSeconds(secondsFromStart))).isTrue(); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + } + + @Test + void fullRefundDoesNotCancelAnAlreadyCancelledReservationAgain() { + Reservation reservation = paidReservation(); + Instant now = START.minusSeconds(1); + assertThat(reservation.cancelOnFullRefundIfNotStarted(now)).isTrue(); + + assertThat(reservation.cancelOnFullRefundIfNotStarted(now)).isFalse(); + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + } + + private Reservation paidReservation() { + Reservation reservation = new Reservation( + new Equipment("REFUND-001", "退款测试设备", "测试仪器", "D104"), + 1L, "Student", "全额退款", START, START.plusSeconds(3_600), START); + reservation.markPaid(); + return reservation; + } }