diff --git a/.claude/rules/admin.md b/.claude/rules/admin.md new file mode 100644 index 0000000..7fa2a92 --- /dev/null +++ b/.claude/rules/admin.md @@ -0,0 +1,11 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/admin/**/*.java" +--- + +# Admin Domain Notes + +- admin은 별도 인증 체계가 아니라 일반 API와 동일한 JWT(`ROLE_ADMIN`)를 사용한다. 서버 렌더링 UI가 아니라 다른 도메인과 동일한 `@ResponseBody` JSON API다. +- admin 패키지는 다른 도메인(`review`, `menu` 등)의 엔티티를 그 도메인의 서비스/리포지토리를 거치지 않고 `domain/admin/persistence`의 자체 리포지토리로 직접 조회/수정한다. 이는 이 도메인의 의도된 컨벤션이며, 새 admin 기능을 추가할 때도 이 패턴을 따른다. +- 리뷰 삭제 시 연관 리포트 삭제는 `ApplicationEventPublisher`로 발행되는 `ReviewDeleteEvent`를 `ReportEventListener`가 동기적으로(`@TransactionalEventListener`가 아닌 일반 `@EventListener`) 처리한다. +- `ManageInquiryService`는 `inquiry` 도메인의 `InquiryStatus` 전이를 직접 처리한다. 자세한 내용은 [[inquiry]] 참고. diff --git a/.claude/rules/auth.md b/.claude/rules/auth.md new file mode 100644 index 0000000..46d7fda --- /dev/null +++ b/.claude/rules/auth.md @@ -0,0 +1,14 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/auth/**/*.java" +--- + +# Auth Domain Notes + +- Access/refresh 토큰이 클레임 구조상 구분되지 않는다(`token_type` 클레임 없음). `JwtTokenProvider`의 `validateToken`/`getAuthentication`은 refresh 토큰도 access 토큰 자리에서 그대로 통과시킨다. +- Public 엔드포인트 화이트리스트는 `SecurityConfig.AUTH_WHITELIST`/`RESOURCE_LIST`와 `JwtAuthenticationFilter.AUTH_WHITELIST` 두 곳에 중복 관리된다. 새 public 엔드포인트를 추가할 때는 반드시 두 곳을 함께 수정한다. +- OAuth 로그인의 "비밀번호"는 `provider + providerId`를 BCrypt 해시한 값이다(`UserService.createCredentials`/`OAuthService.makeOauthCredentials`). 이 조합 로직을 변경하면 기존 유저 전원이 로그인할 수 없게 된다. +- 동일 이메일로 다른 provider(Kakao/Apple)로 로그인하면 `findFirstByEmailOrderByIdAsc`로 기존 계정에 조용히 합쳐진다(코드 내 FIXME로 명시된 known issue). `users` 테이블에 email unique 제약은 없다. +- Apple 이메일 릴레이 감지(`OAuthService.isHideEmail`)는 `@privaterelay.appleid.com` 접미사 + 길이 25자 초과라는 하드코딩된 조건에 의존한다. +- V1(`/oauths/kakao`, `/oauths/apple`)과 V2(`/oauths/v2/kakao`, `/oauths/v2/apple`)가 공존하며 V1은 로그인/회원가입 마이그레이션 이후 삭제 예정으로 표시되어 있다. 새 기능은 V2 기준으로 작성한다. +- CORS는 `addAllowedOriginPattern("*")` + `setAllowCredentials(true)`로 모든 출처를 허용한다. diff --git a/.claude/rules/inquiry.md b/.claude/rules/inquiry.md new file mode 100644 index 0000000..335457c --- /dev/null +++ b/.claude/rules/inquiry.md @@ -0,0 +1,10 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/inquiry/**/*.java" + - "src/main/java/ssu/eatssu/domain/admin/service/ManageInquiryService.java" +--- + +# Inquiry Domain Notes + +- `InquiryController`(문의 작성 API)는 `@Deprecated`다. 문의 기능이 카카오톡으로 이전되어 더는 사용되지 않지만 코드는 아직 남아 동작한다. 새 기능을 여기에 추가하지 않는다. +- 문의 상태(`InquiryStatus`: WAITING/ANSWERED/HOLD) 전이는 `inquiry` 패키지가 아니라 `domain/admin`(`ManageInquiryService`)에서 처리한다. `InquiryStatus`를 변경하면 admin 패키지도 함께 확인해야 한다. `ManageInquiryService`가 다른 위치로 옮겨지면 위 `paths`도 함께 수정할 것. diff --git a/.claude/rules/menu.md b/.claude/rules/menu.md new file mode 100644 index 0000000..9e941bf --- /dev/null +++ b/.claude/rules/menu.md @@ -0,0 +1,12 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/menu/**/*.java" +--- + +# Menu Domain Notes + +- `Menu`에는 이름이 있고 `Meal`에는 없다. VARIABLE 타입(`Menu.createVariable`) 메뉴는 `(name, restaurant)` 기준으로 get-or-create되어 여러 `Meal`에서 `MealMenu`를 통해 재사용된다 — Meal마다 새로 생성되는 것이 아니다. +- i18n 컬럼(`name_en`/`name_ja`/`name_vi`)은 `Menu`에만 있고 `Meal`/`MealMenu`/`MenuCategory`에는 없다. 그마저도 SNACK_CORNER 메뉴만 채워져 있고 FOOD_COURT나 VARIABLE로 생성된 메뉴는 비어 있다. `MealController`는 Language를 아예 받지 않아 변동 식단 메뉴명은 항상 한국어로 나간다. +- `Restaurant.FACULTY`는 `MenuType`/`RestaurantType`의 FIXED/VARIABLE 어느 분류에도 속하지 않는다. 검증 로직(`validateMenuRestaurant`/`validateMealRestaurant`)이 화이트리스트가 아니라 블랙리스트 방식이라 새 restaurant enum 값을 추가할 때 분류 누락에 주의해야 한다. +- Meal 재조회/중복 방지(`getExistingMealId`)는 같은 date/timePart/restaurant에서 메뉴 이름 리스트를 정렬해 문자열로 정확히 비교한다. 공백/오탈자 하나만 달라도 별도 Meal로 새로 생성된다. +- `menu/persistence`의 `Querydsl*Rating*` 클래스들은 `rating` 도메인의 별점 집계를 실제로 구현한다. 자세한 내용은 [[rating]] 참고. diff --git a/.claude/rules/partnership.md b/.claude/rules/partnership.md new file mode 100644 index 0000000..da9b986 --- /dev/null +++ b/.claude/rules/partnership.md @@ -0,0 +1,12 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/partnership/**/*.java" +--- + +# Partnership Domain Notes + +- `PartnershipRestaurant`(가게, 부모)와 `Partnership`(개별 혜택, 자식)은 1:N 관계다. `PartnershipLike`는 개별 혜택이 아니라 가게 단위로 붙는다. +- `Partnership` 엔티티에 `@Where(clause = "end_date >= CURRENT_DATE")`가 걸려 있어 `findById`를 포함한 모든 JPA 조회가 만료된 혜택을 자동으로 숨긴다. 만료된 partnership을 `findById`로 찾으면 "만료됨"이 아니라 "찾을 수 없음"으로 처리된다는 점에 주의한다. +- `PeriodType.FESTIVAL`은 `CreatePartnershipRequest`에 필드가 없어 API로는 생성할 수 없다. FESTIVAL 혜택은 현재 DB를 직접 조작해야만 만들 수 있다. +- 앱 API로 생성한 `Partnership`/`PartnershipRestaurant`는 i18n 필드(`description_en/ja/vi`, `store_name_en/ja/vi`)가 채워지지 않는다. 지금까지 이 필드들은 전부 별도 flyway 마이그레이션으로만 채워졌다. +- 좋아요 수(`likeCount`)는 원자적 카운터가 아니라 로드된 컬렉션의 `size()`로 계산되고, 좋아요 토글도 잠금 없는 read-check-write 방식이다. 동시 요청에 대한 정합성을 기대하지 않는다. diff --git a/.claude/rules/rating.md b/.claude/rules/rating.md new file mode 100644 index 0000000..f37fdce --- /dev/null +++ b/.claude/rules/rating.md @@ -0,0 +1,12 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/rating/**/*.java" + - "src/main/java/ssu/eatssu/domain/menu/persistence/*Rating*.java" + - "src/main/java/ssu/eatssu/domain/review/service/ReviewRatingService.java" +--- + +# Rating Domain Notes + +- 별점 집계는 매 조회마다 DB에서 `AVG()`/`COUNT()`를 다시 계산하는 방식이다(캐시나 비정규화된 집계 필드 없음). `QuerydslMealRatingCalculator`/`QuerydslMenuRatingCalculator`/`QuerydslMealRatingCounter`/`QuerydslMenuRatingCounter`(`menu/persistence`)와 이를 호출하는 `ReviewRatingService`(`review/service`, `RatingCalculator` 구현체)가 실제 사용 경로다. 이 파일들이 다른 위치로 옮겨지거나 이름이 바뀌면 위 `paths`도 함께 수정할 것. +- `rating/entity`의 `JpaLoadCollectionRatingCalculator`, `JpaProjectionRatingCalculator`, `ReviewRating`, `RatingCountMap`은 벤치마크용으로 만들어진 미사용 코드다. 특히 `ReviewRating`은 enum을 mutable singleton으로 사용해 스레드 세이프하지 않으니, 되살려서 쓰지 말 것. +- `amountRating`/`tasteRating`은 값이 있어도 응답 DTO에서 세팅되지 않아 항상 null로 내려간다. `Ratings.java`의 FIXME(2026-07-04)에 이 필드들의 필수 여부가 아직 비즈니스적으로 미정임이 명시되어 있으니, 고칠 때 비즈니스 요구사항부터 확인한다. diff --git a/.claude/rules/report.md b/.claude/rules/report.md new file mode 100644 index 0000000..e46278e --- /dev/null +++ b/.claude/rules/report.md @@ -0,0 +1,11 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/report/**/*.java" + - "src/main/java/ssu/eatssu/domain/review/entity/Report.java" +--- + +# Report Domain Notes + +- `Report` 엔티티는 `report` 패키지가 아니라 `review.entity.Report`에 있다. `ReportType`/`ReportStatus`/서비스/컨트롤러만 `report` 패키지에 있다. 이 엔티티가 다른 패키지로 옮겨지면 위 `paths`도 함께 수정할 것. +- 신고 접수는 Slack 알림(`SlackChannel.REPORT_CHANNEL`)만 보낼 뿐, 리뷰를 자동으로 숨기거나 카운트를 올리는 등의 후속 처리가 전혀 없다. 실제 조치는 전부 Slack을 보고 admin이 수동으로 처리한다. +- `ReportStatus`는 `PENDING` 외 값(`IN_PROGRESS`/`RESOLVED`/`REJECTED`/`FALSE_REPORT`)으로 전이시키는 코드가 어디에도 없다. 상태 관리 기능을 새로 만들기 전까지는 사실상 `PENDING` 고정이라고 생각한다. diff --git a/.claude/rules/restaurant.md b/.claude/rules/restaurant.md new file mode 100644 index 0000000..881e92c --- /dev/null +++ b/.claude/rules/restaurant.md @@ -0,0 +1,9 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/restaurant/**/*.java" +--- + +# Restaurant Domain Notes + +- 이 패키지는 자체 엔티티/테이블이 없고 `Restaurant`(캠퍼스 식당 enum), `RestaurantType`(FIXED/VARIABLE 분류 enum) 두 개로만 구성된다. `menu` 도메인의 하위 개념에 가깝다. +- `partnership` 패키지에도 이름이 완전히 같은 `RestaurantType` enum이 따로 있다(`restaurant`는 FIXED/VARIABLE, `partnership`은 RESTAURANT/CAFE/PUB로 의미가 전혀 다름). import할 때 IDE 자동완성이 잘못된 쪽을 잡아줄 수 있으니 패키지 경로를 반드시 확인한다. diff --git a/.claude/rules/review.md b/.claude/rules/review.md new file mode 100644 index 0000000..ef02363 --- /dev/null +++ b/.claude/rules/review.md @@ -0,0 +1,13 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/review/**/*.java" +--- + +# Review Domain Notes + +- 같은 `Review` 엔티티에 V1용 `@Embedded Ratings ratings`와 V2용 `Integer rating`이 함께 존재한다(엔티티 상단 주석 참고). 두 필드는 서로 배타적으로 채워질 수 있으니 어느 한쪽만 보고 값이 있다고 가정하면 안 된다. +- V1 리뷰는 항상 `review.menu`로 연결되고, V2 meal 리뷰는 `review.meal`을 직접 사용한다. V1 API(`GET /reviews`)는 `review.menu` 기준으로만 조회하므로 V2로 작성된 meal 리뷰는 V1에서 보이지 않는다. +- V2 집계(`ReviewServiceV2.findMenuReviews`/`findMealReviews`/`findRestaurantReviews`)는 리뷰 리스트를 전부 로드한 뒤 Java에서 평균/카운트를 계산한다. V1(`ReviewRatingService`)은 QueryDSL로 DB에서 집계한다. 새 집계 기능을 추가할 때 어느 패턴을 따를지 확인한다. +- 리뷰 이미지 S3 업로드는 V1 엔드포인트(`POST /reviews/upload/image`)에서만 처리한다. V2 리뷰 생성 API는 이미 업로드된 `imageUrls`만 받으므로, V2 클라이언트도 이미지 첨부 시 V1 업로드 API를 먼저 호출해야 한다. +- `Report` 엔티티는 `report` 패키지가 아니라 `review.entity.Report`에 있다. 리뷰 신고 관련 로직을 찾을 때 `report` 패키지만 보면 안 된다. 상태/전이 등 나머지 신고 로직은 [[report]] 참고. +- `ReviewRatingService`(`review/service`)는 `rating` 도메인의 `RatingCalculator`를 구현해 실제 별점 집계를 담당한다. 자세한 내용은 [[rating]] 참고. diff --git a/.claude/rules/slack.md b/.claude/rules/slack.md new file mode 100644 index 0000000..27b8283 --- /dev/null +++ b/.claude/rules/slack.md @@ -0,0 +1,9 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/slack/**/*.java" +--- + +# Slack Domain Notes + +- 예외 필터링 없이 모든 예외(비즈니스 예외 포함)가 `ControllerLogAspect`를 통해 Slack 전송 대상이 된다. 실제 전송 여부는 `SlackErrorNotifier`가 `server.env == "prod"`인지로만 가른다. +- 전송은 동기 방식이며 큐잉/rate-limit/dedup이 없다. 장애로 예외가 몰리면 실패 요청마다 Slack API 호출 지연이 응답 시간에 그대로 추가된다. diff --git a/.claude/rules/slice.md b/.claude/rules/slice.md new file mode 100644 index 0000000..9d874b5 --- /dev/null +++ b/.claude/rules/slice.md @@ -0,0 +1,9 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/slice/**/*.java" +--- + +# Slice Domain Notes + +- 커서는 별도 인코딩 없이 정렬 대상 PK(`review.id`) 값 그대로 쓴다. 커서 기반 페이지네이션을 다른 정렬 기준으로 확장하려면 이 "PK 그대로" 가정이 깨진다는 점을 먼저 고려한다. +- 컨트롤러의 `@PageableDefault(sort = "date")`는 실제로 무시된다. 리포지토리(QueryDSL)는 항상 `id` 기준으로 정렬하므로, 클라이언트가 sort 파라미터를 보내도 조용히 무시된다. diff --git a/.claude/rules/user.md b/.claude/rules/user.md new file mode 100644 index 0000000..53d5d47 --- /dev/null +++ b/.claude/rules/user.md @@ -0,0 +1,11 @@ +--- +paths: + - "src/main/java/ssu/eatssu/domain/user/**/*.java" +--- + +# User Domain Notes + +- `users` 테이블에 email unique 제약이 없다. 다른 provider로 같은 이메일 로그인 시 `findFirstByEmailOrderByIdAsc`로 기존 계정에 합쳐진다([[auth]]와 연결된 이슈). +- `College`/`Department`의 `name_en`은 어떤 마이그레이션에서도 채워진 적이 없다(V10/V11은 ja/vi만 채움). EN 유저는 college/department 이름이 항상 한국어로 폴백된다. +- 회원 탈퇴(`UserService.withdraw`)는 하드 delete이지만 엔티티마다 처리 방식이 다르다: `Review`/`Inquiry`는 `clearUser()`로 FK만 null 처리해 데이터를 보존하고, `reviewLikes`/`partnershipLikes`(`orphanRemoval=true`)와 `reviewReports`(`cascade=ALL`)는 그대로 cascade 삭제된다. `User`에 새 `@OneToMany` 연관관계를 추가할 때 탈퇴 시 어떻게 처리할지 반드시 정해야 한다. +- `NicknameValidator`는 한글/영문 비속어·브랜드명·관리자 사칭 방지 정규식을 담은 코드로, 수정 빈도는 낮지만 잘못 건드리면 닉네임 검증 전체(등록/검증 두 진입점 모두)에 영향을 준다. diff --git a/CLAUDE.md b/CLAUDE.md index 6f81abf..2815a4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Instructions +- `.claude/rules/*.md`의 `paths`가 다른 도메인 패키지에 있는 파일을 가리키는 경우(예: report.md → review 패키지의 Report 엔티티), 그 파일을 이동하거나 이름을 바꿀 때 해당 rule 파일의 `paths`도 함께 수정한다. - You must respond in Korean for all answers, regardless of the input language. - Do not write any comments in code blocks, even if asked to do so. - Do not use wildcard when importing libraries. @@ -61,7 +62,7 @@ src/main/java/ssu/eatssu/ - **rating**: `Ratings` 임베디드 객체로 별점 집계 관리 - **user**: User 엔티티, 학과/단과대학 정보(`department` 하위 패키지), 다국어 설정(`Language` enum: KO/EN/JA/VI) - **partnership**: 제휴 식당 정보 및 좋아요 -- **admin**: Mustache 템플릿 기반 어드민 UI. 식단/메뉴/카테고리/문의/리포트/리뷰 관리 +- **admin**: 일반 API와 동일한 JWT(`ROLE_ADMIN`) 인증을 쓰는 `@ResponseBody` JSON API. 식단/메뉴/카테고리/문의/리포트/리뷰 관리. `login.mustache`가 남아있으나 어떤 컨트롤러도 뷰를 렌더링하지 않는 죽은 코드 - **slice**: 커서 기반 페이지네이션 (`SliceResponse`) - **slack**: 서버 에러 발생 시 Slack 알림 (`SlackErrorNotifier`)