Skip to content

[OT-148][FEAT]: 마이페이지 집계, 회원 탈퇴 API 구현 #91

Merged
marulog merged 37 commits intodevelopfrom
OT-148-feature/mypage-statistics-api
Mar 4, 2026
Merged

[OT-148][FEAT]: 마이페이지 집계, 회원 탈퇴 API 구현 #91
marulog merged 37 commits intodevelopfrom
OT-148-feature/mypage-statistics-api

Conversation

@marulog
Copy link
Copy Markdown
Collaborator

@marulog marulog commented Mar 2, 2026

📝 작업 내용

  • 시청이력 기반 태그 랭킹 조회 API
    wath_history 기준 달력 월(1일 ~ 말일) 단위로 시청 횟수 집계
    상위 4개 태그 반환, 나머지는 기타 항목으로 합산
    lastWatchedAt 컬럼 기준 goe / lt 조건으로 집계

현재 watch_history에 (member_id, contetns_id) 유니크 제약 조건이 없어 중복 row로
인한 집계 오염 가능성이 있음. 해당 파트 API 담당자와 협의 필요

현재 서버 시간대 기준으로 집계마감일을 선정하지만, 경계값 테스트에서 잘못된 구간이 나올 가능성이 있기 때문에
클라이언트에서 쿼리 파라미터로 넘겨주는 방식이 좋아보임

  • 태그별 월간 시청 횟수 비교 API
    태그 ID 기반으로 금월/ 전월 시청 횟수 집계
    이전 달 기록이 없는 경우(신규 유저 포함) previousMonth = null 반환

현재 서버 시간대 기준으로 집계마감일을 선정하지만, 경계값 테스트에서 잘못된 구간이 나올 가능성이 있기 때문에
클라이언트에서 쿼리 파라미터로 넘겨주는 방식이 좋아보임

  • 태그별 추천 콘텐츠 목록 조회 API
    해당 리스트는 시리즈 자체 또는 단편 콘텐츠만 반환 (시리즈 에피소드 제외)
    mediaId, mediaType, poseterUrl 반환 - 상세 페이지 라우팅용
    북마크 수 내림차순 정렬, 최대 20개 반환

  • 시청이력 기반 플레이리스트 조회 API
    전체 시청이력을 최신순으로 10개씩 페이징
    시리즈 에피소드 및 단편 콘텐츠만 반환(숏폼, 시리즈 자체 제외) -> 실제 재생 가능한 영상이 기준
    contentsId 반환 - 상세 페이지 라우팅용
    PageableExecutionUtils 적용으로 마지막 페이지 count 쿼리 skip -> 연산 비용 최적화

  • 카카오 API를 통한 회원 탈퇴
    어드민 키 방식으로 카카오 서버에 연결 끊기 (unlink) 요청
    이후 연관 데이터 Soft Delete 처리
    member.status = DELETE, refreshToken = null
    preferred_tag -> bookmark -> likes -> watch_history -> playback -> comment
    soft Delete 방식으로 재가입시 provider_id기반 멤버 row 재활용
    회원 탈퇴에 더 자세한 사항은 노션 개발 위키 참고 바랍니다.

member테이블을 제외한 다른 테이블 soft delete 시 해당 벌크 연산으로 인해 영속성 컨텍스트가 비워지는 이슈 발생
member의 상태를 먼저 지우고 그 외 테이블 상태값을 변경하여 회원이 살아있는 문제점을 해결했습니다.

@Modifying(clearAutomatically = true)
@Query("UPDATE PreferredTag pt SET pt.status = 'DELETE' WHERE pt.member.id = :memberId")
void softDeleteAllByMemberId(@Param("memberId") Long memberId);

Access Token은 JWT Stateless 구조상 탈퇴 후 최대 30분간 유효합니다.
추후 redis나 블랙리스트를 도입하여 해당 이슈를 해결해야 합니다.

카카오 API 호출을 통한 회원탈퇴로 인한 env 수정사항 있습니다. 해당 PR 승인 이후 업데이트 하겠습니다.

📷 스크린샷

  • 상위 TOP5 태그 조회
image
  • 태그 별 월간 시청횟수 집계
image
  • 태그 별 추천 리스트 조회
image
  • 시청이력 리스트 조회
image
  • 회원 탈퇴
    회원 탈퇴 전
회원탈퇴 전 image

회원 탈퇴 후
회원탈퇴 후
image
회원탈퇴 후2

☑️ 체크 리스트

체크 리스트를 확인해주세요

  • 테스트는 잘 통과했나요?
  • 충돌을 해결했나요?
  • 이슈는 등록했나요?
  • 라벨은 등록했나요?

#️⃣ 연관된 이슈

close #87

💬 리뷰 요구사항

API를 구현하면서 시리즈, 시리즈물, 콘텐츠, 숏폼에 대하여
많이 헷갈렸기 때문에 최대한 자세히 적었습니다.
그럼에도 문제가 발생할 수 있기 때문에 기획부분이랑 같이 점검 해주시면
감사하겠습니다.

  • user
    현재 soft delete로 관리하기 때문에 회원 탈퇴 후 재가입 시 같은 provider_id가 부여되어 row를 재활용합니다.
    그 외 테이블은 delete 처리되어 새로운 row가 생성됩니다.
    이거에 따른 row 관리가 필요해보입니다. (추후 너무 많은 row가 생길 가능성이 존재 member테이블 제외)
    추가적으로 회원 탈퇴 시, 기획적으로 새로운 row를 생성할 지, 현재 처럼
    일정 기간동안 복구 가능하도록 유지할 지 결정해야될 것 같습니다.

  • amdin
    어드민은 따로 회원탈퇴를 만들지 않았습니다. 추후에도 아마 변동 가능성 없습니다.

  • 공통
    현재 로그아웃 시 DB&브라우저 상에서 토큰만 제거하지만
    2차 MVP에서 비밀번호 암호화 및 카카오 API를 사용한 토큰 제거(카카오 서비스 이용 불가)를 리팩토링 할 예정입니다.

  • 프로젝션 사용
    해당 집계 API는 row가 많은 수록 연산 비용이 증가하기 때문에
    프로젝션을 사용하여 최소한의 컬럼만 조회했습니다.

  • env 변경
    카카오 회원탈퇴 API 연동에 따른 설정 파일 수정이 있습니다.
    노션 참고 바랍니다.

Summary by CodeRabbit

  • 새로운 기능

    • 회원 탈퇴(카카오 연동 해제 포함) 및 관련 데이터 일괄 삭제(즐겨찾기·좋아요·시청기록 등)
    • 온보딩 건너뛰기 옵션
    • 태그별 추천 콘텐츠 플레이리스트 및 시청 기록 플레이리스트(페이지네이션)
    • 태그 순위 조회 및 월별 비교
  • 개선사항

    • 일부 엔드포인트의 성공 응답 상태 코드를 200→204로 변경하여 본문 없이 응답함

@marulog marulog self-assigned this Mar 2, 2026
@marulog marulog added chore 설정 파일 등 변경 (.gitignore, .yml 등) feat 새로운 기능 구현 labels Mar 2, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 2, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8880cb1d-1d35-4ffb-a75b-13e62aabb97b

📥 Commits

Reviewing files that changed from the base of the PR and between d25277a and 2ac3719.

📒 Files selected for processing (21)
  • apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java
  • apps/api-admin/src/main/resources/application.yml
  • apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java
  • apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java
  • apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java
  • apps/api-user/src/main/resources/application.yml
  • docker-compose.yml
  • modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java
  • modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java
  • modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java
  • modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java
  • modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java

Walkthrough

회원 탈퇴 및 온보딩 건너뛰기 엔드포인트와 시청 이력/태그 기반 플레이리스트·통계 API를 추가하고, Kakao 연동 해제 클라이언트, RestTemplate 설정, 도메인별 소프트 삭제 쿼리 및 관련 프로젝션/DTO를 도입했습니다.

Changes

Cohort / File(s) Summary
Kakao 연동 해제 / HTTP 클라이언트
apps/api-user/src/main/java/.../auth/client/KakaoUnlinkClient.java, apps/api-user/src/main/java/.../config/RestTemplateConfig.java, apps/api-user/src/main/resources/application.yml, docker-compose.yml
Kakao unlink 클라이언트 추가(관리자키 사용). RestTemplate을 Apache HttpClient 기반으로 빈 등록. kakao.unlink-url, kakao.admin-key 설정 및 도커 env 추가.
회원 탈퇴 / 온보딩
apps/api-user/src/main/java/.../member/controller/MemberApi.java, apps/api-user/src/main/java/.../member/controller/MemberController.java, apps/api-user/src/main/java/.../member/service/MemberService.java, modules/domain/src/main/java/.../member/domain/Member.java
DELETE /me, POST /me/onboarding/skip 엔드포인트 추가. 회원 withdraw/reactivate 도메인 메서드 추가. 탈퇴 시 Kakao unlink 호출 및 관련 엔티티 소프트 삭제 로직 추가.
저장소 소프트 삭제 확장
modules/domain/src/main/java/.../bookmark/BookmarkRepository.java, .../likes/LikesRepository.java, .../comment/CommentRepository.java, .../playback/PlaybackRepository.java, .../preferred_tag/PreferredTagRepository.java, .../watch_history/WatchHistoryRepository.java, .../click_event/ClickRepository.java
여러 리포지토리에 memberId 기반 bulk soft-delete(@Modifying JPQL) 및 likes/bookmark 관련 카운트 감소 쿼리 추가.
Bookmark 프로젝션/조회 개선
modules/domain/src/main/java/.../bookmark/BookmarkMediaProjection.java, modules/domain/src/main/java/.../bookmark/BookmarkRepositoryCustom.java, modules/domain/src/main/java/.../bookmark/BookmarkRepositoryImpl.java, apps/api-user/src/main/java/.../bookmark/dto/response/BookmarkMediaResponse.java, apps/api-user/src/main/java/.../bookmark/service/BookmarkService.java
프로젝션 기반 조회로 전환(BookmarkMediaProjection), positionSec/duration/mediaType 포함. 커스텀 리포지토리/Impl과 DTO 매핑 변경.
시청 이력 및 태그 집계
modules/domain/src/main/java/.../watch_history/RecentWatchProjection.java, TagRankingProjection.java, WatchHistoryRepositoryCustom.java, WatchHistoryRepositoryImpl.java, apps/api-user/src/main/java/.../playlist/dto/response/RecentWatchResponse.java
시청 이력 프로젝션 및 태그 랭킹 프로젝션 추가. 집계·페이징용 커스텀 쿼리 및 DTO 추가.
플레이리스트/추천(태그) API
apps/api-user/src/main/java/.../playlist/controller/PlayListAPI.java, .../playlist/controller/PlaylistController.java, apps/api-user/src/main/java/.../playlist/service/PlaylistService.java, modules/domain/src/main/java/.../media/MediaRepositoryCustom.java, modules/domain/src/main/java/.../media/MediaRepositoryImpl.java, modules/domain/src/main/java/.../media/TagContentProjection.java, apps/api-user/src/main/java/.../playlist/dto/response/TagPlaylistResponse.java
태그별 추천 및 시청이력 기반 플레이리스트 엔드포인트 추가. MediaRepository에 태그 기반 추천 쿼리 및 TagContentProjection 추가.
태그 통계 API
apps/api-user/src/main/java/.../tag/controller/TagAPI.java, .../tag/controller/TagController.java, apps/api-user/src/main/java/.../tag/service/TagService.java, apps/api-user/src/main/java/.../tag/dto/response/TagRankingResponse.java, TagMonthlyCompareResponse.java
회원별 월간 태그 랭킹 및 태그별 월간 비교 API 추가(상위 N + ETC 집계).
응답 상태/문서·사소한 개선
apps/api-user/src/main/java/.../auth/controller/AuthApi.java, apps/api-user/src/main/java/.../bookmark/controller/BookmarkController.java, apps/api-user/src/main/java/.../likes/controller/LikesAPI.java, apps/api-admin/src/main/java/.../config/SecurityConfig.java, modules/common-web/src/main/java/.../WebMvcConfig.java, apps/api-admin/src/main/resources/application.yml, apps/api-user/build.gradle
여러 엔드포인트의 성공 응답을 200→204로 변경. Swagger 백오피스 경로 허용, 정적 리소스 경로 확장, httpclient5 의존성 추가.
공통 에러 코드 변경
modules/common-web/src/main/java/.../exception/ErrorCode.java
FORBIDDEN(A004) 제거 및 KAKAO_UNLINK_FAILED(A005, BAD_GATEWAY) 추가 — Kakao unlink 실패용 에러 코드 도입.
기타 리팩토링/임포트 정리
여러 파일 (ContentsService, MediaRepository 등)
불필요한 import/주석 제거, Lombok 어노테이션 일부 삭제(빌더/AllArgs 제거) 등 정리 작업.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client as 사용자(Client)
participant Controller as MemberController
participant Service as MemberService
participant KakaoClient as KakaoUnlinkClient
participant Repos as Repositories(DB)
Client->>Controller: DELETE /me (인증)
Controller->>Service: withdraw(memberId)
Service->>Repos: findByIdAndStatus(memberId, ACTIVE)
alt provider == KAKAO
Service->>KakaoClient: unlink(providerId)
KakaoClient->>KakaoAPI: POST /v1/user/unlink (AdminKey)
KakaoAPI-->>KakaoClient: 200 / error
KakaoClient-->>Service: success / 예외
end
Service->>Repos: softDeleteAllByMemberId(...) (bookmarks, likes, watch history, comments, clicks, preferred tags, playback)
Service->>Repos: member.withdraw() 저장 (status=DELETE, refreshToken clear)
Service-->>Controller: 완료
Controller-->>Client: 204 No Content

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • phonil
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive All changes are scoped to the two main objectives. However, some modifications extend beyond direct requirements: ErrorCode.FORBIDDEN removal and replacement with KAKAO_UNLINK_FAILED, WebMvcConfig resource handler expansion, and API response code changes (200→204) for unrelated endpoints may not be directly required. Clarify whether ErrorCode.FORBIDDEN removal, WebMvcConfig changes, and HTTP status code modifications (200→204) for auth/bookmark/likes endpoints are intentional refactorings or accidental changes unrelated to issue #87 objectives.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly describes the main changes: implementing my-page aggregation and member withdrawal APIs (마이페이지 집계, 회원 탈퇴 API 구현), which aligns with the changeset.
Linked Issues check ✅ Passed The PR implements both requirements from issue #87: tag ranking API from watch history [OT-148] and member withdrawal API with Kakao unlink functionality, with comprehensive supporting infrastructure changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch OT-148-feature/mypage-statistics-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@marulog marulog requested a review from phonil March 2, 2026 14:36
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java (1)

5-12: ⚠️ Potential issue | 🔴 Critical

중복 import와 JPQL enum 처리 문제를 수정하세요.

파일에 중복된 import가 있어 컴파일이 실패합니다:

  • Lines 3-4와 11-12에서 Comment, Status가 중복 import되어 있습니다. 11-12를 제거하세요.

또한 다음 문제들도 있습니다:

  • Line 5의 wildcard import로 인해 line 8의 JpaRepository 명시적 import는 중복입니다.
  • Line 36의 JPQL 쿼리에서 문자열 리터럴 'DELETE' 대신 enum 참조 Status.DELETE를 사용하거나 파라미터로 전달하세요. 현재 방식은 타입 안전성이 없습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java`
around lines 5 - 12, Remove the duplicate imports and fix the wildcard vs
explicit import: in CommentRepository remove the repeated imports of Comment and
Status (the second set) and either delete the wildcard import
org.springframework.data.jpa.repository.* or the explicit import of
JpaRepository so only one import for JpaRepository remains; then fix the JPQL
that uses the string literal 'DELETE' (the query in the `@Query` on
CommentRepository that currently filters by status = 'DELETE') to use a typed
enum parameter instead (change the JPQL to use status = :status and add a
`@Param`("status") Status status parameter to the repository method, or
alternatively reference the enum constant in a type-safe way), ensuring the
repository method signature is updated accordingly.
🧹 Nitpick comments (3)
apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java (1)

16-17: 컬렉션 필드명은 List suffix 규칙에 맞추는 것이 좋습니다.

내부 필드명을 rankingList로 바꾸고, 외부 응답 키는 @JsonProperty("rankings")로 유지하면 계약과 가이드라인을 동시에 맞출 수 있습니다.

As per coding guidelines, "Collection variable names with List suffix".

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

In
`@apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java`
around lines 16 - 17, The field name in TagRankingResponse doesn't follow the
"List" suffix convention; rename the private field rankings to rankingList in
the TagRankingResponse class and keep the external JSON key by annotating the
field (or its getter) with `@JsonProperty`("rankings"); also update any
constructors, getters/setters (or Lombok annotations) and usages of
TagRankingResponse/rankings to use rankingList to ensure compilation and
preserve the API contract.
modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java (1)

108-108: 컬렉션 변수명은 List 접미사로 통일해주세요.

contentList<RecentWatchProjection> 타입이므로 contentList처럼 명시적인 이름이 가독성에 더 좋습니다.

As per coding guidelines **/*.java: Collection variable names with List suffix.

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

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`
at line 108, Rename the collection variable 'content' (of type
List<RecentWatchProjection>) to 'contentList' to follow the coding guideline
that List-typed collection variables use a List suffix; update all references to
this variable within the same method (the queryFactory result assignment and any
subsequent uses) so the code compiles and maintains consistent naming (e.g.,
change occurrences of 'content' to 'contentList' in WatchHistoryRepositoryImpl).
apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java (1)

160-163: 컬렉션 변수명에 List 접미사를 맞추는 것이 좋습니다.

Line 160의 tagRankingProjections, Line 163의 rankItems는 컬렉션 타입이므로 규칙에 맞춰 ...List 형태로 통일해 주세요.

As per coding guidelines "Collection variable names with List suffix".

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

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`
around lines 160 - 163, Rename the collection variables in MemberService to use
the List suffix and update all their usages: change tagRankingProjections to
tagRankingProjectionList (the List<TagRankingProjection> returned by
watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween) and change
rankItems to rankItemList (the List<TagRankItem>), then update any subsequent
references in the method to the new names so compilation and behavior remain the
same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java`:
- Line 46: The RestTemplate call in KakaoUnlinkClient is missing timeouts and
logs sensitive providerId; update RestTemplateConfig/HttpClientFactory to create
a RestTemplate/HttpClient with explicit connectTimeout and readTimeout and
ensure KakaoUnlinkClient uses that configured RestTemplate for the postForEntity
call, and remove or mask any logging of providerId in KakaoUnlinkClient (or
surrounding methods) so user identifiers are not written to logs.

In `@apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java`:
- Around line 10-13: The RestTemplate bean in RestTemplateConfig#restTemplate
lacks timeouts; replace its creation to use a RestTemplateBuilder (inject
RestTemplateBuilder into the config) and configure connection and read (socket)
timeouts via builder.requestFactory or builder.setConnectTimeout and
builder.setReadTimeout (using Duration values) before calling build(), so the
RestTemplate returned from restTemplate() has both connection and read timeouts
set to prevent indefinite blocking when calling external services like Kakao.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java`:
- Around line 215-220: The endpoint path for getWatchHistoryPlaylist in the
MemberApi interface (method getWatchHistoryPlaylist) is "/me/playlist" but the
implementation in MemberController uses "/me/history/playlist"; update one side
so both match: either change MemberApi.getWatchHistoryPlaylist to
"/me/history/playlist" or change MemberController's mapping to "/me/playlist"
and then run compilation and API contract tests to ensure route consistency
across interface and implementation and client/docs.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java`:
- Around line 92-99: The withdraw endpoint currently soft-deletes a member but
does not prevent already-issued JWTs from being used; update authentication flow
to immediately reject tokens from withdrawn accounts: add a withdrawn-state
check in your authentication/authorization layer (e.g., in the JWT filter or
AuthenticationProvider used by Spring Security) that calls
memberService.isWithdrawn(memberId) (or similar) and fails authentication if
true, or implement immediate token invalidation (e.g., write the token’s jti to
a blacklist/Redis upon MemberController.withdraw(...) invocation using
memberService.withdraw(memberId) so the JWT filter checks the blacklist on each
request); ensure the symbols to change are MemberController.withdraw,
memberService.withdraw, and the JWT authentication filter/AuthenticationProvider
that resolves the authenticated principal.
- Around line 19-22: Add explicit role-based authorization to the
MemberController to enforce the "Strict USER/EDITOR/ADMIN authorization checks"
rule: annotate the class or individual endpoint methods from MemberController
(which implements MemberApi) with Spring Security's `@PreAuthorize` (import
org.springframework.security.access.prepost.PreAuthorize) and specify the
required roles (e.g., hasAnyRole('USER','EDITOR','ADMIN') or more restrictive
expressions per endpoint). Ensure every public API method declared in MemberApi
that MemberController exposes has an appropriate `@PreAuthorize` expression (or
apply at class level if all endpoints share the same requirement) so the
authorization contract is fixed in code.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java`:
- Line 3: RecentWatchResponse currently depends directly on
RecentWatchProjection; remove that module-level coupling by changing
RecentWatchResponse to accept primitive/value types (e.g., id, title, watchedAt,
etc.) via constructor or static factory, and move the mapping from
RecentWatchProjection into the service layer (e.g., in the service method that
currently returns RecentWatchProjection map each projection to new
RecentWatchResponse(id, title, watchedAt)). Update any usages that construct
RecentWatchResponse from RecentWatchProjection to call the new
constructor/factory and import only DTO types in the web layer.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`:
- Around line 289-304: The Kakao unlink external call
(kakaoUnlinkClient.unlink(...)) is performed inside the same flow as local
soft-deletes (member.withdraw(),
preferredTagRepository.softDeleteAllByMemberId(...), etc.), risking inconsistent
state if the DB transaction fails after the external call; move the external
call out of the transaction by doing the DB soft-delete first within the
transactional method (invoke member.withdraw() and all
softDeleteAllByMemberId(...) calls), then trigger the
kakaoUnlinkClient.unlink(...) asynchronously after commit—use
TransactionSynchronizationManager.registerSynchronization or publish an event
handled by an `@TransactionalEventListener`(phase = AFTER_COMMIT) or implement an
outbox/retry mechanism so the external unlink is executed post-commit and
retried on failure.
- Around line 205-213: The end-of-month bound uses 23:59:59 (currentEnd and
prevEnd) which can drop events in the final second or with subsecond timestamps;
change both end bounds to the exclusive next-month start (e.g.,
currentYearMonth.plusMonths(1).atDay(1).atStartOfDay() and
prevYearMonth.plusMonths(1).atDay(1).atStartOfDay()) so the call to
watchHistoryRepository.countByMemberIdAndTagIdAndWatchedBetween(memberId, tagId,
currentStart, currentEnd) and the analogous previousCount call use an exclusive
end that safely includes the entire month.
- Around line 157-158: The current start/end date calculation in MemberService
uses a rolling window (LocalDateTime endDate = LocalDateTime.now();
LocalDateTime startDate = endDate.minusMonths(1)), which produces a 1-month
period relative to now instead of the previous calendar month; change the logic
to compute the previous calendar month boundaries — set startDate to the first
day of the previous month at start-of-day and endDate to the last day of the
previous month at end-of-day (use YearMonth.of(...).atDay(1) and
YearMonth.lengthOfMonth() or equivalent) so the monthly tag ranking aggregates
by complete calendar month.

In
`@modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java`:
- Around line 36-37: The enum entries FORBIDDEN and KAKAO_UNLINK_FAILED in
ErrorCode share the same code "A004"; update KAKAO_UNLINK_FAILED to use a unique
error code (e.g., "A005" or the next unused A-series code) within the ErrorCode
enum so classification and client branching remain unambiguous, and update any
usages or tests that reference KAKAO_UNLINK_FAILED accordingly (search for
KAKAO_UNLINK_FAILED and ErrorCode references to adjust expectations).

In
`@modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java`:
- Around line 35-37: The JPQL update in
CommentRepository.softDeleteAllByMemberId uses a string literal ("DELETE") for
the enum field c.status; change it to use the enum literal syntax (e.g.,
com.ott.domain.comment.domain.Comment.Status.DELETE) so JPQL is type-safe —
update the `@Query` to set c.status = <EnumType>.<ENUM_VALUE> referencing the
actual enum type and constant, and apply the same change for the same pattern in
WatchHistoryRepository, PlaybackRepository, BookmarkRepository, LikesRepository,
and PreferredTagRepository to ensure consistent, type-safe enum usage across
repositories.

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`:
- Around line 22-24: The JPQL currently assigns the string 'DELETE' to
Likes.status; change it to use the Status enum literal for type safety by
updating the query in softDeleteAllByMemberId to set l.status to the enum
constant (e.g., the fully-qualified Status.DELETE enum) instead of the raw
string; reference the Likes.status field and the Status enum (e.g.,
com.ott.domain.likes.model.Status) when making this change so the query uses the
enum literal rather than a string.

In
`@modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java`:
- Around line 19-21: The current softDeleteAllByMemberId in PlaybackRepository
updates the wrong entity (WatchHistory); change the JPQL to update the Playback
entity instead so Playback rows are soft-deleted on account removal. Locate the
method softDeleteAllByMemberId in class PlaybackRepository and replace the query
target from WatchHistory to Playback (i.e., "UPDATE Playback p SET p.status =
'DELETE' WHERE p.member.id = :memberId"), keeping the `@Modifying`, `@Query` and
`@Param` annotations intact and ensure the entity alias and field names (status,
member.id) match the Playback entity.

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`:
- Around line 122-137: The queries in WatchHistoryRepositoryImpl (the fetch and
the countQuery built with queryFactory and watchHistory) only filter by memberId
and status (ACTIVE) and thus miss the PR requirement to restrict to the last 3
months and exclude short-form content; add identical predicates to both queries:
a date predicate using watchHistory.lastWatchedAt (e.g., .gte(threeMonthsAgo) or
.after(threeMonthsAgo) where threeMonthsAgo is computed as
LocalDateTime.now().minusMonths(3)) and a short-form exclusion predicate on the
content type (e.g., watchHistory.content.type.ne(SHORT_FORM) or
watchHistory.content.isShortForm.eq(false) depending on your domain enum/field),
so both the fetch and countQuery include memberId, status, lastWatchedAt >=
threeMonthsAgo, and content != SHORT_FORM.

---

Outside diff comments:
In
`@modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java`:
- Around line 5-12: Remove the duplicate imports and fix the wildcard vs
explicit import: in CommentRepository remove the repeated imports of Comment and
Status (the second set) and either delete the wildcard import
org.springframework.data.jpa.repository.* or the explicit import of
JpaRepository so only one import for JpaRepository remains; then fix the JPQL
that uses the string literal 'DELETE' (the query in the `@Query` on
CommentRepository that currently filters by status = 'DELETE') to use a typed
enum parameter instead (change the JPQL to use status = :status and add a
`@Param`("status") Status status parameter to the repository method, or
alternatively reference the enum constant in a type-safe way), ensuring the
repository method signature is updated accordingly.

---

Nitpick comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java`:
- Around line 16-17: The field name in TagRankingResponse doesn't follow the
"List" suffix convention; rename the private field rankings to rankingList in
the TagRankingResponse class and keep the external JSON key by annotating the
field (or its getter) with `@JsonProperty`("rankings"); also update any
constructors, getters/setters (or Lombok annotations) and usages of
TagRankingResponse/rankings to use rankingList to ensure compilation and
preserve the API contract.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`:
- Around line 160-163: Rename the collection variables in MemberService to use
the List suffix and update all their usages: change tagRankingProjections to
tagRankingProjectionList (the List<TagRankingProjection> returned by
watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween) and change
rankItems to rankItemList (the List<TagRankItem>), then update any subsequent
references in the method to the new names so compilation and behavior remain the
same.

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`:
- Line 108: Rename the collection variable 'content' (of type
List<RecentWatchProjection>) to 'contentList' to follow the coding guideline
that List-typed collection variables use a List suffix; update all references to
this variable within the same method (the queryFactory result assignment and any
subsequent uses) so the code compiles and maintains consistent naming (e.g.,
change occurrences of 'content' to 'contentList' in WatchHistoryRepositoryImpl).

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 183948d and 0b87148.

📒 Files selected for processing (27)
  • apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java
  • apps/api-user/src/main/java/com/ott/api_user/auth/service/KakaoAuthService.java
  • apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java
  • apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagContentResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagMonthlyCompareResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/member/dto/response/TagRankingResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java
  • apps/api-user/src/main/resources/application.yml
  • docker-compose.yml
  • modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java
  • modules/domain/src/main/java/com/ott/domain/comment/repository/CommentRepository.java
  • modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/TagContentProjection.java
  • modules/domain/src/main/java/com/ott/domain/member/domain/Member.java
  • modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java
  • modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/TagRankingProjection.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepository.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryCustom.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java

Comment thread apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java Outdated
Comment thread apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java Outdated
Comment thread modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java Outdated
Copy link
Copy Markdown
Contributor

@phonil phonil left a comment

Choose a reason for hiding this comment

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

고생하셨어요~ 확인해주세요!

)
})
@GetMapping("/me/tag/ranking")
ResponseEntity<SuccessResponse<TagRankingResponse>> getTagRanking(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

'태그' 랭킹 조회니까 Member보단 태그같이 다른 곳에 놓는 건 어때요?
마이페이지 - 내 댓글 목록 조회도 Comment에 놓는 것처럼요

)
})
@GetMapping("/me/tag/ranking/{tagId}")
ResponseEntity<SuccessResponse<TagMonthlyCompareResponse>> getTagMonthlyCompare(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

마찬가지입니다

)
})
@GetMapping("/me/taglist/{tagId}")
ResponseEntity<SuccessResponse<List<TagContentResponse>>> getRecommendContentsByTag(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

마찬가지입니다

content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@GetMapping("/me/playlist")
ResponseEntity<SuccessResponse<PageResponse<RecentWatchResponse>>> getWatchHistoryPlaylist(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

마찬가지입니다

Comment thread apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java (1)

53-54: ⚠️ Potential issue | 🟡 Minor

에러 코드 B009가 중복되어 클라이언트 분기 시 오류 구분이 불가능합니다.

COMMENT_FORBIDDENUNSUPPORTED_IMAGE_EXTENSION이 동일한 코드 B009를 사용하고 있습니다. 클라이언트에서 에러 유형을 구분할 수 없으므로 고유한 코드로 수정이 필요합니다.

🐛 수정 제안
     COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "B009", "본인이 작성한 댓글만 수정/삭제할 수 있습니다."),
-    UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B009", "지원하지 않는 이미지 확장자입니다."),
+    UNSUPPORTED_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "B020", "지원하지 않는 이미지 확장자입니다."),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java`
around lines 53 - 54, The enum ErrorCode has duplicate code "B009" for
COMMENT_FORBIDDEN and UNSUPPORTED_IMAGE_EXTENSION which prevents clients from
distinguishing errors; update one of them (e.g., change
UNSUPPORTED_IMAGE_EXTENSION or COMMENT_FORBIDDEN) to a unique code value in the
ErrorCode enum so each constant has a distinct error code, then run tests and
any usages that switch/compare on these codes to ensure callers use the new
unique identifier (look for references to COMMENT_FORBIDDEN and
UNSUPPORTED_IMAGE_EXTENSION to update dependent logic if needed).
apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java (1)

46-61: ⚠️ Potential issue | 🔴 Critical

백오피스 Swagger 엔드포인트를 permitAll로 여는 것은 관리자 영역 보안 경계를 약화시킵니다.

/back-office/** 네임스페이스의 문서 엔드포인트는 운영 기준에서 인증(최소 ADMIN) 또는 dev/local 프로파일 한정 공개로 제한하는 것이 안전합니다.

수정 방향 예시
                 .authorizeHttpRequests(auth -> auth
                         .requestMatchers(
                                 "/actuator/health/**",
                                 "/actuator/info",
                                 "/back-office/login",
                                 "/back-office/reissue",
                                 "/swagger-ui/**",
                                 "/v3/api-docs/**",
                                 "/swagger-resources/**"
                         ).permitAll()
+                        .requestMatchers(
+                                "/back-office/swagger-ui/**",
+                                "/back-office/v3/api-docs/**",
+                                "/back-office/swagger-resources/**"
+                        ).hasRole("ADMIN")
                         .requestMatchers("/back-office/admin/**").hasRole("ADMIN")
                         .anyRequest().hasAnyRole("ADMIN", "EDITOR")
                 )

As per coding guidelines, apps/api-admin/**: Flag missing route protection or accidental permitAll as P0 and **/SecurityConfig.java: Verify authentication/authorization consistency: No over-broad permitAll.

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

In `@apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java`
around lines 46 - 61, The current
SecurityConfig.requestMatchers(...).permitAll() opens back-office swagger
endpoints too broadly; update SecurityConfig to remove or restrict the
back-office swagger patterns from the global permitAll and instead protect them
by requiring an ADMIN authority (e.g., .hasRole("ADMIN") or
.hasAuthority("ROLE_ADMIN")) or gate them to dev/local profiles only (use Spring
Profile check or conditional bean to only register swagger endpoints when active
profiles contain "dev" or "local"); locate the requestMatchers call in
SecurityConfig and replace the permitAll for "/back-office/**/swagger-ui/**",
"/back-office/**/v3/api-docs/**", and "/back-office/**/swagger-resources/**"
with a secured matcher that enforces ADMIN or conditional registration based on
active profiles.
apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java (1)

91-113: ⚠️ Potential issue | 🟠 Major

204 응답 계약과 반환 타입이 충돌합니다.

문서/구현이 204(No Content)인데 시그니처가 SuccessResponse<Void>라 API 계약이 모호합니다. ResponseEntity<Void>로 통일해 주세요.

♻️ 제안 수정
- ResponseEntity<SuccessResponse<Void>> setPreferredTags(
+ ResponseEntity<Void> setPreferredTags(
         `@AuthenticationPrincipal` Long memberId,
         `@Valid` `@RequestBody` SetPreferredTagRequest request
 );
- public ResponseEntity<SuccessResponse<Void>> setPreferredTags(...)
+ public ResponseEntity<Void> setPreferredTags(...)
As per coding guidelines, "SuccessResponse/PageResponse usage where project contract defines it".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java`
around lines 91 - 113, The method signature for setPreferredTags currently
returns ResponseEntity<SuccessResponse<Void>> while the API contract documents a
204 No Content; change the return type to ResponseEntity<Void> to match the 204
response contract. Locate the setPreferredTags declaration and replace the
SuccessResponse<Void> generic wrapper with Void (i.e., ResponseEntity<Void>),
and ensure any callers/implementations and controller advice/serializers that
construct responses for setPreferredTags return ResponseEntity.noContent() (or
equivalent) instead of wrapping an empty SuccessResponse.
♻️ Duplicate comments (5)
modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java (1)

22-24: ⚠️ Potential issue | 🟠 Major

Status는 문자열 대신 enum 리터럴로 업데이트하세요.

Line 23에서 'DELETE' 문자열을 직접 대입하고 있어 타입 안정성이 떨어집니다. enum 상수를 직접 사용해 두는 편이 안전합니다.

수정 예시
-    `@Query`("UPDATE Likes l SET l.status = 'DELETE' WHERE l.member.id = :memberId")
+    `@Query`("UPDATE Likes l SET l.status = com.ott.domain.common.Status.DELETE WHERE l.member.id = :memberId")

검증 방법(읽기 전용): Likes.status의 enum 매핑과 현재 JPQL 문자열 대입 여부를 확인하세요.
기대 결과: status가 enum이라면 enum 리터럴 사용으로 통일되어야 합니다.

#!/bin/bash
set -e

echo "=== Likes.status 매핑 확인 ==="
fd 'Likes.java$' -t f -x rg -n -C2 '\bStatus\b|@Enumerated|status'

echo
echo "=== 현재 JPQL 문자열 대입 확인 ==="
rg -n -C2 "UPDATE Likes l SET l.status = 'DELETE'" modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`
around lines 22 - 24, The JPQL in LikesRepository.softDeleteAllByMemberId
currently assigns the string literal 'DELETE' to Likes.status; change the update
to use the enum literal instead (i.e., assign the enum constant without quotes)
so the JPQL updates the enum-typed Likes.status in a type-safe way; locate the
`@Query` annotation on softDeleteAllByMemberId in LikesRepository and replace the
string assignment with the enum literal form matching the Likes.status enum
constant.
apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java (2)

154-155: ⚠️ Potential issue | 🔴 Critical

탈퇴/비활성 회원이 API를 계속 호출할 수 있습니다.

여러 메서드에서 findById를 사용해 Status.DELETE 회원을 통과시킵니다. ACTIVE 회원 조회를 공통 헬퍼로 강제해 주세요.

🔐 제안 수정
- memberRepository.findById(memberId)
-         .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
+ getActiveMemberOrThrow(memberId);
private Member getActiveMemberOrThrow(Long memberId) {
    return memberRepository.findByIdAndStatus(memberId, Status.ACTIVE)
            .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}

Also applies to: 198-199, 241-242, 256-257, 322-323

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

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`
around lines 154 - 155, Create a shared helper in MemberService named
getActiveMemberOrThrow that calls memberRepository.findByIdAndStatus(memberId,
Status.ACTIVE) and throws new BusinessException(ErrorCode.USER_NOT_FOUND) when
absent; replace all direct memberRepository.findById(...) calls in MemberService
(the instances that currently allow deleted/inactive users) with calls to
getActiveMemberOrThrow to enforce only ACTIVE members are returned (ensure you
reference the method name getActiveMemberOrThrow and the repository method
findByIdAndStatus and the enum Status.ACTIVE).

290-293: ⚠️ Potential issue | 🟠 Major

Kakao unlink 선행 호출은 부분 실패 시 상태 불일치를 남깁니다.

현재 순서에서는 unlink 성공 후 DB soft-delete 실패 시 “카카오 해제됨 + 로컬 미탈퇴” 상태가 생깁니다. DB 커밋 이후 unlink(After-Commit 이벤트/아웃박스 재시도)로 분리해 주세요.

Also applies to: 295-315

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

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`
around lines 290 - 293, The current immediate call to kakaoUnlinkClient.unlink
when member.getProvider() == Provider.KAKAO can leave external state
inconsistent if the subsequent DB soft-delete fails; change MemberService to
perform the DB soft-delete and commit first, then trigger the Kakao unlink as an
after-commit action (e.g., publish an afterCommit event or write an outbox
entry) instead of calling kakaoUnlinkClient.unlink inline; locate the inline
call to kakaoUnlinkClient.unlink (and related logic around lines 290 and
295-315) and refactor so the unlink is executed by a retryable background
handler or after-commit listener that reads the committed outbox/event and calls
kakaoUnlinkClient.unlink(member.getProviderId()).
modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java (1)

123-138: ⚠️ Potential issue | 🟠 Major

플레이리스트 조회/카운트 쿼리에 SHORT_FORM 제외 조건이 없습니다.

현재 조건이면 숏폼이 포함될 수 있어 API 계약과 어긋납니다. fetch/count 쿼리에 동일 predicate를 넣어주세요.

🐛 제안 수정
+import com.ott.domain.common.MediaType;
@@
                 .where(
                         watchHistory.member.id.eq(memberId),
-                        watchHistory.status.eq(ACTIVE) // delete 된거 조회 x
+                        watchHistory.status.eq(ACTIVE), // delete 된거 조회 x
+                        contents.media.mediaType.ne(MediaType.SHORT_FORM)
                 )
@@
         JPAQuery<Long> countQuery = queryFactory
                 .select(watchHistory.count())
                 .from(watchHistory)
+                .join(contents).on(watchHistory.contents.id.eq(contents.id))
                 .where(
                         watchHistory.member.id.eq(memberId),
-                        watchHistory.status.eq(ACTIVE)
+                        watchHistory.status.eq(ACTIVE),
+                        contents.media.mediaType.ne(MediaType.SHORT_FORM)
                         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`
around lines 123 - 138, The fetch and count queries in
WatchHistoryRepositoryImpl currently lack the predicate to exclude SHORT_FORM
entries, causing mismatch with the API contract; update the query predicates
used in the fetch block (the JPAQuery building watchHistory.fetch()) and the
countQuery (the JPAQuery<Long> countQuery) to include the same condition
excluding watchHistory.contentType.eq(ContentType.SHORT_FORM) (or the
project-specific SHORT_FORM enum constant) so both queries use identical
predicates for memberId, status (ACTIVE) and "not SHORT_FORM" filtering.
apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java (1)

4-5: ⚠️ Potential issue | 🟠 Major

API DTO가 repository projection에 직접 의존하고 있습니다.

RecentWatchResponse는 값 타입만 받도록 두고, RecentWatchProjection -> RecentWatchResponse 매핑은 서비스 계층으로 이동해 계층 결합을 끊어주세요.

♻️ 제안 수정
-import com.ott.domain.watch_history.repository.RecentWatchProjection;
@@
-    public static RecentWatchResponse from(RecentWatchProjection projection) {
+    public static RecentWatchResponse of(
+            Long mediaId,
+            MediaType mediaType,
+            String posterUrl,
+            Integer positionSec,
+            Integer duration
+    ) {
         return RecentWatchResponse.builder()
-                .mediaId(projection.getMediaId())
-                .mediaType(projection.getMediaType())
-                .posterUrl(projection.getPosterUrl())
-                .positionSec(projection.getPositionSec())
-                .duration(projection.getDuration())
+                .mediaId(mediaId)
+                .mediaType(mediaType)
+                .posterUrl(posterUrl)
+                .positionSec(positionSec)
+                .duration(duration)
                 .build();
     }
// MemberService 쪽 예시
.map(p -> RecentWatchResponse.of(
        p.getMediaId(),
        p.getMediaType(),
        p.getPosterUrl(),
        p.getPositionSec(),
        p.getDuration()
))
As per coding guidelines, "Module boundaries: common-web/domain/infra/apps".

Also applies to: 31-38

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

In
`@apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java`
around lines 4 - 5, RecentWatchResponse currently depends on repository
projection RecentWatchProjection; change it to accept only primitive/value
fields (e.g., mediaId, mediaType, posterUrl, positionSec, duration) and remove
any import/usage of RecentWatchProjection from the DTO. Move the mapping from
RecentWatchProjection -> RecentWatchResponse into the service layer (e.g.,
MemberService) by transforming projections to DTOs there (use a static factory
or constructor like RecentWatchResponse.of(mediaId, mediaType, posterUrl,
positionSec, duration)), and update callers to pass plain DTOs so the DTO no
longer references repository types.
🧹 Nitpick comments (3)
modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java (1)

28-37: likes 서브쿼리에 DISTINCT 추가를 권장합니다.

Likes 엔티티와 DB 스키마 확인 결과, (member_id, media_id) 조합에 대한 유니크 제약이 없습니다. 엔티티는 @Table(name = "likes")만 정의하고 있고, V2 마이그레이션 이후 likes 테이블도 유니크 제약을 포함하지 않습니다. 따라서 같은 미디어에 대해 동일 회원이 여러 좋아요 레코드를 가질 수 있는 상황이 발생할 수 있으며, 이 경우 UPDATE 조인 비용과 동작이 DB 구현에 더 의존적이 됩니다. DISTINCT를 추가하면 중복을 명시적으로 제거하여 의도를 명확히 할 수 있습니다.

권장 리팩터 예시
     `@Query`(value = """
     UPDATE media m
     JOIN (
-        SELECT l.media_id
+        SELECT DISTINCT l.media_id
         FROM likes l
         WHERE l.member_id = :memberId
           AND l.status = 'ACTIVE'
     ) t ON t.media_id = m.id
     SET m.likes_count = GREATEST(0, m.likes_count - 1)
     """, nativeQuery = true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`
around lines 28 - 37, The native UPDATE query in LikesRepository uses a subquery
selecting media_id from likes without DISTINCT, which can yield duplicate
media_ids if (member_id, media_id) isn't unique; update the Query so the
subquery selects DISTINCT media_id (i.e., add DISTINCT in the SELECT inside the
JOIN) to de-duplicate rows and ensure deterministic update behavior for the
UPDATE media m ... JOIN (SELECT DISTINCT l.media_id FROM likes l WHERE
l.member_id = :memberId AND l.status = 'ACTIVE') t ON t.media_id = m.id SET
m.likes_count = GREATEST(0, m.likes_count - 1).
modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java (1)

34-35: 컬렉션 변수명에 List 접미사를 맞춰주세요.

Line 34의 content는 컬렉션 타입이므로 네이밍 규칙상 ...List 형태로 맞추는 것이 좋습니다.

♻️ 제안 변경안
-        List<BookmarkMediaProjection> content = queryFactory
+        List<BookmarkMediaProjection> bookmarkMediaList = queryFactory
                 .select(Projections.constructor(BookmarkMediaProjection.class,
@@
-        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
+        return PageableExecutionUtils.getPage(bookmarkMediaList, pageable, countQuery::fetchOne);

As per coding guidelines, "Collection variable names with List suffix".

Also applies to: 75-75

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

In
`@modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java`
around lines 34 - 35, Rename the collection variable `content` in
BookmarkRepositoryImpl to follow the "List" suffix convention (e.g.,
`contentList`) and update all local usages and references accordingly (including
the other occurrence around line 75); ensure constructor projection call using
Projections.constructor(BookmarkMediaProjection.class, ...) and any subsequent
stream/return statements refer to the new `contentList` identifier so
compilation and behavior remain unchanged.
modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java (1)

108-108: 컬렉션 변수명에 List suffix를 맞춰 주세요.

content는 리스트 타입이므로 recentWatchList 같은 이름으로 맞추면 규칙 일관성이 좋아집니다.

As per coding guidelines, "Collection variable names with List suffix".

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

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`
at line 108, Rename the local collection variable named content to follow the
project naming convention (e.g., recentWatchList) in WatchHistoryRepositoryImpl
where the query result is assigned via queryFactory to improve consistency;
update all subsequent references in that method (uses of content) to the new
name (recentWatchList) so compilation and behavior remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java`:
- Around line 56-58: SecurityConfig에 등록된 백오피스 Swagger 경로 문자열에 이중 슬래시가 있어 매처가
의도대로 동작하지 않으니 "/back-office//swagger-ui/**", "/back-office//v3/api-docs/**",
"/back-office//swagger-resources/**"를 각각 "/back-office/swagger-ui/**",
"/back-office/v3/api-docs/**", "/back-office/swagger-resources/**"로 수정하여 중복 슬래시를
제거하고 요청 경로와 일치하도록 고치세요.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`:
- Around line 201-202: The code in MemberService currently uses
tagRepository.findById(tagId) which will return soft-deleted tags; change both
occurrences (the findById calls around the shown lines and the similar calls at
the other location) to only accept ACTIVE tags by either calling a repository
query that filters by status (e.g., findByIdAndStatus(tagId, Status.ACTIVE)) or
by retrieving the tag and immediately checking tag.getStatus() == Status.ACTIVE
and throwing new BusinessException(ErrorCode.TAG_NOT_FOUND) if not active so
deleted tags are rejected for the monthly comparison/recommendation APIs.

In
`@modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java`:
- Around line 41-43: The coalesce(0) on playback.positionSec in
BookmarkRepositoryImpl causes SERIES rows (LEFT JOIN non-matches) to become 0
instead of null; change the projection so playback.positionSec remains null for
SERIES and only apply a default (e.g., 0) for CONTENTS items—identify the
projection building code that references playback.positionSec and
contents.duration and replace the blanket playback.positionSec.coalesce(0) with
a conditional/coalesce that only sets 0 when the row corresponds to a CONTENTS
item (or use a CASE/when on content type) so SERIES retains null while CONTENTS
gets the default.

In
`@modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java`:
- Around line 12-14: The soft-delete Query in ClickRepository is targeting the
wrong entity (it updates Comment); change the JPQL in softDeleteAllByMemberId to
update ClickEvent instead of Comment so the method updates ClickEvent.status to
'DELETE' for the given memberId; keep the method name softDeleteAllByMemberId
and parameter `@Param`("memberId") Long memberId and ensure the query references
ClickEvent and its member and status fields correctly.

---

Outside diff comments:
In `@apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java`:
- Around line 46-61: The current SecurityConfig.requestMatchers(...).permitAll()
opens back-office swagger endpoints too broadly; update SecurityConfig to remove
or restrict the back-office swagger patterns from the global permitAll and
instead protect them by requiring an ADMIN authority (e.g., .hasRole("ADMIN") or
.hasAuthority("ROLE_ADMIN")) or gate them to dev/local profiles only (use Spring
Profile check or conditional bean to only register swagger endpoints when active
profiles contain "dev" or "local"); locate the requestMatchers call in
SecurityConfig and replace the permitAll for "/back-office/**/swagger-ui/**",
"/back-office/**/v3/api-docs/**", and "/back-office/**/swagger-resources/**"
with a secured matcher that enforces ADMIN or conditional registration based on
active profiles.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java`:
- Around line 91-113: The method signature for setPreferredTags currently
returns ResponseEntity<SuccessResponse<Void>> while the API contract documents a
204 No Content; change the return type to ResponseEntity<Void> to match the 204
response contract. Locate the setPreferredTags declaration and replace the
SuccessResponse<Void> generic wrapper with Void (i.e., ResponseEntity<Void>),
and ensure any callers/implementations and controller advice/serializers that
construct responses for setPreferredTags return ResponseEntity.noContent() (or
equivalent) instead of wrapping an empty SuccessResponse.

In
`@modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java`:
- Around line 53-54: The enum ErrorCode has duplicate code "B009" for
COMMENT_FORBIDDEN and UNSUPPORTED_IMAGE_EXTENSION which prevents clients from
distinguishing errors; update one of them (e.g., change
UNSUPPORTED_IMAGE_EXTENSION or COMMENT_FORBIDDEN) to a unique code value in the
ErrorCode enum so each constant has a distinct error code, then run tests and
any usages that switch/compare on these codes to ensure callers use the new
unique identifier (look for references to COMMENT_FORBIDDEN and
UNSUPPORTED_IMAGE_EXTENSION to update dependent logic if needed).

---

Duplicate comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java`:
- Around line 4-5: RecentWatchResponse currently depends on repository
projection RecentWatchProjection; change it to accept only primitive/value
fields (e.g., mediaId, mediaType, posterUrl, positionSec, duration) and remove
any import/usage of RecentWatchProjection from the DTO. Move the mapping from
RecentWatchProjection -> RecentWatchResponse into the service layer (e.g.,
MemberService) by transforming projections to DTOs there (use a static factory
or constructor like RecentWatchResponse.of(mediaId, mediaType, posterUrl,
positionSec, duration)), and update callers to pass plain DTOs so the DTO no
longer references repository types.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java`:
- Around line 154-155: Create a shared helper in MemberService named
getActiveMemberOrThrow that calls memberRepository.findByIdAndStatus(memberId,
Status.ACTIVE) and throws new BusinessException(ErrorCode.USER_NOT_FOUND) when
absent; replace all direct memberRepository.findById(...) calls in MemberService
(the instances that currently allow deleted/inactive users) with calls to
getActiveMemberOrThrow to enforce only ACTIVE members are returned (ensure you
reference the method name getActiveMemberOrThrow and the repository method
findByIdAndStatus and the enum Status.ACTIVE).
- Around line 290-293: The current immediate call to kakaoUnlinkClient.unlink
when member.getProvider() == Provider.KAKAO can leave external state
inconsistent if the subsequent DB soft-delete fails; change MemberService to
perform the DB soft-delete and commit first, then trigger the Kakao unlink as an
after-commit action (e.g., publish an afterCommit event or write an outbox
entry) instead of calling kakaoUnlinkClient.unlink inline; locate the inline
call to kakaoUnlinkClient.unlink (and related logic around lines 290 and
295-315) and refactor so the unlink is executed by a retryable background
handler or after-commit listener that reads the committed outbox/event and calls
kakaoUnlinkClient.unlink(member.getProviderId()).

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`:
- Around line 22-24: The JPQL in LikesRepository.softDeleteAllByMemberId
currently assigns the string literal 'DELETE' to Likes.status; change the update
to use the enum literal instead (i.e., assign the enum constant without quotes)
so the JPQL updates the enum-typed Likes.status in a type-safe way; locate the
`@Query` annotation on softDeleteAllByMemberId in LikesRepository and replace the
string assignment with the enum literal form matching the Likes.status enum
constant.

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`:
- Around line 123-138: The fetch and count queries in WatchHistoryRepositoryImpl
currently lack the predicate to exclude SHORT_FORM entries, causing mismatch
with the API contract; update the query predicates used in the fetch block (the
JPAQuery building watchHistory.fetch()) and the countQuery (the JPAQuery<Long>
countQuery) to include the same condition excluding
watchHistory.contentType.eq(ContentType.SHORT_FORM) (or the project-specific
SHORT_FORM enum constant) so both queries use identical predicates for memberId,
status (ACTIVE) and "not SHORT_FORM" filtering.

---

Nitpick comments:
In
`@modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java`:
- Around line 34-35: Rename the collection variable `content` in
BookmarkRepositoryImpl to follow the "List" suffix convention (e.g.,
`contentList`) and update all local usages and references accordingly (including
the other occurrence around line 75); ensure constructor projection call using
Projections.constructor(BookmarkMediaProjection.class, ...) and any subsequent
stream/return statements refer to the new `contentList` identifier so
compilation and behavior remain unchanged.

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`:
- Around line 28-37: The native UPDATE query in LikesRepository uses a subquery
selecting media_id from likes without DISTINCT, which can yield duplicate
media_ids if (member_id, media_id) isn't unique; update the Query so the
subquery selects DISTINCT media_id (i.e., add DISTINCT in the SELECT inside the
JOIN) to de-duplicate rows and ensure deterministic update behavior for the
UPDATE media m ... JOIN (SELECT DISTINCT l.media_id FROM likes l WHERE
l.member_id = :memberId AND l.status = 'ACTIVE') t ON t.media_id = m.id SET
m.likes_count = GREATEST(0, m.likes_count - 1).

In
`@modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java`:
- Line 108: Rename the local collection variable named content to follow the
project naming convention (e.g., recentWatchList) in WatchHistoryRepositoryImpl
where the query result is assigned via queryFactory to improve consistency;
update all subsequent references in that method (uses of content) to the new
name (recentWatchList) so compilation and behavior remain unchanged.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0b87148 and b878ce8.

📒 Files selected for processing (25)
  • apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java
  • apps/api-user/build.gradle
  • apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java
  • apps/api-user/src/main/java/com/ott/api_user/auth/controller/AuthApi.java
  • apps/api-user/src/main/java/com/ott/api_user/bookmark/controller/BookmarkController.java
  • apps/api-user/src/main/java/com/ott/api_user/bookmark/dto/response/BookmarkMediaResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/bookmark/service/BookmarkService.java
  • apps/api-user/src/main/java/com/ott/api_user/config/RestTemplateConfig.java
  • apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesAPI.java
  • apps/api-user/src/main/java/com/ott/api_user/likes/controller/LikesController.java
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java
  • apps/api-user/src/main/java/com/ott/api_user/member/dto/response/RecentWatchResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java
  • modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkMediaProjection.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepository.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryCustom.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java
  • modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
  • modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java
  • modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/RecentWatchProjection.java
  • modules/domain/src/main/java/com/ott/domain/watch_history/repository/WatchHistoryRepositoryImpl.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api-user/src/main/java/com/ott/api_user/auth/client/KakaoUnlinkClient.java

Comment thread apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java Outdated
Comment thread apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (6)
apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java (1)

4-4: API 응답 DTO가 repository projection에 직접 의존하고 있습니다.

TagPlaylistResponsedomain.media.repository.TagContentProjection을 직접 참조하면 계층 결합이 강해집니다. 서비스 계층에서 projection→DTO 매핑을 처리하거나, DTO의 입력을 primitive/값 타입으로 분리해 주세요.

♻️ 제안하는 수정 방안
-import com.ott.domain.media.repository.TagContentProjection;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Getter;

 `@Getter`
 `@Builder`
 `@AllArgsConstructor`
 `@Schema`(description = "태그별 추천 콘텐츠 아이템")
 public class TagPlaylistResponse {

     `@Schema`(type = "Long", example = "5", description = "미디어 ID")
     private Long mediaId;

     `@Schema`(type = "String", example = "https://cdn.ott.com/poster/thriller01.jpg", description = "포스터 URL")
     private String posterUrl;

     `@Schema`(type = "String", example = "SERIES", description = "미디어 타입 (SERIES / CONTENTS)")
     private MediaType mediaType;

-    public static TagPlaylistResponse from(TagContentProjection projection) {
-        return TagPlaylistResponse.builder()
-                .mediaId(projection.getMediaId())
-                .posterUrl(projection.getPosterUrl())
-                .mediaType(projection.getMediaType())
-                .build();
-    }
+    // 서비스 계층에서 직접 builder를 사용하여 매핑
 }

서비스 계층에서 매핑:

// PlaylistService.java
return mediaRepository.findRecommendContentsByTagId(tagId, 20)
        .stream()
        .map(p -> TagPlaylistResponse.builder()
                .mediaId(p.getMediaId())
                .posterUrl(p.getPosterUrl())
                .mediaType(p.getMediaType())
                .build())
        .toList();

As per coding guidelines, "Module boundaries: common-web/domain/infra/apps".

Also applies to: 25-31

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

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java`
at line 4, TagPlaylistResponse currently depends directly on
TagContentProjection causing tight coupling; change TagPlaylistResponse to
accept primitive/value types (e.g., mediaId, posterUrl, mediaType) or provide a
static factory/builder taking those primitives instead of referencing
TagContentProjection, and move projection→DTO mapping into the service layer
(e.g., in PlaylistService use
mediaRepository.findRecommendContentsByTagId(...).stream().map(p -> new
TagPlaylistResponse(p.getMediaId(), p.getPosterUrl(), p.getMediaType()))).
Update any callers to construct TagPlaylistResponse from primitive values rather
than from TagContentProjection.
apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java (2)

27-30: 인터페이스 이름의 일관성을 확인해 주세요.

인터페이스 이름이 PlayListAPI(대문자 L)인데 구현체는 PlaylistController(소문자 l)입니다. 네이밍 일관성을 위해 PlaylistAPI로 수정하는 것을 권장합니다.

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

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java`
around lines 27 - 30, The interface name PlayListAPI uses inconsistent casing
compared to its implementation PlaylistController—rename the interface to
PlaylistAPI to match the implementation; update the interface declaration
(PlayListAPI -> PlaylistAPI), adjust any references/imports/usages throughout
the codebase (e.g., where PlaylistController implements the interface or any
DI/beans/reference types), and run a quick compile to fix any remaining symbol
references or documentation annotations (e.g., `@Tag`, `@RequestMapping`) that rely
on the interface name.

54-58: tagId 파라미터에 @Parameter 설명이 누락되었습니다.

getWatchHistoryPlaylistpage 파라미터에는 @Parameter 설명이 있지만 tagId에는 없습니다. API 문서 일관성을 위해 추가를 권장합니다.

📝 제안하는 수정 방안
     `@GetMapping`("/me/{tagId}")
     ResponseEntity<SuccessResponse<List<TagPlaylistResponse>>> getRecommendContentsByTag(
             `@AuthenticationPrincipal` Long memberId,
-            `@Positive` `@PathVariable` Long tagId
+            `@Parameter`(description = "태그 ID", example = "1")
+            `@Positive` `@PathVariable` Long tagId
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java`
around lines 54 - 58, Add a missing OpenAPI `@Parameter` description for the tagId
parameter on the PlayListAPI method getRecommendContentsByTag: annotate the
`@PathVariable` Long tagId with `@Parameter`(description = "Tag ID used to filter
recommended contents", required = true) (mirroring the style used for the page
parameter in getWatchHistoryPlaylist) so the API docs remain consistent and
informative.
apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java (1)

139-142: @Parameter 어노테이션이 HttpServletResponse에 부적절하게 사용되었습니다.

@Parameter는 OpenAPI 스펙에서 경로/쿼리 파라미터를 문서화하기 위한 어노테이션입니다. HttpServletResponse는 요청 파라미터가 아닌 서블릿 응답 객체이므로, @Parameter 대신 @Hidden 어노테이션을 사용하여 Swagger 문서에서 숨기거나 어노테이션을 제거하는 것이 적절합니다.

♻️ 수정 제안
 `@DeleteMapping`("/me")
 ResponseEntity<Void> withdraw(
-        `@Parameter` HttpServletResponse response,
+        `@io.swagger.v3.oas.annotations.Hidden` HttpServletResponse response,
         `@AuthenticationPrincipal` Long memberId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java`
around lines 139 - 142, The `@Parameter` annotation is incorrectly applied to the
HttpServletResponse in MemberApi.withdraw; remove the `@Parameter` on the
HttpServletResponse (or replace it with `@Hidden`) so the servlet response is not
treated as an OpenAPI request parameter—update the method signature in
MemberApi.withdraw to either annotate HttpServletResponse with `@Hidden` or have
no OpenAPI annotation, keeping the `@AuthenticationPrincipal` Long memberId
parameter unchanged.
apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java (1)

52-64: withdraw 메서드에 @Override 어노테이션이 누락되었습니다.

다른 메서드들(getMyPage, updateMyInfo, setPreferredTags, skipOnboarding)은 모두 @Override 어노테이션이 있으나, withdraw 메서드만 누락되어 있습니다. 일관성 및 컴파일 타임 검증을 위해 추가하는 것이 좋습니다.

♻️ 수정 제안
     // 회원 탈퇴 - 현재 soft delete
     // 회원 탈퇴 시 DB + 브라우저 토큰 삭제
+    `@Override`
     `@DeleteMapping`("/me")
     public ResponseEntity<Void> withdraw(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java`
around lines 52 - 64, The withdraw method in MemberController is missing the
`@Override` annotation; add `@Override` directly above the public
ResponseEntity<Void> withdraw(HttpServletResponse response,
`@AuthenticationPrincipal` Long memberId) method declaration in the
MemberController class so it matches the other overridden methods (e.g.,
getMyPage, updateMyInfo, setPreferredTags, skipOnboarding) and enables
compile-time override checks.
apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java (1)

47-50: 컬렉션 변수명에 List 접미사를 맞춰주세요.

가이드 기준상 컬렉션 변수는 List 접미사가 필요합니다. 현재 변수명은 규칙과 어긋납니다.

♻️ 제안 수정안
-        List<TagRankingProjection> tagRankingProjections =
+        List<TagRankingProjection> tagRankingProjectionList =
                 watchHistoryRepository.findTopTagsByMemberIdAndWatchedBetween(memberId, startDate, endDate);

-        List<TagRankingResponse.TagRankItem> rankItems = new ArrayList<>();
+        List<TagRankingResponse.TagRankItem> tagRankItemList = new ArrayList<>();
@@
-        if (tagRankingProjections.isEmpty()) {
-            return TagRankingResponse.builder().rankings(rankItems).build();
+        if (tagRankingProjectionList.isEmpty()) {
+            return TagRankingResponse.builder().rankings(tagRankItemList).build();
         }
@@
-        int topN = Math.min(4, tagRankingProjections.size());
+        int topN = Math.min(4, tagRankingProjectionList.size());
@@
-            TagRankingProjection projection = tagRankingProjections.get(i);
-            rankItems.add(TagRankingResponse.TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount()));
+            TagRankingProjection projection = tagRankingProjectionList.get(i);
+            tagRankItemList.add(TagRankingResponse.TagRankItem.of(projection.getTagId(), projection.getTagName(), projection.getCount()));
         }
@@
-        if (tagRankingProjections.size() > 4) {
-            long etcCount = tagRankingProjections.subList(4, tagRankingProjections.size())
+        if (tagRankingProjectionList.size() > 4) {
+            long etcCount = tagRankingProjectionList.subList(4, tagRankingProjectionList.size())
                     .stream()
                     .mapToLong(TagRankingProjection::getCount)
                     .sum();
-            rankItems.add(TagRankingResponse.TagRankItem.ofEtc(etcCount));
+            tagRankItemList.add(TagRankingResponse.TagRankItem.ofEtc(etcCount));
         }

-        return TagRankingResponse.builder().rankings(rankItems).build();
+        return TagRankingResponse.builder().rankings(tagRankItemList).build();

As per coding guidelines, Collection variable names with \List` suffix`.

Also applies to: 57-75

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

In `@apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java`
around lines 47 - 50, Rename collection variables to include the List suffix to
follow naming guidelines: change tagRankingProjections to
tagRankingProjectionsList and rankItems to rankItemsList (and any other
collection variables in the same method/region e.g., variables in lines 57-75)
and update all references in TagService.java accordingly (search for usages of
tagRankingProjections and rankItems and replace with the new names in the method
body and return/constructor calls so compilation stays intact).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java`:
- Around line 43-49: The method signature for MemberController.setPreferredTags
currently declares ResponseEntity<SuccessResponse<Void>> but returns
ResponseEntity.noContent().build(); update the API to use ResponseEntity<Void>
for 204 responses: change the return type on setPreferredTags (and the
corresponding declaration in MemberApi) from
ResponseEntity<SuccessResponse<Void>> to ResponseEntity<Void>, and ensure the
controller still returns ResponseEntity.noContent().build(); alternatively, if
you must keep SuccessResponse, return a SuccessResponse<Void> body
instead—adjust either MemberController.setPreferredTags, MemberApi, and any
usages accordingly to keep the types consistent.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java`:
- Around line 17-20: The PlaylistController class is missing the `@Validated`
annotation so method-level parameter constraints like `@Positive/`@PositiveOrZero
on PlayListAPI implementations are ignored; add the `@Validated` annotation to the
PlaylistController declaration (the class implementing PlayListAPI) so Spring
will enforce those validation annotations at runtime.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java`:
- Around line 30-34: Remove the unused dependency field contentsRepository from
PlaylistService: delete the private final ContentsRepository contentsRepository
declaration and remove its injection from the PlaylistService constructor
(ensure constructor still injects MemberRepository, TagRepository,
MediaRepository, WatchHistoryRepository); verify that getRecommendContentsByTag
and getWatchHistoryPlaylist do not reference contentsRepository after the change
and update any import for ContentsRepository if it becomes unused.

In `@apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java`:
- Around line 29-30: Update the OpenAPI annotation on TagAPI (the `@Operation` on
the method that documents "시청이력 기반 태그 랭킹 조회") so the description matches the
implemented aggregation window: replace "최근 1달간" with text clarifying the
calendar-month boundary (예: "이번 달(해당 월 1일 00:00부터 다음 달 1일 00:00까지) 기준") and
ensure the rest of the summary/description mentions it returns top 4 tags + 기타
as implemented.
- Around line 58-79: The API docs for TagAPI.getTagMonthlyCompare lack a 400
response for `@Positive` tagId validation failures; update the `@ApiResponses` on
the getTagMonthlyCompare method to include an `@ApiResponse` with responseCode =
"400", a brief description like "잘못된 요청 (tagId 유효성 실패)", and content using the
existing ErrorResponse schema so clients see the validation-failure contract for
tagId; ensure you add this entry alongside the existing 200/401/404 entries in
the method declaration.

In `@apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java`:
- Around line 39-40: The lookup using memberRepository.findById(memberId) treats
soft-deleted users as valid; update the validation to also reject deleted
accounts by either using a repository method that filters deleted users (e.g.,
findByIdAndDeletedFalse or findByIdAndIsActiveTrue) or by checking the entity's
soft-delete flag after retrieval and throwing new
BusinessException(ErrorCode.USER_NOT_FOUND) when deleted; apply the same fix at
the other occurrence referenced around lines 82-83 so both
memberRepository.findById(...) usages enforce the soft-delete check.

---

Nitpick comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java`:
- Around line 139-142: The `@Parameter` annotation is incorrectly applied to the
HttpServletResponse in MemberApi.withdraw; remove the `@Parameter` on the
HttpServletResponse (or replace it with `@Hidden`) so the servlet response is not
treated as an OpenAPI request parameter—update the method signature in
MemberApi.withdraw to either annotate HttpServletResponse with `@Hidden` or have
no OpenAPI annotation, keeping the `@AuthenticationPrincipal` Long memberId
parameter unchanged.

In
`@apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java`:
- Around line 52-64: The withdraw method in MemberController is missing the
`@Override` annotation; add `@Override` directly above the public
ResponseEntity<Void> withdraw(HttpServletResponse response,
`@AuthenticationPrincipal` Long memberId) method declaration in the
MemberController class so it matches the other overridden methods (e.g.,
getMyPage, updateMyInfo, setPreferredTags, skipOnboarding) and enables
compile-time override checks.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java`:
- Around line 27-30: The interface name PlayListAPI uses inconsistent casing
compared to its implementation PlaylistController—rename the interface to
PlaylistAPI to match the implementation; update the interface declaration
(PlayListAPI -> PlaylistAPI), adjust any references/imports/usages throughout
the codebase (e.g., where PlaylistController implements the interface or any
DI/beans/reference types), and run a quick compile to fix any remaining symbol
references or documentation annotations (e.g., `@Tag`, `@RequestMapping`) that rely
on the interface name.
- Around line 54-58: Add a missing OpenAPI `@Parameter` description for the tagId
parameter on the PlayListAPI method getRecommendContentsByTag: annotate the
`@PathVariable` Long tagId with `@Parameter`(description = "Tag ID used to filter
recommended contents", required = true) (mirroring the style used for the page
parameter in getWatchHistoryPlaylist) so the API docs remain consistent and
informative.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java`:
- Line 4: TagPlaylistResponse currently depends directly on TagContentProjection
causing tight coupling; change TagPlaylistResponse to accept primitive/value
types (e.g., mediaId, posterUrl, mediaType) or provide a static factory/builder
taking those primitives instead of referencing TagContentProjection, and move
projection→DTO mapping into the service layer (e.g., in PlaylistService use
mediaRepository.findRecommendContentsByTagId(...).stream().map(p -> new
TagPlaylistResponse(p.getMediaId(), p.getPosterUrl(), p.getMediaType()))).
Update any callers to construct TagPlaylistResponse from primitive values rather
than from TagContentProjection.

In `@apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java`:
- Around line 47-50: Rename collection variables to include the List suffix to
follow naming guidelines: change tagRankingProjections to
tagRankingProjectionsList and rankItems to rankItemsList (and any other
collection variables in the same method/region e.g., variables in lines 57-75)
and update all references in TagService.java accordingly (search for usages of
tagRankingProjections and rankItems and replace with the new names in the method
body and return/constructor calls so compilation stays intact).

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2a2bfc7c-4056-43be-b261-bf476922702b

📥 Commits

Reviewing files that changed from the base of the PR and between b878ce8 and d25277a.

📒 Files selected for processing (19)
  • apps/api-admin/src/main/java/com/ott/api_admin/config/SecurityConfig.java
  • apps/api-admin/src/main/resources/application.yml
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberApi.java
  • apps/api-user/src/main/java/com/ott/api_user/member/controller/MemberController.java
  • apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlayListAPI.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/RecentWatchResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TagPlaylistResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagAPI.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/controller/TagController.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagMonthlyCompareResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/dto/response/TagRankingResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/tag/service/TagService.java
  • modules/common-web/src/main/java/com/ott/common/web/config/WebMvcConfig.java
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/click_event/repository/ClickRepository.java
  • modules/domain/src/main/java/com/ott/domain/tag/repository/TagRepository.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • modules/domain/src/main/java/com/ott/domain/bookmark/repository/BookmarkRepositoryImpl.java
  • apps/api-user/src/main/java/com/ott/api_user/member/service/MemberService.java

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore 설정 파일 등 변경 (.gitignore, .yml 등) feat 새로운 기능 구현

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OT-148] [FAET]: 마이페이지 통계, 회원 탈퇴 API 개발

2 participants