lock all stateful mutations #313

Merged
RecentRunner merged 2 commits from worktree-fix-refund-idempotency into main 2026-04-15 16:02:37 -06:00
17 changed files with 98 additions and 16 deletions

View File

@@ -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));
}

View File

@@ -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);
}

View File

@@ -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)")

View File

@@ -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 " +

View File

@@ -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);
}

View File

@@ -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 " +

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 " +

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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())

View File

@@ -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();

View File

@@ -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;

View File

@@ -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());
@@ -208,7 +208,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()) {
@@ -289,6 +289,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) {

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD CONSTRAINT uq_users_phone UNIQUE (phone);