feat: 강아지 관리 API (CRUD + 댕비티아이 성향 태그)
- GET/POST/PUT/DELETE /api/dogs (로그인 필요, 소유권 검증 → 403) - GET /api/trait-tags 성향 태그 마스터 (공개) - Dog-TraitTag 다대다(dog_trait_tags), @EntityGraph 로 지연로딩 예외 방지 - V4 마이그레이션: 성향 태그 시드 / local 시드에 태그+대표견 성향 부여 - /api/dogs/** 인증 인터셉터 보호 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package com.dog.dognation.api;
|
||||
|
||||
import com.dog.dognation.api.dto.DogDtos.DogResponse;
|
||||
import com.dog.dognation.api.dto.DogDtos.DogSaveRequest;
|
||||
import com.dog.dognation.auth.SessionUser;
|
||||
import com.dog.dognation.auth.web.AuthInterceptor;
|
||||
import com.dog.dognation.domain.dog.DogService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
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/dogs")
|
||||
public class DogController {
|
||||
|
||||
private final DogService dogService;
|
||||
|
||||
public DogController(DogService dogService) {
|
||||
this.dogService = dogService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<DogResponse> myDogs(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me) {
|
||||
return dogService.listMyDogs(me.id()).stream().map(DogResponse::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public DogResponse detail(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me,
|
||||
@PathVariable Long id) {
|
||||
return DogResponse.from(dogService.getOwnedDog(me.id(), id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<DogResponse> create(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me,
|
||||
@Valid @RequestBody DogSaveRequest req) {
|
||||
DogResponse body = DogResponse.from(dogService.create(me.id(), req));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(body);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public DogResponse update(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me,
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody DogSaveRequest req) {
|
||||
return DogResponse.from(dogService.update(me.id(), id, req));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me,
|
||||
@PathVariable Long id) {
|
||||
dogService.delete(me.id(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.dog.dognation.api;
|
||||
|
||||
import com.dog.dognation.api.dto.DogDtos.TraitTagResponse;
|
||||
import com.dog.dognation.domain.dog.DogService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 성향 태그 마스터 목록 (공개). */
|
||||
@RestController
|
||||
@RequestMapping("/api/trait-tags")
|
||||
public class TraitTagController {
|
||||
|
||||
private final DogService dogService;
|
||||
|
||||
public TraitTagController(DogService dogService) {
|
||||
this.dogService = dogService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<TraitTagResponse> list() {
|
||||
return dogService.allTraitTags().stream().map(TraitTagResponse::from).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.dog.dognation.api.dto;
|
||||
|
||||
import com.dog.dognation.domain.dog.Dog;
|
||||
import com.dog.dognation.domain.dog.TraitTag;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** 강아지/성향 태그 요청·응답 DTO. */
|
||||
public final class DogDtos {
|
||||
|
||||
private DogDtos() {
|
||||
}
|
||||
|
||||
public record TraitTagResponse(Long id, String code, String label, String category) {
|
||||
public static TraitTagResponse from(TraitTag t) {
|
||||
return new TraitTagResponse(t.getId(), t.getCode(), t.getLabel(), t.getCategory());
|
||||
}
|
||||
}
|
||||
|
||||
public record DogResponse(
|
||||
Long id,
|
||||
String name,
|
||||
String breed,
|
||||
LocalDate birthDate,
|
||||
String size,
|
||||
String gender,
|
||||
boolean neutered,
|
||||
String imageUrl,
|
||||
List<TraitTagResponse> traits) {
|
||||
|
||||
public static DogResponse from(Dog d) {
|
||||
List<TraitTagResponse> traits = d.getTraits().stream()
|
||||
.map(TraitTagResponse::from)
|
||||
.toList();
|
||||
return new DogResponse(
|
||||
d.getId(), d.getName(), d.getBreed(), d.getBirthDate(),
|
||||
d.getSize(), d.getGender(), d.isNeutered(), d.getImageUrl(), traits);
|
||||
}
|
||||
}
|
||||
|
||||
public record DogSaveRequest(
|
||||
@NotBlank(message = "강아지 이름은 필수입니다.") String name,
|
||||
String breed,
|
||||
LocalDate birthDate,
|
||||
String size,
|
||||
String gender,
|
||||
Boolean neutered,
|
||||
String imageUrl,
|
||||
List<Long> traitIds) {
|
||||
|
||||
public boolean neuteredOrFalse() {
|
||||
return neutered != null && neutered;
|
||||
}
|
||||
|
||||
public List<Long> traitIdsOrEmpty() {
|
||||
return traitIds == null ? List.of() : traitIds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,20 @@ public class LocalSeedData implements CommandLineRunner {
|
||||
spotService.addReview(hangang, other, "그늘 시원해요", null);
|
||||
spotService.addReview(hangang, me, "물그릇 있음", null);
|
||||
|
||||
// 성향 태그 마스터 (프로덕션은 V4 마이그레이션, local 은 여기서 시드)
|
||||
insertTrait("FEAR_BIG_DOG", "대형견 무서워함", "CAUTION");
|
||||
long tugId = insertTrait("TUG_LOVER", "터그놀이 매니아", "PLAY");
|
||||
long socId = insertTrait("SOCIALIZING", "사회화 훈련 중", "TRAINING");
|
||||
insertTrait("LOVE_PEOPLE", "사람 좋아함", "PERSONALITY");
|
||||
insertTrait("SHY", "낯가림 있음", "PERSONALITY");
|
||||
insertTrait("ENERGETIC", "에너지 넘침", "PERSONALITY");
|
||||
insertTrait("CALM", "차분함", "PERSONALITY");
|
||||
insertTrait("SMALL_DOG_FRIENDLY", "소형견과 잘 지냄", "SOCIAL");
|
||||
|
||||
// 내 강아지(몽이)에 기본 성향 2개 부여
|
||||
linkTrait(myDog, tugId);
|
||||
linkTrait(myDog, socId);
|
||||
|
||||
// 개발용 고정 세션 토큰 — 프론트에서 실제 소셜 토큰 없이 로그인 플로우를 검증하기 위함.
|
||||
// (local 프로파일 전용. 프론트 VITE_DEV_LOGIN_TOKEN 과 일치)
|
||||
jdbc.update("""
|
||||
@@ -59,6 +73,15 @@ public class LocalSeedData implements CommandLineRunner {
|
||||
me, myDog, hangang);
|
||||
}
|
||||
|
||||
private long insertTrait(String code, String label, String category) {
|
||||
jdbc.update("INSERT INTO trait_tags(code, label, category) VALUES (?, ?, ?)", code, label, category);
|
||||
return jdbc.queryForObject("SELECT id FROM trait_tags WHERE code = ?", Long.class, code);
|
||||
}
|
||||
|
||||
private void linkTrait(long dogId, long tagId) {
|
||||
jdbc.update("INSERT INTO dog_trait_tags(dog_id, tag_id) VALUES (?, ?)", dogId, tagId);
|
||||
}
|
||||
|
||||
private long insertUser(String email, String nickname, String tier) {
|
||||
jdbc.update("""
|
||||
INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at)
|
||||
|
||||
@@ -40,7 +40,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**");
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**", "/api/dogs/**");
|
||||
// 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사)
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
.addPathPatterns("/api/admin/**");
|
||||
|
||||
@@ -2,13 +2,20 @@ package com.dog.dognation.domain.dog;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "dogs")
|
||||
@@ -31,10 +38,10 @@ public class Dog {
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Column(length = 20)
|
||||
private String size;
|
||||
private String size; // SMALL / MEDIUM / LARGE
|
||||
|
||||
@Column(length = 10)
|
||||
private String gender;
|
||||
private String gender; // MALE / FEMALE
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean neutered = false;
|
||||
@@ -45,14 +52,51 @@ public class Dog {
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt = OffsetDateTime.now();
|
||||
|
||||
// 댕비티아이(성향 태그) — dog_trait_tags 조인 테이블
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "dog_trait_tags",
|
||||
joinColumns = @JoinColumn(name = "dog_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||
private Set<TraitTag> traits = new LinkedHashSet<>();
|
||||
|
||||
protected Dog() {
|
||||
}
|
||||
|
||||
public Dog(Long userId, String name, String breed, LocalDate birthDate,
|
||||
String size, String gender, boolean neutered, String imageUrl) {
|
||||
this.userId = userId;
|
||||
this.name = name;
|
||||
this.breed = breed;
|
||||
this.birthDate = birthDate;
|
||||
this.size = size;
|
||||
this.gender = gender;
|
||||
this.neutered = neutered;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public void update(String name, String breed, LocalDate birthDate,
|
||||
String size, String gender, boolean neutered, String imageUrl) {
|
||||
this.name = name;
|
||||
this.breed = breed;
|
||||
this.birthDate = birthDate;
|
||||
this.size = size;
|
||||
this.gender = gender;
|
||||
this.neutered = neutered;
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
/** 성향 태그 일괄 교체. */
|
||||
public void replaceTraits(Collection<TraitTag> tags) {
|
||||
this.traits.clear();
|
||||
this.traits.addAll(tags);
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 소유자(견주) 사용자 ID. 매칭 쿼터/티어 판정 기준. */
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
@@ -64,4 +108,32 @@ public class Dog {
|
||||
public String getBreed() {
|
||||
return breed;
|
||||
}
|
||||
|
||||
public LocalDate getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
public String getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public String getGender() {
|
||||
return gender;
|
||||
}
|
||||
|
||||
public boolean isNeutered() {
|
||||
return neutered;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Set<TraitTag> getTraits() {
|
||||
return traits;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package com.dog.dognation.domain.dog;
|
||||
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface DogRepository extends JpaRepository<Dog, Long> {
|
||||
|
||||
// 성향 태그를 함께 로딩 (DTO 매핑이 트랜잭션 밖에서 일어나므로 LAZY 초기화 예외 방지)
|
||||
@EntityGraph(attributePaths = "traits")
|
||||
List<Dog> findByUserIdOrderByCreatedAtAsc(Long userId);
|
||||
|
||||
@EntityGraph(attributePaths = "traits")
|
||||
Optional<Dog> findWithTraitsById(Long id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.dog.dognation.domain.dog;
|
||||
|
||||
import com.dog.dognation.api.dto.DogDtos.DogSaveRequest;
|
||||
import com.dog.dognation.common.exception.ApiException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class DogService {
|
||||
|
||||
private final DogRepository dogRepository;
|
||||
private final TraitTagRepository traitTagRepository;
|
||||
|
||||
public DogService(DogRepository dogRepository, TraitTagRepository traitTagRepository) {
|
||||
this.dogRepository = dogRepository;
|
||||
this.traitTagRepository = traitTagRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Dog> listMyDogs(Long userId) {
|
||||
return dogRepository.findByUserIdOrderByCreatedAtAsc(userId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Dog getOwnedDog(Long userId, Long dogId) {
|
||||
Dog dog = dogRepository.findWithTraitsById(dogId)
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "강아지를 찾을 수 없습니다."));
|
||||
if (!dog.getUserId().equals(userId)) {
|
||||
throw new ApiException(HttpStatus.FORBIDDEN, "본인의 강아지가 아닙니다.");
|
||||
}
|
||||
return dog;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Dog create(Long userId, DogSaveRequest req) {
|
||||
Dog dog = new Dog(userId, req.name(), req.breed(), req.birthDate(),
|
||||
req.size(), req.gender(), req.neuteredOrFalse(), req.imageUrl());
|
||||
dog.replaceTraits(resolveTraits(req.traitIdsOrEmpty()));
|
||||
return dogRepository.save(dog);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Dog update(Long userId, Long dogId, DogSaveRequest req) {
|
||||
Dog dog = getOwnedDog(userId, dogId);
|
||||
dog.update(req.name(), req.breed(), req.birthDate(),
|
||||
req.size(), req.gender(), req.neuteredOrFalse(), req.imageUrl());
|
||||
dog.replaceTraits(resolveTraits(req.traitIdsOrEmpty()));
|
||||
return dog;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long userId, Long dogId) {
|
||||
Dog dog = getOwnedDog(userId, dogId);
|
||||
dogRepository.delete(dog);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TraitTag> allTraitTags() {
|
||||
return traitTagRepository.findAllByOrderByCategoryAscIdAsc();
|
||||
}
|
||||
|
||||
private List<TraitTag> resolveTraits(List<Long> traitIds) {
|
||||
if (traitIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<TraitTag> found = traitTagRepository.findAllById(traitIds);
|
||||
if (found.size() != traitIds.size()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "존재하지 않는 성향 태그가 포함되어 있습니다.");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
|
||||
/** 댕비티아이 성향 태그 마스터 (예: 대형견 무서워함, 터그놀이 매니아). */
|
||||
@Entity
|
||||
@Table(name = "trait_tags")
|
||||
public class TraitTag {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String label;
|
||||
|
||||
@Column(nullable = false, length = 30)
|
||||
private String category;
|
||||
|
||||
protected TraitTag() {
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.dog.dognation.domain.dog;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface TraitTagRepository extends JpaRepository<TraitTag, Long> {
|
||||
|
||||
List<TraitTag> findAllByOrderByCategoryAscIdAsc();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 댕비티아이 성향 태그 마스터 시드
|
||||
INSERT INTO trait_tags(code, label, category) VALUES
|
||||
('FEAR_BIG_DOG', '대형견 무서워함', 'CAUTION'),
|
||||
('TUG_LOVER', '터그놀이 매니아', 'PLAY'),
|
||||
('SOCIALIZING', '사회화 훈련 중', 'TRAINING'),
|
||||
('LOVE_PEOPLE', '사람 좋아함', 'PERSONALITY'),
|
||||
('SHY', '낯가림 있음', 'PERSONALITY'),
|
||||
('ENERGETIC', '에너지 넘침', 'PERSONALITY'),
|
||||
('CALM', '차분함', 'PERSONALITY'),
|
||||
('SMALL_DOG_FRIENDLY', '소형견과 잘 지냄', 'SOCIAL'),
|
||||
('NEEDS_MUZZLE', '입마개 착용', 'CAUTION'),
|
||||
('BALL_LOVER', '공놀이 좋아함', 'PLAY');
|
||||
Reference in New Issue
Block a user