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:
sb
2026-07-11 08:58:55 +09:00
parent ffbb365c41
commit c6cd754857
11 changed files with 405 additions and 4 deletions
@@ -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, other, "그늘 시원해요", null);
spotService.addReview(hangang, me, "물그릇 있음", 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 과 일치) // (local 프로파일 전용. 프론트 VITE_DEV_LOGIN_TOKEN 과 일치)
jdbc.update(""" jdbc.update("""
@@ -59,6 +73,15 @@ public class LocalSeedData implements CommandLineRunner {
me, myDog, hangang); 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) { private long insertUser(String email, String nickname, String tier) {
jdbc.update(""" jdbc.update("""
INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) 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) { public void addInterceptors(InterceptorRegistry registry) {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
registry.addInterceptor(authInterceptor) 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 검사) // 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**"); .addPathPatterns("/api/admin/**");
@@ -2,13 +2,20 @@ package com.dog.dognation.domain.dog;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
@Entity @Entity
@Table(name = "dogs") @Table(name = "dogs")
@@ -31,10 +38,10 @@ public class Dog {
private LocalDate birthDate; private LocalDate birthDate;
@Column(length = 20) @Column(length = 20)
private String size; private String size; // SMALL / MEDIUM / LARGE
@Column(length = 10) @Column(length = 10)
private String gender; private String gender; // MALE / FEMALE
@Column(nullable = false) @Column(nullable = false)
private boolean neutered = false; private boolean neutered = false;
@@ -45,14 +52,51 @@ public class Dog {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); 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() { 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() { public Long getId() {
return id; return id;
} }
/** 소유자(견주) 사용자 ID. 매칭 쿼터/티어 판정 기준. */
public Long getUserId() { public Long getUserId() {
return userId; return userId;
} }
@@ -64,4 +108,32 @@ public class Dog {
public String getBreed() { public String getBreed() {
return breed; 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; package com.dog.dognation.domain.dog;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface DogRepository extends JpaRepository<Dog, Long> { 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');