Skip to content

Commit 2ccdb59

Browse files
authored
Merge pull request #26 from prgrms-aibe-devcourse/feat/#22
feat/#22 미보유 옷 CRUD
2 parents 5263003 + 0a85632 commit 2ccdb59

7 files changed

Lines changed: 263 additions & 4 deletions

File tree

.pr_agent.toml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
[config]
2+
# 사용 AI 모델 설정
3+
ai_provider = "anthropic"
4+
model = "claude-haiku-4-5-20251001"
5+
6+
# 한국어로 리뷰 출력
7+
response_language = "ko"
8+
9+
[pr_reviewer]
10+
# PR 리뷰 자동 실행
11+
automatic_review = true
12+
13+
# 리뷰 항목 활성화
14+
require_score_review = false
15+
require_tests_review = true
16+
require_security_review = true
17+
require_focused_review = true
18+
19+
# 리뷰 가이드라인
20+
extra_instructions = """
21+
당신은 Spring Boot / Java 21 프로젝트를 리뷰하는 시니어 개발자입니다.
22+
리뷰는 단순히 문제를 지적하는 것이 아니라, **학습 관점에서 왜 문제인지, 어떻게 개선하면 좋은지** 방향을 제시해 주세요.
23+
코드를 처음 배우는 주니어 개발자도 이해할 수 있도록 친절하게 설명해 주세요.
24+
25+
## 프로젝트 컨벤션
26+
27+
### 네이밍 규칙
28+
- 클래스명: PascalCase
29+
- 변수/함수명: camelCase
30+
- 경로(URI): lowercase, kebab-case
31+
- DB 컬럼: snake_case
32+
- DTO 네이밍: XxxCreateRequest, XxxUpdateRequest, XxxResponse 형식
33+
34+
### 코드 스타일
35+
- 들여쓰기: if문, for문 등은 한 줄이라도 반드시 중괄호 {} 사용
36+
- 주석: 꼭 필요한 경우에만 작성, 들여쓰기에 맞게 정렬
37+
- 객체 생성: Builder 패턴 사용 권장 (new 키워드 직접 사용 지양)
38+
- API 문서화: RestDocs 사용 (Swagger 대신)
39+
40+
### 패키지 구조
41+
- controller / service / repository / entity / dto(request, response) 준수
42+
- URI: /api/v1 prefix
43+
44+
## 필수 체크 항목
45+
- Entity를 Response로 직접 반환하지 않는지 확인 (DTO 변환 필요)
46+
- Service에서 비즈니스 로직이 올바르게 분리되었는지 확인
47+
- Repository에 불필요한 쿼리가 없는지 확인
48+
- @Transactional 누락 여부 확인 (쓰기 작업에 반드시 필요)
49+
- null 처리 및 예외 처리가 적절한지 확인
50+
- 인증/인가가 필요한 API에 보안 처리가 되어있는지 확인
51+
52+
## N+1 문제 감지
53+
- 연관 엔티티를 반복문 안에서 조회하는 패턴이 있는지 확인
54+
- FetchType.LAZY 사용 시 추가 쿼리가 발생할 수 있는 상황 감지
55+
- 해결 방법 (fetch join, @EntityGraph, Batch Size 등)을 학습 관점에서 설명
56+
57+
## develop 브랜치 머지 관점
58+
- 기존 코드와 중복되는 로직이 있는지 확인
59+
- 다른 도메인과의 의존성 문제가 생길 수 있는지 확인
60+
- 변경 범위가 너무 넓어 머지 충돌 가능성이 있는지 경고
61+
62+
## 코드 리뷰 방향
63+
- 단순히 문제를 지적하지 말고, 건설적이고 구체적인 피드백을 제시해 주세요
64+
- 가능하다면 대안 코드 예시와 함께 학습에 도움이 되는 설명을 덧붙여 주세요
65+
- 나쁜 예시: "이상함" / 좋은 예시: "NPE가 발생할 수 있으니 Optional 사용을 고려해보세요"
66+
- 문제를 발견했을 때 "이렇게 하세요"가 아닌 "이런 이유로 이 방향을 고려해보세요" 형식으로 제안
67+
- 개선 전/후 예시 코드를 간단히 보여주면 더 좋음
68+
- 좋은 코드에는 긍정적인 피드백도 함께 달아주세요
69+
70+
## 보안 체크 항목
71+
72+
### 인증/인가
73+
- JWT + Spring Security 적용 여부 확인
74+
- 비밀번호 BCrypt 암호화 여부 확인 (평문 저장 절대 금지)
75+
- 관리자/사용자 권한 분리가 올바르게 처리되었는지 확인
76+
77+
### 데이터 보호
78+
- Native Query 사용 최소화, JPA 사용 권장
79+
- 입력값 검증: @Valid, @NotNull 등 사용 여부 확인
80+
- 로그 및 에러 메시지에 민감정보(비밀번호, 토큰, 개인정보 등) 포함 여부 확인
81+
82+
### 환경 보안
83+
- application.yml에 민감정보(API 키, 비밀번호 등) 하드코딩 금지
84+
- 민감정보는 반드시 .env → GitHub Secrets로 관리
85+
- 민감정보가 하드코딩된 경우 반드시 경고 코멘트를 달아주세요
86+
"""
87+
88+
[pr_description]
89+
# PR 열릴 때 자동으로 설명 생성
90+
publish_description_as_comment = false
91+
add_original_user_description = true
92+
93+
[pr_code_suggestions]
94+
# 코드 개선 제안 활성화
95+
automatic_code_suggestions = true
96+
max_code_suggestions = 5
97+
extra_instructions = """
98+
제안은 단순 수정이 아닌 학습 관점에서 작성해 주세요.
99+
왜 이 방식이 더 나은지 이유를 함께 설명하고,
100+
개선된 코드 예시를 보여주세요.
101+
"""

src/main/java/com/closetnangam/be/domain/clothes/controller/ClothesController.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public ResponseEntity<ApiResponse<List<ClothesResponse>>> getFavoriteOwnedClothe
4343
return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteOwnedClothes(userId)));
4444
}
4545

46-
@Operation(summary = "옷 상세 조회", description = "이미지, 이름, 브랜드, 카테고리, 타입, 색상, 스타일 정보를 조회합니다.")
46+
@Operation(summary = "옷 상세 조회", description = "보유/미보유 옷의 상세 정보(이미지, 이름, 브랜드, 카테고리, 타입, 색상, 스타일)를 조회합니다.")
4747
@GetMapping("/api/clothes/{clothesId}")
4848
public ResponseEntity<ApiResponse<ClothesResponse>> getClothes(@PathVariable Long clothesId) {
4949
return ResponseEntity.ok(ApiResponse.ok(clothesService.getClothes(clothesId)));
@@ -60,7 +60,7 @@ public ResponseEntity<ApiResponse<ClothesResponse>> createOwnedClothes(
6060
.body(ApiResponse.ok(clothesService.createOwnedClothes(userId, request)));
6161
}
6262

63-
@Operation(summary = "옷 즐겨찾기 설정", description = "옷의 즐겨찾기 상태를 등록/해제합니다.")
63+
@Operation(summary = "옷 즐겨찾기 설정", description = "보유/미보유 옷의 즐겨찾기 상태를 등록/해제합니다.")
6464
@PatchMapping("/api/clothes/{clothesId}/favorite")
6565
public ResponseEntity<ApiResponse<ClothesResponse>> updateFavorite(
6666
@PathVariable Long clothesId,
@@ -69,7 +69,7 @@ public ResponseEntity<ApiResponse<ClothesResponse>> updateFavorite(
6969
return ResponseEntity.ok(ApiResponse.ok(clothesService.updateFavorite(clothesId, request)));
7070
}
7171

72-
@Operation(summary = "옷 정보 수정", description = "등록된 보유 옷 정보를 수정합니다.")
72+
@Operation(summary = "옷 정보 수정", description = "등록된 보유/미보유 옷 정보를 수정합니다.")
7373
@PatchMapping("/api/clothes/{clothesId}")
7474
public ResponseEntity<ApiResponse<ClothesResponse>> updateClothes(
7575
@PathVariable Long clothesId,
@@ -78,7 +78,7 @@ public ResponseEntity<ApiResponse<ClothesResponse>> updateClothes(
7878
return ResponseEntity.ok(ApiResponse.ok(clothesService.updateClothes(clothesId, request)));
7979
}
8080

81-
@Operation(summary = "옷 삭제", description = "등록된 보유 옷을 삭제합니다. 삭제 확인은 프론트에서 처리합니다.")
81+
@Operation(summary = "옷 삭제", description = "등록된 보유/미보유 옷을 삭제합니다. 삭제 확인은 프론트에서 처리합니다.")
8282
@DeleteMapping("/api/clothes/{clothesId}")
8383
@ResponseStatus(HttpStatus.NO_CONTENT)
8484
public ResponseEntity<Void> deleteClothes(@PathVariable Long clothesId) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.closetnangam.be.domain.clothes.controller;
2+
3+
import com.closetnangam.be.domain.clothes.dto.request.ClothesConvertToOwnedRequest;
4+
import com.closetnangam.be.domain.clothes.dto.request.WishlistClothesCreateRequest;
5+
import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse;
6+
import com.closetnangam.be.domain.clothes.service.ClothesService;
7+
import com.closetnangam.be.global.common.response.ApiResponse;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.web.bind.annotation.GetMapping;
15+
import org.springframework.web.bind.annotation.PatchMapping;
16+
import org.springframework.web.bind.annotation.PathVariable;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
import org.springframework.web.bind.annotation.RestController;
20+
21+
import java.util.List;
22+
23+
@Tag(name = "Wishlist Clothes", description = "미보유 옷(WISHLIST) CRUD API")
24+
@RestController
25+
@RequiredArgsConstructor
26+
public class WishlistClothesController {
27+
28+
private final ClothesService clothesService;
29+
30+
// TODO: JWT 인증 구현 후 @PreAuthorize 또는 SecurityContextHolder로 userId 소유권 검증 추가 필요
31+
@Operation(summary = "미보유 옷 목록 조회", description = "추천받아 저장한 옷, 관심 상품(WISHLIST) 목록을 조회합니다.")
32+
@GetMapping("/api/users/{userId}/wishlist-clothes")
33+
public ResponseEntity<ApiResponse<List<ClothesResponse>>> getWishlistClothes(@PathVariable Long userId) {
34+
return ResponseEntity.ok(ApiResponse.ok(clothesService.getWishlistClothes(userId)));
35+
}
36+
37+
@Operation(summary = "미보유 옷 즐겨찾기 목록 조회", description = "즐겨찾기로 표시한 미보유 옷 목록을 조회합니다.")
38+
@GetMapping("/api/users/{userId}/wishlist-clothes/favorites")
39+
public ResponseEntity<ApiResponse<List<ClothesResponse>>> getFavoriteWishlistClothes(@PathVariable Long userId) {
40+
return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteWishlistClothes(userId)));
41+
}
42+
43+
@Operation(summary = "미보유 옷 등록", description = "추천 상품 또는 외부 쇼핑 상품을 미보유 옷으로 저장합니다.")
44+
@PostMapping("/api/users/{userId}/wishlist-clothes")
45+
public ResponseEntity<ApiResponse<ClothesResponse>> createWishlistClothes(
46+
@PathVariable Long userId,
47+
@Valid @RequestBody WishlistClothesCreateRequest request
48+
) {
49+
return ResponseEntity.status(HttpStatus.CREATED)
50+
.body(ApiResponse.ok(clothesService.createWishlistClothes(userId, request)));
51+
}
52+
53+
// TODO: JWT 인증 구현 후 SecurityContextHolder로 clothesId 소유권 검증 추가 필요
54+
@Operation(summary = "미보유 → 보유 전환", description = "구매 후 미보유 옷을 보유 옷(OWNED)으로 전환합니다.")
55+
@PatchMapping("/api/clothes/{clothesId}/convert-to-owned")
56+
public ResponseEntity<ApiResponse<ClothesResponse>> convertToOwned(
57+
@PathVariable Long clothesId,
58+
@Valid @RequestBody ClothesConvertToOwnedRequest request
59+
) {
60+
return ResponseEntity.ok(ApiResponse.ok(clothesService.convertToOwned(clothesId, request)));
61+
}
62+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.closetnangam.be.domain.clothes.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Size;
6+
7+
public record ClothesConvertToOwnedRequest(
8+
@NotBlank @Size(max = 100) String productCode,
9+
@NotNull Boolean isVerified
10+
) {
11+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.closetnangam.be.domain.clothes.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotEmpty;
5+
import jakarta.validation.constraints.Size;
6+
7+
import java.util.List;
8+
9+
public record WishlistClothesCreateRequest(
10+
@NotBlank @Size(max = 255) String name,
11+
@NotBlank @Size(max = 100) String brandName,
12+
@NotBlank @Size(max = 100) String productCode,
13+
@NotBlank @Size(max = 500) String imageUrl,
14+
@NotBlank @Size(max = 50) String category,
15+
@NotBlank @Size(max = 50) String itemType,
16+
@NotBlank @Size(max = 50) String color,
17+
@NotEmpty List<@NotBlank String> styles,
18+
@NotBlank @Size(max = 50) String externalSource,
19+
@NotBlank @Size(max = 255) String externalProductId,
20+
@NotBlank @Size(max = 500) String externalProductUrl
21+
) {
22+
}

src/main/java/com/closetnangam/be/domain/clothes/entity/Clothes.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
@Table(name = "clothes")
3434
public class Clothes extends BaseEntity {
3535

36+
private static final String EXTERNAL_NONE = "NONE";
37+
3638
@Id
3739
@GeneratedValue(strategy = GenerationType.IDENTITY)
3840
@Column(name = "clothes_id")
@@ -161,4 +163,16 @@ public void addStyleTag(ClothesStyleTag styleTag) {
161163
public void updateFavorite(Boolean isFavorite) {
162164
this.isFavorite = isFavorite;
163165
}
166+
167+
public void convertToOwned(String productCode, Boolean isVerified) {
168+
if (this.sourceType != SourceType.WISHLIST) {
169+
throw new IllegalArgumentException("미보유 옷만 보유 옷으로 전환할 수 있습니다.");
170+
}
171+
this.sourceType = SourceType.OWNED;
172+
this.externalSource = EXTERNAL_NONE;
173+
this.externalProductId = EXTERNAL_NONE;
174+
this.externalProductUrl = EXTERNAL_NONE;
175+
this.productCode = productCode;
176+
this.isVerified = isVerified;
177+
}
164178
}

src/main/java/com/closetnangam/be/domain/clothes/service/ClothesService.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import com.closetnangam.be.domain.catalog.entity.Style;
44
import com.closetnangam.be.domain.catalog.repository.StyleRepository;
55
import com.closetnangam.be.domain.catalog.service.CategoryCatalogService;
6+
import com.closetnangam.be.domain.clothes.dto.request.ClothesConvertToOwnedRequest;
67
import com.closetnangam.be.domain.clothes.dto.request.ClothesCreateRequest;
78
import com.closetnangam.be.domain.clothes.dto.request.ClothesFavoriteRequest;
89
import com.closetnangam.be.domain.clothes.dto.request.ClothesUpdateRequest;
10+
import com.closetnangam.be.domain.clothes.dto.request.WishlistClothesCreateRequest;
911
import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse;
1012
import com.closetnangam.be.domain.clothes.entity.Clothes;
1113
import com.closetnangam.be.domain.clothes.entity.ClothesStyleTag;
@@ -43,6 +45,18 @@ public List<ClothesResponse> getFavoriteOwnedClothes(Long userId) {
4345
.toList();
4446
}
4547

48+
public List<ClothesResponse> getWishlistClothes(Long userId) {
49+
return clothesRepository.findAllByUserIdAndSourceType(userId, SourceType.WISHLIST).stream()
50+
.map(ClothesResponse::from)
51+
.toList();
52+
}
53+
54+
public List<ClothesResponse> getFavoriteWishlistClothes(Long userId) {
55+
return clothesRepository.findFavoritesByUserIdAndSourceType(userId, SourceType.WISHLIST).stream()
56+
.map(ClothesResponse::from)
57+
.toList();
58+
}
59+
4660
public ClothesResponse getClothes(Long clothesId) {
4761
Clothes clothes = getClothesWithDetails(clothesId);
4862
return ClothesResponse.from(clothes);
@@ -76,6 +90,41 @@ public ClothesResponse createOwnedClothes(Long userId, ClothesCreateRequest requ
7690
return ClothesResponse.from(saved);
7791
}
7892

93+
@Transactional
94+
public ClothesResponse createWishlistClothes(Long userId, WishlistClothesCreateRequest request) {
95+
validateClassification(request.category(), request.itemType(), request.color(), request.styles());
96+
97+
Wardrobe wardrobe = wardrobeService.getOrCreateWardrobe(userId);
98+
99+
Clothes clothes = Clothes.builder()
100+
.wardrobe(wardrobe)
101+
.name(request.name())
102+
.brandName(request.brandName())
103+
.productCode(request.productCode())
104+
.imageUrl(request.imageUrl())
105+
.category(request.category())
106+
.itemType(request.itemType())
107+
.color(request.color())
108+
.sourceType(SourceType.WISHLIST)
109+
.externalSource(request.externalSource())
110+
.externalProductId(request.externalProductId())
111+
.externalProductUrl(request.externalProductUrl())
112+
.isVerified(false)
113+
.isFavorite(false)
114+
.build();
115+
116+
applyStyleTags(clothes, request.styles());
117+
Clothes saved = clothesRepository.save(clothes);
118+
return ClothesResponse.from(saved);
119+
}
120+
121+
@Transactional
122+
public ClothesResponse convertToOwned(Long clothesId, ClothesConvertToOwnedRequest request) {
123+
Clothes clothes = getClothesWithDetails(clothesId);
124+
clothes.convertToOwned(request.productCode(), request.isVerified());
125+
return ClothesResponse.from(clothes);
126+
}
127+
79128
@Transactional
80129
public ClothesResponse updateClothes(Long clothesId, ClothesUpdateRequest request) {
81130
validateClassification(request.category(), request.itemType(), request.color(), request.styles());

0 commit comments

Comments
 (0)