Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .pr_agent.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
[config]
# 사용 AI 모델 설정
ai_provider = "anthropic"
model = "claude-haiku-4-5-20251001"

# 한국어로 리뷰 출력
response_language = "ko"

[pr_reviewer]
# PR 리뷰 자동 실행
automatic_review = true

# 리뷰 항목 활성화
require_score_review = false
require_tests_review = true
require_security_review = true
require_focused_review = true

# 리뷰 가이드라인
extra_instructions = """
당신은 Spring Boot / Java 21 프로젝트를 리뷰하는 시니어 개발자입니다.
리뷰는 단순히 문제를 지적하는 것이 아니라, **학습 관점에서 왜 문제인지, 어떻게 개선하면 좋은지** 방향을 제시해 주세요.
코드를 처음 배우는 주니어 개발자도 이해할 수 있도록 친절하게 설명해 주세요.

## 프로젝트 컨벤션

### 네이밍 규칙
- 클래스명: PascalCase
- 변수/함수명: camelCase
- 경로(URI): lowercase, kebab-case
- DB 컬럼: snake_case
- DTO 네이밍: XxxCreateRequest, XxxUpdateRequest, XxxResponse 형식

### 코드 스타일
- 들여쓰기: if문, for문 등은 한 줄이라도 반드시 중괄호 {} 사용
- 주석: 꼭 필요한 경우에만 작성, 들여쓰기에 맞게 정렬
- 객체 생성: Builder 패턴 사용 권장 (new 키워드 직접 사용 지양)
- API 문서화: RestDocs 사용 (Swagger 대신)

### 패키지 구조
- controller / service / repository / entity / dto(request, response) 준수
- URI: /api/v1 prefix

## 필수 체크 항목
- Entity를 Response로 직접 반환하지 않는지 확인 (DTO 변환 필요)
- Service에서 비즈니스 로직이 올바르게 분리되었는지 확인
- Repository에 불필요한 쿼리가 없는지 확인
- @Transactional 누락 여부 확인 (쓰기 작업에 반드시 필요)
- null 처리 및 예외 처리가 적절한지 확인
- 인증/인가가 필요한 API에 보안 처리가 되어있는지 확인

## N+1 문제 감지
- 연관 엔티티를 반복문 안에서 조회하는 패턴이 있는지 확인
- FetchType.LAZY 사용 시 추가 쿼리가 발생할 수 있는 상황 감지
- 해결 방법 (fetch join, @EntityGraph, Batch Size 등)을 학습 관점에서 설명

## develop 브랜치 머지 관점
- 기존 코드와 중복되는 로직이 있는지 확인
- 다른 도메인과의 의존성 문제가 생길 수 있는지 확인
- 변경 범위가 너무 넓어 머지 충돌 가능성이 있는지 경고

## 코드 리뷰 방향
- 단순히 문제를 지적하지 말고, 건설적이고 구체적인 피드백을 제시해 주세요
- 가능하다면 대안 코드 예시와 함께 학습에 도움이 되는 설명을 덧붙여 주세요
- 나쁜 예시: "이상함" / 좋은 예시: "NPE가 발생할 수 있으니 Optional 사용을 고려해보세요"
- 문제를 발견했을 때 "이렇게 하세요"가 아닌 "이런 이유로 이 방향을 고려해보세요" 형식으로 제안
- 개선 전/후 예시 코드를 간단히 보여주면 더 좋음
- 좋은 코드에는 긍정적인 피드백도 함께 달아주세요

## 보안 체크 항목

### 인증/인가
- JWT + Spring Security 적용 여부 확인
- 비밀번호 BCrypt 암호화 여부 확인 (평문 저장 절대 금지)
- 관리자/사용자 권한 분리가 올바르게 처리되었는지 확인

### 데이터 보호
- Native Query 사용 최소화, JPA 사용 권장
- 입력값 검증: @Valid, @NotNull 등 사용 여부 확인
- 로그 및 에러 메시지에 민감정보(비밀번호, 토큰, 개인정보 등) 포함 여부 확인

### 환경 보안
- application.yml에 민감정보(API 키, 비밀번호 등) 하드코딩 금지
- 민감정보는 반드시 .env → GitHub Secrets로 관리
- 민감정보가 하드코딩된 경우 반드시 경고 코멘트를 달아주세요
"""

[pr_description]
# PR 열릴 때 자동으로 설명 생성
publish_description_as_comment = false
add_original_user_description = true

[pr_code_suggestions]
# 코드 개선 제안 활성화
automatic_code_suggestions = true
max_code_suggestions = 5
extra_instructions = """
제안은 단순 수정이 아닌 학습 관점에서 작성해 주세요.
왜 이 방식이 더 나은지 이유를 함께 설명하고,
개선된 코드 예시를 보여주세요.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public ResponseEntity<ApiResponse<List<ClothesResponse>>> getFavoriteOwnedClothe
return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteOwnedClothes(userId)));
}

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

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

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

@Operation(summary = "옷 삭제", description = "등록된 보유 옷을 삭제합니다. 삭제 확인은 프론트에서 처리합니다.")
@Operation(summary = "옷 삭제", description = "등록된 보유/미보유 옷을 삭제합니다. 삭제 확인은 프론트에서 처리합니다.")
@DeleteMapping("/api/clothes/{clothesId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ResponseEntity<Void> deleteClothes(@PathVariable Long clothesId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.closetnangam.be.domain.clothes.controller;

import com.closetnangam.be.domain.clothes.dto.request.ClothesConvertToOwnedRequest;
import com.closetnangam.be.domain.clothes.dto.request.WishlistClothesCreateRequest;
import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse;
import com.closetnangam.be.domain.clothes.service.ClothesService;
import com.closetnangam.be.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RestController;

import java.util.List;

@Tag(name = "Wishlist Clothes", description = "미보유 옷(WISHLIST) CRUD API")
@RestController
@RequiredArgsConstructor
public class WishlistClothesController {

private final ClothesService clothesService;

// TODO: JWT 인증 구현 후 @PreAuthorize 또는 SecurityContextHolder로 userId 소유권 검증 추가 필요
@Operation(summary = "미보유 옷 목록 조회", description = "추천받아 저장한 옷, 관심 상품(WISHLIST) 목록을 조회합니다.")
@GetMapping("/api/users/{userId}/wishlist-clothes")
public ResponseEntity<ApiResponse<List<ClothesResponse>>> getWishlistClothes(@PathVariable Long userId) {
return ResponseEntity.ok(ApiResponse.ok(clothesService.getWishlistClothes(userId)));
}

@Operation(summary = "미보유 옷 즐겨찾기 목록 조회", description = "즐겨찾기로 표시한 미보유 옷 목록을 조회합니다.")
@GetMapping("/api/users/{userId}/wishlist-clothes/favorites")
public ResponseEntity<ApiResponse<List<ClothesResponse>>> getFavoriteWishlistClothes(@PathVariable Long userId) {
return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteWishlistClothes(userId)));
}

@Operation(summary = "미보유 옷 등록", description = "추천 상품 또는 외부 쇼핑 상품을 미보유 옷으로 저장합니다.")
@PostMapping("/api/users/{userId}/wishlist-clothes")
public ResponseEntity<ApiResponse<ClothesResponse>> createWishlistClothes(
@PathVariable Long userId,
@Valid @RequestBody WishlistClothesCreateRequest request
) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(clothesService.createWishlistClothes(userId, request)));
}

// TODO: JWT 인증 구현 후 SecurityContextHolder로 clothesId 소유권 검증 추가 필요
@Operation(summary = "미보유 → 보유 전환", description = "구매 후 미보유 옷을 보유 옷(OWNED)으로 전환합니다.")
@PatchMapping("/api/clothes/{clothesId}/convert-to-owned")
public ResponseEntity<ApiResponse<ClothesResponse>> convertToOwned(
@PathVariable Long clothesId,
@Valid @RequestBody ClothesConvertToOwnedRequest request
) {
return ResponseEntity.ok(ApiResponse.ok(clothesService.convertToOwned(clothesId, request)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.closetnangam.be.domain.clothes.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record ClothesConvertToOwnedRequest(
@NotBlank @Size(max = 100) String productCode,
@NotNull Boolean isVerified
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.closetnangam.be.domain.clothes.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

import java.util.List;

public record WishlistClothesCreateRequest(
@NotBlank @Size(max = 255) String name,
@NotBlank @Size(max = 100) String brandName,
@NotBlank @Size(max = 100) String productCode,
@NotBlank @Size(max = 500) String imageUrl,
@NotBlank @Size(max = 50) String category,
@NotBlank @Size(max = 50) String itemType,
@NotBlank @Size(max = 50) String color,
@NotEmpty List<@NotBlank String> styles,
@NotBlank @Size(max = 50) String externalSource,
@NotBlank @Size(max = 255) String externalProductId,
@NotBlank @Size(max = 500) String externalProductUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
@Table(name = "clothes")
public class Clothes extends BaseEntity {

private static final String EXTERNAL_NONE = "NONE";

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "clothes_id")
Expand Down Expand Up @@ -161,4 +163,16 @@ public void addStyleTag(ClothesStyleTag styleTag) {
public void updateFavorite(Boolean isFavorite) {
this.isFavorite = isFavorite;
}

public void convertToOwned(String productCode, Boolean isVerified) {
if (this.sourceType != SourceType.WISHLIST) {
throw new IllegalArgumentException("미보유 옷만 보유 옷으로 전환할 수 있습니다.");
}
this.sourceType = SourceType.OWNED;
this.externalSource = EXTERNAL_NONE;
this.externalProductId = EXTERNAL_NONE;
this.externalProductUrl = EXTERNAL_NONE;
this.productCode = productCode;
this.isVerified = isVerified;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.closetnangam.be.domain.catalog.entity.Style;
import com.closetnangam.be.domain.catalog.repository.StyleRepository;
import com.closetnangam.be.domain.catalog.service.CategoryCatalogService;
import com.closetnangam.be.domain.clothes.dto.request.ClothesConvertToOwnedRequest;
import com.closetnangam.be.domain.clothes.dto.request.ClothesCreateRequest;
import com.closetnangam.be.domain.clothes.dto.request.ClothesFavoriteRequest;
import com.closetnangam.be.domain.clothes.dto.request.ClothesUpdateRequest;
import com.closetnangam.be.domain.clothes.dto.request.WishlistClothesCreateRequest;
import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse;
import com.closetnangam.be.domain.clothes.entity.Clothes;
import com.closetnangam.be.domain.clothes.entity.ClothesStyleTag;
Expand Down Expand Up @@ -43,6 +45,18 @@ public List<ClothesResponse> getFavoriteOwnedClothes(Long userId) {
.toList();
}

public List<ClothesResponse> getWishlistClothes(Long userId) {
return clothesRepository.findAllByUserIdAndSourceType(userId, SourceType.WISHLIST).stream()
.map(ClothesResponse::from)
.toList();
}

public List<ClothesResponse> getFavoriteWishlistClothes(Long userId) {
return clothesRepository.findFavoritesByUserIdAndSourceType(userId, SourceType.WISHLIST).stream()
.map(ClothesResponse::from)
.toList();
}

public ClothesResponse getClothes(Long clothesId) {
Clothes clothes = getClothesWithDetails(clothesId);
return ClothesResponse.from(clothes);
Expand Down Expand Up @@ -76,6 +90,41 @@ public ClothesResponse createOwnedClothes(Long userId, ClothesCreateRequest requ
return ClothesResponse.from(saved);
}

@Transactional
public ClothesResponse createWishlistClothes(Long userId, WishlistClothesCreateRequest request) {
validateClassification(request.category(), request.itemType(), request.color(), request.styles());

Wardrobe wardrobe = wardrobeService.getOrCreateWardrobe(userId);

Clothes clothes = Clothes.builder()
.wardrobe(wardrobe)
.name(request.name())
.brandName(request.brandName())
.productCode(request.productCode())
.imageUrl(request.imageUrl())
.category(request.category())
.itemType(request.itemType())
.color(request.color())
.sourceType(SourceType.WISHLIST)
.externalSource(request.externalSource())
.externalProductId(request.externalProductId())
.externalProductUrl(request.externalProductUrl())
.isVerified(false)
.isFavorite(false)
.build();

applyStyleTags(clothes, request.styles());
Clothes saved = clothesRepository.save(clothes);
return ClothesResponse.from(saved);
}

@Transactional
public ClothesResponse convertToOwned(Long clothesId, ClothesConvertToOwnedRequest request) {
Clothes clothes = getClothesWithDetails(clothesId);
clothes.convertToOwned(request.productCode(), request.isVerified());
return ClothesResponse.from(clothes);
}

@Transactional
public ClothesResponse updateClothes(Long clothesId, ClothesUpdateRequest request) {
validateClassification(request.category(), request.itemType(), request.color(), request.styles());
Expand Down
Loading