feat: 매칭 신청/응답 API 및 티어별 쿼터 가드

- POST /api/matches (신청), /{id}/accept·/reject (응답), GET /received/{dogId}
- 티어 가드: PREMIUM 무제한, FREE/AD_FREE 하루 3회 소진 시 429
- 자기신청/중복신청 차단, 전역 예외 핸들러(404/409/400/429)
- Dog/MatchingRequest 엔티티 및 리포지토리, 통합 테스트 4건 추가(총 7건 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 07:56:41 +09:00
parent e53e4e12f2
commit 1befad06d2
13 changed files with 528 additions and 0 deletions
@@ -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<Map<String, String>> 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<Map<String, String>> 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<Map<String, String>> handleConflict(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "CONFLICT", "message", e.getMessage()));
}
/** 잘못된 입력 → 400 */
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> 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<Map<String, String>> 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));
}
}
@@ -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<MatchResponse> 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<MatchResponse> received(@PathVariable Long dogId) {
return matchingService.findPendingReceived(dogId).stream()
.map(MatchResponse::from)
.toList();
}
}
@@ -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
) {
}
@@ -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()
);
}
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.dog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DogRepository extends JpaRepository<Dog, Long> {
}
@@ -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;
}
}
@@ -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<MatchingRequest, Long> {
boolean existsByRequesterDogIdAndTargetDogIdAndStatus(
Long requesterDogId, Long targetDogId, MatchingStatus status);
/** 특정 견이 받은 신청 목록(대상 기준). */
List<MatchingRequest> findByTargetDogIdAndStatusOrderByCreatedAtDesc(
Long targetDogId, MatchingStatus status);
}
@@ -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<MatchingRequest> findPendingReceived(Long targetDogId) {
return matchingRequestRepository.findByTargetDogIdAndStatusOrderByCreatedAtDesc(
targetDogId, MatchingStatus.PENDING);
}
}
@@ -0,0 +1,8 @@
package com.dog.dognation.domain.matching;
public enum MatchingStatus {
PENDING, // 신청 대기
ACCEPTED, // 수락됨
REJECTED, // 거절됨
CANCELED // 신청 취소
}
@@ -0,0 +1,8 @@
package com.dog.dognation.domain.matching;
/** 하루 무료 매칭 신청 한도를 초과했을 때 발생. (HTTP 429 로 매핑) */
public class QuotaExceededException extends RuntimeException {
public QuotaExceededException(String message) {
super(message);
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
}