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/README.md b/README.md index 861243c..30650e3 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`;仅当处理回调时刻早于预约 `startTime`,才将 `PAID` 预约置为 `CANCELLED` 并释放时段。已开始(含恰好 `startTime`)和已结束的 `PAID` 预约只记退款,预约状态和版本号不变;部分退款仍保持 `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..fffb8f5 100644 --- a/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java +++ b/src/main/java/com/arthur/labops/payment/PaymentCallbackService.java @@ -210,8 +210,10 @@ private void applyToLedgerAndReservation(PaymentCallbackRequest request, "支付成功 订单号 " + order.getOrderNo() + ",金额 " + request.amountCents() + " 分"); } else { order.applyRefund(request.amountCents()); - if (reservation.getStatus() == ReservationStatus.REFUNDING && order.refundableCents() <= 0) { - 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 84da051..4d5128a 100644 --- a/src/main/java/com/arthur/labops/reservation/Reservation.java +++ b/src/main/java/com/arthur/labops/reservation/Reservation.java @@ -132,11 +132,27 @@ public void beginRefund() { paymentDeadline = null; } - /** The refund landed; the reservation is finally closed. */ + /** + * 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 new file mode 100644 index 0000000..b0b80e2 --- /dev/null +++ b/src/test/java/com/arthur/labops/ChannelRefundAutoCancellationIntegrationTest.java @@ -0,0 +1,206 @@ +package com.arthur.labops; + +import static org.assertj.core.api.Assertions.assertThat; + +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.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; +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; + @Autowired private JdbcTemplate jdbcTemplate; + + 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); + } + + @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 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; + } + + 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); + }); + } +} 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; } 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; + } }