diff --git a/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java b/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java new file mode 100644 index 0000000..ad015db --- /dev/null +++ b/src/main/java/com/dog/dognation/api/ApiExceptionHandler.java @@ -0,0 +1,54 @@ +package com.dog.dognation.api; + +import com.dog.dognation.domain.matching.QuotaExceededException; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class ApiExceptionHandler { + + /** 매칭 쿼터 초과 → 429 Too Many Requests */ + @ExceptionHandler(QuotaExceededException.class) + public ResponseEntity> handleQuota(QuotaExceededException e) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("code", "MATCH_QUOTA_EXCEEDED", "message", e.getMessage())); + } + + /** 존재하지 않는 리소스 → 404 */ + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity> handleNotFound(EntityNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("code", "NOT_FOUND", "message", e.getMessage())); + } + + /** 잘못된 상태(중복 신청/이미 처리됨) → 409 Conflict */ + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleConflict(IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("code", "CONFLICT", "message", e.getMessage())); + } + + /** 잘못된 입력 → 400 */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("code", "BAD_REQUEST", "message", e.getMessage())); + } + + /** @Valid 검증 실패 → 400 */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(err -> err.getDefaultMessage()) + .orElse("잘못된 요청입니다."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("code", "VALIDATION_ERROR", "message", msg)); + } +} diff --git a/src/main/java/com/dog/dognation/api/MatchingController.java b/src/main/java/com/dog/dognation/api/MatchingController.java new file mode 100644 index 0000000..8e2f6a5 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/MatchingController.java @@ -0,0 +1,56 @@ +package com.dog.dognation.api; + +import com.dog.dognation.api.dto.CreateMatchRequest; +import com.dog.dognation.api.dto.MatchResponse; +import com.dog.dognation.domain.matching.MatchingRequest; +import com.dog.dognation.domain.matching.MatchingService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/matches") +public class MatchingController { + + private final MatchingService matchingService; + + public MatchingController(MatchingService matchingService) { + this.matchingService = matchingService; + } + + /** 매칭 신청. FREE/AD_FREE 는 하루 3회 제한, 초과 시 429. */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateMatchRequest request) { + MatchingRequest created = matchingService.createRequest( + request.requesterDogId(), request.targetDogId()); + return ResponseEntity.status(HttpStatus.CREATED).body(MatchResponse.from(created)); + } + + /** 대상 견주가 신청 수락. */ + @PostMapping("/{id}/accept") + public MatchResponse accept(@PathVariable Long id) { + return MatchResponse.from(matchingService.respond(id, true)); + } + + /** 대상 견주가 신청 거절. */ + @PostMapping("/{id}/reject") + public MatchResponse reject(@PathVariable Long id) { + return MatchResponse.from(matchingService.respond(id, false)); + } + + /** 특정 견이 받은 대기중 신청 목록. */ + @GetMapping("/received/{dogId}") + public List received(@PathVariable Long dogId) { + return matchingService.findPendingReceived(dogId).stream() + .map(MatchResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/dog/dognation/api/dto/CreateMatchRequest.java b/src/main/java/com/dog/dognation/api/dto/CreateMatchRequest.java new file mode 100644 index 0000000..2966d6f --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/CreateMatchRequest.java @@ -0,0 +1,9 @@ +package com.dog.dognation.api.dto; + +import jakarta.validation.constraints.NotNull; + +public record CreateMatchRequest( + @NotNull(message = "requesterDogId 는 필수입니다.") Long requesterDogId, + @NotNull(message = "targetDogId 는 필수입니다.") Long targetDogId +) { +} diff --git a/src/main/java/com/dog/dognation/api/dto/MatchResponse.java b/src/main/java/com/dog/dognation/api/dto/MatchResponse.java new file mode 100644 index 0000000..beb7d8e --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/MatchResponse.java @@ -0,0 +1,25 @@ +package com.dog.dognation.api.dto; + +import com.dog.dognation.domain.matching.MatchingRequest; + +import java.time.OffsetDateTime; + +public record MatchResponse( + Long id, + Long requesterDogId, + Long targetDogId, + String status, + OffsetDateTime createdAt, + OffsetDateTime respondedAt +) { + public static MatchResponse from(MatchingRequest r) { + return new MatchResponse( + r.getId(), + r.getRequesterDogId(), + r.getTargetDogId(), + r.getStatus().name(), + r.getCreatedAt(), + r.getRespondedAt() + ); + } +} diff --git a/src/main/java/com/dog/dognation/domain/dog/Dog.java b/src/main/java/com/dog/dognation/domain/dog/Dog.java new file mode 100644 index 0000000..1214031 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/dog/Dog.java @@ -0,0 +1,63 @@ +package com.dog.dognation.domain.dog; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "dogs") +public class Dog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 50) + private String name; + + @Column(length = 100) + private String breed; + + @Column(name = "birth_date") + private LocalDate birthDate; + + @Column(length = 20) + private String size; + + @Column(length = 10) + private String gender; + + @Column(nullable = false) + private boolean neutered = false; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected Dog() { + } + + public Long getId() { + return id; + } + + /** 소유자(견주) 사용자 ID. 매칭 쿼터/티어 판정 기준. */ + public Long getUserId() { + return userId; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/dog/dognation/domain/dog/DogRepository.java b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java new file mode 100644 index 0000000..0538281 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.dog; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DogRepository extends JpaRepository { +} diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java b/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java new file mode 100644 index 0000000..6607a84 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequest.java @@ -0,0 +1,80 @@ +package com.dog.dognation.domain.matching; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "matching_requests") +public class MatchingRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "requester_dog_id", nullable = false) + private Long requesterDogId; + + @Column(name = "target_dog_id", nullable = false) + private Long targetDogId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MatchingStatus status = MatchingStatus.PENDING; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "responded_at") + private OffsetDateTime respondedAt; + + protected MatchingRequest() { + } + + public MatchingRequest(Long requesterDogId, Long targetDogId) { + this.requesterDogId = requesterDogId; + this.targetDogId = targetDogId; + this.status = MatchingStatus.PENDING; + this.createdAt = OffsetDateTime.now(); + } + + /** 대상 견주가 신청을 수락/거절 처리한다. */ + public void respond(MatchingStatus result) { + if (result != MatchingStatus.ACCEPTED && result != MatchingStatus.REJECTED) { + throw new IllegalArgumentException("응답은 ACCEPTED 또는 REJECTED 여야 합니다."); + } + this.status = result; + this.respondedAt = OffsetDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getRequesterDogId() { + return requesterDogId; + } + + public Long getTargetDogId() { + return targetDogId; + } + + public MatchingStatus getStatus() { + return status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public OffsetDateTime getRespondedAt() { + return respondedAt; + } +} diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java b/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java new file mode 100644 index 0000000..3a08e17 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingRequestRepository.java @@ -0,0 +1,15 @@ +package com.dog.dognation.domain.matching; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MatchingRequestRepository extends JpaRepository { + + boolean existsByRequesterDogIdAndTargetDogIdAndStatus( + Long requesterDogId, Long targetDogId, MatchingStatus status); + + /** 특정 견이 받은 신청 목록(대상 기준). */ + List findByTargetDogIdAndStatusOrderByCreatedAtDesc( + Long targetDogId, MatchingStatus status); +} diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingService.java b/src/main/java/com/dog/dognation/domain/matching/MatchingService.java new file mode 100644 index 0000000..e73fbc2 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingService.java @@ -0,0 +1,85 @@ +package com.dog.dognation.domain.matching; + +import com.dog.dognation.domain.dog.Dog; +import com.dog.dognation.domain.dog.DogRepository; +import com.dog.dognation.domain.user.User; +import com.dog.dognation.domain.user.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 매칭 신청/응답 도메인 서비스. + * + * 티어별 쿼터 가드: + * - PREMIUM : 무제한 (쿼터 차감 없음) + * - FREE/AD_FREE : 하루 3회. consumeOne() 으로 1회 차감, 소진 시 QuotaExceededException. + */ +@Service +public class MatchingService { + + private final MatchingRequestRepository matchingRequestRepository; + private final DogRepository dogRepository; + private final UserRepository userRepository; + private final MatchQuotaService quotaService; + + public MatchingService(MatchingRequestRepository matchingRequestRepository, + DogRepository dogRepository, + UserRepository userRepository, + MatchQuotaService quotaService) { + this.matchingRequestRepository = matchingRequestRepository; + this.dogRepository = dogRepository; + this.userRepository = userRepository; + this.quotaService = quotaService; + } + + @Transactional + public MatchingRequest createRequest(Long requesterDogId, Long targetDogId) { + if (requesterDogId.equals(targetDogId)) { + throw new IllegalArgumentException("자기 자신에게는 매칭을 신청할 수 없습니다."); + } + + Dog requesterDog = dogRepository.findById(requesterDogId) + .orElseThrow(() -> new EntityNotFoundException("신청 견을 찾을 수 없습니다: " + requesterDogId)); + if (!dogRepository.existsById(targetDogId)) { + throw new EntityNotFoundException("대상 견을 찾을 수 없습니다: " + targetDogId); + } + + // 같은 대상에게 이미 대기중인 신청이 있으면 중복 방지 (쿼터 차감 전에 검사) + if (matchingRequestRepository.existsByRequesterDogIdAndTargetDogIdAndStatus( + requesterDogId, targetDogId, MatchingStatus.PENDING)) { + throw new IllegalStateException("이미 대기중인 매칭 신청이 있습니다."); + } + + // 티어별 쿼터 가드 + User owner = userRepository.findById(requesterDog.getUserId()) + .orElseThrow(() -> new EntityNotFoundException("견주를 찾을 수 없습니다: " + requesterDog.getUserId())); + if (owner.getSubscriptionTier().isQuotaLimited()) { + if (!quotaService.consumeOne(owner.getId())) { + throw new QuotaExceededException( + "오늘의 무료 매칭 신청 횟수(3회)를 모두 사용했습니다. PREMIUM 에서 무제한으로 이용하세요."); + } + } + + return matchingRequestRepository.save(new MatchingRequest(requesterDogId, targetDogId)); + } + + @Transactional + public MatchingRequest respond(Long requestId, boolean accept) { + MatchingRequest request = matchingRequestRepository.findById(requestId) + .orElseThrow(() -> new EntityNotFoundException("매칭 신청을 찾을 수 없습니다: " + requestId)); + if (request.getStatus() != MatchingStatus.PENDING) { + throw new IllegalStateException("이미 처리된 매칭 신청입니다. (현재 상태: " + request.getStatus() + ")"); + } + request.respond(accept ? MatchingStatus.ACCEPTED : MatchingStatus.REJECTED); + return request; // 더티 체킹으로 flush + } + + @Transactional(readOnly = true) + public List findPendingReceived(Long targetDogId) { + return matchingRequestRepository.findByTargetDogIdAndStatusOrderByCreatedAtDesc( + targetDogId, MatchingStatus.PENDING); + } +} diff --git a/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java b/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java new file mode 100644 index 0000000..0e359ee --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/MatchingStatus.java @@ -0,0 +1,8 @@ +package com.dog.dognation.domain.matching; + +public enum MatchingStatus { + PENDING, // 신청 대기 + ACCEPTED, // 수락됨 + REJECTED, // 거절됨 + CANCELED // 신청 취소 +} diff --git a/src/main/java/com/dog/dognation/domain/matching/QuotaExceededException.java b/src/main/java/com/dog/dognation/domain/matching/QuotaExceededException.java new file mode 100644 index 0000000..5aadf3f --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/matching/QuotaExceededException.java @@ -0,0 +1,8 @@ +package com.dog.dognation.domain.matching; + +/** 하루 무료 매칭 신청 한도를 초과했을 때 발생. (HTTP 429 로 매핑) */ +public class QuotaExceededException extends RuntimeException { + public QuotaExceededException(String message) { + super(message); + } +} diff --git a/src/main/java/com/dog/dognation/domain/user/UserRepository.java b/src/main/java/com/dog/dognation/domain/user/UserRepository.java new file mode 100644 index 0000000..0bb0049 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/user/UserRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java b/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java new file mode 100644 index 0000000..1d96468 --- /dev/null +++ b/src/test/java/com/dog/dognation/matching/MatchingServiceTest.java @@ -0,0 +1,113 @@ +package com.dog.dognation.matching; + +import com.dog.dognation.domain.matching.MatchingRequest; +import com.dog.dognation.domain.matching.MatchingService; +import com.dog.dognation.domain.matching.MatchingStatus; +import com.dog.dognation.domain.matching.QuotaExceededException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 매칭 신청 티어 가드 검증. + * - FREE/AD_FREE : 하루 3회, 4번째부터 QuotaExceededException + * - PREMIUM : 무제한 + */ +@SpringBootTest +class MatchingServiceTest { + + @Autowired MatchingService matchingService; + @Autowired JdbcTemplate jdbc; + + private long freeDogId; + private long premiumDogId; + private final long[] targetDogIds = new long[6]; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM matching_requests"); + jdbc.update("DELETE FROM daily_match_quota"); + jdbc.update("DELETE FROM dogs"); + jdbc.update("DELETE FROM users"); + + long freeUser = seedUser("free@dog.com", "FREE"); + long premiumUser = seedUser("premium@dog.com", "PREMIUM"); + long targetUser = seedUser("target@dog.com", "FREE"); + + freeDogId = seedDog(freeUser, "몽이"); + premiumDogId = seedDog(premiumUser, "레오"); + for (int i = 0; i < targetDogIds.length; i++) { + targetDogIds[i] = seedDog(targetUser, "대상견" + i); + } + } + + @Test + @DisplayName("FREE: 하루 3회까지 신청 성공, 4번째는 429(QuotaExceededException)") + void free_blockedAfterThree() { + for (int i = 0; i < 3; i++) { + MatchingRequest req = matchingService.createRequest(freeDogId, targetDogIds[i]); + assertThat(req.getStatus()).isEqualTo(MatchingStatus.PENDING); + } + + assertThatThrownBy(() -> matchingService.createRequest(freeDogId, targetDogIds[3])) + .isInstanceOf(QuotaExceededException.class); + + // 차단된 신청은 저장되지 않아야 함 → 총 3건 + Integer count = jdbc.queryForObject( + "SELECT count(*) FROM matching_requests WHERE requester_dog_id = ?", Integer.class, freeDogId); + assertThat(count).isEqualTo(3); + } + + @Test + @DisplayName("PREMIUM: 3회 초과해도 무제한 신청 가능") + void premium_unlimited() { + for (int i = 0; i < targetDogIds.length; i++) { + matchingService.createRequest(premiumDogId, targetDogIds[i]); + } + Integer count = jdbc.queryForObject( + "SELECT count(*) FROM matching_requests WHERE requester_dog_id = ?", Integer.class, premiumDogId); + assertThat(count).isEqualTo(targetDogIds.length); // 6건 전부 성공 + } + + @Test + @DisplayName("수락 처리 시 상태가 ACCEPTED 로 바뀌고 respondedAt 이 기록된다") + void respond_accept() { + MatchingRequest req = matchingService.createRequest(freeDogId, targetDogIds[0]); + MatchingRequest accepted = matchingService.respond(req.getId(), true); + + assertThat(accepted.getStatus()).isEqualTo(MatchingStatus.ACCEPTED); + assertThat(accepted.getRespondedAt()).isNotNull(); + } + + @Test + @DisplayName("자기 자신에게는 신청할 수 없다") + void cannotRequestSelf() { + assertThatThrownBy(() -> matchingService.createRequest(freeDogId, freeDogId)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --- helpers --- + + private long seedUser(String email, String tier) { + jdbc.update(""" + INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at) + VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, email, email, tier); + return jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email); + } + + private long seedDog(long userId, String name) { + jdbc.update(""" + INSERT INTO dogs(user_id, name, neutered, created_at) + VALUES (?, ?, false, CURRENT_TIMESTAMP) + """, userId, name); + return jdbc.queryForObject( + "SELECT id FROM dogs WHERE user_id = ? AND name = ?", Long.class, userId, name); + } +}