Skip to content
Open
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
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,70 @@

## 전체 API 컨벤션

### Request/Response 구조 원칙
- **하나의 Controller에 하나의 Request, 하나의 Response**로 관리
- 어쩔 수 없는 예외 상황을 제외하면 Controller마다 전용 Request/Response 클래스 사용
- 예: `AdminApiController` → `AdminRequest`, `AdminResponse`

### POST API 패턴 (전체 공통)
- **모든 API는 POST + `consumes = MediaType.MULTIPART_FORM_DATA_VALUE` + `@ModelAttribute` 패턴 사용**
- 예시:
```java
@PostMapping(value = "/sign-in", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogMonitor
public ResponseEntity<AuthResponse> signIn(@ModelAttribute AuthRequest request) {
return ResponseEntity.ok(authService.signIn(request));
}
```

Comment on lines +16 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

문서 규칙과 실제 구현이 어긋날 수 있어 예외/범위를 명시해두는 게 좋겠습니다.

  • Line 17-25에서 “모든 API는 POST + multipart + @ModelAttribute”를 “전체 공통”으로 선언했는데, 이번 PR의 AdminApiController만 봐도 GET /dashboard/*, GET /items, GET /members, DELETE /items/{itemId}가 존재합니다. “예외 케이스(예: 조회는 GET, 삭제는 DELETE 등)”를 문서에 같이 적어두면 혼선이 줄어들어요.

또한 markdownlint 힌트(MD040)처럼 Line 55의 fenced code block에 언어가 빠져 있습니다.

제안 diff (MD040 해결)
@@
-  ```
+  ```md
   ## 인증(JWT): **필요/불필요**
@@
-  ```
+  ```

Also applies to: 52-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 16 - 26, Update the "POST API 패턴 (전체 공통)" section to
explicitly state exceptions (e.g., read-only endpoints using GET and destructive
endpoints using DELETE) and mention that controllers like AdminApiController
expose GET /dashboard/*, GET /items, GET /members and DELETE /items/{itemId} as
allowed deviations from the POST+multipart+@ModelAttribute rule; also fix the
missing fenced-code-block language by adding the appropriate language tag (e.g.,
```md) to the fenced block in the documentation so markdownlint MD040 is
satisfied.

### Response 원칙
- 프로젝트 전체적으로 Entity 객체를 DTO로 변환하지 않고 DB 값 그대로 Response에 담아서 전송
- 별도의 data class 변환 없이 JPA Entity를 직접 응답에 포함하는 것이 기본 원칙
- 일부 예외 케이스를 제외하면 Entity → DTO 매핑 없이 직접 반환

## API 문서화 컨벤션 (Swagger / @ApiChangeLog)

### 구조 원칙
- **각 Controller마다 전용 `*ControllerDocs` 인터페이스를 생성**하고, Controller는 이 인터페이스를 `implements`
- Swagger 어노테이션(`@Operation`, `@Tag`, `@ApiChangeLogs`)은 **인터페이스에만** 작성, 구현체(Controller)는 깔끔하게 유지
- 파일 위치: Controller와 **동일한 패키지**에 `{ControllerName}Docs.java`로 생성

### @ApiChangeLog 작성 규칙
- 라이브러리: `me.suhsaechan.suhapilog.annotation.ApiChangeLog` / `ApiChangeLogs`
- **날짜 형식**: `"YYYY.MM.DD"` (예: `"2026.02.27"`)
- **author**: `Author.SUHSAECHAN` 등 `com.romrom.common.dto.Author` 상수 사용
- **issueNumber**: GitHub Issue 번호 (int)
- **description**: 변경 내용 한 줄 설명
- 예시:
```java
@ApiChangeLogs({
@ApiChangeLog(date = "2026.02.27", author = Author.SUHSAECHAN, issueNumber = 552, description = "관리자용 물품 단건 조회 API 추가"),
})
```

### @Operation 작성 규칙
- **summary**: API 한 줄 요약
- **description**: 아래 마크다운 포맷 준수
```
## 인증(JWT): **필요/불필요**

## 요청 파라미터 (DTO명)
- **`fieldName`**: 설명

## 반환값 (DTO명)
- **`fieldName`**: 설명

## 에러코드
- **`ERROR_CODE`**: 설명
```

### Author 상수 목록 (com.romrom.common.dto.Author)
- `Author.SUHSAECHAN` = "서새찬"
- `Author.BAEKJIHOON` = "백지훈"
- `Author.WISEUNGJAE` = "위승재"
- `Author.KIMNAYOUNG` = "김나영"
- `Author.KIMKYUSEOP` = "김규섭"

## Admin API 컨벤션

### DTO 네이밍
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.romrom.common.dto;

import com.romrom.common.constant.AccountStatus;
import com.romrom.item.entity.postgres.Item;
import com.romrom.member.entity.Member;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
Expand All @@ -22,6 +25,13 @@ public class AdminResponse {
@Schema(description = "전체 카운트")
private Long totalCount;

// 단건 조회 응답 데이터
@Schema(description = "단건 물품 (상세 조회용)")
private Item item;

@Schema(description = "단건 회원 (상세 조회용)")
private Member member;

// 물품 관련 응답 데이터
@Schema(description = "페이지네이션된 물품 목록")
private Page<AdminItemDto> items;
Expand Down Expand Up @@ -89,11 +99,6 @@ public static class AdminItemDto {
@Schema(description = "수정일")
private LocalDateTime updatedDate;

public static AdminItemDto from(Object item, Object itemImages) {
// 이 메서드는 ItemService에서 구체적인 타입으로 구현되어야 합니다
// 현재는 타입 안전성을 위해 기본 구현만 제공
return AdminItemDto.builder().build();
}
}

@ToString
Expand All @@ -116,13 +121,16 @@ public static class AdminMemberDto {
@Schema(description = "이메일")
private String email;

@Schema(description = "활성 상태")
@Schema(description = "활성 상태 (isDeleted 반전)")
private Boolean isActive;

@Schema(description = "계정 상태 (ACTIVE, SUSPENDED 등)")
private AccountStatus accountStatus;

@Schema(description = "가입일")
private LocalDateTime createdDate;

@Schema(description = "최종 로그인일")
@Schema(description = "최종 활동일")
private LocalDateTime lastLoginDate;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.romrom.item.entity.postgres.Item;
import com.romrom.member.entity.Member;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -54,4 +55,7 @@ public interface ItemRepository extends JpaRepository<Item, UUID>, ItemRepositor

@Query("SELECT i.itemId FROM Item i WHERE i.member.memberId = :memberId AND i.isDeleted = false")
List<UUID> findAllIdsByMemberId(@Param("memberId") UUID memberId);

@Query("SELECT i FROM Item i LEFT JOIN FETCH i.member LEFT JOIN FETCH i.itemImages WHERE i.itemId = :itemId")
Optional<Item> findByItemIdWithDetails(@Param("itemId") UUID itemId);
Comment on lines +59 to +60
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "ItemRepository.java" | head -5

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 155


🏁 Script executed:

find . -type f -name "Item.java" | head -5

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 141


🏁 Script executed:

fd 'ItemRepository\.java$' -x cat -n {}

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 3095


🏁 Script executed:

fd 'Item\.java$' -x cat -n {}

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 4982


🏁 Script executed:

# Check if there are any tests for this method
fd -type f -name "*Test.java" | xargs rg -l "findByItemIdWithDetails" 2>/dev/null || echo "No tests found for this method"

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 415


🏁 Script executed:

# Search for any other similar patterns to understand if DISTINCT is already used elsewhere
rg "SELECT DISTINCT" RomRom-Domain-Item/src/main/java/com/romrom/item/repository/ 2>/dev/null || echo "No DISTINCT queries found in ItemRepository"

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 198


LEFT JOIN FETCH i.itemImages 단건 조회는 DISTINCT 없으면 중복 결과 예외 위험입니다.

Line 59의 컬렉션 fetch join(@OneToMany)은 행 중복을 생성하여 Optional<Item> 단건 반환에서 NonUniqueResultException을 발생시킬 수 있습니다. SELECT DISTINCT i로 수정이 필요합니다.

수정안
-  `@Query`("SELECT i FROM Item i LEFT JOIN FETCH i.member LEFT JOIN FETCH i.itemImages WHERE i.itemId = :itemId")
+  `@Query`("SELECT DISTINCT i FROM Item i LEFT JOIN FETCH i.member LEFT JOIN FETCH i.itemImages WHERE i.itemId = :itemId")
   Optional<Item> findByItemIdWithDetails(`@Param`("itemId") UUID itemId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Query("SELECT i FROM Item i LEFT JOIN FETCH i.member LEFT JOIN FETCH i.itemImages WHERE i.itemId = :itemId")
Optional<Item> findByItemIdWithDetails(@Param("itemId") UUID itemId);
`@Query`("SELECT DISTINCT i FROM Item i LEFT JOIN FETCH i.member LEFT JOIN FETCH i.itemImages WHERE i.itemId = :itemId")
Optional<Item> findByItemIdWithDetails(`@Param`("itemId") UUID itemId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@RomRom-Domain-Item/src/main/java/com/romrom/item/repository/postgres/ItemRepository.java`
around lines 59 - 60, The JPQL fetch join in the repository method
findByItemIdWithDetails can return duplicate rows (causing
NonUniqueResultException) because of the collection fetch (i.itemImages); update
the query string used by findByItemIdWithDetails to use SELECT DISTINCT i
instead of SELECT i (i.e., "SELECT DISTINCT i FROM Item i LEFT JOIN FETCH
i.member LEFT JOIN FETCH i.itemImages") so the Optional<Item> single-result
retrieval is safe.

}
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,23 @@ public AdminResponse getRecentItemsForAdmin(int limit) {

return AdminResponse.builder()
.items(adminItemDtoPage)
.totalCount((long) adminItemDtoPage.getContent().size())
.totalCount(adminItemDtoPage.getTotalElements())
.build();
}

/**
* 관리자용 물품 단건 조회
*/
@Transactional(readOnly = true)
public AdminResponse getItemDetailForAdmin(AdminRequest request) {
Item item = itemRepository.findByItemIdWithDetails(request.getItemId())
.orElseThrow(() -> {
log.error("관리자 물품 단건 조회 실패 - 존재하지 않는 itemId: {}", request.getItemId());
return new CustomException(ErrorCode.ITEM_NOT_FOUND);
});

return AdminResponse.builder()
.item(item)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,17 +297,34 @@ public AdminResponse getRecentMembersForAdmin(int limit) {
.profileUrl(member.getProfileUrl())
.email(member.getEmail())
.isActive(!member.getIsDeleted())
.accountStatus(member.getAccountStatus())
.createdDate(member.getCreatedDate())
.lastLoginDate(member.getUpdatedDate()) // 임시로 updatedDate 사용
.lastLoginDate(member.getLastActiveAt())
.build()
);

return AdminResponse.builder()
.members(adminMemberDtoPage)
.totalCount((long) adminMemberDtoPage.getContent().size())
.totalCount(adminMemberDtoPage.getTotalElements())
.build();
}

/**
* 관리자용 회원 단건 조회
*/
@Transactional(readOnly = true)
public AdminResponse getMemberDetailForAdmin(AdminRequest request) {
Member member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> {
log.error("관리자 회원 단건 조회 실패 - 존재하지 않는 memberId: {}", request.getMemberId());
return new CustomException(ErrorCode.MEMBER_NOT_FOUND);
});

return AdminResponse.builder()
.member(member)
.build();
}

Comment on lines +312 to +327
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 1. AdminRequest 검증 어노테이션 확인 =="
fd -t f 'AdminRequest.java' | head -5 | while read file; do
  echo "--- $file ---"
  cat -n "$file" || true
done

echo
echo "== 2. AdminApiController에서 getMemberDetail 관련 코드 확인 =="
fd -t f 'AdminApiController.java' | head -1 | while read file; do
  echo "--- $file ---"
  cat -n "$file" | grep -A 20 -B 5 "getMemberDetail\|@Valid\|@Validated" || true
done

echo
echo "== 3. MemberService에서 findMemberById 메서드 확인 =="
fd -t f 'MemberService.java' | head -1 | while read file; do
  echo "--- $file ---"
  cat -n "$file" | sed -n '330,345p'
done

echo
echo "== 4. MemberService에서 Admin 관련 메서드 검색 =="
fd -t f 'MemberService.java' | head -1 | while read file; do
  echo "--- $file ---"
  rg -n 'public.*Admin|getMemberDetail' "$file" || true
done

Repository: TEAM-ROMROM/RomRom-BE

Length of output: 5210


관리자 회원 단건 조회: memberId 누락 시 500 가능 + 기존 조회 로직 중복입니다.

  • AdminRequestmemberId 필드(line 76)가 검증 어노테이션 없이 선언되어 있고, 컨트롤러의 getMemberDetail 메서드(line 164)도 @Valid 또는 @Validated가 없어서, null 값이 그대로 memberRepository.findById(null)로 전달되어 Spring Data JPA가 IllegalArgumentException을 던집니다. 이는 500 에러를 발생시키는데, 400 응답이 올바릅니다.

  • 같은 클래스의 findMemberById(UUID memberId) 메서드(line 331~)가 동일한 로직을 처리하고 있어서, getMemberDetailForAdmin에서 이를 재사용하면 중복을 제거할 수 있습니다.

제안 diff (중복 제거 방향)
   `@Transactional`(readOnly = true)
   public AdminResponse getMemberDetailForAdmin(AdminRequest request) {
-    Member member = memberRepository.findById(request.getMemberId())
-        .orElseThrow(() -> {
-          log.error("관리자 회원 단건 조회 실패 - 존재하지 않는 memberId: {}", request.getMemberId());
-          return new CustomException(ErrorCode.MEMBER_NOT_FOUND);
-        });
+    // AdminRequest에 `@NotNull` + 컨트롤러에 `@Valid` 추가 필수
+    Member member = findMemberById(request.getMemberId());

     return AdminResponse.builder()
         .member(member)
         .build();
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@RomRom-Domain-Member/src/main/java/com/romrom/member/service/MemberService.java`
around lines 311 - 326, Ensure AdminRequest.memberId is validated and remove
duplicate lookup: add a `@NotNull` on AdminRequest.memberId and make the
controller getMemberDetail endpoint use `@Valid` or `@Validated` so null memberId
yields a 400; then change MemberService.getMemberDetailForAdmin(AdminRequest) to
call the existing findMemberById(UUID memberId) helper (or explicitly null-check
request.getMemberId() and throw a 400-mapped CustomException) instead of calling
memberRepository.findById(...) inline, reusing the centralized error handling in
findMemberById and eliminating the duplicated lookup/logging.

/**
* PK 기반 회원조회
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
@RequiredArgsConstructor
@RequestMapping("/api/admin")
@Slf4j
public class AdminApiController {
public class AdminApiController implements AdminApiControllerDocs {

private final AdminAuthService adminAuthService;
private final ItemService itemService;
private final MemberService memberService;
private final AdminReportService adminReportService;


@Override
@PostMapping(value = "/login", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AdminResponse> login(@ModelAttribute AdminRequest request,
HttpServletResponse response) {
Expand Down Expand Up @@ -71,6 +72,7 @@ public ResponseEntity<AdminResponse> login(@ModelAttribute AdminRequest request,
}
}

@Override
@PostMapping(value = "/logout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> logout(@CookieValue(value = "refreshToken", required = false) String refreshTokenFromCookie,
HttpServletResponse response) {
Expand Down Expand Up @@ -104,6 +106,7 @@ public ResponseEntity<Void> logout(@CookieValue(value = "refreshToken", required

// ==================== Dashboard ====================

@Override
@GetMapping("/dashboard/stats")
@LogMonitor
public ResponseEntity<AdminResponse> getDashboardStats() {
Expand All @@ -115,12 +118,14 @@ public ResponseEntity<AdminResponse> getDashboardStats() {
.build());
}

@Override
@GetMapping("/dashboard/recent-members")
@LogMonitor
public ResponseEntity<AdminResponse> getRecentMembers() {
return ResponseEntity.ok(memberService.getRecentMembersForAdmin(8));
}

@Override
@GetMapping("/dashboard/recent-items")
@LogMonitor
public ResponseEntity<AdminResponse> getRecentItems() {
Expand All @@ -129,12 +134,21 @@ public ResponseEntity<AdminResponse> getRecentItems() {

// ==================== Items ====================

@Override
@PostMapping(value = "/items/detail", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogMonitor
public ResponseEntity<AdminResponse> getItemDetail(@ModelAttribute AdminRequest request) {
return ResponseEntity.ok(itemService.getItemDetailForAdmin(request));
}

@Override
@GetMapping("/items")
@LogMonitor
public ResponseEntity<AdminResponse> getItems(@ModelAttribute AdminRequest request) {
return ResponseEntity.ok(itemService.getItemsForAdmin(request));
}

@Override
@DeleteMapping("/items/{itemId}")
@LogMonitor
public ResponseEntity<Void> deleteItem(@PathVariable UUID itemId) {
Expand All @@ -144,6 +158,14 @@ public ResponseEntity<Void> deleteItem(@PathVariable UUID itemId) {

// ==================== Members ====================

@Override
@PostMapping(value = "/members/detail", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogMonitor
public ResponseEntity<AdminResponse> getMemberDetail(@ModelAttribute AdminRequest request) {
return ResponseEntity.ok(memberService.getMemberDetailForAdmin(request));
}

@Override
@GetMapping("/members")
@LogMonitor
public ResponseEntity<AdminResponse> getMembers(@ModelAttribute AdminRequest request) {
Expand All @@ -152,6 +174,7 @@ public ResponseEntity<AdminResponse> getMembers(@ModelAttribute AdminRequest req

// ==================== Reports ====================

@Override
@PostMapping("/reports")
@LogMonitor
public ResponseEntity<AdminReportResponse> handleReports(@RequestBody AdminReportRequest request) {
Expand Down
Loading