Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

**报修:** 提交工单 → 有权限的报修会取消冲突预约并让设备进入维护 → 管理员派单或维修员接单 → 处理、解决、关闭后设备恢复可预约。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() + " 分");
Expand Down
18 changes: 17 additions & 1 deletion src/main/java/com/arthur/labops/reservation/Reservation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}

Expand Down
46 changes: 46 additions & 0 deletions src/test/java/com/arthur/labops/reservation/ReservationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
}
Loading