Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.tripgoapi.application.port.in;

import com.tripgoapi.domain.model.Booking;

public interface CancelBookingUseCase {

/**
* @param requesterId id of the currently authenticated user; enforces that only the
* booking's owner may cancel it
*/
Booking cancelBooking(Long bookingId, Long requesterId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.tripgoapi.application.port.in;

import java.time.LocalDate;

public record CreateBookingCommand(
String idempotencyKey,
Long userId,
Long tourId,
LocalDate date,
int adults,
int children,
String contactName,
String contactEmail,
String contactPhone
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tripgoapi.application.port.in;

import com.tripgoapi.domain.model.Booking;

public interface CreateBookingUseCase {
Booking createBooking(CreateBookingCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.tripgoapi.application.port.in;

import com.tripgoapi.domain.model.Booking;

public interface GetBookingDetailUseCase {

/**
* @param requesterId id of the currently authenticated user; enforces that only the
* booking's owner may view it
*/
Booking getBookingDetail(Long bookingId, Long requesterId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.tripgoapi.application.port.in;

import com.tripgoapi.domain.model.Booking;
import com.tripgoapi.domain.model.BookingStatus;

import java.util.List;

public interface GetBookingsUseCase {

/**
* @param status optional filter; {@code null} returns bookings in any status
*/
List<Booking> getBookingsForUser(Long userId, BookingStatus status);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.tripgoapi.application.port.out;

import com.tripgoapi.domain.model.Booking;
import com.tripgoapi.domain.model.BookingStatus;

import java.util.List;
import java.util.Optional;

public interface BookingRepositoryInterface {

/**
* Persists a new booking. The given {@code booking} has no {@code id}/{@code bookingCode}
* yet; the returned instance has both populated.
*/
Booking save(Booking booking);

/**
* @param status optional filter; {@code null} returns bookings in any status
*/
List<Booking> findByUserId(Long userId, BookingStatus status);

/**
* Idempotency keys are client-generated and only unique per submitting user — never look
* this up without also constraining by userId, or one user's retried request could return
* another user's booking.
*/
Optional<Booking> findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey);

/**
* Same lookup as {@link #findByUserIdAndIdempotencyKey}, but always runs in a brand new
* transaction. Needed to recover after a failed {@link #save} caused by a concurrent request
* winning the same (userId, idempotencyKey) race: Postgres aborts the whole transaction on a
* unique-constraint violation, so no further statement can run in the caller's transaction
* until it rolls back.
*/
Optional<Booking> findByUserIdAndIdempotencyKeyInNewTransaction(Long userId, String idempotencyKey);

Optional<Booking> findById(Long id);

/**
* Atomically transitions PENDING/CONFIRMED -> CANCELLED in a single statement, so two
* concurrent cancel requests for the same booking can't both succeed and double-release slots.
* @return true if the transition happened (booking existed and was in a cancellable state)
*/
boolean cancelIfCancellable(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.tripgoapi.application.port.out;

import java.time.LocalDate;
import java.util.Optional;

public interface TourDepartureRepositoryInterface {

Optional<Long> findDepartureId(Long tourId, LocalDate departureDate);

/**
* Atomically reserves {@code guestCount} slots on the given departure.
* @return true if there was enough remaining capacity and the reservation succeeded
*/
boolean reserveSlots(Long departureId, int guestCount);

/**
* Atomically releases {@code guestCount} previously-reserved slots back to the departure
* (e.g. on booking cancellation).
*/
void releaseSlots(Long departureId, int guestCount);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.tripgoapi.application.service;

import com.tripgoapi.application.port.in.CancelBookingUseCase;
import com.tripgoapi.application.port.out.BookingRepositoryInterface;
import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface;
import com.tripgoapi.domain.exception.BookingAccessDeniedException;
import com.tripgoapi.domain.exception.BookingCancellationNotAllowedException;
import com.tripgoapi.domain.exception.BookingNotFoundException;
import com.tripgoapi.domain.model.Booking;
import com.tripgoapi.domain.model.BookingStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class CancelBookingService implements CancelBookingUseCase {

private final BookingRepositoryInterface bookingRepository;
private final TourDepartureRepositoryInterface tourDepartureRepository;

@Override
@Transactional
public Booking cancelBooking(Long bookingId, Long requesterId) {
Booking booking = bookingRepository.findById(bookingId)
.orElseThrow(() -> new BookingNotFoundException(bookingId));

if (!booking.userId().equals(requesterId)) {
throw new BookingAccessDeniedException(bookingId);
}

// Atomic guard: only one of several concurrent cancel requests for this booking can
// flip PENDING/CONFIRMED -> CANCELLED; a losing request gets false here instead of both
// passing a stale in-memory status check and double-releasing slots.
if (!bookingRepository.cancelIfCancellable(bookingId)) {
// Re-read rather than reuse `booking.status()`: that snapshot was taken before
// cancelIfCancellable ran, so for a request that lost the race it would still say
// PENDING/CONFIRMED even though the real, current status is CANCELLED/COMPLETED.
BookingStatus currentStatus = bookingRepository.findById(bookingId)
.orElseThrow(() -> new BookingNotFoundException(bookingId))
.status();
throw new BookingCancellationNotAllowedException(currentStatus);
}

tourDepartureRepository.releaseSlots(booking.departureId(), booking.adults() + booking.children());

return bookingRepository.findById(bookingId)
.orElseThrow(() -> new BookingNotFoundException(bookingId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.tripgoapi.application.service;

import com.tripgoapi.application.port.in.CreateBookingCommand;
import com.tripgoapi.application.port.in.CreateBookingUseCase;
import com.tripgoapi.application.port.out.BookingRepositoryInterface;
import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface;
import com.tripgoapi.application.port.out.TourDetailRepositoryInterface;
import com.tripgoapi.domain.exception.BookingGroupTooLargeException;
import com.tripgoapi.domain.exception.NoAvailableSlotsException;
import com.tripgoapi.domain.exception.TourDepartureNotFoundException;
import com.tripgoapi.domain.exception.TourNotFoundException;
import com.tripgoapi.domain.model.Booking;
import com.tripgoapi.domain.model.TourDetail;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.math.BigDecimal;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class CreateBookingService implements CreateBookingUseCase {

private final TourDetailRepositoryInterface tourDetailRepository;
private final TourDepartureRepositoryInterface tourDepartureRepository;
private final BookingRepositoryInterface bookingRepository;

@Override
@Transactional
public Booking createBooking(CreateBookingCommand command) {
// Idempotency: a retried request (client timeout/double-submit) with the same key
// returns the already-created booking instead of reserving slots a second time. Scoped
// to the caller's userId — the key is client-generated and only unique per user, so an
// unscoped lookup would let user B fetch user A's booking by guessing/colliding a key.
Optional<Booking> existing = bookingRepository.findByUserIdAndIdempotencyKey(command.userId(), command.idempotencyKey());
if (existing.isPresent()) {
return existing.get();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker — rò rỉ đơn hàng giữa các user (IDOR). findByIdempotencyKey đang tra cứu toàn cục, không gắn với command.userId(), và booking tìm được được trả thẳng về mà không kiểm tra chủ sở hữu. Key này do client tự sinh và chỉ bị ràng buộc @NotBlank, nên chỉ cần user B gửi một key trùng với user A (vd "1", "booking-1") là nhận nguyên đơn của A: bookingCode, totalPrice, contactName/contactEmail/contactPhone. Unique index toàn cục ở V16 làm điều này thành hành vi chắc chắn xảy ra chứ không phải hi hữu. Nên đổi sang tra cứu theo cặp (userId, idempotencyKey):

Optional<Booking> existing = bookingRepository
        .findByUserIdAndIdempotencyKey(command.userId(), command.idempotencyKey());
if (existing.isPresent()) {
    return existing.get();
}

}

TourDetail tour = tourDetailRepository.findById(command.tourId())
.orElseThrow(() -> new TourNotFoundException(command.tourId()));

Long departureId = tourDepartureRepository.findDepartureId(command.tourId(), command.date())
.orElseThrow(() -> new TourDepartureNotFoundException(command.tourId(), command.date()));

int guestCount = command.adults() + command.children();
if (tour.maxGuests() != null && guestCount > tour.maxGuests()) {
throw new BookingGroupTooLargeException(guestCount, tour.maxGuests());
}

if (!tourDepartureRepository.reserveSlots(departureId, guestCount)) {
throw new NoAvailableSlotsException();
}

BigDecimal unitPrice = tour.discountPrice() != null ? tour.discountPrice() : tour.price();
BigDecimal totalPrice = unitPrice.multiply(BigDecimal.valueOf(guestCount));

Booking booking = Booking.pending(
command.idempotencyKey(),
command.userId(),
tour,
departureId,
command.date(),
command.adults(),
command.children(),
totalPrice,
command.contactName(),
command.contactEmail(),
command.contactPhone()
);

try {
return bookingRepository.save(booking);
} catch (DataIntegrityViolationException ex) {
// Lost a double-submit race: a concurrent request with the same (userId,
// idempotencyKey) committed first, and Postgres aborted this transaction on the
// unique-constraint violation — no further statement can run in it. Mark it
// rollback-only (releasing the slots this losing request reserved) and hand the
// client the winner's booking instead of a raw 500, reading it in a fresh transaction
// since this one is now unusable. Guarded: only active when this method actually runs
// behind the @Transactional proxy (always true in production; not the case when a
// unit test calls the service directly with no Spring context).
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
return bookingRepository.findByUserIdAndIdempotencyKeyInNewTransaction(command.userId(), command.idempotencyKey())
.orElseThrow(() -> ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.tripgoapi.application.service;

import com.tripgoapi.application.port.in.GetBookingDetailUseCase;
import com.tripgoapi.application.port.out.BookingRepositoryInterface;
import com.tripgoapi.domain.exception.BookingAccessDeniedException;
import com.tripgoapi.domain.exception.BookingNotFoundException;
import com.tripgoapi.domain.model.Booking;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class GetBookingDetailService implements GetBookingDetailUseCase {

private final BookingRepositoryInterface bookingRepository;

@Override
@Transactional(readOnly = true)
public Booking getBookingDetail(Long bookingId, Long requesterId) {
Booking booking = bookingRepository.findById(bookingId)
.orElseThrow(() -> new BookingNotFoundException(bookingId));

if (!booking.userId().equals(requesterId)) {
throw new BookingAccessDeniedException(bookingId);
}

return booking;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.tripgoapi.application.service;

import com.tripgoapi.application.port.in.GetBookingsUseCase;
import com.tripgoapi.application.port.out.BookingRepositoryInterface;
import com.tripgoapi.domain.model.Booking;
import com.tripgoapi.domain.model.BookingStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class GetBookingsService implements GetBookingsUseCase {

private final BookingRepositoryInterface bookingRepository;

@Override
@Transactional(readOnly = true)
public List<Booking> getBookingsForUser(Long userId, BookingStatus status) {
return bookingRepository.findByUserId(userId, status);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.tripgoapi.domain.exception;

public class BookingAccessDeniedException extends ForbiddenException {

public BookingAccessDeniedException(Long bookingId) {
super("Bạn không có quyền truy cập đơn đặt tour: id=" + bookingId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.tripgoapi.domain.exception;

import com.tripgoapi.domain.model.BookingStatus;

public class BookingCancellationNotAllowedException extends ConflictException {

public BookingCancellationNotAllowedException(BookingStatus currentStatus) {
super("Không thể hủy đơn ở trạng thái: " + currentStatus);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.tripgoapi.domain.exception;

public class BookingGroupTooLargeException extends ConflictException {

public BookingGroupTooLargeException(int guestCount, int maxGuests) {
super("Số khách (" + guestCount + ") vượt quá giới hạn " + maxGuests + " khách/đơn của tour");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.tripgoapi.domain.exception;

public class BookingNotFoundException extends NotFoundException {

public BookingNotFoundException(Long id) {
super("Booking not found: id=" + id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.tripgoapi.domain.exception;

public abstract class ForbiddenException extends RuntimeException {

protected ForbiddenException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.tripgoapi.domain.exception;

public class InvalidBookingStatusException extends UnprocessableException {

public InvalidBookingStatusException(String status) {
super("status không hợp lệ: " + status);
}
}
Loading