From ac02fd3b24e812c7f678e6883af26226be54aa62 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 17:52:49 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=8F=99=ED=98=B8=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EC=A6=8C=EB=B0=A9=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=B6=84=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/service/PostService.java | 17 +++ .../global/config/DataInitializer.java | 9 ++ .../snowthing/global/error/ErrorCode.java | 14 ++ .../domain/post/service/PostServiceTest.java | 133 ++++++++++++++++++ .../014_migration_crew_and_season_room.sql | 13 ++ .../006_recruit_boards/01_requirements.md | 39 +++++ .../006_recruit_boards/02_domain-model.md | 46 ++++++ docs/conception/006_recruit_boards/03_erd.md | 45 ++++++ .../006_recruit_boards/04_api-spec.md | 79 +++++++++++ docs/project/work.md | 37 +++++ frontend/app/components/SiteChrome.tsx | 6 +- frontend/app/posts/create/page.tsx | 17 ++- frontend/app/posts/page.tsx | 10 ++ .../verify-production-migration-007-013.sh | 64 +++++++++ 14 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 database/production/014_migration_crew_and_season_room.sql create mode 100644 docs/conception/006_recruit_boards/01_requirements.md create mode 100644 docs/conception/006_recruit_boards/02_domain-model.md create mode 100644 docs/conception/006_recruit_boards/03_erd.md create mode 100644 docs/conception/006_recruit_boards/04_api-spec.md create mode 100644 scripts/verify-production-migration-007-013.sh diff --git a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java index 5907dc7..334f4ef 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/post/service/PostService.java @@ -35,6 +35,8 @@ public class PostService { private static final String ANONYMOUS_CATEGORY_CODE = "ANONYMOUS"; private static final String MARKET_CATEGORY_CODE = "MARKET"; + private static final String CREW_CATEGORY_CODE = "CREW"; + private static final String SEASON_ROOM_CATEGORY_CODE = "SEASON_ROOM"; private static final String DEFAULT_WRITER_IP = "127.0.0.1"; private static final int FIRST_IMAGE_SORT_ORDER = 1; private static final int MAX_OFFSET_PAGE = 100; @@ -59,6 +61,12 @@ public PostResponse createPost( () -> new CustomAuthException(ErrorCode.POST_CATEGORY_NOT_FOUND)); rejectMarketCategory(category); + if (isAnonymousDisallowedCategory(category.getCode())) { + if (request.isAnonymous() || userDetails == null) { + throw new CustomAuthException(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + } + Member member = null; String encodedPassword = null; @@ -208,6 +216,10 @@ public PostResponse updatePost( rejectMarketPost(post); rejectMarketCategory(category); + if (isAnonymousDisallowedCategory(category.getCode()) && post.isAnonymous()) { + throw new CustomAuthException(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + post.update(request.title(), request.content(), category); post.replaceImages(toPostImages(request.imageUrls())); return PostResponse.from(post); @@ -304,6 +316,11 @@ private void rejectMarketCategory(PostCategory category) { } } + private boolean isAnonymousDisallowedCategory(String categoryCode) { + return CREW_CATEGORY_CODE.equalsIgnoreCase(categoryCode) + || SEASON_ROOM_CATEGORY_CODE.equalsIgnoreCase(categoryCode); + } + private boolean isAdmin(CustomUserDetails userDetails) { return userDetails != null && userDetails.getAuthorities().stream() diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index 77adefa..5daee8b 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -150,6 +150,15 @@ public void run(String... args) { categoryRepository.save(PostCategory.builder().name("중고장터").code("MARKET").build()); } + if (categoryRepository.findByCode("CREW").isEmpty()) { + categoryRepository.save(PostCategory.builder().name("동호회 모집").code("CREW").build()); + } + + if (categoryRepository.findByCode("SEASON_ROOM").isEmpty()) { + categoryRepository.save( + PostCategory.builder().name("시즌방 모집").code("SEASON_ROOM").build()); + } + if (marketCategoryRepository.count() == 0) { marketCategoryRepository.saveAll( List.of( diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index edd0dad..583829e 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -13,6 +13,19 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_001", "존재하지 않는 회원입니다."), DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "MEMBER_002", "이미 사용 중인 이메일입니다."), DUPLICATE_NICKNAME(HttpStatus.BAD_REQUEST, "MEMBER_003", "이미 사용 중인 닉네임입니다."), + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT, "EMAIL_001", "이미 가입된 이메일입니다."), + EMAIL_CODE_FORMAT_INVALID(HttpStatus.BAD_REQUEST, "EMAIL_002", "인증번호는 숫자 6자리여야 합니다."), + EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "EMAIL_003", "인증번호가 일치하지 않습니다."), + EMAIL_CODE_EXPIRED(HttpStatus.GONE, "EMAIL_004", "인증번호가 만료되었습니다. 다시 요청해 주세요."), + EMAIL_ATTEMPT_LIMIT(HttpStatus.TOO_MANY_REQUESTS, "EMAIL_005", "인증번호 입력 횟수를 초과했습니다."), + EMAIL_SEND_LIMIT( + HttpStatus.TOO_MANY_REQUESTS, "EMAIL_006", "인증 메일 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."), + EMAIL_DELIVERY_UNAVAILABLE( + HttpStatus.SERVICE_UNAVAILABLE, "EMAIL_007", "인증 메일을 보내지 못했습니다. 잠시 후 다시 시도해 주세요."), + EMAIL_TOKEN_INVALID(HttpStatus.BAD_REQUEST, "EMAIL_008", "이메일 인증 정보가 유효하지 않습니다."), + EMAIL_TOKEN_EXPIRED(HttpStatus.GONE, "EMAIL_009", "이메일 인증이 만료되었거나 이미 사용되었습니다."), + EMAIL_AVAILABILITY_LIMIT( + HttpStatus.TOO_MANY_REQUESTS, "EMAIL_010", "이메일 중복 확인 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."), POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_001", "존재하지 않거나 삭제된 게시글입니다."), POST_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_002", "존재하지 않는 게시판 카테고리입니다."), ALREADY_REACTED(HttpStatus.CONFLICT, "POST_003", "이미 추천 또는 비추천 투표를 완료한 게시글입니다."), @@ -21,6 +34,7 @@ public enum ErrorCode { HttpStatus.BAD_REQUEST, "POST_005", "게시판 조회의 최대 한계선은 100페이지(2,000개 글)까지입니다. 더 이전 글은 검색 기능을 이용해 주세요."), + ANONYMOUS_POST_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "POST_006", "해당 게시판은 익명 작성을 지원하지 않습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "존재하지 않거나 이미 삭제된 댓글입니다."), PARENT_COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_002", "존재하지 않는 부모 댓글입니다."), INVALID_COMMENT_PARENT(HttpStatus.BAD_REQUEST, "COMMENT_003", "동일한 게시글의 댓글에만 대댓글을 달 수 있습니다."), diff --git a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java index 336bc1f..2b6fecb 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/post/service/PostServiceTest.java @@ -91,6 +91,26 @@ void setUp() { .name("자유게시판") .code("FREE") .build())); + + categoryRepository + .findByCode("CREW") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("동호회 모집") + .code("CREW") + .build())); + + categoryRepository + .findByCode("SEASON_ROOM") + .orElseGet( + () -> + categoryRepository.save( + PostCategory.builder() + .name("시즌방 모집") + .code("SEASON_ROOM") + .build())); } @Nested @@ -155,6 +175,88 @@ void createPost_unauthorized() { .extracting("errorCode") .isEqualTo(ErrorCode.INVALID_CREDENTIALS); } + + @Test + @DisplayName("로그인 회원이 동호회 및 시즌방 홍보글을 정상적으로 등록한다.") + void createPost_recruitBoards_success_member() { + PostCreateRequest crewRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("휘닉스파크 주말 크루 모집") + .content("크루원 모집합니다.") + .isAnonymous(false) + .build(); + + PostResponse crewResponse = + postService.createPost(crewRequest, userDetails1, "127.0.0.1"); + assertThat(crewResponse.categoryCode()).isEqualTo("CREW"); + assertThat(crewResponse.writerName()).isEqualTo("보더1호"); + + PostCreateRequest roomRequest = + PostCreateRequest.builder() + .categoryCode("SEASON_ROOM") + .title("하이원 26/27 시즌방 모집") + .content("주말조 남 2명 모집합니다.") + .isAnonymous(false) + .build(); + + PostResponse roomResponse = + postService.createPost(roomRequest, userDetails2, "127.0.0.1"); + assertThat(roomResponse.categoryCode()).isEqualTo("SEASON_ROOM"); + assertThat(roomResponse.writerName()).isEqualTo("보더2호"); + } + + @Test + @DisplayName("동호회 및 시즌방 홍보 게시판에 익명 작성을 시도하면 ANONYMOUS_POST_NOT_ALLOWED 예외가 발생한다.") + void createPost_recruitBoards_anonymous_rejected() { + PostCreateRequest crewAnonRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("익명 크루 모집") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Pass1234!") + .build(); + + assertThatThrownBy( + () -> + postService.createPost( + crewAnonRequest, userDetails1, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + + PostCreateRequest roomAnonRequest = + PostCreateRequest.builder() + .categoryCode("SEASON_ROOM") + .title("익명 시즌방 모집") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Pass1234!") + .build(); + + assertThatThrownBy(() -> postService.createPost(roomAnonRequest, null, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + + @Test + @DisplayName("비로그인 유저가 동호회/시즌방 게시판 작성을 시도하면 ANONYMOUS_POST_NOT_ALLOWED 예외가 발생한다.") + void createPost_recruitBoards_unauthorized_rejected() { + PostCreateRequest crewRequest = + PostCreateRequest.builder() + .categoryCode("CREW") + .title("비로그인 크루 모집") + .content("내용") + .isAnonymous(false) + .build(); + + assertThatThrownBy(() -> postService.createPost(crewRequest, null, "127.0.0.1")) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } } @Nested @@ -342,6 +444,37 @@ void updatePost_forbidden_otherUser() { .isEqualTo(ErrorCode.ACCESS_DENIED); } + @Test + @DisplayName("익명 게시글을 동호회/시즌방 홍보 카테고리로 변경 시도 시 ANONYMOUS_POST_NOT_ALLOWED 예외가 발생한다.") + void updatePost_anonymousToRecruitBoard_rejected() { + PostCreateRequest anonReq = + PostCreateRequest.builder() + .categoryCode("FREE") + .title("익명 원본글") + .content("익명 내용") + .isAnonymous(true) + .anonymousPassword("Password123!") + .build(); + + PostResponse anonPost = postService.createPost(anonReq, null, "127.0.0.1"); + + PostUpdateRequest updateToCrewReq = + PostUpdateRequest.builder() + .categoryCode("CREW") + .title("크루로 변경 시도") + .content("변경 내용") + .anonymousPassword("Password123!") + .build(); + + assertThatThrownBy( + () -> + postService.updatePost( + anonPost.publicId(), updateToCrewReq, null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ANONYMOUS_POST_NOT_ALLOWED); + } + @Test @DisplayName( "작성자 본인은 Soft Delete로 게시글을 정상 삭제하며 DB에 is_deleted=true, status=DELETED, deleted_at이 기록된다.") diff --git a/database/production/014_migration_crew_and_season_room.sql b/database/production/014_migration_crew_and_season_room.sql new file mode 100644 index 0000000..6e14cfe --- /dev/null +++ b/database/production/014_migration_crew_and_season_room.sql @@ -0,0 +1,13 @@ +-- ==================================================================== +-- Snowthing Production Migration: 014_migration_crew_and_season_room.sql +-- Version: 014 +-- Description: 동호회 모집(CREW) 및 시즌방 모집(SEASON_ROOM) 게시판 카테고리 추가 +-- ==================================================================== + +INSERT INTO `post_category` (`name`, `code`) +SELECT '동호회 모집', 'CREW' +WHERE NOT EXISTS (SELECT 1 FROM `post_category` WHERE `code` = 'CREW'); + +INSERT INTO `post_category` (`name`, `code`) +SELECT '시즌방 모집', 'SEASON_ROOM' +WHERE NOT EXISTS (SELECT 1 FROM `post_category` WHERE `code` = 'SEASON_ROOM'); diff --git a/docs/conception/006_recruit_boards/01_requirements.md b/docs/conception/006_recruit_boards/01_requirements.md new file mode 100644 index 0000000..6c7a177 --- /dev/null +++ b/docs/conception/006_recruit_boards/01_requirements.md @@ -0,0 +1,39 @@ +# 동호회 및 시즌방 홍보 게시판 요구사항 + +## 1. 목적 + +스노우보드/스키 커뮤니티에서 동호회원(크루원) 모집 및 시즌방(투자자·멤버) 모집을 위한 순수 공개 홍보 플랫폼을 제공합니다. +플랫폼은 폐쇄형 크루 관리나 금전 계약에 직접 개입하지 않고, 기존 게시판 인프라(`Post`)를 재사용하여 정보 공유와 댓글 소통의 장을 지원합니다. + +## 2. 사용자 시나리오 + +1. **동호회 홍보글 작성**: + - 로그인한 회원이 글쓰기 화면에서 '동호회 모집' 카테고리를 선택합니다. + - 크루 소개, 주 베이스 리조트, 라이딩 성향, 지원 자격, 오픈카톡 링크 등을 제목 및 본문에 자유롭게 작성합니다. + - 익명 작성은 지원되지 않으므로 회원 본인의 닉네임으로 등록됩니다. +2. **시즌방 홍보글 작성**: + - 로그인한 회원이 글쓰기 화면에서 '시즌방 모집' 카테고리를 선택합니다. + - 대상 리조트, 숙소 위치, 모집 인원 및 성비, 투자 비용, 방 규칙 등을 제목 및 본문에 작성합니다. + - 익명 작성은 지원되지 않으므로 회원 본인의 닉네임으로 등록됩니다. +3. **목록 및 상세 조회**: + - 방문자는 '전체글' 탭 또는 전용 '동호회 모집', '시즌방 모집' 탭에서 홍보글 목록을 열람합니다. + - 상세 페이지에서 본문 내용을 확인하고 댓글/대댓글을 통해 문의 및 소통합니다. + +## 3. 기능 요구사항 + +| ID | 요구사항 | +|---|---| +| RECRUIT-F-001 | `post_category`에 '동호회 모집'(`CREW`) 및 '시즌방 모집'(`SEASON_ROOM`) 카테고리를 제공한다. | +| RECRUIT-F-002 | 두 게시판은 **로그인 회원만 작성 가능**하며, 비회원 작성 및 익명(`isAnonymous = true`) 작성을 원천 차단한다. | +| RECRUIT-F-003 | 익명 글쓰기 시도 시 표준 비즈니스 에러 코드 `POST_006` (`ANONYMOUS_POST_NOT_ALLOWED`)를 반환한다. | +| RECRUIT-F-004 | 기존 게시글 수정 시에도 익명 글이 `CREW`나 `SEASON_ROOM` 카테고리로 변경되는 것을 차단한다. | +| RECRUIT-F-005 | '전체글' 목록 조회 시 두 카테고리의 게시글도 함께 정상 노출된다 (방식 A). | +| RECRUIT-F-006 | 상단 내비게이션 드롭다운 메뉴 및 게시판 카테고리 탭에 '동호회 모집', '시즌방 모집'을 노출한다. | +| RECRUIT-F-007 | 글쓰기 화면에서 두 카테고리 선택 시 '익명으로 작성' 체크박스 및 익명 비밀번호 입력을 숨긴다. | + +## 4. 비기능 요구사항 + +- **무결성 및 안정성**: 신규 테이블 추가 없이 기존 검증된 `Post`, `Comment`, `PostImage` 인프라를 100% 재사용하여 시스템 복잡도를 최소화한다. +- **예외 처리 표준화**: 문자열 리터럴 예외를 금지하고 `ErrorCode.ANONYMOUS_POST_NOT_ALLOWED`와 `CustomAuthException`으로 일원화한다. +- **DTO 불변성**: DTO 생성 시 컬렉션 방어적 복사 원칙을 준수한다. +- **계층 분리**: 서비스 레이어에서 세션/서블릿에 직접 결합하지 않는다. diff --git a/docs/conception/006_recruit_boards/02_domain-model.md b/docs/conception/006_recruit_boards/02_domain-model.md new file mode 100644 index 0000000..6b1c0cd --- /dev/null +++ b/docs/conception/006_recruit_boards/02_domain-model.md @@ -0,0 +1,46 @@ +# 동호회 및 시즌방 홍보 게시판 도메인 모델 + +## 1. 도메인 구조 개요 + +기존 게시판 도메인(`Post`)을 재사용하는 범용 커뮤니티 파티셔닝 구조입니다. + +```mermaid +classDiagram + class PostCategory { + +Long id + +String name + +String code ("CREW", "SEASON_ROOM") + } + + class Post { + +Long id + +String publicId + +String title + +String content + +boolean isAnonymous + +int viewCount + +int commentCount + +int likeCount + +PostStatus status + } + + class Member { + +Long id + +String publicId + +String nickname + +Role role + } + + PostCategory "1" <-- "N" Post : category + Member "1" <-- "N" Post : member (필수) +``` + +## 2. 도메인 불변식 (Invariants) + +1. **작성자 필수성 (Non-null Member)**: + - `post.category.code IN ('CREW', 'SEASON_ROOM')`인 경우 `post.member`는 반드시 `NOT NULL`이어야 한다. +2. **익명성 금지 (Non-anonymous)**: + - `post.isAnonymous`는 반드시 `false`여야 한다. + - `post.anonymousPassword`는 반드시 `NULL`이어야 한다. +3. **카테고리 불변식**: + - `post.category.code`는 `post_category` 테이블의 정식 등록 코드(`CREW`, `SEASON_ROOM`)여야 한다. diff --git a/docs/conception/006_recruit_boards/03_erd.md b/docs/conception/006_recruit_boards/03_erd.md new file mode 100644 index 0000000..3e5c1bd --- /dev/null +++ b/docs/conception/006_recruit_boards/03_erd.md @@ -0,0 +1,45 @@ +# 동호회 및 시즌방 홍보 게시판 ERD 스펙 + +## 1. ERD 관계도 + +기존 `post_category`와 `post` 테이블을 그대로 사용하며, 신규 물리 테이블 추가는 없습니다. + +```mermaid +erDiagram + POST_CATEGORY ||--o{ POST : "1:N" + MEMBER ||--o{ POST : "1:N" + + POST_CATEGORY { + bigint category_id PK + varchar(50) name + varchar(50) code UK + } + + POST { + bigint post_id PK + varchar(36) public_id UK + bigint category_id FK + bigint member_id FK + varchar(200) title + longtext content + varchar(45) writer_ip + boolean is_anonymous + varchar(255) anonymous_password + int view_count + int comment_count + int like_count + int dislike_count + boolean has_image + varchar(20) status + boolean is_deleted + datetime created_at + datetime updated_at + } +``` + +## 2. 신규 기준데이터 (마스터 레코드) + +| category_id (자동증가) | name | code | 비고 | +|---|---|---|---| +| Auto | 동호회 모집 | `CREW` | 익명 작성 불가 | +| Auto | 시즌방 모집 | `SEASON_ROOM` | 익명 작성 불가 | diff --git a/docs/conception/006_recruit_boards/04_api-spec.md b/docs/conception/006_recruit_boards/04_api-spec.md new file mode 100644 index 0000000..5e6518b --- /dev/null +++ b/docs/conception/006_recruit_boards/04_api-spec.md @@ -0,0 +1,79 @@ +# 동호회 및 시즌방 홍보 게시판 API 명세서 + +기존 게시글 API(`/api/v1/posts`)를 그대로 사용하며, 카테고리 코드(`categoryCode`) 파라미터로 구분합니다. + +## 1. 게시글 목록 조회 + +- **엔드포인트**: `GET /api/v1/posts` +- **쿼리 파라미터**: + - `categoryCode` (string, optional): `CREW` (동호회 모집), `SEASON_ROOM` (시즌방 모집), 또는 빈 값 (전체글) + - `page` (int, optional): 페이지 번호 (1부터 시작, 기본값 1) + - `size` (int, optional): 페이지 크기 (기본값 20, 최대 100) + - `keyword` (string, optional): 검색어 + - `searchType` (string, optional): `TITLE`, `WRITER`, `CONTENT`, `TITLE_CONTENT` +- **응답 (200 OK)**: + ```json + { + "content": [ + { + "publicId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "categoryName": "동호회 모집", + "categoryCode": "CREW", + "title": "[휘닉스파크] 26/27 스노우보드 주말 라이딩 크루원 모집합니다", + "writerNickname": "파우더보더", + "thumbnailImageUrl": null, + "hasImage": false, + "viewCount": 15, + "commentCount": 3, + "likeCount": 2, + "dislikeCount": 0, + "status": "NORMAL", + "isDeleted": false, + "createdAt": "2026-10-06T16:00:00" + } + ], + "pageInfo": { + "currentPage": 1, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "hasNext": false + } + } + ``` + +--- + +## 2. 게시글 등록 + +- **엔드포인트**: `POST /api/v1/posts` +- **인증**: 필수 (로그인 세션 필요) +- **요청 Body**: + ```json + { + "categoryCode": "CREW", // 또는 "SEASON_ROOM" + "title": "동호회/시즌방 홍보 제목", + "content": "홍보 본문 내용", + "isAnonymous": false, + "anonymousPassword": null, + "imageUrls": [] + } + ``` +- **예외 응답**: + - **비로그인 사용자가 요청 시**: + - HTTP 401 Unauthorized (`ErrorCode.INVALID_CREDENTIALS` / `AUTH_001`) + - **`isAnonymous = true`로 요청 시**: + - HTTP 400 Bad Request (`ErrorCode.ANONYMOUS_POST_NOT_ALLOWED` / `POST_006`) + ```json + { + "code": "POST_006", + "message": "해당 게시판은 익명 작성을 지원하지 않습니다." + } + ``` + +--- + +## 3. 게시글 상세 조회 / 수정 / 삭제 + +기존 `GET /api/v1/posts/{publicId}`, `PUT /api/v1/posts/{publicId}`, `DELETE /api/v1/posts/{publicId}` 엔드포인트를 그대로 사용합니다. +- 수정 시에도 익명 게시글이 `CREW` 또는 `SEASON_ROOM`으로 카테고리 변경을 시도하면 `POST_006` 예외가 발생합니다. diff --git a/docs/project/work.md b/docs/project/work.md index 6d7498a..679a16f 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,16 @@ +## 동호회 및 시즌방 홍보 게시판 분리 구현 (2026-10-06) + +- 상태: 구현·전체 테스트·프로덕션 빌드 통과 완료 +- 동호회원 모집(`CREW`) 및 시즌방 모집(`SEASON_ROOM`)을 위한 순수 공개 홍보 플랫폼 게시판 신설 +- 기존 `post` 엔티티 및 댓글·이미지 인프라를 재사용하는 카테고리 분리 아키텍처 적용 +- 두 게시판은 허위/스팸 방지를 위해 익명 글쓰기(`isAnonymous = true`) 및 비로그인 작성을 원천 차단 (`ErrorCode.ANONYMOUS_POST_NOT_ALLOWED`, `POST_006`) +- 익명 게시글을 동호회/시즌방 홍보 카테고리로 변경하는 수정 요청 또한 차단 가드 적용 +- '전체글' 탭 조회 시 두 홍보 카테고리 글도 함께 통합 노출되도록 구성 +- DB 마이그레이션 `014_migration_crew_and_season_room.sql` 작성 및 `DataInitializer.java` 시드 반영 +- `scripts/verify-production-migration-007-013.sh`에 014 마이그레이션 및 카테고리 무결성 검증 추가 +- 상단 내비게이션 드롭다운 메뉴 및 게시판 카테고리 탭, 글쓰기 화면에 `동호회 모집`, `시즌방 모집` 반영 +- 백엔드 전체 테스트(306건) 100% 통과 및 프론트엔드 Next.js 16 Turbopack 프로덕션 빌드 통과 + ## 카풀 자동 경로·유가 계산 연동 (2026-10-03) - 상태: 구현·전체 테스트·외부 API 실제 호출 검증 완료 @@ -1968,3 +1981,27 @@ - 작성·수정 화면은 장애 오류 코드에 따라 경로 또는 유가 수동 입력 영역을 열고, 상세 화면은 자동·수동 경로와 오피넷·캐시·사용자 입력 유가를 구분해 표시한다. - 수동 경로의 거리·통행료와 사용자 입력 유가는 프런트 입력 범위에만 의존하지 않고 백엔드에서 각각 `1~2,000km`, `0~20,000원`, `1,000~3,000원/L`로 다시 검증한다. 관련 단위·계약 테스트, 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build, Docker MySQL 8의 `001~012` 순차 마이그레이션 검증을 모두 통과했다. - 카풀 목록의 최신 등록순을 유지하면서 정렬 비용을 줄일 수 있도록 기존 `010`은 수정하지 않고 `012_migration_carpool_latest_sort_index.sql`에 `(created_at DESC, post_id DESC)` 인덱스를 추가했다. 미래 출발일 조건용 인덱스와 최신순 정렬용 인덱스 중 어떤 실행 계획이 유리한지는 운영 데이터 분포에 따라 달라지므로 `EXPLAIN ANALYZE`로 확인한다. 0부터 시작하는 페이지는 `0~99`로 제한하고 프런트 페이지 이동도 최대 100페이지로 맞춰 깊은 OFFSET 요청을 차단했다. +- **이메일 인증 회원가입 및 비밀번호 재설정 (2026-10-04)** + - 병합된 `main`에서 `feature/email-verification-password-reset` 브랜치를 분리했습니다. + - 가입 전 이메일 중복 확인, SES 인증번호 발송, 6자리 번호 확인, 15분짜리 일회용 가입 토큰 검증을 연결했습니다. + - 인증번호는 원문 대신 HMAC-SHA256으로 저장하며 5분 만료, 60초 재전송 제한, 이메일당 시간당 5회, IP당 시간당 20회, 오입력 5회 제한을 적용했습니다. + - 비밀번호 재설정은 계정 존재 여부를 같은 응답으로 숨기고, 성공 시 BCrypt 비밀번호 변경과 `email_verified_at` 기록을 한 트랜잭션으로 처리합니다. + - 비밀번호 변경 후 회원의 기존 HTTP 세션과 라이브톡 연결을 종료하며, 세션 만료 시 인메모리 레지스트리도 정리되도록 보완했습니다. + - `013_migration_email_verification.sql`에 회원 인증 일시와 이메일 인증 상태 테이블·인덱스를 추가하고 배포 CI의 마이그레이션 검증 범위를 `007~013`으로 확장했습니다. + - 만료된 인증 데이터는 만료 시각으로부터 24시간 후 매일 04:20에 자동 삭제하도록 구성했습니다. + - 회원가입 화면에 이메일 인증 단계를 추가하고 로그인 화면에서 비밀번호 재설정 화면으로 진입할 수 있도록 연결했습니다. + - 운영 컨테이너에 SES 리전·발신자·Configuration Set·HMAC 비밀값 환경변수를 전달하고, 애플리케이션은 EC2 역할 자격 증명으로 SES v2 API를 호출하도록 구성했습니다. + - 백엔드 전체 테스트와 Spotless, 프론트엔드 ESLint와 프로덕션 빌드를 통과했습니다. MySQL 8 임시 스키마에서 `001~013` 순차 적용과 `013` 컬럼·테이블·인덱스를 확인했습니다. + - 남은 운영 작업: SES 도메인 DKIM 검증, Production access 승인, EC2 역할의 `ses:SendEmail`, Configuration Set 및 `/etc/snowthing/prod.env` 비밀값 설정이 필요합니다. + - 커밋 전 설계 계약과 테스트 누락을 재검토해 테스트 설정의 JPA 경로, 가입·재설정 토큰 응답 필드, 인증번호 형식 오류 코드, 인증번호·토큰 만료 및 재사용, 목적 불일치, 비밀번호 변경, 세션 폐기, SES 장애 매핑 테스트를 보완했습니다. + - 인증 시간 계산에 `Clock`을 주입하고, 동일 세션 재로그인 시 이전 회원 매핑이 남지 않도록 세션 레지스트리를 보완했습니다. 인증 관련 비즈니스 예외는 `ErrorCode`와 `CustomException`으로 통일했습니다. + - 가입 이메일 중복 확인 API에 발송 제한과 분리된 IP별 슬라이딩 60초 10회 제한을 적용했습니다. 11번째 요청은 회원 조회 전에 `429 EMAIL_010`으로 차단하며, 서버 재시작·다중 인스턴스·공유 NAT 한계와 Redis·WAF 전환 조건을 설계 문서에 기록했습니다. + - 운영 마이그레이션 `013`에 상태·만료시각 복합 인덱스와 목적·상태·실패 횟수·발송 횟수 CHECK 제약을 추가하고, MySQL 8 임시 컨테이너에서 `001~013` 최초 적용·재실행과 제약조건을 검증한 뒤 컨테이너를 제거했습니다. + - 회원가입 인증 만료 카운트다운과 응답 필드 연결, 비밀번호 재설정 완료 후 로그인 화면 이동을 보완했습니다. + - 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build, `git diff --check`를 통과했습니다. 테스트 종료 시 Hibernate의 기존 외래 키 정리 경고가 출력되지만 테스트 결과는 성공입니다. + - 운영 EC2의 `/etc/snowthing/prod.env`에 SES provider·서울 리전·`help@snowthing.org` 발신자·`snowthing-mail-transactional` Configuration Set을 설정하고, 서버에서 64자리 hex HMAC 비밀값을 생성했습니다. 원본은 `prod.env.bak-20261006082540`으로 보관했으며 파일 권한 `600`과 각 키의 단일 선언을 확인했습니다. 새 값은 백엔드 컨테이너를 재배포 또는 재생성한 뒤 적용됩니다. + - 저장소의 애플리케이션·Compose·환경변수 예시 기본값, SES 어댑터 테스트와 운영 설계 문서의 발신 주소를 `help@snowthing.org`로 통일하고 대상 테스트를 통과했습니다. 운영 EC2는 이메일 인증 기능과 SES 환경변수 전달이 포함되기 전 커밋 `3e3d19e`를 실행 중이므로 단순 재시작은 하지 않았으며, 커밋·CI/ECR 배포 후 실제 발송 검증이 필요합니다. + - EC2 인스턴스 역할로 SES mailbox simulator 발송을 점검한 결과 서울 리전에 `snowthing-transactional` Configuration Set이 없어 명시 발송은 `NotFoundException`으로 실패했습니다. Configuration Set을 생략하면 Identity의 기존 기본값 `my-first-configuration-set`이 적용되지만 EC2 역할에 해당 Configuration Set 리소스 권한이 없어 `AccessDeniedException`이 발생했습니다. `snowthing-transactional` 생성, Identity 기본값 변경, IAM 정책에 Identity와 Configuration Set ARN을 함께 허용한 뒤 재검증해야 합니다. + - 실제 생성된 Configuration Set 이름에 맞춰 저장소·운영 문서·EC2 환경변수를 `snowthing-mail-transactional`로 통일하고 SES 어댑터 테스트와 diff 검사를 통과했습니다. SES 호출에서 Configuration Set의 존재는 확인됐으나 EC2 역할이 새 Configuration Set ARN의 `ses:SendEmail`을 허용하지 않아 `AccessDeniedException`이 남아 있습니다. + - EC2 역할의 Identity·`snowthing-mail-transactional` Configuration Set 권한과 `help@snowthing.org` 발신 조건을 반영한 뒤 AWS mailbox simulator 발송이 성공했고 SES `MessageId`를 확인했습니다. SES 인프라 경로는 검증됐으며 실제 사용자 수신과 애플리케이션 로그 검증은 이메일 인증 코드 운영 배포 후 진행합니다. + - 로컬 MySQL과 백엔드·프런트엔드 개발 서버를 기동하고 회원가입·비밀번호 재설정 페이지가 모두 정상 응답하는지 확인했습니다. Orca 브라우저에 두 화면을 열어 현재 구현을 변경하지 않은 상태로 데스크톱 레이아웃과 모바일 대응 요소를 검토했으며, 디자인 수정은 사용자와 개선안을 확정한 뒤 진행합니다. diff --git a/frontend/app/components/SiteChrome.tsx b/frontend/app/components/SiteChrome.tsx index f15754a..95cfe09 100644 --- a/frontend/app/components/SiteChrome.tsx +++ b/frontend/app/components/SiteChrome.tsx @@ -54,7 +54,7 @@ function ThemeToggle() { ); } -export type ActiveNav = "home" | "posts" | "best" | "free" | "anonymous" | "gear" | "food" | "carpool" | "market" | "resort" | "profile" | "login" | "signup"; +export type ActiveNav = "home" | "posts" | "best" | "free" | "anonymous" | "gear" | "food" | "crew" | "seasonRoom" | "carpool" | "market" | "resort" | "profile" | "login" | "signup"; interface MemberUser { publicId: string; @@ -76,6 +76,8 @@ const boardItems: NavItem[] = [ { href: "/posts?category=ANONYMOUS", label: "익명게시판", key: "anonymous" }, { href: "/posts?category=QNA", label: "장비 후기", key: "gear" }, { href: "/posts?category=FOOD", label: "리조트 맛집", key: "food" }, + { href: "/posts?category=CREW", label: "동호회 모집", key: "crew" }, + { href: "/posts?category=SEASON_ROOM", label: "시즌방 모집", key: "seasonRoom" }, ]; export function TopNav({ active = "home" }: { active?: ActiveNav }) { @@ -85,7 +87,7 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { const [search, setSearch] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); - const boardActive = ["posts", "best", "free", "anonymous", "gear", "food"].includes(active); + const boardActive = ["posts", "best", "free", "anonymous", "gear", "food", "crew", "seasonRoom"].includes(active); const isBoardItemActive = (key: string) => active === key; useEffect(() => { diff --git a/frontend/app/posts/create/page.tsx b/frontend/app/posts/create/page.tsx index 175817e..ee46961 100644 --- a/frontend/app/posts/create/page.tsx +++ b/frontend/app/posts/create/page.tsx @@ -73,8 +73,8 @@ function PostCreateForm() { } else if (currentCategory !== "ANONYMOUS") { if (!hasAlertedRef.current) { hasAlertedRef.current = true; - alert("자유/Q&A/맛집 게시판 글쓰기는 로그인이 필요합니다."); - router.replace("/login?redirect=/posts/create"); + alert("해당 게시판 글쓰기는 로그인이 필요합니다."); + router.replace(`/login?redirect=/posts/create?category=${currentCategory}`); } } } @@ -177,7 +177,18 @@ function PostCreateForm() { ); } - const boardName = categoryCode === "ANONYMOUS" ? "익명게시판" : categoryCode === "QNA" ? "장비 후기" : categoryCode === "FOOD" ? "리조트 맛집" : "자유게시판"; + const boardName = + categoryCode === "ANONYMOUS" + ? "익명게시판" + : categoryCode === "QNA" + ? "장비 후기" + : categoryCode === "FOOD" + ? "리조트 맛집" + : categoryCode === "CREW" + ? "동호회 모집" + : categoryCode === "SEASON_ROOM" + ? "시즌방 모집" + : "자유게시판"; return (
diff --git a/frontend/app/posts/page.tsx b/frontend/app/posts/page.tsx index fe8bcbe..354fbbc 100644 --- a/frontend/app/posts/page.tsx +++ b/frontend/app/posts/page.tsx @@ -47,6 +47,8 @@ const categories = [ { code: "ANONYMOUS", name: "익명게시판" }, { code: "QNA", name: "장비 후기" }, { code: "FOOD", name: "리조트 맛집" }, + { code: "CREW", name: "동호회 모집" }, + { code: "SEASON_ROOM", name: "시즌방 모집" }, ]; const defaultNotices = [ @@ -63,6 +65,14 @@ const boardConfigs: Record = { ], topics: ["셔틀버스 매너", "휘팍 실시간 파우더", "시즌방 비용 정산", "데크 구매 고민", "복귀길 정체"] }, QNA: { name: "장비 후기", navKey: "gear", notices: defaultNotices, topics: ["부츠 열성형", "바인딩 각도", "엣지 튜닝", "입문 데크", "카빙 자세"] }, FOOD: { name: "리조트 맛집", navKey: "food", notices: defaultNotices, topics: ["용평 아침식사", "휘팍 국밥", "하이원 야식", "비발디 카페", "웰리힐리 맛집"] }, + CREW: { name: "동호회 모집", navKey: "crew", notices: [ + { label: "안내", title: "건전하고 안전한 동호회(크루) 활동 및 정기 라이딩을 응원합니다.", date: "24.12.15", views: "9,800" }, + { label: "필독", title: "동호회 홍보 시 주 베이스 리조트와 라이딩 성향, 연락 수단을 명시해 주세요.", date: "24.12.20", views: "6,200" }, + ], topics: ["휘팍 주말 크루", "하이원 트릭 크루", "용평 카빙 모임", "초보 환영 동호회", "평일 직장인 보더"] }, + SEASON_ROOM: { name: "시즌방 모집", navKey: "seasonRoom", notices: [ + { label: "안내", title: "시즌방 투자금 및 관리비 관련 분쟁 예방을 위해 상세 조건을 투명하게 기재해 주세요.", date: "24.12.15", views: "1.1만" }, + { label: "필독", title: "금전 거래 전 방 규칙, 숙소 위치, 정원 및 환불 규정을 반드시 확인하세요.", date: "24.12.20", views: "8,900" }, + ], topics: ["휘팍 셔틀 5분 펜션", "하이원 풀상주 모집", "웰팍 주말조 남녀", "용평 콘도 지분 양도", "게스트 환영 시즌방"] }, }; function PostListContent() { diff --git a/scripts/verify-production-migration-007-013.sh b/scripts/verify-production-migration-007-013.sh new file mode 100644 index 0000000..a2a3c0e --- /dev/null +++ b/scripts/verify-production-migration-007-013.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +DB_HOST="${SNOWTHING_TEST_DB_HOST:-127.0.0.1}" +DB_PORT="${SNOWTHING_TEST_DB_PORT:-3306}" +DB_NAME="${SNOWTHING_TEST_DB_NAME:-snowthing_test}" +DB_USERNAME="${SNOWTHING_TEST_DB_USERNAME:?SNOWTHING_TEST_DB_USERNAME is required}" +DB_PASSWORD="${SNOWTHING_TEST_DB_PASSWORD:?SNOWTHING_TEST_DB_PASSWORD is required}" + +mysql_test() { + MYSQL_PWD="$DB_PASSWORD" mysql \ + --protocol=TCP --host="$DB_HOST" --port="$DB_PORT" \ + --user="$DB_USERNAME" --database="$DB_NAME" \ + --batch --skip-column-names "$@" +} + +mysql_test < database/production/001_initial_schema.sql +mysql_test < database/production/002_reference_data.sql + +run_migrations() { + SNOWTHING_MIGRATION_DB_HOST="$DB_HOST" \ + SNOWTHING_MIGRATION_DB_PORT="$DB_PORT" \ + SNOWTHING_MIGRATION_DB_NAME="$DB_NAME" \ + SNOWTHING_MIGRATION_DB_USERNAME="$DB_USERNAME" \ + SNOWTHING_MIGRATION_DB_PASSWORD="$DB_PASSWORD" \ + SNOWTHING_MIGRATION_DB_AUTH_MODE=password \ + SNOWTHING_MIGRATION_DB_SSL_MODE=DISABLED \ + scripts/run-production-migrations.sh database/production +} + +run_migrations +second_run_output="$(run_migrations)" + +for version in \ + 007_migration_carpool \ + 008_migration_carpool_cost_mode \ + 009_migration_carpool_equipment_load \ + 010_migration_carpool_query_index_and_resort_coordinates \ + 011_migration_carpool_manual_fallback_snapshots \ + 012_migration_carpool_latest_sort_index \ + 013_migration_email_verification \ + 014_migration_crew_and_season_room; do + grep -q "Migration already applied: $version" <<< "$second_run_output" + [[ "$(mysql_test --execute="SELECT COUNT(*) FROM schema_migration WHERE version = '$version'")" == "1" ]] +done + +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND column_name IN ('cost_mode', 'equipment_load_available')")" == "2" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'CARPOOL'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'CREW'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM post_category WHERE code = 'SEASON_ROOM'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'resort' AND column_name IN ('route_latitude', 'route_longitude')")" == "2" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM resort WHERE code IN ('PHOENIX', 'VIVALDI', 'HIGH1', 'YONGPYONG', 'WELLI_HILLI', 'JISAN', 'KONJIAM', 'MUJU', 'EDEN_VALLEY', 'ELYSIAN', 'ALPENSIA', 'OAK_VALLEY', 'O2_RESORT') AND route_latitude IS NOT NULL AND route_longitude IS NOT NULL")" == "13" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND index_name = 'idx_carpool_detail_departure_created_post'")" == "departure_at,created_at,post_id" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND index_name = 'idx_carpool_detail_created_post'")" == "created_at,post_id" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'carpool_detail' AND column_name IN ('destination_latitude', 'destination_longitude', 'fuel_price_source')")" == "3" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM carpool_detail WHERE destination_latitude IS NULL OR destination_longitude IS NULL")" == "0" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'member' AND column_name = 'email_verified_at'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND index_name IN ('uk_email_verification_email_purpose', 'idx_email_verification_token_purpose', 'idx_email_verification_status_expires_at', 'idx_email_verification_expires_at')")" == "4" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND constraint_type = 'CHECK' AND constraint_name IN ('chk_email_verification_purpose', 'chk_email_verification_status', 'chk_email_verification_failed_attempt_count', 'chk_email_verification_send_count')")" == "4" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM member WHERE email_verified_at IS NOT NULL")" == "0" ]] + +echo "Production migrations 007-013 verification passed." From a0395ac3e2f3592ae17a115b302a46032af1a700 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 17:54:51 +0900 Subject: [PATCH 02/12] =?UTF-8?q?style:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=EB=AF=B8=EC=9D=B8=EC=A6=9D=20UX?= =?UTF-8?q?=20=EB=B0=8F=20work.md=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 1 + frontend/app/forgot-password/page.tsx | 2 +- frontend/app/signup/page.tsx | 23 +++++++++++++++++------ frontend/next-env.d.ts | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 679a16f..4d21660 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2005,3 +2005,4 @@ - 실제 생성된 Configuration Set 이름에 맞춰 저장소·운영 문서·EC2 환경변수를 `snowthing-mail-transactional`로 통일하고 SES 어댑터 테스트와 diff 검사를 통과했습니다. SES 호출에서 Configuration Set의 존재는 확인됐으나 EC2 역할이 새 Configuration Set ARN의 `ses:SendEmail`을 허용하지 않아 `AccessDeniedException`이 남아 있습니다. - EC2 역할의 Identity·`snowthing-mail-transactional` Configuration Set 권한과 `help@snowthing.org` 발신 조건을 반영한 뒤 AWS mailbox simulator 발송이 성공했고 SES `MessageId`를 확인했습니다. SES 인프라 경로는 검증됐으며 실제 사용자 수신과 애플리케이션 로그 검증은 이메일 인증 코드 운영 배포 후 진행합니다. - 로컬 MySQL과 백엔드·프런트엔드 개발 서버를 기동하고 회원가입·비밀번호 재설정 페이지가 모두 정상 응답하는지 확인했습니다. Orca 브라우저에 두 화면을 열어 현재 구현을 변경하지 않은 상태로 데스크톱 레이아웃과 모바일 대응 요소를 검토했으며, 디자인 수정은 사용자와 개선안을 확정한 뒤 진행합니다. + - 회원가입과 비밀번호 재설정 화면의 `인증번호 받기` 문구를 `이메일 인증`으로 변경했습니다. 회원가입 버튼은 미인증 상태에서도 누를 수 있게 하고, 누르면 이메일 영역으로 이동·포커스하면서 `이메일 인증이 필요합니다.`를 인라인 경고로 표시하도록 보완했습니다. 프런트엔드 ESLint와 실제 브라우저의 버튼 문구·미인증 클릭 동작을 확인했습니다. diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx index 1fccd28..7684f2e 100644 --- a/frontend/app/forgot-password/page.tsx +++ b/frontend/app/forgot-password/page.tsx @@ -66,7 +66,7 @@ export default function ForgotPasswordPage() { }; return

비밀번호 재설정

가입 이메일로 받은 6자리 인증번호를 확인합니다.

{error &&
{error}
}{notice &&
{notice}
} - {!requestId &&
setEmail(event.target.value)} placeholder="가입 이메일" />
} + {!requestId &&
setEmail(event.target.value)} placeholder="가입 이메일" />
} {requestId && !resetToken &&
setCode(event.target.value.replace(/\D/g, ""))} placeholder="인증번호 6자리" />
} {resetToken && resetToken !== "completed" &&
setPassword(event.target.value)} placeholder="새 비밀번호" autoComplete="new-password" /> setPasswordConfirm(event.target.value)} placeholder="새 비밀번호 확인" autoComplete="new-password" />
} 로그인으로 돌아가기 diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 927cf99..4d3b9ff 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Footer, TopNav } from "../components/SiteChrome"; @@ -26,6 +26,8 @@ async function readError(response: Response, fallback: string) { export default function SignUpPage() { const router = useRouter(); + const emailSectionRef = useRef(null); + const emailInputRef = useRef(null); const [email, setEmail] = useState(""); const [verificationCode, setVerificationCode] = useState(""); const [verificationRequestId, setVerificationRequestId] = useState(""); @@ -42,6 +44,7 @@ export default function SignUpPage() { const [selectedStyleIds, setSelectedStyleIds] = useState([]); const [notice, setNotice] = useState(""); const [errorMsg, setErrorMsg] = useState(""); + const [emailVerificationError, setEmailVerificationError] = useState(""); const [loading, setLoading] = useState(false); const isEmailVerified = emailVerificationToken.length > 0; @@ -80,10 +83,11 @@ export default function SignUpPage() { setResendSeconds(0); setVerificationExpiresSeconds(0); setNotice(""); + setEmailVerificationError(""); }; const requestVerification = async () => { - setErrorMsg(""); setNotice(""); + setErrorMsg(""); setEmailVerificationError(""); setNotice(""); if (!EMAIL_REGEX.test(email)) return setErrorMsg("올바른 이메일 형식으로 입력해 주세요."); setLoading(true); try { @@ -113,6 +117,7 @@ export default function SignUpPage() { const body: VerificationTokenResponse = await response.json(); setEmailVerificationToken(body.verificationToken); setVerificationExpiresSeconds(0); + setEmailVerificationError(""); setNotice("이메일 인증이 완료되었습니다."); } catch (error) { setErrorMsg(error instanceof Error ? error.message : "인증번호 확인에 실패했습니다."); @@ -121,9 +126,15 @@ export default function SignUpPage() { const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setErrorMsg(""); - if (!isEmailVerified) return setErrorMsg("이메일 인증을 먼저 완료해 주세요."); + if (!isEmailVerified) { + setEmailVerificationError("이메일 인증이 필요합니다."); + emailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + emailInputRef.current?.focus({ preventScroll: true }); + return; + } if (!isPasswordValid) return setErrorMsg("비밀번호는 8자 이상이며 영문 대문자와 특수문자를 포함해야 합니다."); if (!isPasswordMatch) return setErrorMsg("비밀번호 확인이 일치하지 않습니다."); + if (!nickname.trim()) return setErrorMsg("닉네임을 입력해 주세요."); setLoading(true); try { const response = await csrfFetch(API_ENDPOINTS.members.signup, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, emailVerificationToken, password, nickname, departureRegion, resortIds: selectedResortIds, ridingStyleIds: selectedStyleIds }) }); @@ -137,12 +148,12 @@ export default function SignUpPage() { const toggle = (id: number, values: number[], setter: React.Dispatch>) => setter(values.includes(id) ? values.filter((value) => value !== id) : [...values, id]); - return

회원가입

+ return

회원가입

{errorMsg &&
{errorMsg}
} {notice &&
{notice}
} -
이메일
changeEmail(event.target.value)} className="snow-input flex-1" placeholder="user@snowthing.org" disabled={isEmailVerified} required />
{verificationRequestId && !isEmailVerified && <>
setVerificationCode(event.target.value.replace(/\D/g, ""))} className="snow-input flex-1" placeholder="인증번호 6자리" />

{verificationExpiresSeconds > 0 ? `인증번호 유효 시간 ${Math.floor(verificationExpiresSeconds / 60)}:${String(verificationExpiresSeconds % 60).padStart(2, "0")}` : "인증번호가 만료되었습니다. 다시 받아 주세요."}

}
+
이메일
changeEmail(event.target.value)} className="snow-input flex-1" placeholder="user@snowthing.org" disabled={isEmailVerified} required aria-invalid={emailVerificationError ? true : undefined} aria-describedby={emailVerificationError ? "email-verification-error" : undefined} />
{emailVerificationError && }{verificationRequestId && !isEmailVerified && <>
setVerificationCode(event.target.value.replace(/\D/g, ""))} className="snow-input flex-1" placeholder="인증번호 6자리" />

{verificationExpiresSeconds > 0 ? `인증번호 유효 시간 ${Math.floor(verificationExpiresSeconds / 60)}:${String(verificationExpiresSeconds % 60).padStart(2, "0")}` : "인증번호가 만료되었습니다. 다시 받아 주세요."}

}
- ({ id: item.id, label: item.name }))} selectedIds={selectedResortIds} onToggle={(id) => toggle(id, selectedResortIds, setSelectedResortIds)} /> ({ id: item.id, label: item.styleName }))} selectedIds={selectedStyleIds} onToggle={(id) => toggle(id, selectedStyleIds, setSelectedStyleIds)} /> + ({ id: item.id, label: item.name }))} selectedIds={selectedResortIds} onToggle={(id) => toggle(id, selectedResortIds, setSelectedResortIds)} /> ({ id: item.id, label: item.styleName }))} selectedIds={selectedStyleIds} onToggle={(id) => toggle(id, selectedStyleIds, setSelectedStyleIds)} />

이미 계정이 있나요? 로그인

; } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 1827ffb7091cb28be1040f7ade7d73b9256e1f7f Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 18:04:34 +0900 Subject: [PATCH 03/12] =?UTF-8?q?style:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 1 + frontend/app/signup/page.tsx | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 4d21660..7319e41 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2006,3 +2006,4 @@ - EC2 역할의 Identity·`snowthing-mail-transactional` Configuration Set 권한과 `help@snowthing.org` 발신 조건을 반영한 뒤 AWS mailbox simulator 발송이 성공했고 SES `MessageId`를 확인했습니다. SES 인프라 경로는 검증됐으며 실제 사용자 수신과 애플리케이션 로그 검증은 이메일 인증 코드 운영 배포 후 진행합니다. - 로컬 MySQL과 백엔드·프런트엔드 개발 서버를 기동하고 회원가입·비밀번호 재설정 페이지가 모두 정상 응답하는지 확인했습니다. Orca 브라우저에 두 화면을 열어 현재 구현을 변경하지 않은 상태로 데스크톱 레이아웃과 모바일 대응 요소를 검토했으며, 디자인 수정은 사용자와 개선안을 확정한 뒤 진행합니다. - 회원가입과 비밀번호 재설정 화면의 `인증번호 받기` 문구를 `이메일 인증`으로 변경했습니다. 회원가입 버튼은 미인증 상태에서도 누를 수 있게 하고, 누르면 이메일 영역으로 이동·포커스하면서 `이메일 인증이 필요합니다.`를 인라인 경고로 표시하도록 보완했습니다. 프런트엔드 ESLint와 실제 브라우저의 버튼 문구·미인증 클릭 동작을 확인했습니다. + - 선택값인 출발 지역은 회원가입 단계에서 받지 않도록 입력란·프런트 상태·가입 요청 필드에서 제거했습니다. 백엔드의 nullable 필드와 프로필 수정 기능은 유지하고, 닉네임 입력란은 데스크톱에서 한 줄 전체 너비로 재배치했습니다. 프런트엔드 ESLint와 실제 브라우저 화면을 확인했습니다. diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 4d3b9ff..32c6e8a 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -37,7 +37,6 @@ export default function SignUpPage() { const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [nickname, setNickname] = useState(""); - const [departureRegion, setDepartureRegion] = useState(""); const [resorts, setResorts] = useState([]); const [ridingStyles, setRidingStyles] = useState([]); const [selectedResortIds, setSelectedResortIds] = useState([]); @@ -137,7 +136,7 @@ export default function SignUpPage() { if (!nickname.trim()) return setErrorMsg("닉네임을 입력해 주세요."); setLoading(true); try { - const response = await csrfFetch(API_ENDPOINTS.members.signup, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, emailVerificationToken, password, nickname, departureRegion, resortIds: selectedResortIds, ridingStyleIds: selectedStyleIds }) }); + const response = await csrfFetch(API_ENDPOINTS.members.signup, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, emailVerificationToken, password, nickname, resortIds: selectedResortIds, ridingStyleIds: selectedStyleIds }) }); if (!response.ok) throw new Error(await readError(response, "회원가입에 실패했습니다.")); const loginResponse = await csrfFetch(API_ENDPOINTS.auth.login, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, rememberMe: true }) }); router.push(loginResponse.ok ? "/" : "/login"); @@ -152,7 +151,7 @@ export default function SignUpPage() { {errorMsg &&
{errorMsg}
} {notice &&
{notice}
}
이메일
changeEmail(event.target.value)} className="snow-input flex-1" placeholder="user@snowthing.org" disabled={isEmailVerified} required aria-invalid={emailVerificationError ? true : undefined} aria-describedby={emailVerificationError ? "email-verification-error" : undefined} />
{emailVerificationError && }{verificationRequestId && !isEmailVerified && <>
setVerificationCode(event.target.value.replace(/\D/g, ""))} className="snow-input flex-1" placeholder="인증번호 6자리" />

{verificationExpiresSeconds > 0 ? `인증번호 유효 시간 ${Math.floor(verificationExpiresSeconds / 60)}:${String(verificationExpiresSeconds % 60).padStart(2, "0")}` : "인증번호가 만료되었습니다. 다시 받아 주세요."}

}
-
+
({ id: item.id, label: item.name }))} selectedIds={selectedResortIds} onToggle={(id) => toggle(id, selectedResortIds, setSelectedResortIds)} /> ({ id: item.id, label: item.styleName }))} selectedIds={selectedStyleIds} onToggle={(id) => toggle(id, selectedStyleIds, setSelectedStyleIds)} />

이미 계정이 있나요? 로그인

; } From 4fd72a8d18f9cbe1696222a0bd8a06fc531aeacb Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 19:06:09 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=EC=84=A4=EC=A7=88=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=EC=A0=9C=EB=B3=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ResortReportController.java | 48 ++++ .../dto/ResortReportCreateRequest.java | 25 ++ .../dto/ResortReportResponse.java | 33 +++ .../resortreport/entity/ResortReport.java | 72 +++++ .../repository/ResortReportRepository.java | 34 +++ .../service/ResortReportService.java | 87 ++++++ .../global/config/SecurityConfig.java | 3 +- .../snowthing/global/error/ErrorCode.java | 3 + .../ResortReportControllerTest.java | 137 ++++++++++ .../service/ResortReportServiceTest.java | 257 ++++++++++++++++++ .../015_migration_resort_report.sql | 12 + .../007_resort_report/01_requirements.md | 43 +++ .../007_resort_report/02_domain-model.md | 47 ++++ docs/conception/007_resort_report/03_erd.md | 49 ++++ .../007_resort_report/04_api-spec.md | 78 ++++++ docs/project/work.md | 24 +- frontend/app/lib/api.ts | 16 ++ frontend/app/lib/resortReports.ts | 15 + frontend/app/page.tsx | 94 ++++++- frontend/app/resort-reports/page.tsx | 115 +++++++- 20 files changed, 1167 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java create mode 100644 database/production/015_migration_resort_report.sql create mode 100644 docs/conception/007_resort_report/01_requirements.md create mode 100644 docs/conception/007_resort_report/02_domain-model.md create mode 100644 docs/conception/007_resort_report/03_erd.md create mode 100644 docs/conception/007_resort_report/04_api-spec.md diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java new file mode 100644 index 0000000..deee19e --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java @@ -0,0 +1,48 @@ +package com.ikae.snowthing.domain.resortreport.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.service.ResortReportService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/resort-reports") +@RequiredArgsConstructor +public class ResortReportController { + + private final ResortReportService resortReportService; + + @PostMapping + public ResponseEntity createReport( + @Valid @RequestBody ResortReportCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + if (userDetails == null || userDetails.getMember() == null) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + ResortReportResponse response = + resortReportService.createReport(userDetails.getMember().getId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/today") + public ResponseEntity> getTodayReports( + @RequestParam(required = false) Long resortId, + @RequestParam(required = false, defaultValue = "20") Integer limit) { + List responses = + resortReportService.getTodayReports(resortId, limit); + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java new file mode 100644 index 0000000..b30a5b3 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportCreateRequest.java @@ -0,0 +1,25 @@ +package com.ikae.snowthing.domain.resortreport.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ResortReportCreateRequest { + + @NotNull(message = "리조트를 선택해 주세요.") + private Long resortId; + + @NotBlank(message = "설질 제보 내용을 입력해 주세요.") + @Size(max = 100, message = "설질 제보 내용은 최대 100자까지 작성할 수 있습니다.") + private String content; +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java new file mode 100644 index 0000000..8806f82 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/dto/ResortReportResponse.java @@ -0,0 +1,33 @@ +package com.ikae.snowthing.domain.resortreport.dto; + +import java.time.LocalDateTime; + +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ResortReportResponse { + + private final Long reportId; + private final Long resortId; + private final String resortName; + private final String resortCode; + private final String authorNickname; + private final String content; + private final LocalDateTime createdAt; + + public static ResortReportResponse from(ResortReport report) { + return ResortReportResponse.builder() + .reportId(report.getId()) + .resortId(report.getResort().getId()) + .resortName(report.getResort().getName()) + .resortCode(report.getResort().getCode()) + .authorNickname(report.getAuthor().getNickname()) + .content(report.getContent()) + .createdAt(report.getCreatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java new file mode 100644 index 0000000..10b0131 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/entity/ResortReport.java @@ -0,0 +1,72 @@ +package com.ikae.snowthing.domain.resortreport.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "resort_report", + indexes = { + @Index( + name = "idx_resort_report_created_resort", + columnList = "created_at DESC, resort_id") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResortReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resort_id", nullable = false) + private Resort resort; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member author; + + @Column(name = "content", nullable = false, length = 100) + private String content; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + public ResortReport(Resort resort, Member author, String content, LocalDateTime createdAt) { + if (author == null) { + throw new CustomException(ErrorCode.MEMBER_NOT_FOUND); + } + if (resort == null || !resort.isActive()) { + throw new CustomException(ErrorCode.RESORT_NOT_FOUND); + } + this.resort = resort; + this.author = author; + this.content = validateAndNormalizeContent(content); + this.createdAt = createdAt != null ? createdAt : LocalDateTime.now(); + } + + private static String validateAndNormalizeContent(String rawContent) { + if (rawContent == null) { + throw new CustomException(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + String trimmed = rawContent.trim(); + if (trimmed.isEmpty() || trimmed.length() > 100) { + throw new CustomException(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + return trimmed; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java new file mode 100644 index 0000000..e09bb8f --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java @@ -0,0 +1,34 @@ +package com.ikae.snowthing.domain.resortreport.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; + +public interface ResortReportRepository extends JpaRepository { + + @Query( + "SELECT r FROM ResortReport r " + + "JOIN FETCH r.resort " + + "JOIN FETCH r.author " + + "WHERE r.createdAt >= :start " + + "ORDER BY r.createdAt DESC") + List findTodayReports( + @Param("start") LocalDateTime start, Pageable pageable); + + @Query( + "SELECT r FROM ResortReport r " + + "JOIN FETCH r.resort " + + "JOIN FETCH r.author " + + "WHERE r.resort.id = :resortId AND r.createdAt >= :start " + + "ORDER BY r.createdAt DESC") + List findTodayReportsByResortId( + @Param("resortId") Long resortId, + @Param("start") LocalDateTime start, + Pageable pageable); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java new file mode 100644 index 0000000..c3f21c6 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java @@ -0,0 +1,87 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResortReportService { + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + + private final ResortReportRepository resortReportRepository; + private final ResortRepository resortRepository; + private final MemberRepository memberRepository; + + @Transactional + public ResortReportResponse createReport(Long memberId, ResortReportCreateRequest request) { + Member author = + memberRepository + .findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + Resort resort = + resortRepository + .findById(request.getResortId()) + .filter(Resort::isActive) + .orElseThrow(() -> new CustomException(ErrorCode.RESORT_NOT_FOUND)); + + ResortReport report = + ResortReport.builder() + .resort(resort) + .author(author) + .content(request.getContent()) + .createdAt(LocalDateTime.now()) + .build(); + + ResortReport savedReport = resortReportRepository.save(report); + return ResortReportResponse.from(savedReport); + } + + public List getTodayReports(Long resortId, Integer limit) { + LocalDateTime todayStart = LocalDate.now(KST_ZONE).atStartOfDay(); + int safeLimit = sanitizeLimit(limit); + Pageable pageable = PageRequest.of(0, safeLimit); + + List reports; + if (resortId != null) { + reports = + resortReportRepository.findTodayReportsByResortId( + resortId, todayStart, pageable); + } else { + reports = resortReportRepository.findTodayReports(todayStart, pageable); + } + + return reports.stream().map(ResortReportResponse::from).toList(); + } + + private int sanitizeLimit(Integer limit) { + if (limit == null || limit <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(limit, MAX_LIMIT); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index aa6d030..9c1c3a0 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -171,7 +171,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { HttpMethod.GET, "/api/v1/market-listings/preview", "/api/v1/carpools/**", - "/api/carpools/**") + "/api/carpools/**", + "/api/v1/resort-reports/**") .permitAll() .requestMatchers( HttpMethod.POST, diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 583829e..1715ff5 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -99,6 +99,9 @@ public enum ErrorCode { FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE_002", "파일 크기는 최대 5MB까지 업로드 가능합니다."), FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_003", "존재하지 않는 파일입니다."), FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "파일 업로드에 실패했습니다."), + RESORT_NOT_FOUND(HttpStatus.NOT_FOUND, "RESORT_001", "존재하지 않거나 비활성화된 리조트입니다."), + INVALID_RESORT_REPORT_CONTENT( + HttpStatus.BAD_REQUEST, "REPORT_001", "설질 제보 내용은 1자 이상 100자 이하여야 합니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 오류가 발생했습니다."); private final HttpStatus status; diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java new file mode 100644 index 0000000..ee02aaa --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportControllerTest.java @@ -0,0 +1,137 @@ +package com.ikae.snowthing.domain.resortreport.controller; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.service.ResortReportService; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ResortReportControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private MemberRepository memberRepository; + @Autowired private ResortRepository resortRepository; + @Autowired private ResortReportService resortReportService; + + private Member member; + private CustomUserDetails userDetails; + private Resort resort; + + @BeforeEach + void setUp() { + member = + memberRepository.save( + Member.builder() + .email("testrider@snowthing.com") + .password("password123!") + .nickname("테스트라이더") + .build()); + userDetails = new CustomUserDetails(member); + + resort = + resortRepository.save( + Resort.builder() + .name("하이원리조트") + .code("HIGH1_TEST") + .regionName("강원") + .displayOrder(1) + .active(true) + .build()); + } + + @Test + @DisplayName("로그인 회원이 올바른 설질 제보를 등록하면 201 Created를 반환한다") + void createReport_Authenticated_Success() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("마운틴탑 아테나 슬로프 설질 훌륭합니다.") + .build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.reportId").isNumber()) + .andExpect(jsonPath("$.resortName").value("하이원리조트")) + .andExpect(jsonPath("$.authorNickname").value("테스트라이더")) + .andExpect(jsonPath("$.content").value("마운틴탑 아테나 슬로프 설질 훌륭합니다.")); + } + + @Test + @DisplayName("비로그인 사용자가 설질 제보를 등록하려고 하면 401 Unauthorized를 반환한다") + void createReport_Unauthenticated_Fail() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("익명 제보 시도") + .build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("내용이 비어있는 설질 제보를 등록하면 400 Bad Request를 반환한다") + void createReport_InvalidContent_Fail() throws Exception { + ResortReportCreateRequest request = + ResortReportCreateRequest.builder().resortId(resort.getId()).content(" ").build(); + + mockMvc.perform( + post("/api/v1/resort-reports") + .with(csrf()) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("비로그인 사용자도 오늘 등록된 설질 제보 목록을 정상 조회할 수 있다 (200 OK)") + void getTodayReports_Success() throws Exception { + // 제보 1건 등록 + resortReportService.createReport( + member.getId(), + ResortReportCreateRequest.builder() + .resortId(resort.getId()) + .content("오늘 설질 굿!") + .build()); + + mockMvc.perform(get("/api/v1/resort-reports/today").param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].content").value("오늘 설질 굿!")) + .andExpect(jsonPath("$[0].resortName").value("하이원리조트")); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java new file mode 100644 index 0000000..66da13b --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java @@ -0,0 +1,257 @@ +package com.ikae.snowthing.domain.resortreport.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportCreateRequest; +import com.ikae.snowthing.domain.resortreport.dto.ResortReportResponse; +import com.ikae.snowthing.domain.resortreport.entity.ResortReport; +import com.ikae.snowthing.domain.resortreport.repository.ResortReportRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; + +@ExtendWith(MockitoExtension.class) +class ResortReportServiceTest { + + @Mock private ResortReportRepository resortReportRepository; + @Mock private ResortRepository resortRepository; + @Mock private MemberRepository memberRepository; + + @InjectMocks private ResortReportService resortReportService; + + private Resort createMockResort(Long id, String name, String code, boolean active) { + Resort resort = + Resort.builder() + .name(name) + .code(code) + .regionName("강원") + .displayOrder(1) + .active(active) + .build(); + ReflectionTestUtils.setField(resort, "id", id); + return resort; + } + + private Member createMockMember(Long id, String email, String nickname) { + Member member = + Member.builder() + .email(email) + .nickname(nickname) + .password("hashed_pw") + .build(); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + @Test + @DisplayName("정상 제보 등록 시 저장 및 응답 DTO 반환") + void createReport_Success() { + // given + Long memberId = 1L; + Long resortId = 10L; + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resortId) + .content("하이원 마운틴탑 눈 진짜 좋아요!") + .build(); + + Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); + Resort resort = createMockResort(resortId, "하이원", "HIGH1", true); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(author)); + given(resortRepository.findById(resortId)).willReturn(Optional.of(resort)); + + ResortReport savedReport = + ResortReport.builder() + .resort(resort) + .author(author) + .content("하이원 마운틴탑 눈 진짜 좋아요!") + .createdAt(LocalDateTime.of(2026, 10, 6, 15, 30)) + .build(); + ReflectionTestUtils.setField(savedReport, "id", 100L); + + given(resortReportRepository.save(any(ResortReport.class))).willReturn(savedReport); + + // when + ResortReportResponse response = resortReportService.createReport(memberId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.getReportId()).isEqualTo(100L); + assertThat(response.getResortName()).isEqualTo("하이원"); + assertThat(response.getResortCode()).isEqualTo("HIGH1"); + assertThat(response.getAuthorNickname()).isEqualTo("눈꽃보더"); + assertThat(response.getContent()).isEqualTo("하이원 마운틴탑 눈 진짜 좋아요!"); + verify(resortReportRepository).save(any(ResortReport.class)); + } + + @Test + @DisplayName("존재하지 않는 회원이 제보 등록 시 MEMBER_NOT_FOUND 예외 발생") + void createReport_MemberNotFound() { + // given + Long memberId = 999L; + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(1L) + .content("설질 제보 테스트") + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> resortReportService.createReport(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("존재하지 않거나 비활성화된 리조트 제보 등록 시 RESORT_NOT_FOUND 예외 발생") + void createReport_ResortNotFound() { + // given + Long memberId = 1L; + Long resortId = 999L; + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resortId) + .content("설질 제보 테스트") + .build(); + + Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); + given(memberRepository.findById(memberId)).willReturn(Optional.of(author)); + given(resortRepository.findById(resortId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> resortReportService.createReport(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.RESORT_NOT_FOUND); + } + + @Test + @DisplayName("빈 본문 또는 공백만 있는 제보 내용 등록 시 INVALID_RESORT_REPORT_CONTENT 예외 발생") + void createReport_EmptyContent() { + // given + Long memberId = 1L; + Long resortId = 1L; + ResortReportCreateRequest request = + ResortReportCreateRequest.builder().resortId(resortId).content(" ").build(); + + Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); + Resort resort = createMockResort(resortId, "하이원", "HIGH1", true); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(author)); + given(resortRepository.findById(resortId)).willReturn(Optional.of(resort)); + + // when & then + assertThatThrownBy(() -> resortReportService.createReport(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + + @Test + @DisplayName("100자를 초과하는 제보 내용 등록 시 INVALID_RESORT_REPORT_CONTENT 예외 발생") + void createReport_TooLongContent() { + // given + Long memberId = 1L; + Long resortId = 1L; + String longContent = "A".repeat(101); + ResortReportCreateRequest request = + ResortReportCreateRequest.builder() + .resortId(resortId) + .content(longContent) + .build(); + + Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); + Resort resort = createMockResort(resortId, "하이원", "HIGH1", true); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(author)); + given(resortRepository.findById(resortId)).willReturn(Optional.of(resort)); + + // when & then + assertThatThrownBy(() -> resortReportService.createReport(memberId, request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_RESORT_REPORT_CONTENT); + } + + @Test + @DisplayName("오늘의 설질 전체 목록 조회") + void getTodayReports_All() { + // given + Member author = createMockMember(1L, "rider@snowthing.com", "눈꽃보더"); + Resort resort = createMockResort(1L, "하이원", "HIGH1", true); + ResortReport report = + ResortReport.builder() + .resort(resort) + .author(author) + .content("설질 굿") + .createdAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(report, "id", 1L); + + given(resortReportRepository.findTodayReports(any(LocalDateTime.class), any(Pageable.class))) + .willReturn(List.of(report)); + + // when + List results = resortReportService.getTodayReports(null, 10); + + // then + assertThat(results).hasSize(1); + assertThat(results.get(0).getContent()).isEqualTo("설질 굿"); + verify(resortReportRepository).findTodayReports(any(LocalDateTime.class), any(Pageable.class)); + } + + @Test + @DisplayName("특정 리조트 필터로 오늘의 설질 목록 조회") + void getTodayReports_ByResortId() { + // given + Long resortId = 2L; + Member author = createMockMember(1L, "rider@snowthing.com", "눈꽃보더"); + Resort resort = createMockResort(resortId, "용평", "YONGPYONG", true); + ResortReport report = + ResortReport.builder() + .resort(resort) + .author(author) + .content("레인보우 설질 최상") + .createdAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(report, "id", 2L); + + given( + resortReportRepository.findTodayReportsByResortId( + eq(resortId), any(LocalDateTime.class), any(Pageable.class))) + .willReturn(List.of(report)); + + // when + List results = resortReportService.getTodayReports(resortId, 10); + + // then + assertThat(results).hasSize(1); + assertThat(results.get(0).getResortName()).isEqualTo("용평"); + verify(resortReportRepository) + .findTodayReportsByResortId(eq(resortId), any(LocalDateTime.class), any(Pageable.class)); + } +} diff --git a/database/production/015_migration_resort_report.sql b/database/production/015_migration_resort_report.sql new file mode 100644 index 0000000..2eae68b --- /dev/null +++ b/database/production/015_migration_resort_report.sql @@ -0,0 +1,12 @@ +-- Migration 015: 오늘의 설질 (Resort Report) 테이블 및 인덱스 추가 + +CREATE TABLE IF NOT EXISTS resort_report ( + report_id BIGINT AUTO_INCREMENT PRIMARY KEY, + resort_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_resort_report_resort FOREIGN KEY (resort_id) REFERENCES resort (resort_id) ON DELETE CASCADE, + CONSTRAINT fk_resort_report_member FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE, + INDEX idx_resort_report_created_resort (created_at DESC, resort_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/conception/007_resort_report/01_requirements.md b/docs/conception/007_resort_report/01_requirements.md new file mode 100644 index 0000000..7df797f --- /dev/null +++ b/docs/conception/007_resort_report/01_requirements.md @@ -0,0 +1,43 @@ +# 오늘의 설질(Resort Report) 요구사항 명세서 + +## 1. 배경 및 목적 +- 슬로프 현장에 있는 이용자(보더/스키어)들이 당일 각 스키장의 설질 상태를 빠르고 가볍게 한 줄로 제보하고 공유할 수 있는 실시간 현장 소통 채널을 제공한다. +- 일반 게시판(`post`)처럼 긴 본문, 제목, 댓글, 리액션, 첨부 이미지가 필요하지 않으며, 오직 '리조트'와 '100자 이내의 한 줄 내용'만으로 신속하게 작성·조회되는 것을 목표로 한다. +- 메인 홈 화면의 위젯 및 전용 페이지(`/resort-reports`)에서 당일(00:00:00 KST ~ 현재) 등록된 제보를 최신순으로 제공한다. + +## 2. 기능 요구사항 + +### 2.1 설질 제보 등록 (Create) +- **접근 권한**: 로그인 회원(`Member`) 전용 (익명 미지원) +- **입력 항목**: + - 리조트 ID (`resortId`, 필수) + - 한 줄 제보 내용 (`content`, 필수, 1자 이상 100자 이하, 공백 제거 후 비어있지 않아야 함) +- **검증 규칙**: + - 리조트가 존재하고 활성(`isActive = true`) 상태여야 함. 존재하지 않으면 404 (`RESORT_NOT_FOUND`). + - 본문이 비어있거나 100자를 초과하면 400 (`INVALID_RESORT_REPORT_CONTENT`). +- **저장 데이터**: + - 제보 ID, 리조트 참조, 회원(작성자) 참조, 내용, 생성일시(`createdAt`) + +### 2.2 오늘의 설질 제보 목록 조회 (List) +- **접근 권한**: 모든 사용자 (비로그인 허용, Public) +- **조회 기준**: + - 당일 기준: 한국 표준시(Asia/Seoul, KST) 기준 오늘 00:00:00부터 현재 시점까지 등록된 제보만 조회 + - 정렬: 최신 등록순 (`createdAt DESC`) + - 필터: 특정 리조트 ID(`resortId`) 필터링 지원 (옵션) + - 페이징/제한: 메인 화면 위젯용 최신 N건 조회 또는 당일 전체 목록 조회 +- **반환 항목**: + - `reportId`: 제보 ID + - `resortId`: 리조트 ID + - `resortName`: 리조트 한글 이름 (예: "하이원", "휘닉스파크") + - `resortCode`: 리조트 코드 (예: "HIGH1", "PHOENIX") + - `authorNickname`: 작성자 닉네임 + - `content`: 제보 내용 + - `createdAt`: 제보 등록 일시 (ISO-8601 문자열) + +### 2.3 비기능 요구사항 +- **비즈니스 격리**: 일반 게시글(`Post`), 댓글(`Comment`), 피드와의 데이터 간섭을 없애기 위해 전용 테이블(`resort_report`)로 격리한다. +- **성능 최적화**: 당일 일자 범위 및 리조트 필터 조회를 위해 `(created_at, resort_id)` 복합 인덱스를 구성한다. +- **아키텍처 규칙 준수**: + - DTO 컬렉션 방어적 복사 (Rule 22) + - ErrorCode 기반 CustomException 사용 (Rule 23) + - 계층 분리 준수 (Rule 24) diff --git a/docs/conception/007_resort_report/02_domain-model.md b/docs/conception/007_resort_report/02_domain-model.md new file mode 100644 index 0000000..259c55d --- /dev/null +++ b/docs/conception/007_resort_report/02_domain-model.md @@ -0,0 +1,47 @@ +# 오늘의 설질(Resort Report) 도메인 모델 설계서 + +## 1. 도메인 다이어그램 + +```mermaid +classDiagram + class Member { + +Long memberId + +String email + +String nickname + } + + class Resort { + +Long resortId + +String code + +String name + +String region + +Boolean isActive + } + + class ResortReport { + +Long reportId + +Member author + +Resort resort + +String content + +LocalDateTime createdAt + +validateContent() + } + + ResortReport --> Member : author (N:1, FetchType.LAZY) + ResortReport --> Resort : resort (N:1, FetchType.LAZY) +``` + +## 2. 도메인 엔티티 상세 명세 + +### ResortReport +- **책임**: 특정 리조트에 대해 회원이 남긴 당일 한 줄 설질 제보 상태를 표현하고 유효성을 검증한다. +- **주요 필드**: + - `reportId` (Long): PK, 자동 증가(IDENTITY) + - `author` (Member): 작성자 (ManyToOne, FetchType.LAZY, NOT NULL) + - `resort` (Resort): 대상 리조트 (ManyToOne, FetchType.LAZY, NOT NULL) + - `content` (String): 한 줄 설질 제보 내용 (VARCHAR(100), NOT NULL) + - `createdAt` (LocalDateTime): 제보 등록 일시 (NOT NULL, 기본값 현재 시각) +- **도메인 규칙**: + - `content`는 null이거나 공백만으로 이루어질 수 없으며, 최대 길이는 100자이다. + - 작성자는 반드시 가입된 회원이어야 한다 (익명 불가). + - 리조트는 반드시 활성 상태여야 한다. diff --git a/docs/conception/007_resort_report/03_erd.md b/docs/conception/007_resort_report/03_erd.md new file mode 100644 index 0000000..5c5a6a6 --- /dev/null +++ b/docs/conception/007_resort_report/03_erd.md @@ -0,0 +1,49 @@ +# 오늘의 설질(Resort Report) ERD 설계서 + +## 1. ERD 다이어그램 + +```mermaid +erDiagram + MEMBER ||--o{ RESORT_REPORT : "작성한다" + RESORT ||--o{ RESORT_REPORT : "제보대상이다" + + MEMBER { + bigint member_id PK + varchar email + varchar nickname + } + + RESORT { + bigint resort_id PK + varchar code + varchar name + tinyint is_active + } + + RESORT_REPORT { + bigint report_id PK "AUTO_INCREMENT" + bigint resort_id FK "NOT NULL" + bigint member_id FK "NOT NULL" + varchar(100) content "NOT NULL" + datetime(6) created_at "NOT NULL" + } +``` + +## 2. 테이블 스키마 DDL 정의 + +```sql +CREATE TABLE IF NOT EXISTS resort_report ( + report_id BIGINT AUTO_INCREMENT PRIMARY KEY, + resort_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_resort_report_resort FOREIGN KEY (resort_id) REFERENCES resort (resort_id) ON DELETE CASCADE, + CONSTRAINT fk_resort_report_member FOREIGN KEY (member_id) REFERENCES member (member_id) ON DELETE CASCADE, + INDEX idx_resort_report_created_resort (created_at DESC, resort_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## 3. 인덱스 설계 근거 +- **`idx_resort_report_created_resort (created_at DESC, resort_id)`**: + - 오늘의 설질 조회는 기본적으로 `created_at >= :todayStart` 범위 조건을 최우선으로 필터링하며, 리조트별 필터링(`resort_id = :resortId`) 및 최신순 정렬(`ORDER BY created_at DESC`)을 동시 수행하므로 Range Scan 및 정렬 부하(FileSort)를 원천 방지한다. diff --git a/docs/conception/007_resort_report/04_api-spec.md b/docs/conception/007_resort_report/04_api-spec.md new file mode 100644 index 0000000..4ebf0ce --- /dev/null +++ b/docs/conception/007_resort_report/04_api-spec.md @@ -0,0 +1,78 @@ +# 오늘의 설질(Resort Report) API 명세서 + +## 1. 개요 +오늘의 설질 제보 등록 및 당일 제보 목록 조회 엔드포인트 명세이다. + +--- + +## 2. API 상세 + +### 2.1 오늘의 설질 제보 등록 +- **URI**: `POST /api/v1/resort-reports` +- **인증**: 필요 (인증된 회원 세션 쿠키 또는 인증 토큰) +- **Content-Type**: `application/json` + +#### 요청 Body +```json +{ + "resortId": 1, + "content": "하이원 아테나 슬로프 현재 설질 뽀송하고 대기줄 거의 없습니다!" +} +``` + +#### 필드 제약사항 +- `resortId`: Long, 필수 +- `content`: String, 필수, 1자 이상 100자 이하 + +#### 응답 +- **성공 (201 Created)** +```json +{ + "reportId": 101, + "resortId": 1, + "resortName": "하이원", + "resortCode": "HIGH1", + "authorNickname": "눈꽃라이더", + "content": "하이원 아테나 슬로프 현재 설질 뽀송하고 대기줄 거의 없습니다!", + "createdAt": "2026-10-06T18:45:00" +} +``` + +- **실패 응답**: + - `400 Bad Request`: `{"code": "INVALID_RESORT_REPORT_CONTENT", "message": "설질 제보 내용은 1자 이상 100자 이하여야 합니다."}` + - `401 Unauthorized`: 로그인 필요 + - `404 Not Found`: `{"code": "RESORT_NOT_FOUND", "message": "리조트를 찾을 수 없습니다."}` + +--- + +### 2.2 오늘의 설질 제보 목록 조회 +- **URI**: `GET /api/v1/resort-reports/today` +- **인증**: 불필요 (Public) +- **Query Parameters**: + - `resortId` (Long, Optional): 특정 리조트 ID로 필터링 (미지정 시 전체 리조트) + - `limit` (Integer, Optional, 기본값 20, 최대 100): 조회 건수 제한 + +#### 응답 +- **성공 (200 OK)** +```json +[ + { + "reportId": 101, + "resortId": 1, + "resortName": "하이원", + "resortCode": "HIGH1", + "authorNickname": "눈꽃라이더", + "content": "하이원 아테나 슬로프 현재 설질 뽀송하고 대기줄 거의 없습니다!", + "createdAt": "2026-10-06T18:45:00" + }, + { + "reportId": 100, + "resortId": 3, + "resortName": "휘닉스파크", + "resortCode": "PHOENIX", + "authorNickname": "스노우맨", + "content": "휘팍 챔피언 약간 아이스 있어요 조심하세요", + "createdAt": "2026-10-06T17:30:12" + } +] +``` diff --git a/docs/project/work.md b/docs/project/work.md index 7319e41..cad785a 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2005,5 +2005,27 @@ - 실제 생성된 Configuration Set 이름에 맞춰 저장소·운영 문서·EC2 환경변수를 `snowthing-mail-transactional`로 통일하고 SES 어댑터 테스트와 diff 검사를 통과했습니다. SES 호출에서 Configuration Set의 존재는 확인됐으나 EC2 역할이 새 Configuration Set ARN의 `ses:SendEmail`을 허용하지 않아 `AccessDeniedException`이 남아 있습니다. - EC2 역할의 Identity·`snowthing-mail-transactional` Configuration Set 권한과 `help@snowthing.org` 발신 조건을 반영한 뒤 AWS mailbox simulator 발송이 성공했고 SES `MessageId`를 확인했습니다. SES 인프라 경로는 검증됐으며 실제 사용자 수신과 애플리케이션 로그 검증은 이메일 인증 코드 운영 배포 후 진행합니다. - 로컬 MySQL과 백엔드·프런트엔드 개발 서버를 기동하고 회원가입·비밀번호 재설정 페이지가 모두 정상 응답하는지 확인했습니다. Orca 브라우저에 두 화면을 열어 현재 구현을 변경하지 않은 상태로 데스크톱 레이아웃과 모바일 대응 요소를 검토했으며, 디자인 수정은 사용자와 개선안을 확정한 뒤 진행합니다. - - 회원가입과 비밀번호 재설정 화면의 `인증번호 받기` 문구를 `이메일 인증`으로 변경했습니다. 회원가입 버튼은 미인증 상태에서도 누를 수 있게 하고, 누르면 이메일 영역으로 이동·포커스하면서 `이메일 인증이 필요합니다.`를 인라인 경고로 표시하도록 보완했습니다. 프런트엔드 ESLint와 실제 브라우저의 버튼 문구·미인증 클릭 동작을 확인했습니다. - 선택값인 출발 지역은 회원가입 단계에서 받지 않도록 입력란·프런트 상태·가입 요청 필드에서 제거했습니다. 백엔드의 nullable 필드와 프로필 수정 기능은 유지하고, 닉네임 입력란은 데스크톱에서 한 줄 전체 너비로 재배치했습니다. 프런트엔드 ESLint와 실제 브라우저 화면을 확인했습니다. +- **오늘의 설질(Resort Report) 실시간 한 줄 제보 기능 구현 (2026-10-06)** + - 브랜치: `feature/resort-report` (`feature/recruit-boards`에서 분리 생성) + - 설계 문서 작성: `docs/conception/007_resort_report/` (`01_requirements.md`, `02_domain-model.md`, `03_erd.md`, `04_api-spec.md`) + - 데이터베이스 마이그레이션: `database/production/015_migration_resort_report.sql` 생성 및 로컬 MySQL 적용 (`resort_report` 테이블 및 `(created_at DESC, resort_id)` 복합 인덱스) + - 백엔드 구현: + - `ErrorCode.java`: `RESORT_NOT_FOUND` (404), `INVALID_RESORT_REPORT_CONTENT` (400) 추가 + - `ResortReport.java` 엔티티 생성 (FetchType.LAZY 매핑, 1~100자 본문 검증) + - `ResortReportRepository.java` 인터페이스 생성 (JPQL `JOIN FETCH`로 Resort/Member N+1 원천 차단) + - `ResortReportCreateRequest.java`, `ResortReportResponse.java` DTO 생성 + - `ResortReportService.java` 비즈니스 로직 구현 (KST 기준 당일 범위 00:00:00~현재 필터링, 회원/리조트 검증) + - `ResortReportController.java` API 엔드포인트 구현 (`POST /api/v1/resort-reports`, `GET /api/v1/resort-reports/today`) + - `SecurityConfig.java`: `GET /api/v1/resort-reports/**` 비로그인 허용 (Public), POST 등록은 로그인 회원 전용 + - 단위 및 통합 테스트 작성 (`ResortReportServiceTest`, `ResortReportControllerTest`) + - 프론트엔드 연동: + - `frontend/app/lib/api.ts`: `resortReports` API 엔드포인트 및 `ResortReportItem` 타입 인터페이스 추가 + - `frontend/app/lib/resortReports.ts`: `formatReportTime` 시간 포맷팅 헬퍼 추가 + - `frontend/app/page.tsx`: 메인 화면 사이드바 설질 위젯 실시간 등록(`csrfFetch`) 및 오늘 제보 목록 연동 + - `frontend/app/resort-reports/page.tsx`: 당일 설질 제보 전체 목록 및 리조트별 필터링 연동 + - 검증: + - 백엔드 전체 테스트 통과 (`./gradlew test` 100% 성공) + - 프론트엔드 프로덕션 빌드 통과 (`npm run build` 성공) + - 백엔드(8080) 및 프론트엔드(3000) 로컬 개발 서버 정상 구동 확인 + diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts index 915efbc..883d800 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -69,4 +69,20 @@ export const API_ENDPOINTS = { autoPreview: `${API_V1_URL}/carpools/auto-preview`, places: (query: string) => `${API_V1_URL}/carpools/places?query=${encodeURIComponent(query)}`, }, + resortReports: { + today: (resortId?: number, limit = 20) => + `${API_V1_URL}/resort-reports/today?limit=${limit}${resortId ? `&resortId=${resortId}` : ""}`, + create: `${API_V1_URL}/resort-reports`, + }, } as const; + +export interface ResortReportItem { + reportId: number; + resortId: number; + resortName: string; + resortCode: string; + authorNickname: string; + content: string; + createdAt: string; +} + diff --git a/frontend/app/lib/resortReports.ts b/frontend/app/lib/resortReports.ts index 5ec1f5a..8925be7 100644 --- a/frontend/app/lib/resortReports.ts +++ b/frontend/app/lib/resortReports.ts @@ -23,3 +23,18 @@ export function formatKoreanCalendarDate(date: Date): string { day: "numeric", }).format(date); } + +export function formatReportTime(dateString: string): string { + try { + const date = new Date(dateString); + return new Intl.DateTimeFormat("ko-KR", { + timeZone: "Asia/Seoul", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); + } catch { + return ""; + } +} + diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 86be715..d78d956 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,9 +5,10 @@ import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { Footer, TopNav } from "./components/SiteChrome"; import LiveChatSection, { MemberProfile } from "./components/LiveChatSection"; -import { API_ENDPOINTS } from "./lib/api"; +import { API_ENDPOINTS, ResortReportItem } from "./lib/api"; import { RESORT_MAP, RESORT_OPTIONS } from "./lib/resortTags"; -import { formatKoreanCalendarDate, RESORT_REPORT_PREVIEWS } from "./lib/resortReports"; +import { formatKoreanCalendarDate, formatReportTime } from "./lib/resortReports"; +import { csrfFetch } from "./lib/csrfFetch"; const HERO_IMAGES = [ "https://lh3.googleusercontent.com/aida/AEtjO1UbsQy3vUnB80P1wDDnbGosvZS9vqvYFfKYsbt-ATgRpqmc2zAPzC52mv7kE-dFt3s-FEwC34VCTJRYlYi_Rv20X4gbV1Ot4EXHI4_0yNB7xgvC-4jj_0S5zRoyhDgx6tmmOf3WlnzXxe1_njPrVcsEQvpsjpP-uLoumLkQrGk_Sl87eNShpSVr4YpqH1lzrGDTFYJBa1ek0ZngAq1VNj9Hp9K8uVOjTkHDEFp6cfh7IlqT2pMxpgODiMr9", @@ -80,12 +81,80 @@ export default function HomePage() { const [feedsLoading, setFeedsLoading] = useState(true); const [marketPreviews, setMarketPreviews] = useState([]); const [carpools, setCarpools] = useState([]); - const [snowReportResort, setSnowReportResort] = useState("PHOENIX"); + const [snowReportResort, setSnowReportResort] = useState("1"); const [snowReportContent, setSnowReportContent] = useState(""); + const [todayReports, setTodayReports] = useState([]); + const [resortMasterList, setResortMasterList] = useState<{ id: number; name: string }[]>([]); + const [submittingReport, setSubmittingReport] = useState(false); const [koreanToday, setKoreanToday] = useState(() => formatKoreanCalendarDate(new Date())); const resortRef = useRef(null); const activeFeedConfig = HOME_FEED_TABS.find((tab) => tab.key === activeFeed) ?? HOME_FEED_TABS[0]; + const loadTodayReports = async () => { + try { + const response = await fetch(API_ENDPOINTS.resortReports.today(undefined, 10), { credentials: "include" }); + if (response.ok) { + const data = await response.json(); + setTodayReports(Array.isArray(data) ? data : []); + } + } catch { + setTodayReports([]); + } + }; + + useEffect(() => { + void loadTodayReports(); + }, []); + + useEffect(() => { + void fetch(API_ENDPOINTS.master.resorts, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: { id: number; name: string }[]) => { + if (Array.isArray(data) && data.length > 0) { + setResortMasterList(data); + setSnowReportResort(String(data[0].id)); + } + }) + .catch(() => {}); + }, []); + + const handleSnowReportSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!snowReportContent.trim() || submittingReport) return; + if (!profile) { + alert("설질 제보는 로그인 후 등록할 수 있습니다."); + return; + } + const resortId = Number(snowReportResort); + if (!resortId) { + alert("리조트를 선택해 주세요."); + return; + } + + setSubmittingReport(true); + try { + const res = await csrfFetch(API_ENDPOINTS.resortReports.create, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + resortId, + content: snowReportContent.trim(), + }), + }); + if (res.ok) { + setSnowReportContent(""); + await loadTodayReports(); + } else { + const data = await res.json().catch(() => ({})); + alert(data.message || "설질 제보 등록에 실패했습니다."); + } + } catch { + alert("서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingReport(false); + } + }; + useEffect(() => { void (async () => { try { @@ -238,18 +307,23 @@ export default function HomePage() {
From 824ef521cfb7d19567132fc8974cd267e0396a78 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 19:25:57 +0900 Subject: [PATCH 05/12] =?UTF-8?q?style:=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=8A=AC=EB=A1=9C?= =?UTF-8?q?=ED=94=84=20=EB=B0=8F=20=EC=84=A4=EC=A7=88=20=ED=98=84=ED=99=A9?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=9E=84=EC=8B=9C=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 1 + frontend/app/page.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/project/work.md b/docs/project/work.md index cad785a..0e82871 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2024,6 +2024,7 @@ - `frontend/app/lib/resortReports.ts`: `formatReportTime` 시간 포맷팅 헬퍼 추가 - `frontend/app/page.tsx`: 메인 화면 사이드바 설질 위젯 실시간 등록(`csrfFetch`) 및 오늘 제보 목록 연동 - `frontend/app/resort-reports/page.tsx`: 당일 설질 제보 전체 목록 및 리조트별 필터링 연동 + - `frontend/app/page.tsx`: 미완성 목업 상태인 '전국 주요 스키장 실시간 슬로프 & 설질 현황' 패널(`resort-panel`)은 추후 크롤링/연동 완성 시까지 JSX 주석으로 임시 비활성화 - 검증: - 백엔드 전체 테스트 통과 (`./gradlew test` 100% 성공) - 프론트엔드 프로덕션 빌드 통과 (`npm run build` 성공) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d78d956..51cf09c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -259,6 +259,7 @@ export default function HomePage() {
+ {/* 실시간 슬로프 & 설질 현황 (완성 후 재오픈 예정)

전국 주요 스키장 실시간 슬로프 & 설질 현황 (10분 주기 갱신)

@@ -274,6 +275,7 @@ export default function HomePage() { ))}
+ */}
From 8ff72e51761a0497952ab60fcf610668c3558012 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 6 Oct 2026 20:46:02 +0900 Subject: [PATCH 06/12] =?UTF-8?q?style:=20Spotless=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20ESLint=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ResortReportController.java | 3 +- .../repository/ResortReportRepository.java | 3 +- .../service/ResortReportServiceTest.java | 31 +++++++------------ frontend/app/page.tsx | 13 +++++--- frontend/app/resort-reports/page.tsx | 6 ++-- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java index deee19e..2dc986a 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/controller/ResortReportController.java @@ -41,8 +41,7 @@ public ResponseEntity createReport( public ResponseEntity> getTodayReports( @RequestParam(required = false) Long resortId, @RequestParam(required = false, defaultValue = "20") Integer limit) { - List responses = - resortReportService.getTodayReports(resortId, limit); + List responses = resortReportService.getTodayReports(resortId, limit); return ResponseEntity.ok(responses); } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java index e09bb8f..f908dfe 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/repository/ResortReportRepository.java @@ -18,8 +18,7 @@ public interface ResortReportRepository extends JpaRepository= :start " + "ORDER BY r.createdAt DESC") - List findTodayReports( - @Param("start") LocalDateTime start, Pageable pageable); + List findTodayReports(@Param("start") LocalDateTime start, Pageable pageable); @Query( "SELECT r FROM ResortReport r " diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java index 66da13b..1b0d309 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java @@ -55,11 +55,7 @@ private Resort createMockResort(Long id, String name, String code, boolean activ private Member createMockMember(Long id, String email, String nickname) { Member member = - Member.builder() - .email(email) - .nickname(nickname) - .password("hashed_pw") - .build(); + Member.builder().email(email).nickname(nickname).password("hashed_pw").build(); ReflectionTestUtils.setField(member, "id", id); return member; } @@ -112,10 +108,7 @@ void createReport_MemberNotFound() { // given Long memberId = 999L; ResortReportCreateRequest request = - ResortReportCreateRequest.builder() - .resortId(1L) - .content("설질 제보 테스트") - .build(); + ResortReportCreateRequest.builder().resortId(1L).content("설질 제보 테스트").build(); given(memberRepository.findById(memberId)).willReturn(Optional.empty()); @@ -133,10 +126,7 @@ void createReport_ResortNotFound() { Long memberId = 1L; Long resortId = 999L; ResortReportCreateRequest request = - ResortReportCreateRequest.builder() - .resortId(resortId) - .content("설질 제보 테스트") - .build(); + ResortReportCreateRequest.builder().resortId(resortId).content("설질 제보 테스트").build(); Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); given(memberRepository.findById(memberId)).willReturn(Optional.of(author)); @@ -179,10 +169,7 @@ void createReport_TooLongContent() { Long resortId = 1L; String longContent = "A".repeat(101); ResortReportCreateRequest request = - ResortReportCreateRequest.builder() - .resortId(resortId) - .content(longContent) - .build(); + ResortReportCreateRequest.builder().resortId(resortId).content(longContent).build(); Member author = createMockMember(memberId, "rider@snowthing.com", "눈꽃보더"); Resort resort = createMockResort(resortId, "하이원", "HIGH1", true); @@ -212,7 +199,9 @@ void getTodayReports_All() { .build(); ReflectionTestUtils.setField(report, "id", 1L); - given(resortReportRepository.findTodayReports(any(LocalDateTime.class), any(Pageable.class))) + given( + resortReportRepository.findTodayReports( + any(LocalDateTime.class), any(Pageable.class))) .willReturn(List.of(report)); // when @@ -221,7 +210,8 @@ void getTodayReports_All() { // then assertThat(results).hasSize(1); assertThat(results.get(0).getContent()).isEqualTo("설질 굿"); - verify(resortReportRepository).findTodayReports(any(LocalDateTime.class), any(Pageable.class)); + verify(resortReportRepository) + .findTodayReports(any(LocalDateTime.class), any(Pageable.class)); } @Test @@ -252,6 +242,7 @@ void getTodayReports_ByResortId() { assertThat(results).hasSize(1); assertThat(results.get(0).getResortName()).isEqualTo("용평"); verify(resortReportRepository) - .findTodayReportsByResortId(eq(resortId), any(LocalDateTime.class), any(Pageable.class)); + .findTodayReportsByResortId( + eq(resortId), any(LocalDateTime.class), any(Pageable.class)); } } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 51cf09c..5b880c1 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import Image from "next/image"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Footer, TopNav } from "./components/SiteChrome"; import LiveChatSection, { MemberProfile } from "./components/LiveChatSection"; import { API_ENDPOINTS, ResortReportItem } from "./lib/api"; @@ -15,14 +15,14 @@ const HERO_IMAGES = [ "https://lh3.googleusercontent.com/aida/AEtjO1Vcno6No205vArthV-VYm_1xWKA9tsOEYUO3gVlGWnI5gZTow2ELVmgfr8pg185_aUMpvVZ8e4z4F1PbMyRxb3M7hGNaZtOc5set_3eOhXq7bWRfEt2wrA2p8NYhZJHoVinT-4qax2j-zrOhbTWQ0yXmg6rzznW_92J_zQJ-rcSAncKtYkzAsWdayvEpFAYDbpp_Q2WsQeagnmchLIQKsY5uzWyAbfa4iaRW8TVldr_G1j_UNfHRcspz22v", ]; -const resorts = [ +/* const resorts = [ { name: "휘닉스 평창", status: "정상운영", temp: "-4.5°C", open: "12 / 18면", detail: "야간 8", snow: "+4cm (압설 양호)", crowd: "쾌적 (대기 3분)", slopes: "펭귄/챔피언 슬로프", tone: "good" }, { name: "비발디파크", status: "정상운영", temp: "-2.1°C", open: "9 / 12면", detail: "새벽운영", snow: "강설 (하단 아이스 약간)", crowd: "보통 (초급 혼잡)", slopes: "발라드/테크노", tone: "warn" }, { name: "하이원 리조트", status: "전면개방", temp: "-6.8°C", open: "18 / 18면", detail: "전코스", snow: "+6cm (극상 파우더)", crowd: "쾌적 (대기 없음)", slopes: "마운틴탑/아테나", tone: "good" }, { name: "모나 용평", status: "정상운영", temp: "-5.2°C", open: "22 / 28면", detail: "", snow: "강설 하드팩 (엣징 양호)", crowd: "쾌적 (레인보우 여유)", slopes: "레드/골드/레인보우", tone: "good" }, { name: "웰리힐리파크", status: "정상운영", temp: "-5.0°C", open: "14 / 19면", detail: "", snow: "압설 (C3 모글밭 주의)", crowd: "쾌적 (대기 2분)", slopes: "에코/챌린지", tone: "good" }, { name: "지산 포레스트", status: "야간운영", temp: "-1.5°C", open: "6 / 7면", detail: "", snow: "인공설 압설 (슬러시 약간)", crowd: "혼잡 (대기 10분)", slopes: "1/2/3번 슬로프", tone: "busy" }, -]; +]; */ interface HomePost { publicId: string; @@ -87,7 +87,7 @@ export default function HomePage() { const [resortMasterList, setResortMasterList] = useState<{ id: number; name: string }[]>([]); const [submittingReport, setSubmittingReport] = useState(false); const [koreanToday, setKoreanToday] = useState(() => formatKoreanCalendarDate(new Date())); - const resortRef = useRef(null); + // const resortRef = useRef(null); const activeFeedConfig = HOME_FEED_TABS.find((tab) => tab.key === activeFeed) ?? HOME_FEED_TABS[0]; const loadTodayReports = async () => { @@ -103,7 +103,10 @@ export default function HomePage() { }; useEffect(() => { - void loadTodayReports(); + void fetch(API_ENDPOINTS.resortReports.today(undefined, 10), { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: ResortReportItem[]) => setTodayReports(Array.isArray(data) ? data : [])) + .catch(() => setTodayReports([])); }, []); useEffect(() => { diff --git a/frontend/app/resort-reports/page.tsx b/frontend/app/resort-reports/page.tsx index 211b63b..8f21072 100644 --- a/frontend/app/resort-reports/page.tsx +++ b/frontend/app/resort-reports/page.tsx @@ -37,7 +37,6 @@ export default function ResortReportsPage() { useEffect(() => { let cancelled = false; - setLoading(true); void fetch(API_ENDPOINTS.resortReports.today(selectedResortId, 100), { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) @@ -71,7 +70,10 @@ export default function ResortReportsPage() {
setSnowReportResort(event.target.value)} aria-label="리조트 선택"> - {resortMasterList.length > 0 - ? resortMasterList.map((r) => ) - : RESORT_OPTIONS.filter((option) => option.value).map((option) => )} + - setSnowReportContent(event.target.value)} maxLength={100} placeholder={profile ? "오늘 설질을 한 줄로 알려주세요" : "로그인 후 제보를 남겨주세요"} aria-label="설질 제보 내용" /> - + setSnowReportContent(event.target.value)} maxLength={100} placeholder={!reportSeasonOpen ? "설질 제보는 11월부터 4월까지 등록할 수 있습니다" : profile ? "오늘 설질을 한 줄로 알려주세요" : "로그인 후 제보를 남겨주세요"} aria-label="설질 제보 내용" disabled={!reportSeasonOpen || resortMasterError} /> + + {resortMasterError &&

리조트 목록을 불러오지 못해 제보를 등록할 수 없습니다.

}
{todayReports.length === 0 ? (

오늘 등록된 설질 제보가 없습니다.

) : ( todayReports.map((report) => { const resort = RESORT_MAP[report.resortCode]; - return
{report.resortName}{report.content}
; + return
{report.resortName}{report.content}{report.canDelete && }
; }))}
diff --git a/frontend/app/resort-reports/page.tsx b/frontend/app/resort-reports/page.tsx index 8f21072..0a513dd 100644 --- a/frontend/app/resort-reports/page.tsx +++ b/frontend/app/resort-reports/page.tsx @@ -1,23 +1,35 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { Footer, TopNav } from "../components/SiteChrome"; import { formatKoreanCalendarDate, formatReportTime } from "../lib/resortReports"; import { RESORT_MAP } from "../lib/resortTags"; -import { API_ENDPOINTS, ResortReportItem } from "../lib/api"; +import { API_ENDPOINTS, OffsetPage, ResortReportItem } from "../lib/api"; +import { csrfFetch } from "../lib/csrfFetch"; interface ResortMaster { id: number; name: string; } +const PAGE_SIZE = 20; + export default function ResortReportsPage() { const [koreanToday, setKoreanToday] = useState(() => formatKoreanCalendarDate(new Date())); const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); - const [selectedResortId, setSelectedResortId] = useState(undefined); + const [selectedResortId, setSelectedResortId] = useState(); const [resortList, setResortList] = useState([]); + const [resortListError, setResortListError] = useState(false); + const [page, setPage] = useState(1); + const [pageInfo, setPageInfo] = useState["pageInfo"]>({ + page: 1, + totalPages: 0, + totalElements: 0, + hasNext: false, + pageSize: PAGE_SIZE, + }); useEffect(() => { const timer = window.setInterval(() => setKoreanToday(formatKoreanCalendarDate(new Date())), 60_000); @@ -26,111 +38,112 @@ export default function ResortReportsPage() { useEffect(() => { void fetch(API_ENDPOINTS.master.resorts, { credentials: "include" }) - .then((res) => (res.ok ? res.json() : [])) + .then((res) => (res.ok ? res.json() : Promise.reject())) .then((data: ResortMaster[]) => { - if (Array.isArray(data)) { - setResortList(data); - } + setResortList(Array.isArray(data) ? data : []); + setResortListError(false); }) - .catch(() => {}); + .catch(() => setResortListError(true)); }, []); + const loadReports = useCallback(async () => { + try { + const response = await fetch(API_ENDPOINTS.resortReports.today(selectedResortId, page, PAGE_SIZE), { credentials: "include" }); + if (!response.ok) throw new Error(String(response.status)); + const data: OffsetPage = await response.json(); + setReports(Array.isArray(data.content) ? data.content : []); + setPageInfo(data.pageInfo); + } catch { + setReports([]); + setPageInfo({ page, totalPages: 0, totalElements: 0, hasNext: false, pageSize: PAGE_SIZE }); + } finally { + setLoading(false); + } + }, [page, selectedResortId]); + useEffect(() => { let cancelled = false; - - void fetch(API_ENDPOINTS.resortReports.today(selectedResortId, 100), { credentials: "include" }) - .then((res) => (res.ok ? res.json() : [])) - .then((data: ResortReportItem[]) => { - if (!cancelled) { - setReports(Array.isArray(data) ? data : []); - setLoading(false); - } + void fetch(API_ENDPOINTS.resortReports.today(selectedResortId, page, PAGE_SIZE), { credentials: "include" }) + .then((response) => (response.ok ? response.json() : Promise.reject())) + .then((data: OffsetPage) => { + if (cancelled) return; + setReports(Array.isArray(data.content) ? data.content : []); + setPageInfo(data.pageInfo); }) .catch(() => { - if (!cancelled) { - setReports([]); - setLoading(false); - } + if (cancelled) return; + setReports([]); + setPageInfo({ page, totalPages: 0, totalElements: 0, hasNext: false, pageSize: PAGE_SIZE }); + }) + .finally(() => { + if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [page, selectedResortId]); - return () => { - cancelled = true; - }; - }, [selectedResortId]); + const deleteReport = async (reportId: number) => { + if (!window.confirm("이 설질 제보를 삭제할까요?")) return; + const response = await csrfFetch(API_ENDPOINTS.resortReports.delete(reportId), { method: "DELETE" }); + if (response.ok) { + await loadReports(); + return; + } + const data = await response.json().catch(() => ({})); + alert(data.message || "설질 제보 삭제에 실패했습니다."); + }; return (
-
-

❄️ 오늘의 설질

- -
+

❄️ 오늘의 설질

홈으로
+ {resortListError &&

리조트 목록을 불러오지 못했습니다. 전체 제보는 계속 확인할 수 있습니다.

}
-
- 오늘 등록된 제보 - {loading ? "불러오는 중..." : `${reports.length}건`} -
+
오늘 등록된 제보{loading ? "불러오는 중..." : `${pageInfo.totalElements}건`}
{loading ? ( -

- 설질 제보 목록을 불러오는 중입니다... -

+

설질 제보 목록을 불러오는 중입니다...

) : reports.length === 0 ? ( -

- 오늘 등록된 설질 제보가 없습니다. -

- ) : ( - reports.map((report) => { - const resortMeta = RESORT_MAP[report.resortCode]; - const markerClass = resortMeta?.markerClass ?? "bg-[#3f6f8f]"; - return ( -
- {report.resortName} -
- {report.content} - {report.authorNickname && ( - - by {report.authorNickname} - - )} -
- -
- ); - }) - )} +

오늘 등록된 설질 제보가 없습니다.

+ ) : reports.map((report) => { + const markerClass = RESORT_MAP[report.resortCode]?.markerClass ?? "bg-[#3f6f8f]"; + return ( +
+ {report.resortName} +
{report.content}{report.authorNickname && by {report.authorNickname}}
+ + {report.canDelete && } +
+ ); + })}
+ {pageInfo.totalPages > 1 && ( +
+ + {page} / {pageInfo.totalPages} + +
+ )}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts deleted file mode 100644 index c4b7818..0000000 --- a/frontend/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/verify-production-migration-007-013.sh b/scripts/verify-production-migration-007-015.sh similarity index 74% rename from scripts/verify-production-migration-007-013.sh rename to scripts/verify-production-migration-007-015.sh index a2a3c0e..2e6abf7 100644 --- a/scripts/verify-production-migration-007-013.sh +++ b/scripts/verify-production-migration-007-015.sh @@ -39,7 +39,8 @@ for version in \ 011_migration_carpool_manual_fallback_snapshots \ 012_migration_carpool_latest_sort_index \ 013_migration_email_verification \ - 014_migration_crew_and_season_room; do + 014_migration_crew_and_season_room \ + 015_migration_resort_report; do grep -q "Migration already applied: $version" <<< "$second_run_output" [[ "$(mysql_test --execute="SELECT COUNT(*) FROM schema_migration WHERE version = '$version'")" == "1" ]] done @@ -60,5 +61,11 @@ done [[ "$(mysql_test --execute="SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND index_name IN ('uk_email_verification_email_purpose', 'idx_email_verification_token_purpose', 'idx_email_verification_status_expires_at', 'idx_email_verification_expires_at')")" == "4" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema = '$DB_NAME' AND table_name = 'email_verification' AND constraint_type = 'CHECK' AND constraint_name IN ('chk_email_verification_purpose', 'chk_email_verification_status', 'chk_email_verification_failed_attempt_count', 'chk_email_verification_send_count')")" == "4" ]] [[ "$(mysql_test --execute="SELECT COUNT(*) FROM member WHERE email_verified_at IS NOT NULL")" == "0" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report'")" == "1" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND column_name IN ('report_id', 'resort_id', 'member_id', 'content', 'status', 'deleted_at', 'created_at')")" == "7" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_status_created'")" == "status,created_at,report_id" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_resort_status_created'")" == "resort_id,status,created_at,report_id" ]] +[[ "$(mysql_test --execute="SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') FROM information_schema.statistics WHERE table_schema = '$DB_NAME' AND table_name = 'resort_report' AND index_name = 'idx_resort_report_member_created'")" == "member_id,created_at" ]] +[[ "$(mysql_test --execute="SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema = '$DB_NAME' AND table_name = 'resort_report' AND constraint_name IN ('fk_resort_report_resort', 'fk_resort_report_member')")" == "2" ]] -echo "Production migrations 007-013 verification passed." +echo "Production migrations 007-015 verification passed." From d5e5aebdf61c14d3eaad345beca798c0ac52ff55 Mon Sep 17 00:00:00 2001 From: ikae Date: Wed, 7 Oct 2026 01:07:36 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=EC=84=A4=EC=A7=88=20=EC=A0=9C?= =?UTF-8?q?=EB=B3=B4=20=EC=8B=9C=EC=A6=8C=EC=9D=84=2010=EC=9B=94=EB=B6=80?= =?UTF-8?q?=ED=84=B0=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ResortReportService.java | 2 +- .../snowthing/global/error/ErrorCode.java | 2 +- .../service/ResortReportServiceTest.java | 24 ++++++++++++++++++- .../007_resort_report/01_requirements.md | 2 +- docs/project/work.md | 3 ++- frontend/app/lib/resortReports.ts | 2 +- frontend/app/page.tsx | 4 ++-- 7 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java index 624c7b8..ce063be 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortreport/service/ResortReportService.java @@ -159,7 +159,7 @@ public void updateModerationStatus( private void validateSeason(LocalDate date) { Month month = date.getMonth(); if (month.getValue() >= Month.MAY.getValue() - && month.getValue() <= Month.OCTOBER.getValue()) { + && month.getValue() <= Month.SEPTEMBER.getValue()) { throw new CustomException(ErrorCode.RESORT_REPORT_SEASON_CLOSED); } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java index 2240c4c..e4f6437 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java +++ b/backend/src/main/java/com/ikae/snowthing/global/error/ErrorCode.java @@ -106,7 +106,7 @@ public enum ErrorCode { RESORT_REPORT_DAILY_LIMIT_EXCEEDED( HttpStatus.TOO_MANY_REQUESTS, "REPORT_003", "설질 제보는 하루에 최대 5개까지 작성할 수 있습니다."), RESORT_REPORT_SEASON_CLOSED( - HttpStatus.FORBIDDEN, "REPORT_004", "설질 제보는 11월부터 다음 해 4월까지만 작성할 수 있습니다."), + HttpStatus.FORBIDDEN, "REPORT_004", "설질 제보는 10월부터 다음 해 4월까지만 작성할 수 있습니다."), RESORT_REPORT_PAGE_LIMIT_EXCEEDED( HttpStatus.BAD_REQUEST, "REPORT_005", "설질 제보는 최대 100페이지까지 조회할 수 있습니다."), RESORT_REPORT_INVALID_PAGE_SIZE( diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java index e58c0c0..1d91295 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortreport/service/ResortReportServiceTest.java @@ -104,7 +104,7 @@ void createReportRejectsDailyLimit() { } @Test - @DisplayName("5월부터 10월까지는 제보 등록을 거절한다") + @DisplayName("5월부터 9월까지는 제보 등록을 거절한다") void createReportRejectsOffSeason() { ResortReportService summerService = new ResortReportService( @@ -119,6 +119,28 @@ void createReportRejectsOffSeason() { .isEqualTo(ErrorCode.RESORT_REPORT_SEASON_CLOSED); } + @Test + @DisplayName("10월에는 시즌 준비 제보를 등록할 수 있다") + void createReportAllowsOctober() { + ResortReportService octoberService = + new ResortReportService( + resortReportRepository, + resortRepository, + memberRepository, + Clock.fixed(Instant.parse("2026-10-06T15:30:00Z"), KST)); + Member author = member(1L, "눈꽃보더", Role.ROLE_USER); + Resort resort = resort(10L); + given(memberRepository.findByIdForUpdate(author.getId())).willReturn(Optional.of(author)); + given(resortRepository.findById(resort.getId())).willReturn(Optional.of(resort)); + given(resortReportRepository.save(any(ResortReport.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + ResortReportResponse response = + octoberService.createReport(author.getId(), request(resort.getId())); + + assertThat(response.getContent()).isEqualTo("설질이 좋아요"); + } + @Test @DisplayName("오늘의 공개 제보를 페이지 정보와 삭제 권한을 포함해 반환한다") void getTodayReports() { diff --git a/docs/conception/007_resort_report/01_requirements.md b/docs/conception/007_resort_report/01_requirements.md index ae35b04..124f14f 100644 --- a/docs/conception/007_resort_report/01_requirements.md +++ b/docs/conception/007_resort_report/01_requirements.md @@ -10,7 +10,7 @@ - 로그인 회원만 등록할 수 있으며 비로그인 요청은 `401 AUTH_001`을 반환한다. - 활성 리조트 ID와 공백 제거 후 1~100자인 내용을 받는다. -- 작성 가능 기간은 매년 11월 1일 00:00부터 다음 해 4월 30일 23:59:59.999999까지다. 5~10월에는 `403 REPORT_004`를 반환한다. +- 작성 가능 기간은 매년 10월 1일 00:00부터 다음 해 4월 30일 23:59:59.999999까지다. 5~9월에는 `403 REPORT_004`를 반환한다. - 회원 한 명이 작성할 수 있는 제보는 KST 날짜별 최대 5개다. 삭제·숨김·차단된 제보도 도배 우회를 막기 위해 당일 한도에 포함한다. - 별도 메모리 제한기나 카운터 테이블을 사용하지 않는다. 회원 행을 비관적 잠금한 뒤 `member_id + created_at`으로 기존 제보를 집계해 동시 요청을 직렬화한다. diff --git a/docs/project/work.md b/docs/project/work.md index bcc2ddc..8bf938b 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2032,10 +2032,11 @@ - `./gradlew.bat spotlessCheck test` 통과 (320+건 전체 성공) 및 `npm run lint` 통과 (0 errors, 0 warnings) - GitHub PR #38(`feat: 동호회·시즌방 홍보 게시판 분리 및 오늘의 설질 제보 구현`) 생성 완료 - PR #38 리뷰 반영으로 비로그인 모집글 작성 오류를 `401 AUTH_001`로 바로잡고 익명 작성 시도인 경우만 `400 POST_006`을 반환하도록 분리했다. - - 설질 제보는 KST 기준 11월~4월에만 작성 가능하고 회원당 하루 5개로 제한했다. 별도 메모리·카운터 테이블 없이 회원 행 비관적 잠금 후 기존 제보 테이블을 직접 집계하며, 삭제·숨김·차단 데이터도 당일 한도에 포함한다. + - 설질 제보는 KST 기준 10월~4월에만 작성 가능하고 회원당 하루 5개로 제한했다. 별도 메모리·카운터 테이블 없이 회원 행 비관적 잠금 후 기존 제보 테이블을 직접 집계하며, 삭제·숨김·차단 데이터도 당일 한도에 포함한다. - 작성자와 관리자의 소프트 삭제, 관리자 숨김·차단, 매월 1일 04:40 KST 이전 달 데이터 물리 삭제를 추가했다. - 공개 목록을 `NORMAL` 상태와 KST 당일의 시작·종료 범위로 제한하고 1부터 시작하는 페이지 응답, 안정적인 ID 보조 정렬, `+09:00` 시각, 서버 계산 삭제 권한을 반환하도록 변경했다. - 전체·리조트별 조회와 회원별 당일 집계에 맞춰 015 마이그레이션 인덱스를 재설계하고 운영 마이그레이션 검증 범위를 `007~015`로 확장했다. - 프런트의 하드코딩 리조트 ID 대체값과 미사용 제보 목업을 제거했다. 활성 리조트 마스터 실패 시 등록을 차단하고 시즌 안내, 실제 전체 건수, 페이지 이동, 제보 삭제를 연결했다. - 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build를 통과했다. Docker MySQL 8의 격리 스키마에서 운영 마이그레이션 `001~015` 최초 적용·재실행과 015 컬럼·인덱스·외래 키를 검증한 뒤 임시 컨테이너를 제거했다. + - 홈페이지 오픈과 시즌 준비 기간을 반영해 설질 제보 작성 시작일을 매년 11월 1일에서 10월 1일(KST)로 앞당겼다. 더보기 화면은 별도 상세 없이 당일 목록·리조트 필터·페이지 이동만 제공한다. diff --git a/frontend/app/lib/resortReports.ts b/frontend/app/lib/resortReports.ts index 7969bfa..f6414e9 100644 --- a/frontend/app/lib/resortReports.ts +++ b/frontend/app/lib/resortReports.ts @@ -11,7 +11,7 @@ export function isResortReportSeason(date: Date): boolean { const month = Number( new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Seoul", month: "numeric" }).format(date), ); - return month >= 11 || month <= 4; + return month >= 10 || month <= 4; } export function formatReportTime(dateString: string): string { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 1947db1..ef7510e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -120,7 +120,7 @@ export default function HomePage() { event.preventDefault(); if (!snowReportContent.trim() || submittingReport) return; if (!reportSeasonOpen) { - alert("설질 제보는 11월부터 다음 해 4월까지만 등록할 수 있습니다."); + alert("설질 제보는 10월부터 다음 해 4월까지만 등록할 수 있습니다."); return; } if (!profile) { @@ -308,7 +308,7 @@ export default function HomePage() { - setSnowReportContent(event.target.value)} maxLength={100} placeholder={!reportSeasonOpen ? "설질 제보는 11월부터 4월까지 등록할 수 있습니다" : profile ? "오늘 설질을 한 줄로 알려주세요" : "로그인 후 제보를 남겨주세요"} aria-label="설질 제보 내용" disabled={!reportSeasonOpen || resortMasterError} /> + setSnowReportContent(event.target.value)} maxLength={100} placeholder={!reportSeasonOpen ? "설질 제보는 10월부터 4월까지 등록할 수 있습니다" : profile ? "오늘 설질을 한 줄로 알려주세요" : "로그인 후 제보를 남겨주세요"} aria-label="설질 제보 내용" disabled={!reportSeasonOpen || resortMasterError} /> {resortMasterError &&

리조트 목록을 불러오지 못해 제보를 등록할 수 없습니다.

} From e332b80a8863f1606cb80c43278fac0a2a7fe82c Mon Sep 17 00:00:00 2001 From: ikae Date: Wed, 7 Oct 2026 01:17:56 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20=EB=A6=AC=EC=A1=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=AA=A9=EB=A1=9D=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 1 + frontend/app/components/CompactSelect.tsx | 113 ++++++++++++++++++++++ frontend/app/globals.css | 23 +++-- frontend/app/page.tsx | 11 ++- frontend/app/resort-reports/page.tsx | 19 ++-- 5 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 frontend/app/components/CompactSelect.tsx diff --git a/docs/project/work.md b/docs/project/work.md index 8bf938b..f01cc46 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2039,4 +2039,5 @@ - 프런트의 하드코딩 리조트 ID 대체값과 미사용 제보 목업을 제거했다. 활성 리조트 마스터 실패 시 등록을 차단하고 시즌 안내, 실제 전체 건수, 페이지 이동, 제보 삭제를 연결했다. - 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build를 통과했다. Docker MySQL 8의 격리 스키마에서 운영 마이그레이션 `001~015` 최초 적용·재실행과 015 컬럼·인덱스·외래 키를 검증한 뒤 임시 컨테이너를 제거했다. - 홈페이지 오픈과 시즌 준비 기간을 반영해 설질 제보 작성 시작일을 매년 11월 1일에서 10월 1일(KST)로 앞당겼다. 더보기 화면은 별도 상세 없이 당일 목록·리조트 필터·페이지 이동만 제공한다. + - 활성 리조트 13개를 유지하면서 기본 ` setSnowReportResort(event.target.value)} aria-label="리조트 선택" disabled={resortMasterError || resortMasterList.length === 0 || !reportSeasonOpen}> - {resortMasterList.map((r) => )} - + ({ value: String(resort.id), label: resort.name }))} + onChange={setSnowReportResort} + ariaLabel="리조트 선택" + disabled={resortMasterError || resortMasterList.length === 0 || !reportSeasonOpen} + /> setSnowReportContent(event.target.value)} maxLength={100} placeholder={!reportSeasonOpen ? "설질 제보는 10월부터 4월까지 등록할 수 있습니다" : profile ? "오늘 설질을 한 줄로 알려주세요" : "로그인 후 제보를 남겨주세요"} aria-label="설질 제보 내용" disabled={!reportSeasonOpen || resortMasterError} /> diff --git a/frontend/app/resort-reports/page.tsx b/frontend/app/resort-reports/page.tsx index 0a513dd..9da8014 100644 --- a/frontend/app/resort-reports/page.tsx +++ b/frontend/app/resort-reports/page.tsx @@ -7,6 +7,7 @@ import { formatKoreanCalendarDate, formatReportTime } from "../lib/resortReports import { RESORT_MAP } from "../lib/resortTags"; import { API_ENDPOINTS, OffsetPage, ResortReportItem } from "../lib/api"; import { csrfFetch } from "../lib/csrfFetch"; +import { CompactSelect } from "../components/CompactSelect"; interface ResortMaster { id: number; @@ -99,20 +100,18 @@ export default function ResortReportsPage() {

❄️ 오늘의 설질

- + ariaLabel="리조트 필터" + className="resort-filter-select" + /> 홈으로
From 716743f0e4dec8827ac81c4d9f337dcbdf7f701b Mon Sep 17 00:00:00 2001 From: ikae Date: Wed, 7 Oct 2026 02:04:57 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=99=94=EB=A9=B4=20=EC=9E=85=EB=A0=A5=20=EB=B0=8F?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/MemberProfileUpdateRequest.java | 4 +- .../member/dto/MemberSignUpRequest.java | 3 +- .../member/validation/NicknamePolicy.java | 11 ++++ .../dto/NicknamePolicyValidationTest.java | 64 +++++++++++++++++++ docs/project/work.md | 7 ++ .../app/components/CommentProfileAvatar.tsx | 19 ++++++ frontend/app/components/PostComments.tsx | 10 +-- frontend/app/globals.css | 27 ++++---- frontend/app/lib/resortReports.ts | 20 ++++++ frontend/app/lib/resortTags.ts | 10 ++- frontend/app/market/[publicId]/edit/page.tsx | 2 +- frontend/app/market/[publicId]/page.tsx | 7 +- frontend/app/market/new/page.tsx | 2 +- frontend/app/page.tsx | 19 ++---- frontend/app/posts/[publicId]/page.tsx | 14 +--- frontend/app/resort-cam/ResortCamView.tsx | 2 + frontend/app/resort-reports/page.tsx | 17 ++--- frontend/app/resort/page.tsx | 2 + frontend/app/signup/page.tsx | 5 +- 19 files changed, 179 insertions(+), 66 deletions(-) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java create mode 100644 frontend/app/components/CommentProfileAvatar.tsx diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java index f87f4a9..c74b8d5 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberProfileUpdateRequest.java @@ -6,6 +6,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import com.ikae.snowthing.domain.member.validation.NicknamePolicy; + import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,7 +17,7 @@ public class MemberProfileUpdateRequest { @NotBlank(message = "닉네임은 필수 입력값입니다.") - @Pattern(regexp = "^[a-zA-Z0-9가-힣]{2,10}$", message = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다.") + @Pattern(regexp = NicknamePolicy.REGEXP, message = NicknamePolicy.MESSAGE) private String nickname; private String bio; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java index 4e01914..dde27ce 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/dto/MemberSignUpRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Size; import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.validation.NicknamePolicy; import lombok.Builder; import lombok.Getter; @@ -35,7 +36,7 @@ public class MemberSignUpRequest { private String emailVerificationToken; @NotBlank(message = "닉네임은 필수 입력값입니다.") - @jakarta.validation.constraints.Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하이어야 합니다.") + @Pattern(regexp = NicknamePolicy.REGEXP, message = NicknamePolicy.MESSAGE) private String nickname; private String bio; diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java b/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java new file mode 100644 index 0000000..dc36038 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/validation/NicknamePolicy.java @@ -0,0 +1,11 @@ +package com.ikae.snowthing.domain.member.validation; + +public final class NicknamePolicy { + + public static final int MIN_LENGTH = 2; + public static final int MAX_LENGTH = 10; + public static final String REGEXP = "^[a-zA-Z0-9가-힣]{2,10}$"; + public static final String MESSAGE = "닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자이어야 합니다."; + + private NicknamePolicy() {} +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java new file mode 100644 index 0000000..90906b8 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/dto/NicknamePolicyValidationTest.java @@ -0,0 +1,64 @@ +package com.ikae.snowthing.domain.member.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NicknamePolicyValidationTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void closeValidatorFactory() { + validatorFactory.close(); + } + + @Test + @DisplayName("회원가입과 프로필 수정은 10자 닉네임을 허용한다") + void acceptsTenCharacterNickname() { + String nickname = "가".repeat(10); + + assertThat(validator.validate(signUpRequest(nickname))) + .noneMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + assertThat(validator.validate(profileUpdateRequest(nickname))) + .noneMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + } + + @Test + @DisplayName("회원가입과 프로필 수정은 11자 닉네임을 거절한다") + void rejectsElevenCharacterNickname() { + String nickname = "가".repeat(11); + + assertThat(validator.validate(signUpRequest(nickname))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + assertThat(validator.validate(profileUpdateRequest(nickname))) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("nickname")); + } + + private MemberSignUpRequest signUpRequest(String nickname) { + return MemberSignUpRequest.builder() + .email("nickname@snowthing.org") + .password("Password123!") + .emailVerificationToken("verification-token") + .nickname(nickname) + .build(); + } + + private MemberProfileUpdateRequest profileUpdateRequest(String nickname) { + return MemberProfileUpdateRequest.builder().nickname(nickname).build(); + } +} diff --git a/docs/project/work.md b/docs/project/work.md index f01cc46..4366d7f 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -2040,4 +2040,11 @@ - 백엔드 전체 테스트와 Spotless, 프런트엔드 ESLint와 production build를 통과했다. Docker MySQL 8의 격리 스키마에서 운영 마이그레이션 `001~015` 최초 적용·재실행과 015 컬럼·인덱스·외래 키를 검증한 뒤 임시 컨테이너를 제거했다. - 홈페이지 오픈과 시즌 준비 기간을 반영해 설질 제보 작성 시작일을 매년 11월 1일에서 10월 1일(KST)로 앞당겼다. 더보기 화면은 별도 상세 없이 당일 목록·리조트 필터·페이지 이동만 제공한다. - 활성 리조트 13개를 유지하면서 기본 ` setTransactionMethod(code)} />{label})} -
+
본문 *
diff --git a/frontend/app/market/[publicId]/page.tsx b/frontend/app/market/[publicId]/page.tsx index 10343a3..b0e2f76 100644 --- a/frontend/app/market/[publicId]/page.tsx +++ b/frontend/app/market/[publicId]/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { use, useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Footer, TopNav } from "../../components/SiteChrome"; +import { CommentProfileAvatar } from "../../components/CommentProfileAvatar"; import { API_ENDPOINTS } from "../../lib/api"; import { csrfFetch } from "../../lib/csrfFetch"; @@ -259,9 +260,5 @@ export default function MarketDetailPage({ params }: { params: Promise<{ publicI function MarketCommentAvatar({ item }: { item: CommentItem }) { const nickname = item.writer?.nickname ?? "탈퇴한 회원"; - return item.writer?.profileImageUrl ? ( - - ) : ( - - ); + return ; } diff --git a/frontend/app/market/new/page.tsx b/frontend/app/market/new/page.tsx index 49767f1..712d2fc 100644 --- a/frontend/app/market/new/page.tsx +++ b/frontend/app/market/new/page.tsx @@ -164,7 +164,7 @@ export default function MarketCreatePage() {
거래 방식 *{transactionMethods.map(([code, label]) => )}
- +
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6523512..6cee998 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,7 +8,7 @@ import LiveChatSection, { MemberProfile } from "./components/LiveChatSection"; import { CompactSelect } from "./components/CompactSelect"; import { API_ENDPOINTS, OffsetPage, ResortReportItem } from "./lib/api"; import { RESORT_MAP } from "./lib/resortTags"; -import { formatKoreanCalendarDate, formatReportTime, isResortReportSeason } from "./lib/resortReports"; +import { formatKoreanCalendarDate, formatReportTime, getResortReportDisplayName, isResortReportSeason } from "./lib/resortReports"; import { csrfFetch } from "./lib/csrfFetch"; const HERO_IMAGES = [ @@ -158,17 +158,6 @@ export default function HomePage() { } }; - const handleSnowReportDelete = async (reportId: number) => { - if (!window.confirm("이 설질 제보를 삭제할까요?")) return; - const response = await csrfFetch(API_ENDPOINTS.resortReports.delete(reportId), { method: "DELETE" }); - if (response.ok) { - await loadTodayReports(); - return; - } - const data = await response.json().catch(() => ({})); - alert(data.message || "설질 제보 삭제에 실패했습니다."); - }; - useEffect(() => { void (async () => { try { @@ -304,11 +293,11 @@ export default function HomePage() {
diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index 57f5540..9bbb1ce 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Footer, TopNav } from "../../components/SiteChrome"; +import { CommentProfileAvatar } from "../../components/CommentProfileAvatar"; import { DeleteConfirmModal } from "../../components/DeleteConfirmModal"; import dynamic from "next/dynamic"; import { csrfFetch } from "../../lib/csrfFetch"; @@ -907,7 +908,7 @@ function CommentRow({ return (
- {!isAnonymousPost && } + {!isAnonymousPost && }
@@ -1085,7 +1086,7 @@ function ReplyRow({
- {!isAnonymousPost && } + {!isAnonymousPost && }
@@ -1130,15 +1131,6 @@ function ReplyRow({ ); } -function CommentProfileAvatar({ item }: { item: CommentItem }) { - const name = getWriterName(item); - return item.writer?.profileImageUrl ? ( - - ) : ( - - ); -} - function CommentEditForm({ comment, content, diff --git a/frontend/app/resort-cam/ResortCamView.tsx b/frontend/app/resort-cam/ResortCamView.tsx index 303ff8b..d2dbed2 100644 --- a/frontend/app/resort-cam/ResortCamView.tsx +++ b/frontend/app/resort-cam/ResortCamView.tsx @@ -63,10 +63,12 @@ export function ResortCamView({ {/* 상단 탭 네비게이션: 리조트 현황 vs 슬로프캠 */}
+ {/* 실시간 현황 화면 공개 시 다시 노출한다. info 실시간 현황 + */} videocam 슬로프캠 LIVE diff --git a/frontend/app/resort-reports/page.tsx b/frontend/app/resort-reports/page.tsx index 9da8014..6410afb 100644 --- a/frontend/app/resort-reports/page.tsx +++ b/frontend/app/resort-reports/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { Footer, TopNav } from "../components/SiteChrome"; -import { formatKoreanCalendarDate, formatReportTime } from "../lib/resortReports"; +import { formatKoreanCalendarDate, formatReportTime, getResortReportDisplayName } from "../lib/resortReports"; import { RESORT_MAP } from "../lib/resortTags"; import { API_ENDPOINTS, OffsetPage, ResortReportItem } from "../lib/api"; import { csrfFetch } from "../lib/csrfFetch"; @@ -102,7 +102,7 @@ export default function ResortReportsPage() {
({ value: String(resort.id), label: resort.name }))]} + options={[{ value: "", label: "전체 리조트" }, ...resortList.map((resort) => ({ value: String(resort.id), label: getResortReportDisplayName(resort.name) }))]} disabled={resortListError} onChange={(value) => { setLoading(true); @@ -125,13 +125,14 @@ export default function ResortReportsPage() { ) : reports.length === 0 ? (

오늘 등록된 설질 제보가 없습니다.

) : reports.map((report) => { - const markerClass = RESORT_MAP[report.resortCode]?.markerClass ?? "bg-[#3f6f8f]"; + const resort = RESORT_MAP[report.resortCode]; return ( -
- {report.resortName} -
{report.content}{report.authorNickname && by {report.authorNickname}}
- - {report.canDelete && } +
+ {getResortReportDisplayName(report.resortName)} + {report.content} + - {report.authorNickname} + + {report.canDelete && }
); })} diff --git a/frontend/app/resort/page.tsx b/frontend/app/resort/page.tsx index f33210a..0826e7a 100644 --- a/frontend/app/resort/page.tsx +++ b/frontend/app/resort/page.tsx @@ -32,6 +32,7 @@ export default function ResortStatusPage() { {/* 상단 탭 네비게이션: 리조트 현황 vs 슬로프캠 */}
+ {/* 실시간 현황 화면 공개 시 다시 노출한다. info 실시간 현황 + */} /?]).{8,}$/; +const NICKNAME_REGEX = /^[a-zA-Z0-9가-힣]{2,10}$/; async function readError(response: Response, fallback: string) { try { @@ -133,7 +134,7 @@ export default function SignUpPage() { } if (!isPasswordValid) return setErrorMsg("비밀번호는 8자 이상이며 영문 대문자와 특수문자를 포함해야 합니다."); if (!isPasswordMatch) return setErrorMsg("비밀번호 확인이 일치하지 않습니다."); - if (!nickname.trim()) return setErrorMsg("닉네임을 입력해 주세요."); + if (!NICKNAME_REGEX.test(nickname)) return setErrorMsg("닉네임은 2자 이상 10자 이하의 한글, 영문, 숫자로 입력해 주세요."); setLoading(true); try { const response = await csrfFetch(API_ENDPOINTS.members.signup, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, emailVerificationToken, password, nickname, resortIds: selectedResortIds, ridingStyleIds: selectedStyleIds }) }); @@ -151,7 +152,7 @@ export default function SignUpPage() { {errorMsg &&
{errorMsg}
} {notice &&
{notice}
}
이메일
changeEmail(event.target.value)} className="snow-input flex-1" placeholder="user@snowthing.org" disabled={isEmailVerified} required aria-invalid={emailVerificationError ? true : undefined} aria-describedby={emailVerificationError ? "email-verification-error" : undefined} />
{emailVerificationError && }{verificationRequestId && !isEmailVerified && <>
setVerificationCode(event.target.value.replace(/\D/g, ""))} className="snow-input flex-1" placeholder="인증번호 6자리" />

{verificationExpiresSeconds > 0 ? `인증번호 유효 시간 ${Math.floor(verificationExpiresSeconds / 60)}:${String(verificationExpiresSeconds % 60).padStart(2, "0")}` : "인증번호가 만료되었습니다. 다시 받아 주세요."}

}
-
+
({ id: item.id, label: item.name }))} selectedIds={selectedResortIds} onToggle={(id) => toggle(id, selectedResortIds, setSelectedResortIds)} /> ({ id: item.id, label: item.styleName }))} selectedIds={selectedStyleIds} onToggle={(id) => toggle(id, selectedStyleIds, setSelectedStyleIds)} />

이미 계정이 있나요? 로그인