Merge pull request #313 from RecentRunner/worktree-fix-refund-idempotency
lock all stateful mutations
This commit was merged in pull request #313.
This commit is contained in:
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.InternalAuthenticationService
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -109,7 +110,14 @@ public class AuthController {
|
||||
user.setRole(User.Role.CUSTOMER);
|
||||
user.setActive(true);
|
||||
|
||||
User savedUser = userRepository.save(user);
|
||||
User savedUser;
|
||||
try {
|
||||
savedUser = userRepository.save(user);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Username, email, or phone already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
|
||||
emailService.sendWelcome(savedUser);
|
||||
|
||||
@@ -183,9 +191,12 @@ public class AuthController {
|
||||
return ResponseEntity.ok(toUserInfoResponse(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PutMapping("/me")
|
||||
public ResponseEntity<?> updateProfile(@Valid @RequestBody ProfileUpdateRequest request) {
|
||||
User user = getAuthenticatedUser();
|
||||
Long userId = AuthenticationHelper.getAuthenticatedUserId();
|
||||
User user = userRepository.findByIdForUpdate(userId)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
boolean invalidateToken = false;
|
||||
|
||||
String username = trimToNull(request.getUsername());
|
||||
@@ -244,7 +255,14 @@ public class AuthController {
|
||||
user.setTokenVersion(user.getTokenVersion() + 1);
|
||||
}
|
||||
|
||||
User updatedUser = userRepository.save(user);
|
||||
User updatedUser;
|
||||
try {
|
||||
updatedUser = userRepository.save(user);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Username, email, or phone already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
return ResponseEntity.ok(toUserInfoResponse(updatedUser));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Cart;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -20,4 +22,8 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||
Optional<Cart> findActiveCartByUserAndStore(@Param("userId") Long userId,
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("status") String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT c FROM Cart c WHERE c.cartId = :id")
|
||||
Optional<Cart> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Coupon;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -16,6 +18,10 @@ public interface CouponRepository extends JpaRepository<Coupon, Long> {
|
||||
Optional<Coupon> findByCouponCode(String couponCode);
|
||||
|
||||
Optional<Coupon> findByCouponCodeIgnoreCase(String couponCode);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT c FROM Coupon c WHERE c.couponId = :id")
|
||||
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);
|
||||
@Query("SELECT c FROM Coupon c WHERE " +
|
||||
"(:q IS NULL OR LOWER(c.couponCode) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:active IS NULL OR c.active = :active)")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Inventory;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -19,6 +21,14 @@ public interface InventoryRepository extends JpaRepository<Inventory, Long> {
|
||||
@Query("SELECT i FROM Inventory i WHERE i.product.prodId = :productId AND i.store.storeId = :storeId")
|
||||
Optional<Inventory> findByProductIdAndStoreId(@Param("productId") Long productId, @Param("storeId") Long storeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT i FROM Inventory i WHERE i.product.prodId = :productId AND i.store.storeId = :storeId")
|
||||
Optional<Inventory> findByProductIdAndStoreIdForUpdate(@Param("productId") Long productId, @Param("storeId") Long storeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT i FROM Inventory i WHERE i.inventoryId = :id")
|
||||
Optional<Inventory> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("SELECT i FROM Inventory i LEFT JOIN i.store s WHERE " +
|
||||
"(:q IS NULL OR (" +
|
||||
"LOWER(i.product.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.PasswordResetToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -14,4 +18,8 @@ public interface PasswordResetTokenRepository extends JpaRepository<PasswordRese
|
||||
List<PasswordResetToken> findByUser_IdAndUsedAtIsNull(Long userId);
|
||||
|
||||
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, LocalDateTime now);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT t FROM PasswordResetToken t WHERE t.tokenHash = :hash AND t.usedAt IS NULL AND t.expiresAt > :now")
|
||||
Optional<PasswordResetToken> findByTokenHashForUpdate(@Param("hash") String hash, @Param("now") LocalDateTime now);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Pet;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -37,6 +39,10 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
|
||||
List<Pet> findAllByOwner_IdOrderByPetNameAsc(Long ownerId);
|
||||
Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT p FROM Pet p WHERE p.petId = :id")
|
||||
Optional<Pet> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("SELECT p FROM Pet p WHERE " +
|
||||
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Refund;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface RefundRepository extends JpaRepository<Refund, Long> {
|
||||
List<Refund> findByCustomerIdOrderByCreatedAtDesc(Long customerId);
|
||||
List<Refund> findAllByOrderByCreatedAtDesc();
|
||||
List<Refund> findBySaleId(Long saleId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT r FROM Refund r WHERE r.id = :id")
|
||||
Optional<Refund> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.Sale;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.time.LocalDateTime;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -42,5 +44,9 @@ public interface SaleRepository extends JpaRepository<Sale, Long> {
|
||||
|
||||
List<Sale> findByOriginalSaleSaleId(Long originalSaleId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT s FROM Sale s WHERE s.saleId = :id")
|
||||
Optional<Sale> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
Optional<Sale> findByCartCartId(Long cartId);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.User;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -32,6 +34,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
"LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||
Page<User> searchUsers(@Param("q") String query, Pageable pageable);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT u FROM User u WHERE u.id = :id")
|
||||
Optional<User> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
@Query("SELECT u FROM User u WHERE u.role = :role AND (" +
|
||||
"LOWER(COALESCE(u.username, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
"LOWER(COALESCE(u.firstName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
|
||||
@@ -80,7 +80,7 @@ public class AdoptionService {
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse createAdoption(AdoptionRequest request) {
|
||||
Pet pet = petRepository.findById(request.getPetId())
|
||||
Pet pet = petRepository.findByIdForUpdate(request.getPetId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + request.getPetId()));
|
||||
|
||||
User customer = userRepository.findById(request.getCustomerId())
|
||||
@@ -120,7 +120,7 @@ public class AdoptionService {
|
||||
Adoption adoption = adoptionRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id));
|
||||
|
||||
Pet pet = petRepository.findById(request.getPetId())
|
||||
Pet pet = petRepository.findByIdForUpdate(request.getPetId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + request.getPetId()));
|
||||
|
||||
User customer = userRepository.findById(request.getCustomerId())
|
||||
@@ -160,7 +160,7 @@ public class AdoptionService {
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) {
|
||||
Pet pet = petRepository.findById(petId)
|
||||
Pet pet = petRepository.findByIdForUpdate(petId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found"));
|
||||
|
||||
// Verify the pet is actually located at the claimed store
|
||||
|
||||
@@ -362,7 +362,7 @@ public class CartService {
|
||||
throw new BusinessException("Unauthorized");
|
||||
}
|
||||
|
||||
Cart cart = cartRepository.findById(cartId)
|
||||
Cart cart = cartRepository.findByIdForUpdate(cartId)
|
||||
.orElseThrow(() -> new BusinessException("Cart not found"));
|
||||
|
||||
if (!cart.getUser().getId().equals(userId)) {
|
||||
|
||||
@@ -61,7 +61,7 @@ public class InventoryService {
|
||||
|
||||
@Transactional
|
||||
public InventoryResponse updateInventory(Long id, InventoryRequest request) {
|
||||
Inventory inventory = inventoryRepository.findById(id)
|
||||
Inventory inventory = inventoryRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Inventory not found with id: " + id));
|
||||
|
||||
Product product = productRepository.findById(request.getProdId())
|
||||
|
||||
@@ -85,7 +85,7 @@ public class PasswordResetService {
|
||||
}
|
||||
|
||||
PasswordResetToken token = passwordResetTokenRepository
|
||||
.findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(hashToken(normalizedToken), LocalDateTime.now())
|
||||
.findByTokenHashForUpdate(hashToken(normalizedToken), LocalDateTime.now())
|
||||
.orElseThrow(() -> new BusinessException("Reset token is invalid or has expired"));
|
||||
|
||||
User user = token.getUser();
|
||||
|
||||
@@ -112,7 +112,7 @@ public class RefundService {
|
||||
|
||||
@Transactional
|
||||
public RefundResponse updateRefundStatus(Long id, String status) {
|
||||
Refund refund = refundRepository.findById(id)
|
||||
Refund refund = refundRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Refund not found"));
|
||||
|
||||
Refund.RefundStatus newStatus;
|
||||
|
||||
@@ -82,7 +82,7 @@ public class SaleService {
|
||||
sale.setChannel(request.getChannel() != null ? request.getChannel() : "IN_STORE");
|
||||
|
||||
if (request.getCouponId() != null) {
|
||||
Coupon coupon = couponRepository.findById(request.getCouponId())
|
||||
Coupon coupon = couponRepository.findByIdForUpdate(request.getCouponId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Coupon not found with id: " + request.getCouponId()));
|
||||
validateCoupon(coupon);
|
||||
sale.setCoupon(coupon);
|
||||
@@ -96,7 +96,7 @@ public class SaleService {
|
||||
|
||||
User customer = null;
|
||||
if (request.getCustomerId() != null) {
|
||||
customer = userRepository.findById(request.getCustomerId())
|
||||
customer = userRepository.findByIdForUpdate(request.getCustomerId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId()));
|
||||
sale.setCustomer(customer);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ public class SaleService {
|
||||
}
|
||||
|
||||
if (sale.getIsRefund() && request.getOriginalSaleId() != null) {
|
||||
Sale originalSale = saleRepository.findById(request.getOriginalSaleId())
|
||||
Sale originalSale = saleRepository.findByIdForUpdate(request.getOriginalSaleId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Original sale not found with id: " + request.getOriginalSaleId()));
|
||||
sale.setOriginalSale(originalSale);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ public class SaleService {
|
||||
" for product: " + product.getProdName());
|
||||
}
|
||||
|
||||
Inventory inventory = inventoryRepository.findByProductIdAndStoreId(itemRequest.getProdId(), store.getStoreId())
|
||||
Inventory inventory = inventoryRepository.findByProductIdAndStoreIdForUpdate(itemRequest.getProdId(), store.getStoreId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Inventory not found for product " + itemRequest.getProdId() + " at store " + store.getStoreId()));
|
||||
|
||||
inventory.setQuantity(inventory.getQuantity() + itemRequest.getQuantity());
|
||||
@@ -209,7 +209,7 @@ public class SaleService {
|
||||
Product product = productRepository.findById(itemRequest.getProdId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + itemRequest.getProdId()));
|
||||
|
||||
Inventory inventory = inventoryRepository.findByProductIdAndStoreId(itemRequest.getProdId(), store.getStoreId())
|
||||
Inventory inventory = inventoryRepository.findByProductIdAndStoreIdForUpdate(itemRequest.getProdId(), store.getStoreId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Inventory not found for product " + itemRequest.getProdId() + " at store " + store.getStoreId()));
|
||||
|
||||
if (inventory.getQuantity() < itemRequest.getQuantity()) {
|
||||
@@ -291,6 +291,12 @@ public class SaleService {
|
||||
if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) {
|
||||
throw new BusinessException("Coupon has expired");
|
||||
}
|
||||
if (coupon.getUsageLimit() != null) {
|
||||
long used = saleRepository.countByCoupon_CouponId(coupon.getCouponId());
|
||||
if (used >= coupon.getUsageLimit()) {
|
||||
throw new BusinessException("Coupon usage limit has been reached");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal calculateCouponDiscount(Coupon coupon, BigDecimal subtotal) {
|
||||
|
||||
@@ -109,7 +109,7 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
User user = userRepository.findByIdForUpdate(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD CONSTRAINT uq_users_phone UNIQUE (phone);
|
||||
Reference in New Issue
Block a user