From c6cd7548578fb15de2930aa71d6091337a3a79a0 Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 08:58:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=95=EC=95=84=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20API=20(CRUD=20+=20=EB=8C=95=EB=B9=84=ED=8B=B0?= =?UTF-8?q?=EC=95=84=EC=9D=B4=20=EC=84=B1=ED=96=A5=20=ED=83=9C=EA=B7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../com/dog/dognation/api/DogController.java | 65 ++++++++++++++++ .../dog/dognation/api/TraitTagController.java | 26 +++++++ .../com/dog/dognation/api/dto/DogDtos.java | 61 +++++++++++++++ .../dog/dognation/config/LocalSeedData.java | 23 ++++++ .../com/dog/dognation/config/WebConfig.java | 2 +- .../com/dog/dognation/domain/dog/Dog.java | 78 ++++++++++++++++++- .../dognation/domain/dog/DogRepository.java | 11 +++ .../dog/dognation/domain/dog/DogService.java | 75 ++++++++++++++++++ .../dog/dognation/domain/dog/TraitTag.java | 46 +++++++++++ .../domain/dog/TraitTagRepository.java | 10 +++ .../db/migration/V4__seed_trait_tags.sql | 12 +++ 11 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/dog/dognation/api/DogController.java create mode 100644 src/main/java/com/dog/dognation/api/TraitTagController.java create mode 100644 src/main/java/com/dog/dognation/api/dto/DogDtos.java create mode 100644 src/main/java/com/dog/dognation/domain/dog/DogService.java create mode 100644 src/main/java/com/dog/dognation/domain/dog/TraitTag.java create mode 100644 src/main/java/com/dog/dognation/domain/dog/TraitTagRepository.java create mode 100644 src/main/resources/db/migration/V4__seed_trait_tags.sql diff --git a/src/main/java/com/dog/dognation/api/DogController.java b/src/main/java/com/dog/dognation/api/DogController.java new file mode 100644 index 0000000..02f6cf6 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/DogController.java @@ -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 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 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 delete(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser me, + @PathVariable Long id) { + dogService.delete(me.id(), id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dog/dognation/api/TraitTagController.java b/src/main/java/com/dog/dognation/api/TraitTagController.java new file mode 100644 index 0000000..d3ef313 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/TraitTagController.java @@ -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 list() { + return dogService.allTraitTags().stream().map(TraitTagResponse::from).toList(); + } +} diff --git a/src/main/java/com/dog/dognation/api/dto/DogDtos.java b/src/main/java/com/dog/dognation/api/dto/DogDtos.java new file mode 100644 index 0000000..1fddb50 --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/DogDtos.java @@ -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 traits) { + + public static DogResponse from(Dog d) { + List 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 traitIds) { + + public boolean neuteredOrFalse() { + return neutered != null && neutered; + } + + public List traitIdsOrEmpty() { + return traitIds == null ? List.of() : traitIds; + } + } +} diff --git a/src/main/java/com/dog/dognation/config/LocalSeedData.java b/src/main/java/com/dog/dognation/config/LocalSeedData.java index ebace6c..5ec2b20 100644 --- a/src/main/java/com/dog/dognation/config/LocalSeedData.java +++ b/src/main/java/com/dog/dognation/config/LocalSeedData.java @@ -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) diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java index 2256818..d863d7c 100644 --- a/src/main/java/com/dog/dognation/config/WebConfig.java +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -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/**"); diff --git a/src/main/java/com/dog/dognation/domain/dog/Dog.java b/src/main/java/com/dog/dognation/domain/dog/Dog.java index 1ce9019..fa5ea1c 100644 --- a/src/main/java/com/dog/dognation/domain/dog/Dog.java +++ b/src/main/java/com/dog/dognation/domain/dog/Dog.java @@ -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 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 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 getTraits() { + return traits; + } } diff --git a/src/main/java/com/dog/dognation/domain/dog/DogRepository.java b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java index 0538281..6fdc799 100644 --- a/src/main/java/com/dog/dognation/domain/dog/DogRepository.java +++ b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java @@ -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 { + + // 성향 태그를 함께 로딩 (DTO 매핑이 트랜잭션 밖에서 일어나므로 LAZY 초기화 예외 방지) + @EntityGraph(attributePaths = "traits") + List findByUserIdOrderByCreatedAtAsc(Long userId); + + @EntityGraph(attributePaths = "traits") + Optional findWithTraitsById(Long id); } diff --git a/src/main/java/com/dog/dognation/domain/dog/DogService.java b/src/main/java/com/dog/dognation/domain/dog/DogService.java new file mode 100644 index 0000000..ee47e9b --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/dog/DogService.java @@ -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 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 allTraitTags() { + return traitTagRepository.findAllByOrderByCategoryAscIdAsc(); + } + + private List resolveTraits(List traitIds) { + if (traitIds.isEmpty()) { + return List.of(); + } + List found = traitTagRepository.findAllById(traitIds); + if (found.size() != traitIds.size()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "존재하지 않는 성향 태그가 포함되어 있습니다."); + } + return found; + } +} diff --git a/src/main/java/com/dog/dognation/domain/dog/TraitTag.java b/src/main/java/com/dog/dognation/domain/dog/TraitTag.java new file mode 100644 index 0000000..3b4b523 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/dog/TraitTag.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/domain/dog/TraitTagRepository.java b/src/main/java/com/dog/dognation/domain/dog/TraitTagRepository.java new file mode 100644 index 0000000..a9e47da --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/dog/TraitTagRepository.java @@ -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 { + + List findAllByOrderByCategoryAscIdAsc(); +} diff --git a/src/main/resources/db/migration/V4__seed_trait_tags.sql b/src/main/resources/db/migration/V4__seed_trait_tags.sql new file mode 100644 index 0000000..023b644 --- /dev/null +++ b/src/main/resources/db/migration/V4__seed_trait_tags.sql @@ -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');