From 69dac53bacf23b196dda8f6ae94492f52c03edb2 Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Thu, 28 May 2026 14:11:11 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20date=20course=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=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 --- scripts/seed-local.sql | 263 ++++++++++++++++++ .../api/controller/DateCourseController.java | 57 ++++ .../api/controller/swagger/DateCourseApi.java | 62 +++++ .../api/request/CategorySlotRequest.java | 14 + .../request/DateCourseGenerationRequest.java | 25 ++ .../api/response/DateCourseBatchResponse.java | 22 ++ .../DateCourseGenerationResponse.java | 17 ++ .../api/response/DateCoursePlaceResponse.java | 39 +++ .../api/response/DateCourseResponse.java | 29 ++ .../course/application/AvailablePool.java | 39 +++ .../application/AvailablePoolBuilder.java | 57 ++++ .../BusinessHoursAtTimeChecker.java | 30 ++ .../course/application/CourseScorer.java | 82 ++++++ .../course/application/CourseSelector.java | 95 +++++++ .../DateCourseGenerationService.java | 137 +++++++++ .../application/DateCourseQueryService.java | 140 ++++++++++ .../backend/course/application/Haversine.java | 24 ++ .../application/dto/AvailableCandidate.java | 9 + .../application/dto/CategorySlotCommand.java | 11 + .../dto/CourseSelectionResult.java | 10 + .../dto/DateCourseBatchResult.java | 12 + .../dto/DateCourseGenerationCommand.java | 12 + .../dto/DateCourseGenerationResult.java | 9 + .../dto/DateCoursePlaceResult.java | 20 ++ .../application/dto/DateCourseResult.java | 16 ++ .../application/dto/NormalizationContext.java | 13 + .../course/domain/entity/DateCourse.java | 106 +++++++ .../course/domain/entity/DateCoursePlace.java | 57 ++++ .../course/domain/enums/CourseMode.java | 7 + .../DateCourseCandidateRepository.java | 11 + .../DateCourseCandidateRepositoryImpl.java | 76 +++++ .../repository/DateCoursePlaceRepository.java | 23 ++ .../repository/DateCourseRepository.java | 25 ++ .../BusinessHoursDisplayResolver.java | 12 + .../BusinessHoursAtTimeCheckerTest.java | 95 +++++++ .../course/application/CourseScorerTest.java | 165 +++++++++++ .../application/CourseSelectorTest.java | 157 +++++++++++ .../course/application/HaversineTest.java | 57 ++++ 38 files changed, 2035 insertions(+) create mode 100644 scripts/seed-local.sql create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseGenerationResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePlaceResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/AvailablePool.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/Haversine.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/AvailableCandidate.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/CategorySlotCommand.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/CourseSelectionResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePlaceResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/NormalizationContext.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/enums/CourseMode.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepository.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepositoryImpl.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java diff --git a/scripts/seed-local.sql b/scripts/seed-local.sql new file mode 100644 index 0000000..fdfdb38 --- /dev/null +++ b/scripts/seed-local.sql @@ -0,0 +1,263 @@ +-- ============================================================================= +-- scripts/seed-local.sql +-- 데이트 코스 로컬 테스트용 장소 Mock 데이터 시드 +-- +-- 사용 순서: +-- 1. 앱 구동 (PlaceTaxonomySeedDataInitializer 자동 실행 대기) +-- 2. Swagger: GET /api/v1/auth/dev/master-token → userId 확인 +-- 3. Swagger: POST /api/v1/rooms → publicId 확인 +-- 4. 아래 v_user_id, v_room_pid 값 수정 후 저장 +-- 5. 터미널에서 실행: +-- docker exec -i udidura-postgres psql -U udidura -d udidura < scripts/seed-local.sql +-- +-- 멱등 스크립트: ON CONFLICT DO NOTHING 사용, 중복 실행 안전 +-- Docker 재시작 후에도 named volume(udidura_pg_data)이 데이터를 유지함 +-- ============================================================================= + +DO $$ +DECLARE + -- ★★★ 아래 두 값을 반드시 수정하세요 ★★★ + v_user_id BIGINT := 0; -- ① GET /api/v1/auth/dev/master-token 응답의 userId + v_room_pid TEXT := 'INPUT ROOM_ID HERE'; -- ② POST /api/v1/rooms 응답의 publicId + -- ★★★★★★★★★★★★★★★★★★★★★★★★★★ + + v_room_id BIGINT; + v_food_cat_id BIGINT; + v_cafe_cat_id BIGINT; + v_act_cat_id BIGINT; + + v_tag_korean BIGINT; + v_tag_chinese BIGINT; + v_tag_japanese BIGINT; + v_tag_western BIGINT; + v_tag_bar BIGINT; + + v_tag_bakery BIGINT; + v_tag_coffee_des BIGINT; + v_tag_cafe_misc BIGINT; + + v_tag_escape BIGINT; + v_tag_photo BIGINT; + v_tag_park BIGINT; + + v_place_id BIGINT; + v_now TIMESTAMPTZ := NOW(); + v_bh_expires TIMESTAMPTZ := '2027-12-31 00:00:00+00'; + v_bh_json TEXT; + +BEGIN + -- 입력 검증 + IF v_user_id = 0 THEN + RAISE EXCEPTION '[seed] v_user_id를 실제 userId로 수정하세요.'; + END IF; + IF v_room_pid = 'YOUR-ROOM-PUBLIC-ID' THEN + RAISE EXCEPTION '[seed] v_room_pid를 실제 roomPublicId로 수정하세요.'; + END IF; + + -- 영업시간 JSON: 월~일 10:00-22:00 + v_bh_json := '{"daily_hours":[' + || '{"day":"월","open":"10:00","close":"22:00"},' + || '{"day":"화","open":"10:00","close":"22:00"},' + || '{"day":"수","open":"10:00","close":"22:00"},' + || '{"day":"목","open":"10:00","close":"22:00"},' + || '{"day":"금","open":"10:00","close":"22:00"},' + || '{"day":"토","open":"10:00","close":"22:00"},' + || '{"day":"일","open":"10:00","close":"22:00"}' + || ']}'; + + -- 룸 조회 + SELECT id INTO v_room_id FROM rooms WHERE public_id = v_room_pid; + IF v_room_id IS NULL THEN + RAISE EXCEPTION '[seed] 룸을 찾을 수 없습니다: %', v_room_pid; + END IF; + + -- 카테고리 ID 조회 (앱 구동 시 PlaceTaxonomySeedDataInitializer가 자동 삽입) + SELECT id INTO v_food_cat_id FROM place_category WHERE code = 'FOOD'; + SELECT id INTO v_cafe_cat_id FROM place_category WHERE code = 'CAFE'; + SELECT id INTO v_act_cat_id FROM place_category WHERE code = 'ACTIVITY'; + + IF v_food_cat_id IS NULL OR v_cafe_cat_id IS NULL OR v_act_cat_id IS NULL THEN + RAISE EXCEPTION '[seed] place_category를 찾을 수 없습니다. 앱을 먼저 구동해 taxonomy seed를 완료하세요.'; + END IF; + + -- 음식점 태그 ID 조회 + SELECT id INTO v_tag_korean FROM place_tag WHERE code = 'KOREAN' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_chinese FROM place_tag WHERE code = 'CHINESE' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_japanese FROM place_tag WHERE code = 'JAPANESE' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_western FROM place_tag WHERE code = 'WESTERN' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_bar FROM place_tag WHERE code = 'BAR' AND category_id = v_food_cat_id; + + -- 카페 태그 ID 조회 + SELECT id INTO v_tag_bakery FROM place_tag WHERE code = 'BAKERY' AND category_id = v_cafe_cat_id; + SELECT id INTO v_tag_coffee_des FROM place_tag WHERE code = 'COFFEE_DESSERT' AND category_id = v_cafe_cat_id; + SELECT id INTO v_tag_cafe_misc FROM place_tag WHERE code = 'MISC' AND category_id = v_cafe_cat_id; + + -- 활동 태그 ID 조회 + SELECT id INTO v_tag_escape FROM place_tag WHERE code = 'ESCAPE_ROOM_CAFE' AND category_id = v_act_cat_id; + SELECT id INTO v_tag_photo FROM place_tag WHERE code = 'PHOTO_STUDIO' AND category_id = v_act_cat_id; + SELECT id INTO v_tag_park FROM place_tag WHERE code = 'PARK' AND category_id = v_act_cat_id; + + -- ========================================================================= + -- 룸 멤버 등록 + -- ========================================================================= + INSERT INTO room_members (room_id, user_id, pinned, created_at, updated_at) + VALUES (v_room_id, v_user_id, false, v_now, v_now) + ON CONFLICT (room_id, user_id) DO NOTHING; + + -- ========================================================================= + -- 장소 삽입 (11개 — 홍대·연남동 일대) + -- ========================================================================= + + -- FOOD (5개) + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + ('KAKAO', 'mock_food_001', 'mock_food_001', + '홍대 행복 삼겹살', '음식점 > 한식', 'FD6', + '서울 마포구 서교동 1-1', '서울 마포구 홍익로 1', + 37.553000, 126.921000, + v_food_cat_id, v_tag_korean, v_now, v_now), + + ('KAKAO', 'mock_food_002', 'mock_food_002', + '연남동 신룡 중화요리', '음식점 > 중식', 'FD6', + '서울 마포구 연남동 2-2', '서울 마포구 동교로 2', + 37.560500, 126.928000, + v_food_cat_id, v_tag_chinese, v_now, v_now), + + ('KAKAO', 'mock_food_003', 'mock_food_003', + '홍대입구 츠키 라멘', '음식점 > 일식', 'FD6', + '서울 마포구 서교동 3-3', '서울 마포구 홍익로 3', + 37.554800, 126.922500, + v_food_cat_id, v_tag_japanese, v_now, v_now), + + ('KAKAO', 'mock_food_004', 'mock_food_004', + '홍대 버거앤비어', '음식점 > 양식', 'FD6', + '서울 마포구 서교동 4-4', '서울 마포구 홍익로 4', + 37.552500, 126.923500, + v_food_cat_id, v_tag_western, v_now, v_now), + + ('KAKAO', 'mock_food_005', 'mock_food_005', + '연남동 포차마당', '음식점 > 술집', 'FD6', + '서울 마포구 연남동 5-5', '서울 마포구 동교로 5', + 37.561200, 126.926500, + v_food_cat_id, v_tag_bar, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- CAFE (3개) + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + ('KAKAO', 'mock_cafe_001', 'mock_cafe_001', + '연남동 노을빛 베이커리', '카페 > 베이커리', 'CE7', + '서울 마포구 연남동 6-6', '서울 마포구 동교로 6', + 37.559800, 126.927000, + v_cafe_cat_id, v_tag_bakery, v_now, v_now), + + ('KAKAO', 'mock_cafe_002', 'mock_cafe_002', + '홍대 달콤 스위트 카페', '카페 > 디저트', 'CE7', + '서울 마포구 서교동 7-7', '서울 마포구 홍익로 7', + 37.554200, 126.921800, + v_cafe_cat_id, v_tag_coffee_des, v_now, v_now), + + ('KAKAO', 'mock_cafe_003', 'mock_cafe_003', + '경의선 커피인더숲', '카페', 'CE7', + '서울 마포구 연남동 8-8', '서울 마포구 경의로 8', + 37.558800, 126.925500, + v_cafe_cat_id, v_tag_cafe_misc, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ACTIVITY (3개) + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + ('KAKAO', 'mock_act_001', 'mock_act_001', + '홍대 미로탈출 방탈출카페', '여가 > 방탈출카페', 'AT4', + '서울 마포구 서교동 9-9', '서울 마포구 홍익로 9', + 37.553500, 126.922800, + v_act_cat_id, v_tag_escape, v_now, v_now), + + ('KAKAO', 'mock_act_002', 'mock_act_002', + '홍대 온필름 사진관', '여가 > 사진관', 'AT4', + '서울 마포구 서교동 10-1', '서울 마포구 홍익로 10', + 37.555200, 126.922200, + v_act_cat_id, v_tag_photo, v_now, v_now), + + ('KAKAO', 'mock_act_003', 'mock_act_003', + '경의선숲길 공원', '관광명소 > 공원', 'AT4', + '서울 마포구 연남동 11-1', '서울 마포구 경의로 11', + 37.558000, 126.926200, + v_act_cat_id, v_tag_park, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ========================================================================= + -- room_places 삽입 (위에서 삽입한 11개 장소를 룸에 연결) + -- ========================================================================= + FOR v_place_id IN + SELECT id FROM places + WHERE kakao_place_id IN ( + 'mock_food_001', 'mock_food_002', 'mock_food_003', 'mock_food_004', 'mock_food_005', + 'mock_cafe_001', 'mock_cafe_002', 'mock_cafe_003', + 'mock_act_001', 'mock_act_002', 'mock_act_003' + ) + LOOP + INSERT INTO room_places ( + room_id, place_id, created_by_user_id, added_via, + sido_code, sido_name, sigungu_code, sigungu_name, + created_at, updated_at + ) + VALUES ( + v_room_id, v_place_id, v_user_id, 'EXTERNAL_SEARCH', + '11', '서울특별시', '11440', '마포구', + v_now, v_now + ) + ON CONFLICT (room_id, place_id) DO NOTHING; + END LOOP; + + -- ========================================================================= + -- place_business_hours 삽입 (코스 생성 필터 통과를 위해 필수) + -- status=SUCCEEDED, expires_at=2027-12-31 로 모든 시간대 영업 중으로 설정 + -- ========================================================================= + INSERT INTO place_business_hours ( + kakao_place_id, place_name, + business_hours_json, business_hours_status, + business_hours_fetched_at, business_hours_expires_at, + version, created_at, updated_at + ) + SELECT + p.kakao_place_id, + p.name, + v_bh_json, + 'SUCCEEDED', + v_now, + v_bh_expires, + 0, + v_now, + v_now + FROM places p + WHERE p.kakao_place_id IN ( + 'mock_food_001', 'mock_food_002', 'mock_food_003', 'mock_food_004', 'mock_food_005', + 'mock_cafe_001', 'mock_cafe_002', 'mock_cafe_003', + 'mock_act_001', 'mock_act_002', 'mock_act_003' + ) + ON CONFLICT (kakao_place_id) DO NOTHING; + + RAISE NOTICE '[seed] 완료 — 장소 11개, 영업시간 11개, room_places 삽입됨 (room: %)', v_room_pid; +END $$; diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java new file mode 100644 index 0000000..84afe99 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -0,0 +1,57 @@ +package com.hufs.capstone.backend.course.api.controller; + +import com.hufs.capstone.backend.auth.security.SecurityUtils; +import com.hufs.capstone.backend.course.api.controller.swagger.DateCourseApi; +import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; +import com.hufs.capstone.backend.course.api.response.DateCourseBatchResponse; +import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; +import com.hufs.capstone.backend.course.api.response.DateCourseResponse; +import com.hufs.capstone.backend.course.application.DateCourseGenerationService; +import com.hufs.capstone.backend.course.application.DateCourseQueryService; +import com.hufs.capstone.backend.global.response.CommonResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class DateCourseController implements DateCourseApi { + + private final DateCourseGenerationService generationService; + private final DateCourseQueryService queryService; + + @Override + public CommonResponse generateCourse( + @PathVariable String roomId, + @Valid @RequestBody DateCourseGenerationRequest request, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok( + DateCourseGenerationResponse.from(generationService.generate(request.toCommand(roomId), userId)) + ); + } + + @Override + public CommonResponse> listCourses(@PathVariable String roomId) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok( + queryService.listBatches(roomId, userId).stream() + .map(DateCourseBatchResponse::from) + .toList() + ); + } + + @Override + public CommonResponse getCourse( + @PathVariable String roomId, + @PathVariable String coursePublicId + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, coursePublicId, userId))); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java new file mode 100644 index 0000000..ac95ea2 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -0,0 +1,62 @@ +package com.hufs.capstone.backend.course.api.controller.swagger; + +import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; +import com.hufs.capstone.backend.course.api.response.DateCourseBatchResponse; +import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; +import com.hufs.capstone.backend.course.api.response.DateCourseResponse; +import com.hufs.capstone.backend.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; + +@RequestMapping("/api/v1/rooms/{roomId}/date-courses") +@SecurityRequirement(name = "bearer-jwt") +public interface DateCourseApi { + + @Operation( + tags = {"Date course"}, + summary = "데이트 코스 생성 API", + description = "방에 저장된 장소를 바탕으로 General/Trendy/Popular 3가지 코스를 생성하고 저장합니다." + ) + @ApiResponse(responseCode = "201", description = "코스 생성 성공") + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + CommonResponse generateCourse( + @PathVariable String roomId, + @Valid @RequestBody DateCourseGenerationRequest request, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ); + + @Operation( + tags = {"Date course"}, + summary = "데이트 코스 목록 조회 API", + description = "방에서 생성된 데이트 코스를 생성 배치(batchId) 단위로 최신순 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "OK") + @GetMapping + CommonResponse> listCourses( + @PathVariable String roomId + ); + + @Operation( + tags = {"Date course"}, + summary = "데이트 코스 상세 조회 API", + description = "특정 코스의 장소 목록을 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "OK") + @GetMapping("/{coursePublicId}") + CommonResponse getCourse( + @PathVariable String roomId, + @PathVariable String coursePublicId + ); +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java new file mode 100644 index 0000000..f6bc657 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java @@ -0,0 +1,14 @@ +package com.hufs.capstone.backend.course.api.request; + +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import jakarta.validation.constraints.NotBlank; + +public record CategorySlotRequest( + @NotBlank String categoryCode, + String tagCode +) { + + public CategorySlotCommand toCommand() { + return new CategorySlotCommand(categoryCode, tagCode); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java new file mode 100644 index 0000000..e3d51ec --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java @@ -0,0 +1,25 @@ +package com.hufs.capstone.backend.course.api.request; + +import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationCommand; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; + +public record DateCourseGenerationRequest( + @NotEmpty @Valid List categorySequence, + @NotNull Instant plannedDateTime, + @NotBlank String sigunguCode +) { + + public DateCourseGenerationCommand toCommand(String roomPublicId) { + return new DateCourseGenerationCommand( + roomPublicId, + categorySequence.stream().map(CategorySlotRequest::toCommand).toList(), + plannedDateTime, + sigunguCode + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java new file mode 100644 index 0000000..e6177d3 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java @@ -0,0 +1,22 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; +import java.time.Instant; +import java.util.List; + +public record DateCourseBatchResponse( + String generationBatchId, + Instant createdAt, + Instant plannedDateTime, + List courses +) { + + public static DateCourseBatchResponse from(DateCourseBatchResult result) { + return new DateCourseBatchResponse( + result.generationBatchId(), + result.createdAt(), + result.plannedDateTime(), + result.courses().stream().map(DateCourseResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseGenerationResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseGenerationResponse.java new file mode 100644 index 0000000..ffa8195 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseGenerationResponse.java @@ -0,0 +1,17 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationResult; +import java.util.List; + +public record DateCourseGenerationResponse( + String generationBatchId, + List courses +) { + + public static DateCourseGenerationResponse from(DateCourseGenerationResult result) { + return new DateCourseGenerationResponse( + result.generationBatchId(), + result.courses().stream().map(DateCourseResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePlaceResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePlaceResponse.java new file mode 100644 index 0000000..1ae90ff --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePlaceResponse.java @@ -0,0 +1,39 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import java.math.BigDecimal; + +public record DateCoursePlaceResponse( + Long roomPlaceId, + Long placeId, + String kakaoPlaceId, + String name, + String address, + String roadAddress, + BigDecimal latitude, + BigDecimal longitude, + String categoryCode, + String categoryName, + String tagCode, + String tagName, + int sequenceOrder +) { + + public static DateCoursePlaceResponse from(DateCoursePlaceResult result) { + return new DateCoursePlaceResponse( + result.roomPlaceId(), + result.placeId(), + result.kakaoPlaceId(), + result.name(), + result.address(), + result.roadAddress(), + result.latitude(), + result.longitude(), + result.categoryCode(), + result.categoryName(), + result.tagCode(), + result.tagName(), + result.sequenceOrder() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java new file mode 100644 index 0000000..0ece97e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java @@ -0,0 +1,29 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.time.Instant; +import java.util.List; + +public record DateCourseResponse( + String publicId, + CourseMode mode, + String generationBatchId, + Instant plannedDateTime, + Instant createdAt, + List places, + List skippedSlotIndices +) { + + public static DateCourseResponse from(DateCourseResult result) { + return new DateCourseResponse( + result.publicId(), + result.courseMode(), + result.generationBatchId(), + result.plannedDateTime(), + result.createdAt(), + result.places().stream().map(DateCoursePlaceResponse::from).toList(), + result.skippedSlotIndices() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/AvailablePool.java b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePool.java new file mode 100644 index 0000000..685e139 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePool.java @@ -0,0 +1,39 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.place.domain.entity.Place; +import java.util.List; + +class AvailablePool { + + private final List all; + + AvailablePool(List candidates) { + this.all = List.copyOf(candidates); + } + + List all() { + return all; + } + + List forSlot(CategorySlotCommand slot) { + return all.stream() + .filter(c -> matches(c, slot)) + .toList(); + } + + boolean isEmpty() { + return all.isEmpty(); + } + + private static boolean matches(AvailableCandidate candidate, CategorySlotCommand slot) { + Place place = candidate.roomPlace().getPlace(); + String categoryCode = place.getServiceCategory().getCode(); + String tagCode = place.getServiceTag().getCode(); + if (!categoryCode.equals(slot.categoryCode())) { + return false; + } + return slot.isWildcard() || tagCode.equals(slot.tagCode()); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java new file mode 100644 index 0000000..b5ac7ae --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java @@ -0,0 +1,57 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.course.domain.repository.DateCourseCandidateRepository; +import com.hufs.capstone.backend.place.domain.entity.PlaceBusinessHours; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.PlaceBusinessHoursRepository; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class AvailablePoolBuilder { + + private final DateCourseCandidateRepository candidateRepository; + private final PlaceBusinessHoursRepository placeBusinessHoursRepository; + private final BusinessHoursAtTimeChecker businessHoursAtTimeChecker; + + AvailablePool build(Long roomId, List slots, Instant plannedDateTime, String sigunguCode) { + Instant now = Instant.now(); + List roomPlaces = candidateRepository.findCandidates(roomId, slots, now, sigunguCode); + if (roomPlaces.isEmpty()) { + return new AvailablePool(List.of()); + } + + List kakaoPlaceIds = roomPlaces.stream() + .map(RoomPlace::getKakaoPlaceId) + .distinct() + .toList(); + + Map businessHoursByKakaoId = + placeBusinessHoursRepository.findByKakaoPlaceIdIn(kakaoPlaceIds).stream() + .collect(Collectors.toMap(PlaceBusinessHours::getKakaoPlaceId, Function.identity())); + + List candidates = roomPlaces.stream() + .filter(rp -> { + PlaceBusinessHours pbh = businessHoursByKakaoId.get(rp.getKakaoPlaceId()); + if (pbh == null || pbh.getBusinessHoursJson() == null) { + return false; + } + return businessHoursAtTimeChecker.isOpenAt(pbh.getBusinessHoursJson(), plannedDateTime); + }) + .map(rp -> { + PlaceBusinessHours pbh = businessHoursByKakaoId.get(rp.getKakaoPlaceId()); + return new AvailableCandidate(rp, pbh.getBusinessHoursJson()); + }) + .toList(); + + return new AvailablePool(candidates); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java new file mode 100644 index 0000000..0760e7e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java @@ -0,0 +1,30 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.place.application.BusinessHoursDisplayResolver; +import com.hufs.capstone.backend.place.domain.enums.BusinessStatus; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BusinessHoursAtTimeChecker { + + private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); + + private static final java.util.Set OPEN_STATUSES = java.util.Set.of( + BusinessStatus.OPEN, + BusinessStatus.OPEN_24_HOURS, + BusinessStatus.CLOSING_SOON + ); + + private final BusinessHoursDisplayResolver resolver; + + public boolean isOpenAt(String businessHoursJson, Instant plannedDateTime) { + ZonedDateTime at = plannedDateTime.atZone(SEOUL_ZONE); + BusinessStatus status = resolver.statusAt(businessHoursJson, at); + return OPEN_STATUSES.contains(status); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java new file mode 100644 index 0000000..4536aca --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java @@ -0,0 +1,82 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.NormalizationContext; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.link.domain.LinkSourceType; +import com.hufs.capstone.backend.link.domain.entity.Link; +import com.hufs.capstone.backend.link.domain.entity.RoomLink; +import com.hufs.capstone.backend.place.domain.entity.Place; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.springframework.stereotype.Component; + +@Component +class CourseScorer { + + private static final double MIN_DIST_KM = 0.001; + + double score( + AvailableCandidate candidate, + AvailableCandidate prev, + CourseMode mode, + NormalizationContext ctx, + Instant plannedDateTime + ) { + double distFactor = distFactor(candidate, prev); + double modeWeight = modeWeight(candidate, mode, ctx, plannedDateTime); + return distFactor * modeWeight; + } + + private static double distFactor(AvailableCandidate candidate, AvailableCandidate prev) { + if (prev == null) { + return 1.0; + } + Place prevPlace = prev.roomPlace().getPlace(); + Place currPlace = candidate.roomPlace().getPlace(); + if (prevPlace.getLatitude() == null || prevPlace.getLongitude() == null + || currPlace.getLatitude() == null || currPlace.getLongitude() == null) { + return 1.0; + } + double dist = Haversine.km( + prevPlace.getLatitude(), prevPlace.getLongitude(), + currPlace.getLatitude(), currPlace.getLongitude() + ); + return 1.0 / Math.max(MIN_DIST_KM, dist); + } + + private static double modeWeight( + AvailableCandidate candidate, + CourseMode mode, + NormalizationContext ctx, + Instant plannedDateTime + ) { + return switch (mode) { + case GENERAL -> 1.0; + case TRENDY -> trendyWeight(candidate, plannedDateTime); + case POPULAR -> popularWeight(candidate, ctx); + }; + } + + private static double trendyWeight(AvailableCandidate candidate, Instant plannedDateTime) { + Instant savedAt = candidate.roomPlace().getCreatedAt(); + long daysSince = Math.max(0L, ChronoUnit.DAYS.between(savedAt, plannedDateTime)); + return 1.0 + 0.5 * Math.exp(-daysSince / 30.0); + } + + private static double popularWeight(AvailableCandidate candidate, NormalizationContext ctx) { + RoomLink originRoomLink = candidate.roomPlace().getOriginRoomLink(); + if (originRoomLink == null || originRoomLink.getLink() == null) { + return 1.0; + } + Link link = originRoomLink.getLink(); + LinkSourceType sourceType = link.getLinkSourceType(); + long likeCount = link.getLikeCount() != null ? link.getLikeCount() : 0L; + long maxLikeCount = ctx.maxLikeCount(sourceType); + if (maxLikeCount <= 0) { + return 1.0; + } + return 1.0 + 0.8 * ((double) likeCount / maxLikeCount); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java new file mode 100644 index 0000000..0e4841e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java @@ -0,0 +1,95 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; +import com.hufs.capstone.backend.course.application.dto.NormalizationContext; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.link.domain.LinkSourceType; +import com.hufs.capstone.backend.link.domain.entity.Link; +import com.hufs.capstone.backend.link.domain.entity.RoomLink; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class CourseSelector { + + private final CourseScorer scorer; + + CourseSelectionResult select( + CourseMode mode, + List slots, + AvailablePool pool, + Set globallyUsedIds, + Instant plannedDateTime + ) { + NormalizationContext ctx = buildNormalizationContext(pool, mode); + List picked = new ArrayList<>(); + List skipped = new ArrayList<>(); + AvailableCandidate prev = null; + + for (int i = 0; i < slots.size(); i++) { + CategorySlotCommand slot = slots.get(i); + final AvailableCandidate prevForLambda = prev; + Optional best = pool.forSlot(slot).stream() + .filter(c -> !globallyUsedIds.contains(c.roomPlace().getId())) + .filter(c -> !picked.contains(c.roomPlace().getId())) + .filter(c -> mode != CourseMode.POPULAR || c.roomPlace().getOriginRoomLink() != null) + .max(Comparator.comparingDouble( + c -> scorer.score(c, prevForLambda, mode, ctx, plannedDateTime) + )); + + if (best.isEmpty()) { + skipped.add(i); + continue; + } + + AvailableCandidate chosen = best.get(); + picked.add(chosen.roomPlace().getId()); + prev = chosen; + } + + List pickedPlaces = pool.all().stream() + .filter(c -> picked.contains(c.roomPlace().getId())) + .sorted(Comparator.comparingInt(c -> picked.indexOf(c.roomPlace().getId()))) + .map(AvailableCandidate::roomPlace) + .toList(); + + globallyUsedIds.addAll(picked); + return new CourseSelectionResult(pickedPlaces, skipped); + } + + private static NormalizationContext buildNormalizationContext(AvailablePool pool, CourseMode mode) { + if (mode != CourseMode.POPULAR) { + return new NormalizationContext(Map.of()); + } + Map maxBySourceType = new EnumMap<>(LinkSourceType.class); + for (AvailableCandidate candidate : pool.all()) { + RoomLink originRoomLink = candidate.roomPlace().getOriginRoomLink(); + if (originRoomLink == null || originRoomLink.getLink() == null) { + continue; + } + Link link = originRoomLink.getLink(); + if (link.getLikeCount() == null) { + continue; + } + maxBySourceType.merge(link.getLinkSourceType(), link.getLikeCount(), Math::max); + } + return new NormalizationContext(maxBySourceType); + } + + Set newGloballyUsedIds() { + return new HashSet<>(); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java new file mode 100644 index 0000000..c9bd2de --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -0,0 +1,137 @@ +package com.hufs.capstone.backend.course.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationCommand; +import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationResult; +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DateCourseGenerationService { + + private final RoomAccessService roomAccessService; + private final AvailablePoolBuilder poolBuilder; + private final CourseSelector courseSelector; + private final DateCourseRepository dateCourseRepository; + private final DateCoursePlaceRepository dateCourseParaRepository; + private final ObjectMapper objectMapper; + + @Transactional + public DateCourseGenerationResult generate(DateCourseGenerationCommand command, Long userId) { + Room room = roomAccessService.requireMemberRoom(command.roomPublicId(), userId); + + AvailablePool pool = poolBuilder.build(room.getId(), command.categorySequence(), command.plannedDateTime(), command.sigunguCode()); + if (pool.isEmpty()) { + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "코스 생성 가능한 장소가 없습니다."); + } + + String batchId = UUID.randomUUID().toString(); + String categorySequenceJson = serializeSlots(command.categorySequence()); + + Set globallyUsedIds = courseSelector.newGloballyUsedIds(); + List results = new ArrayList<>(); + + for (CourseMode mode : List.of(CourseMode.GENERAL, CourseMode.TRENDY, CourseMode.POPULAR)) { + CourseSelectionResult selection = courseSelector.select( + mode, command.categorySequence(), pool, globallyUsedIds, command.plannedDateTime()); + + String skippedJson = serializeSkipped(selection.skippedSlotIndices()); + DateCourse dateCourse = dateCourseRepository.save(DateCourse.create( + UUID.randomUUID().toString(), + room, + userId, + mode, + command.plannedDateTime(), + batchId, + command.sigunguCode(), + categorySequenceJson, + skippedJson + )); + + List places = new ArrayList<>(); + for (int i = 0; i < selection.pickedPlaces().size(); i++) { + places.add(DateCoursePlace.create(dateCourse, selection.pickedPlaces().get(i), i)); + } + dateCourseParaRepository.saveAll(places); + + results.add(toResult(dateCourse, selection.pickedPlaces(), selection.skippedSlotIndices())); + } + + return new DateCourseGenerationResult(batchId, results); + } + + private static DateCourseResult toResult(DateCourse dateCourse, List pickedPlaces, List skipped) { + List placeResults = new ArrayList<>(); + for (int i = 0; i < pickedPlaces.size(); i++) { + placeResults.add(toPlaceResult(pickedPlaces.get(i), i)); + } + return new DateCourseResult( + dateCourse.getPublicId(), + dateCourse.getCourseMode(), + dateCourse.getGenerationBatchId(), + dateCourse.getPlannedDateTime(), + dateCourse.getCreatedAt(), + placeResults, + skipped + ); + } + + private static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequenceOrder) { + Place place = roomPlace.getPlace(); + return new DateCoursePlaceResult( + roomPlace.getId(), + place.getId(), + place.getKakaoPlaceId(), + place.getName(), + place.getAddress(), + place.getRoadAddress(), + place.getLatitude(), + place.getLongitude(), + place.getServiceCategory().getCode(), + place.getServiceCategory().getName(), + place.getServiceTag().getCode(), + place.getServiceTag().getName(), + sequenceOrder + ); + } + + private String serializeSlots(List slots) { + try { + return objectMapper.writeValueAsString(slots); + } catch (JsonProcessingException e) { + return "[]"; + } + } + + private String serializeSkipped(List skipped) { + try { + return objectMapper.writeValueAsString(skipped); + } catch (JsonProcessingException e) { + return "[]"; + } + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java new file mode 100644 index 0000000..9c3b27f --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -0,0 +1,140 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DateCourseQueryService { + + private final RoomAccessService roomAccessService; + private final DateCourseRepository dateCourseRepository; + private final DateCoursePlaceRepository dateCourseParaRepository; + + @Transactional(readOnly = true) + public List listBatches(String roomPublicId, Long userId) { + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + + List courses = dateCourseRepository.findByRoomIdOrderByCreatedAtDesc(room.getId()); + if (courses.isEmpty()) { + return List.of(); + } + + List courseIds = courses.stream().map(DateCourse::getId).toList(); + List allPlaces = dateCourseParaRepository.findWithRoomPlacesByCourseIdIn(courseIds); + + Map> placesByCourseId = allPlaces.stream() + .collect(Collectors.groupingBy(dcp -> dcp.getDateCourse().getId())); + + Map> coursesByBatch = new LinkedHashMap<>(); + for (DateCourse course : courses) { + coursesByBatch.computeIfAbsent(course.getGenerationBatchId(), k -> new ArrayList<>()).add(course); + } + + return coursesByBatch.entrySet().stream() + .map(entry -> { + List batchCourses = entry.getValue(); + DateCourse first = batchCourses.get(0); + List courseResults = batchCourses.stream() + .map(c -> toCourseResult(c, placesByCourseId.getOrDefault(c.getId(), List.of()))) + .toList(); + return new DateCourseBatchResult( + entry.getKey(), + first.getCreatedAt(), + first.getPlannedDateTime(), + courseResults + ); + }) + .toList(); + } + + @Transactional(readOnly = true) + public DateCourseResult getCourse(String roomPublicId, String coursePublicId, Long userId) { + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + + DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); + + List places = dateCourseParaRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); + return toCourseResult(course, places); + } + + private static DateCourseResult toCourseResult(DateCourse course, List places) { + List placeResults = places.stream() + .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) + .toList(); + + List skippedSlotIndices = parseSkipped(course.getSkippedSlotIndicesJson()); + + return new DateCourseResult( + course.getPublicId(), + course.getCourseMode(), + course.getGenerationBatchId(), + course.getPlannedDateTime(), + course.getCreatedAt(), + placeResults, + skippedSlotIndices + ); + } + + private static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequenceOrder) { + Place place = roomPlace.getPlace(); + return new DateCoursePlaceResult( + roomPlace.getId(), + place.getId(), + place.getKakaoPlaceId(), + place.getName(), + place.getAddress(), + place.getRoadAddress(), + place.getLatitude(), + place.getLongitude(), + place.getServiceCategory().getCode(), + place.getServiceCategory().getName(), + place.getServiceTag().getCode(), + place.getServiceTag().getName(), + sequenceOrder + ); + } + + private static List parseSkipped(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + List result = new ArrayList<>(); + String trimmed = json.trim(); + if (trimmed.equals("[]")) { + return List.of(); + } + String inner = trimmed.substring(1, trimmed.length() - 1); + for (String part : inner.split(",")) { + String num = part.trim(); + if (!num.isEmpty()) { + result.add(Integer.parseInt(num)); + } + } + return result; + } catch (Exception e) { + return List.of(); + } + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/Haversine.java b/src/main/java/com/hufs/capstone/backend/course/application/Haversine.java new file mode 100644 index 0000000..afed04e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/Haversine.java @@ -0,0 +1,24 @@ +package com.hufs.capstone.backend.course.application; + +import java.math.BigDecimal; + +final class Haversine { + + private static final double EARTH_RADIUS_KM = 6371.0; + + private Haversine() { + } + + static double km(BigDecimal lat1, BigDecimal lng1, BigDecimal lat2, BigDecimal lng2) { + double lat1Rad = Math.toRadians(lat1.doubleValue()); + double lat2Rad = Math.toRadians(lat2.doubleValue()); + double deltaLat = Math.toRadians(lat2.doubleValue() - lat1.doubleValue()); + double deltaLng = Math.toRadians(lng2.doubleValue() - lng1.doubleValue()); + + double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) + * Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_KM * c; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/AvailableCandidate.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/AvailableCandidate.java new file mode 100644 index 0000000..a91ccae --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/AvailableCandidate.java @@ -0,0 +1,9 @@ +package com.hufs.capstone.backend.course.application.dto; + +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; + +public record AvailableCandidate( + RoomPlace roomPlace, + String businessHoursJson +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/CategorySlotCommand.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/CategorySlotCommand.java new file mode 100644 index 0000000..ae2b71d --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/CategorySlotCommand.java @@ -0,0 +1,11 @@ +package com.hufs.capstone.backend.course.application.dto; + +public record CategorySlotCommand( + String categoryCode, + String tagCode +) { + + public boolean isWildcard() { + return tagCode == null || tagCode.isBlank(); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/CourseSelectionResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/CourseSelectionResult.java new file mode 100644 index 0000000..d241427 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/CourseSelectionResult.java @@ -0,0 +1,10 @@ +package com.hufs.capstone.backend.course.application.dto; + +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.util.List; + +public record CourseSelectionResult( + List pickedPlaces, + List skippedSlotIndices +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java new file mode 100644 index 0000000..12ae4bf --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java @@ -0,0 +1,12 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.time.Instant; +import java.util.List; + +public record DateCourseBatchResult( + String generationBatchId, + Instant createdAt, + Instant plannedDateTime, + List courses +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java new file mode 100644 index 0000000..55c8ba4 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java @@ -0,0 +1,12 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.time.Instant; +import java.util.List; + +public record DateCourseGenerationCommand( + String roomPublicId, + List categorySequence, + Instant plannedDateTime, + String sigunguCode +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationResult.java new file mode 100644 index 0000000..77b8841 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationResult.java @@ -0,0 +1,9 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.util.List; + +public record DateCourseGenerationResult( + String generationBatchId, + List courses +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePlaceResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePlaceResult.java new file mode 100644 index 0000000..4bbd0d3 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePlaceResult.java @@ -0,0 +1,20 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.math.BigDecimal; + +public record DateCoursePlaceResult( + Long roomPlaceId, + Long placeId, + String kakaoPlaceId, + String name, + String address, + String roadAddress, + BigDecimal latitude, + BigDecimal longitude, + String categoryCode, + String categoryName, + String tagCode, + String tagName, + int sequenceOrder +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java new file mode 100644 index 0000000..51dd10b --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java @@ -0,0 +1,16 @@ +package com.hufs.capstone.backend.course.application.dto; + +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.time.Instant; +import java.util.List; + +public record DateCourseResult( + String publicId, + CourseMode courseMode, + String generationBatchId, + Instant plannedDateTime, + Instant createdAt, + List places, + List skippedSlotIndices +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/NormalizationContext.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/NormalizationContext.java new file mode 100644 index 0000000..081d408 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/NormalizationContext.java @@ -0,0 +1,13 @@ +package com.hufs.capstone.backend.course.application.dto; + +import com.hufs.capstone.backend.link.domain.LinkSourceType; +import java.util.Map; + +public record NormalizationContext( + Map maxLikeCountBySourceType +) { + + public long maxLikeCount(LinkSourceType sourceType) { + return maxLikeCountBySourceType.getOrDefault(sourceType, 0L); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java new file mode 100644 index 0000000..af1f345 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -0,0 +1,106 @@ +package com.hufs.capstone.backend.course.domain.entity; + +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.global.common.entity.AuditableEntity; +import com.hufs.capstone.backend.room.domain.entity.Room; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "date_courses", + indexes = { + @Index(name = "idx_date_courses_room_id_created_at", columnList = "room_id, created_at"), + @Index(name = "idx_date_courses_created_by_user_id_created_at", columnList = "created_by_user_id, created_at"), + @Index(name = "idx_date_courses_generation_batch_id", columnList = "generation_batch_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uq_date_courses_public_id", columnNames = "public_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DateCourse extends AuditableEntity { + + @Column(name = "public_id", nullable = false, length = 36) + private String publicId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "course_mode", nullable = false, length = 20) + private CourseMode courseMode; + + @Column(name = "planned_date_time", nullable = false) + private Instant plannedDateTime; + + @Column(name = "generation_batch_id", nullable = false, length = 36) + private String generationBatchId; + + @Column(name = "sigungu_code", length = 5) + private String sigunguCode; + + @Column(name = "category_sequence_json", columnDefinition = "text") + private String categorySequenceJson; + + @Column(name = "skipped_slot_indices_json", columnDefinition = "text") + private String skippedSlotIndicesJson; + + private DateCourse( + String publicId, + Room room, + Long createdByUserId, + CourseMode courseMode, + Instant plannedDateTime, + String generationBatchId, + String sigunguCode, + String categorySequenceJson, + String skippedSlotIndicesJson + ) { + this.publicId = publicId; + this.room = room; + this.createdByUserId = createdByUserId; + this.courseMode = courseMode; + this.plannedDateTime = plannedDateTime; + this.generationBatchId = generationBatchId; + this.sigunguCode = sigunguCode; + this.categorySequenceJson = categorySequenceJson; + this.skippedSlotIndicesJson = skippedSlotIndicesJson; + } + + public static DateCourse create( + String publicId, + Room room, + Long createdByUserId, + CourseMode courseMode, + Instant plannedDateTime, + String generationBatchId, + String sigunguCode, + String categorySequenceJson, + String skippedSlotIndicesJson + ) { + if (publicId == null || room == null || createdByUserId == null || courseMode == null + || plannedDateTime == null || generationBatchId == null) { + throw new IllegalArgumentException("DateCourse required values are missing."); + } + return new DateCourse(publicId, room, createdByUserId, courseMode, plannedDateTime, + generationBatchId, sigunguCode, categorySequenceJson, skippedSlotIndicesJson); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java new file mode 100644 index 0000000..bef99e7 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java @@ -0,0 +1,57 @@ +package com.hufs.capstone.backend.course.domain.entity; + +import com.hufs.capstone.backend.global.common.entity.AuditableEntity; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "date_course_places", + indexes = { + @Index(name = "idx_date_course_places_date_course_id", columnList = "date_course_id") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uq_date_course_places_course_order", + columnNames = {"date_course_id", "sequence_order"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DateCoursePlace extends AuditableEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "date_course_id", nullable = false) + private DateCourse dateCourse; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "room_place_id", nullable = false) + private RoomPlace roomPlace; + + @Column(name = "sequence_order", nullable = false) + private Integer sequenceOrder; + + private DateCoursePlace(DateCourse dateCourse, RoomPlace roomPlace, Integer sequenceOrder) { + this.dateCourse = dateCourse; + this.roomPlace = roomPlace; + this.sequenceOrder = sequenceOrder; + } + + public static DateCoursePlace create(DateCourse dateCourse, RoomPlace roomPlace, Integer sequenceOrder) { + if (dateCourse == null || roomPlace == null || sequenceOrder == null) { + throw new IllegalArgumentException("DateCoursePlace required values are missing."); + } + return new DateCoursePlace(dateCourse, roomPlace, sequenceOrder); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/enums/CourseMode.java b/src/main/java/com/hufs/capstone/backend/course/domain/enums/CourseMode.java new file mode 100644 index 0000000..ca840bd --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/enums/CourseMode.java @@ -0,0 +1,7 @@ +package com.hufs.capstone.backend.course.domain.enums; + +public enum CourseMode { + GENERAL, + TRENDY, + POPULAR +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepository.java new file mode 100644 index 0000000..fe3d79e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepository.java @@ -0,0 +1,11 @@ +package com.hufs.capstone.backend.course.domain.repository; + +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.time.Instant; +import java.util.List; + +public interface DateCourseCandidateRepository { + + List findCandidates(Long roomId, List slots, Instant now, String sigunguCode); +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepositoryImpl.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepositoryImpl.java new file mode 100644 index 0000000..4de6374 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseCandidateRepositoryImpl.java @@ -0,0 +1,76 @@ +package com.hufs.capstone.backend.course.domain.repository; + +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.link.domain.entity.QLink; +import com.hufs.capstone.backend.link.domain.entity.QRoomLink; +import com.hufs.capstone.backend.place.domain.entity.QPlace; +import com.hufs.capstone.backend.place.domain.entity.QPlaceBusinessHours; +import com.hufs.capstone.backend.place.domain.entity.QPlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.QPlaceTag; +import com.hufs.capstone.backend.place.domain.entity.QRoomPlace; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.enums.BusinessHoursStatus; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class DateCourseCandidateRepositoryImpl implements DateCourseCandidateRepository { + + private static final QRoomPlace ROOM_PLACE = QRoomPlace.roomPlace; + private static final QPlace PLACE = QPlace.place; + private static final QPlaceCategory CATEGORY = QPlaceCategory.placeCategory; + private static final QPlaceTag TAG = QPlaceTag.placeTag; + private static final QRoomLink ORIGIN_ROOM_LINK = QRoomLink.roomLink; + private static final QLink ORIGIN_LINK = QLink.link; + private static final QPlaceBusinessHours PBH = QPlaceBusinessHours.placeBusinessHours; + + private final JPAQueryFactory queryFactory; + + public DateCourseCandidateRepositoryImpl(EntityManager entityManager) { + this.queryFactory = new JPAQueryFactory(entityManager); + } + + @Override + public List findCandidates(Long roomId, List slots, Instant now, String sigunguCode) { + BooleanExpression categoryFilter = buildCategoryFilter(slots); + if (categoryFilter == null) { + return List.of(); + } + return queryFactory + .selectFrom(ROOM_PLACE) + .distinct() + .join(ROOM_PLACE.place, PLACE).fetchJoin() + .join(PLACE.serviceCategory, CATEGORY).fetchJoin() + .join(PLACE.serviceTag, TAG).fetchJoin() + .leftJoin(ROOM_PLACE.originRoomLink, ORIGIN_ROOM_LINK).fetchJoin() + .leftJoin(ORIGIN_ROOM_LINK.link, ORIGIN_LINK).fetchJoin() + .join(PBH).on( + PBH.kakaoPlaceId.eq(PLACE.kakaoPlaceId), + PBH.businessHoursStatus.eq(BusinessHoursStatus.SUCCEEDED), + PBH.businessHoursExpiresAt.after(now) + ) + .where( + ROOM_PLACE.room.id.eq(roomId), + ROOM_PLACE.sigunguCode.eq(sigunguCode), + categoryFilter + ) + .orderBy(ROOM_PLACE.createdAt.desc(), ROOM_PLACE.id.desc()) + .fetch(); + } + + private static BooleanExpression buildCategoryFilter(List slots) { + BooleanExpression combined = null; + for (CategorySlotCommand slot : slots) { + BooleanExpression slotExpr = CATEGORY.code.eq(slot.categoryCode()); + if (!slot.isWildcard()) { + slotExpr = slotExpr.and(TAG.code.eq(slot.tagCode())); + } + combined = (combined == null) ? slotExpr : combined.or(slotExpr); + } + return combined; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java new file mode 100644 index 0000000..3183deb --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java @@ -0,0 +1,23 @@ +package com.hufs.capstone.backend.course.domain.repository; + +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DateCoursePlaceRepository extends JpaRepository { + + @Query(""" + SELECT dcp FROM DateCoursePlace dcp + JOIN FETCH dcp.roomPlace rp + JOIN FETCH rp.place p + JOIN FETCH p.serviceCategory + JOIN FETCH p.serviceTag + LEFT JOIN FETCH rp.originRoomLink orl + LEFT JOIN FETCH orl.link + WHERE dcp.dateCourse.id IN :courseIds + ORDER BY dcp.sequenceOrder ASC + """) + List findWithRoomPlacesByCourseIdIn(@Param("courseIds") List courseIds); +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java new file mode 100644 index 0000000..0cd6001 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -0,0 +1,25 @@ +package com.hufs.capstone.backend.course.domain.repository; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DateCourseRepository extends JpaRepository { + + Optional findByPublicId(String publicId); + + Optional findByPublicIdAndRoomId(String publicId, Long roomId); + + @Query(""" + SELECT dc FROM DateCourse dc + JOIN FETCH dc.room + WHERE dc.room.id = :roomId + ORDER BY dc.createdAt DESC + """) + List findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId); + + List findByGenerationBatchId(String generationBatchId); +} diff --git a/src/main/java/com/hufs/capstone/backend/place/application/BusinessHoursDisplayResolver.java b/src/main/java/com/hufs/capstone/backend/place/application/BusinessHoursDisplayResolver.java index 958ec2a..99f696e 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/BusinessHoursDisplayResolver.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/BusinessHoursDisplayResolver.java @@ -52,6 +52,18 @@ public BusinessHoursDisplayResult resolve(String businessHoursJson, BusinessHour } } + public BusinessStatus statusAt(String businessHoursJson, ZonedDateTime at) { + if (isBlank(businessHoursJson)) { + return BusinessStatus.UNKNOWN; + } + try { + BusinessHoursDisplayResult result = resolve(objectMapper.readTree(businessHoursJson), at); + return result == null ? BusinessStatus.UNKNOWN : result.businessStatus(); + } catch (Exception ex) { + return BusinessStatus.UNKNOWN; + } + } + BusinessHoursDisplayResult resolve(JsonNode businessHours, ZonedDateTime now) { if (businessHours == null || businessHours.isNull()) { return null; diff --git a/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java new file mode 100644 index 0000000..be2f5ef --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java @@ -0,0 +1,95 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hufs.capstone.backend.place.application.BusinessHoursDisplayResolver; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.Test; + +class BusinessHoursAtTimeCheckerTest { + + private final BusinessHoursDisplayResolver resolver = + new BusinessHoursDisplayResolver(new ObjectMapper(), Clock.systemUTC()); + + private final BusinessHoursAtTimeChecker checker = new BusinessHoursAtTimeChecker(resolver); + + private static final String WEEKLY_JSON = """ + {"daily_hours":[ + {"day":"월","open":"11:30","close":"21:00"}, + {"day":"화","open":"11:30","close":"21:00"}, + {"day":"수","open":"11:30","close":"21:00"}, + {"day":"목","open":"11:30","close":"21:00"}, + {"day":"금","open":"11:30","close":"21:00"}, + {"day":"토","open":"11:30","close":"21:00"}, + {"day":"일","open":"11:30","close":"21:00"} + ]} + """; + + @Test + void openDuringBusinessHours_returnsTrue() { + // 2026-05-12 Tuesday 15:00 KST = 06:00 UTC + Instant at = Instant.parse("2026-05-12T06:00:00Z"); + assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isTrue(); + } + + @Test + void beforeOpeningTime_returnsFalse() { + // 2026-05-12 Tuesday 10:00 KST = 01:00 UTC (before 11:30) + Instant at = Instant.parse("2026-05-12T01:00:00Z"); + assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isFalse(); + } + + @Test + void afterClosingTime_returnsFalse() { + // 2026-05-12 Tuesday 22:00 KST = 13:00 UTC (after 21:00) + Instant at = Instant.parse("2026-05-12T13:00:00Z"); + assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isFalse(); + } + + @Test + void closingSoon_returnsTrue() { + // 2026-05-12 Tuesday 20:45 KST = 11:45 UTC (within 30 min of 21:00) + Instant at = Instant.parse("2026-05-12T11:45:00Z"); + assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isTrue(); + } + + @Test + void open24Hours_alwaysReturnsTrue() { + String json = """ + {"daily_hours":[ + {"day":"월","raw":"24시간"},{"day":"화","raw":"24시간"},{"day":"수","raw":"24시간"}, + {"day":"목","raw":"24시간"},{"day":"금","raw":"24시간"},{"day":"토","raw":"24시간"}, + {"day":"일","raw":"24시간"} + ]} + """; + // Any time + assertThat(checker.isOpenAt(json, Instant.parse("2026-05-12T00:00:00Z"))).isTrue(); + assertThat(checker.isOpenAt(json, Instant.parse("2026-05-12T12:00:00Z"))).isTrue(); + } + + @Test + void dayOff_returnsFalse() { + String json = """ + {"daily_hours":[ + {"day":"화","raw":"정기휴무"}, + {"day":"수","open":"11:30","close":"21:00"} + ]} + """; + // Tuesday KST + Instant at = Instant.parse("2026-05-12T06:00:00Z"); + assertThat(checker.isOpenAt(json, at)).isFalse(); + } + + @Test + void nullJson_returnsFalse() { + assertThat(checker.isOpenAt(null, Instant.now())).isFalse(); + } + + @Test + void blankJson_returnsFalse() { + assertThat(checker.isOpenAt("", Instant.now())).isFalse(); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java new file mode 100644 index 0000000..175c8fe --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java @@ -0,0 +1,165 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.NormalizationContext; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.link.domain.LinkSourceType; +import com.hufs.capstone.backend.link.domain.entity.Link; +import com.hufs.capstone.backend.link.domain.entity.RoomLink; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CourseScorerTest { + + private final CourseScorer scorer = new CourseScorer(); + + @Test + void general_noPrev_returnsOne() { + AvailableCandidate candidate = candidateWithCoords(37.5, 127.0); + double score = scorer.score(candidate, null, CourseMode.GENERAL, noCtx(), Instant.now()); + assertThat(score).isCloseTo(1.0, within(0.001)); + } + + @Test + void general_withPrev_returnsInverseDistance() { + AvailableCandidate prev = candidateWithCoords(37.5665, 126.9780); + // ~1 km away + AvailableCandidate candidate = candidateWithCoords(37.5755, 126.9780); + double score = scorer.score(candidate, prev, CourseMode.GENERAL, noCtx(), Instant.now()); + // dist ~1km → score ≈ 1/1 = 1.0 + assertThat(score).isCloseTo(1.0, within(0.2)); + } + + @Test + void general_distClamp_preventsInfinity() { + AvailableCandidate prev = candidateWithCoords(37.5665, 126.9780); + AvailableCandidate candidate = candidateWithCoords(37.5665, 126.9780); // same point, dist=0 + double score = scorer.score(candidate, prev, CourseMode.GENERAL, noCtx(), Instant.now()); + // clamped to 1/0.001 = 1000 + assertThat(score).isCloseTo(1000.0, within(1.0)); + } + + @Test + void trendy_daysSinceZero_isMax() { + AvailableCandidate candidate = candidateWithCreatedAt(Instant.now()); + double score = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); + // 1.0 + 0.5 * exp(0) = 1.5 + assertThat(score).isCloseTo(1.5, within(0.05)); + } + + @Test + void trendy_daysSinceFar_approachesOne() { + Instant veryOld = Instant.now().minus(365 * 3L, ChronoUnit.DAYS); + AvailableCandidate candidate = candidateWithCreatedAt(veryOld); + double score = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); + // 1.0 + 0.5 * exp(-large) ≈ 1.0 + assertThat(score).isCloseTo(1.0, within(0.01)); + } + + @Test + void trendy_weightRange_between1and1point5() { + for (int days : new int[]{0, 7, 30, 90, 365}) { + Instant savedAt = Instant.now().minus(days, ChronoUnit.DAYS); + AvailableCandidate candidate = candidateWithCreatedAt(savedAt); + double weight = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); + assertThat(weight).isBetween(1.0, 1.5); + } + } + + @Test + void popular_noLink_returnsOne() { + AvailableCandidate candidate = candidateNoLink(); + double score = scorer.score(candidate, null, CourseMode.POPULAR, noCtx(), Instant.now()); + assertThat(score).isCloseTo(1.0, within(0.001)); + } + + @Test + void popular_maxLikeCount_returnsOnePoint8() { + NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.INSTAGRAM, 1000L)); + AvailableCandidate candidate = candidateWithLikeCount(1000L, LinkSourceType.INSTAGRAM); + double score = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); + // 1.0 + 0.8 * (1000/1000) = 1.8 + assertThat(score).isCloseTo(1.8, within(0.001)); + } + + @Test + void popular_maxIsZero_returnsOne() { + NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.INSTAGRAM, 0L)); + AvailableCandidate candidate = candidateWithLikeCount(0L, LinkSourceType.INSTAGRAM); + double score = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); + assertThat(score).isCloseTo(1.0, within(0.001)); + } + + @Test + void popular_weightRange_between1and1point8() { + NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.YOUTUBE, 500L)); + for (long likes : new long[]{0, 100, 250, 500}) { + AvailableCandidate candidate = candidateWithLikeCount(likes, LinkSourceType.YOUTUBE); + double weight = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); + assertThat(weight).isBetween(1.0, 1.8); + } + } + + private static AvailableCandidate candidateWithCoords(double lat, double lng) { + Place place = mock(Place.class); + when(place.getLatitude()).thenReturn(BigDecimal.valueOf(lat)); + when(place.getLongitude()).thenReturn(BigDecimal.valueOf(lng)); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(Instant.now()); + when(roomPlace.getOriginRoomLink()).thenReturn(null); + return new AvailableCandidate(roomPlace, null); + } + + private static AvailableCandidate candidateWithCreatedAt(Instant createdAt) { + Place place = mock(Place.class); + when(place.getLatitude()).thenReturn(null); + when(place.getLongitude()).thenReturn(null); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(createdAt); + when(roomPlace.getOriginRoomLink()).thenReturn(null); + return new AvailableCandidate(roomPlace, null); + } + + private static AvailableCandidate candidateNoLink() { + Place place = mock(Place.class); + when(place.getLatitude()).thenReturn(null); + when(place.getLongitude()).thenReturn(null); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(Instant.now()); + when(roomPlace.getOriginRoomLink()).thenReturn(null); + return new AvailableCandidate(roomPlace, null); + } + + private static AvailableCandidate candidateWithLikeCount(long likeCount, LinkSourceType sourceType) { + Place place = mock(Place.class); + when(place.getLatitude()).thenReturn(null); + when(place.getLongitude()).thenReturn(null); + Link link = mock(Link.class); + when(link.getLikeCount()).thenReturn(likeCount); + when(link.getLinkSourceType()).thenReturn(sourceType); + RoomLink roomLink = mock(RoomLink.class); + when(roomLink.getLink()).thenReturn(link); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(Instant.now()); + when(roomPlace.getOriginRoomLink()).thenReturn(roomLink); + return new AvailableCandidate(roomPlace, null); + } + + private static NormalizationContext noCtx() { + return new NormalizationContext(Map.of()); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java new file mode 100644 index 0000000..9785a77 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java @@ -0,0 +1,157 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.PlaceTag; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class CourseSelectorTest { + + private final CourseScorer scorer = new CourseScorer(); + private final CourseSelector selector = new CourseSelector(scorer); + + @Test + void firstSlot_noPrevious_distanceIgnored() { + AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(candidate)); + List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); + Set used = new HashSet<>(); + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + + assertThat(result.pickedPlaces()).hasSize(1); + assertThat(result.skippedSlotIndices()).isEmpty(); + } + + @Test + void noMatchingCandidate_slotIsSkipped() { + AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(candidate)); + // slot asks for CAFE but pool only has FOOD + List slots = List.of(new CategorySlotCommand("CAFE", "BAKERY")); + Set used = new HashSet<>(); + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + + assertThat(result.pickedPlaces()).isEmpty(); + assertThat(result.skippedSlotIndices()).containsExactly(0); + } + + @Test + void wildcardSlot_matchesAllTagsInCategory() { + AvailableCandidate cafe1 = candidate(1L, "CAFE", "BAKERY", 37.0, 127.0, Instant.now()); + AvailableCandidate cafe2 = candidate(2L, "CAFE", "DESSERT", 37.01, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(cafe1, cafe2)); + List slots = List.of( + new CategorySlotCommand("CAFE", null), // wildcard + new CategorySlotCommand("CAFE", null) // second wildcard slot + ); + Set used = new HashSet<>(); + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + + assertThat(result.pickedPlaces()).hasSize(2); + assertThat(result.skippedSlotIndices()).isEmpty(); + } + + @Test + void globallyUsedIds_preventCrossCourseDuplication() { + AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(candidate)); + List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); + + Set used = new HashSet<>(); + // First course consumes id=1 + CourseSelectionResult first = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + assertThat(first.pickedPlaces()).hasSize(1); + assertThat(used).contains(1L); + + // Second course cannot reuse id=1 + CourseSelectionResult second = selector.select(CourseMode.TRENDY, slots, pool, used, Instant.now()); + assertThat(second.pickedPlaces()).isEmpty(); + assertThat(second.skippedSlotIndices()).containsExactly(0); + } + + @Test + void sameCourseDuplication_prevented() { + AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(candidate)); + // Two identical slots — only one candidate available + List slots = List.of( + new CategorySlotCommand("FOOD", "KOREAN"), + new CategorySlotCommand("FOOD", "KOREAN") + ); + Set used = new HashSet<>(); + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + + assertThat(result.pickedPlaces()).hasSize(1); + assertThat(result.skippedSlotIndices()).containsExactly(1); + } + + @Test + void popular_candidateWithoutLink_excluded() { + AvailableCandidate withLink = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailableCandidate noLink = candidateNoLink(2L, "FOOD", "KOREAN"); + + AvailablePool pool = new AvailablePool(List.of(noLink, withLink)); + List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); + Set used = new HashSet<>(); + + CourseSelectionResult result = selector.select(CourseMode.POPULAR, slots, pool, used, Instant.now()); + + assertThat(result.pickedPlaces()).hasSize(1); + assertThat(result.pickedPlaces().get(0).getId()).isEqualTo(1L); + } + + private static AvailableCandidate candidate(Long id, String catCode, String tagCode, + double lat, double lng, Instant createdAt) { + PlaceCategory category = mock(PlaceCategory.class); + when(category.getCode()).thenReturn(catCode); + PlaceTag tag = mock(PlaceTag.class); + when(tag.getCode()).thenReturn(tagCode); + Place place = mock(Place.class); + when(place.getServiceCategory()).thenReturn(category); + when(place.getServiceTag()).thenReturn(tag); + when(place.getLatitude()).thenReturn(BigDecimal.valueOf(lat)); + when(place.getLongitude()).thenReturn(BigDecimal.valueOf(lng)); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(createdAt); + when(roomPlace.getOriginRoomLink()).thenReturn(mock(com.hufs.capstone.backend.link.domain.entity.RoomLink.class)); + return new AvailableCandidate(roomPlace, null); + } + + private static AvailableCandidate candidateNoLink(Long id, String catCode, String tagCode) { + PlaceCategory category = mock(PlaceCategory.class); + when(category.getCode()).thenReturn(catCode); + PlaceTag tag = mock(PlaceTag.class); + when(tag.getCode()).thenReturn(tagCode); + Place place = mock(Place.class); + when(place.getServiceCategory()).thenReturn(category); + when(place.getServiceTag()).thenReturn(tag); + when(place.getLatitude()).thenReturn(null); + when(place.getLongitude()).thenReturn(null); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(Instant.now()); + when(roomPlace.getOriginRoomLink()).thenReturn(null); + return new AvailableCandidate(roomPlace, null); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java new file mode 100644 index 0000000..92c906b --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java @@ -0,0 +1,57 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class HaversineTest { + + @Test + void samePoint_returnsZero() { + double km = Haversine.km( + BigDecimal.valueOf(37.5665), + BigDecimal.valueOf(126.9780), + BigDecimal.valueOf(37.5665), + BigDecimal.valueOf(126.9780) + ); + assertThat(km).isCloseTo(0.0, within(0.001)); + } + + @Test + void approxOneKm_betweenNearbyPoints() { + // Seoul City Hall vs ~1km north + double km = Haversine.km( + BigDecimal.valueOf(37.5665), + BigDecimal.valueOf(126.9780), + BigDecimal.valueOf(37.5755), + BigDecimal.valueOf(126.9780) + ); + assertThat(km).isCloseTo(1.0, within(0.1)); + } + + @Test + void approxHundredKm_betweenSeoulAndSuwon() { + // Seoul to Suwon (~45 km by Haversine) + double km = Haversine.km( + BigDecimal.valueOf(37.5665), + BigDecimal.valueOf(126.9780), + BigDecimal.valueOf(37.2636), + BigDecimal.valueOf(127.0286) + ); + assertThat(km).isBetween(30.0, 50.0); + } + + @Test + void antipode_isApproxHalfEarthCircumference() { + // Seoul antipode is roughly in South Atlantic + double km = Haversine.km( + BigDecimal.valueOf(37.5665), + BigDecimal.valueOf(126.9780), + BigDecimal.valueOf(-37.5665), + BigDecimal.valueOf(-53.0220) + ); + assertThat(km).isBetween(19000.0, 20200.0); + } +} From 74b9a52046976270134d76a7b70772e1df0b223e Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Fri, 29 May 2026 18:31:14 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix:=20Checkstyle=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=83=9D=EC=84=B1/=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC,=20=EC=8B=A0=EA=B7=9C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkstyle: 미사용 import 4개 제거, DateCoursePlace 들여쓰기 수정, 테스트 메서드명 28개 camelCase 변환 생성 로직: 빈 코스 저장 방지, sigunguCode/categoryCode/tagCode 입력 검증(400), categorySequence 크기 제한 생성/저장 분리: DateCourse에 savedByUserId/savedAt 추가, Save API 신설, 저장자 정보 응답 포함 조회 변경: listCourses를 saved 코스 전용으로 전환하고 페이지네이션 추가 마이페이지: GET /api/v1/users/me/date-courses API 신설 품질 개선: distScore 정규화(1/(1+dist)), weighted sum 방식으로 모드 가중치 균형 조정 기타: dateCourseParaRepository 네이밍 수정, parseSkipped objectMapper로 개선 Co-Authored-By: Claude Sonnet 4.6 --- .../api/controller/DateCourseController.java | 29 ++- .../controller/MeDateCourseController.java | 28 +++ .../api/controller/swagger/DateCourseApi.java | 31 +++- .../controller/swagger/MeDateCourseApi.java | 27 +++ .../request/DateCourseGenerationRequest.java | 3 +- .../api/response/DateCourseBatchResponse.java | 22 --- .../api/response/DateCoursePageResponse.java | 23 +++ .../api/response/DateCourseResponse.java | 12 +- .../response/MyDateCoursePageResponse.java | 23 +++ .../api/response/MyDateCourseResponse.java | 33 ++++ .../course/application/CourseScorer.java | 12 +- .../DateCourseGenerationService.java | 26 ++- .../application/DateCourseInputValidator.java | 48 +++++ .../application/DateCourseQueryService.java | 169 +++++++++++++----- .../application/DateCourseSaveService.java | 33 ++++ .../dto/DateCourseBatchResult.java | 12 -- .../application/dto/DateCoursePageResult.java | 12 ++ .../application/dto/DateCourseResult.java | 6 +- .../dto/MyDateCoursePageResult.java | 12 ++ .../application/dto/MyDateCourseResult.java | 18 ++ .../course/domain/entity/DateCourse.java | 17 +- .../course/domain/entity/DateCoursePlace.java | 2 +- .../repository/DateCourseRepository.java | 23 ++- .../BusinessHoursAtTimeCheckerTest.java | 17 +- .../course/application/CourseScorerTest.java | 40 ++--- .../application/CourseSelectorTest.java | 12 +- .../course/application/HaversineTest.java | 8 +- 27 files changed, 540 insertions(+), 158 deletions(-) create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/controller/MeDateCourseController.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java delete mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePageResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCoursePageResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java delete mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePageResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCoursePageResult.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index 84afe99..6f5d3b0 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -3,18 +3,19 @@ import com.hufs.capstone.backend.auth.security.SecurityUtils; import com.hufs.capstone.backend.course.api.controller.swagger.DateCourseApi; import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; -import com.hufs.capstone.backend.course.api.response.DateCourseBatchResponse; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; +import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; import com.hufs.capstone.backend.course.application.DateCourseGenerationService; import com.hufs.capstone.backend.course.application.DateCourseQueryService; +import com.hufs.capstone.backend.course.application.DateCourseSaveService; import com.hufs.capstone.backend.global.response.CommonResponse; import jakarta.validation.Valid; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -22,6 +23,7 @@ public class DateCourseController implements DateCourseApi { private final DateCourseGenerationService generationService; + private final DateCourseSaveService saveService; private final DateCourseQueryService queryService; @Override @@ -37,12 +39,25 @@ public CommonResponse generateCourse( } @Override - public CommonResponse> listCourses(@PathVariable String roomId) { + public CommonResponse saveCourse( + @PathVariable String roomId, + @PathVariable String coursePublicId, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + saveService.save(roomId, coursePublicId, userId); + return CommonResponse.ok(null); + } + + @Override + public CommonResponse listCourses( + @PathVariable String roomId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit + ) { Long userId = SecurityUtils.currentUserIdOrThrow(); return CommonResponse.ok( - queryService.listBatches(roomId, userId).stream() - .map(DateCourseBatchResponse::from) - .toList() + DateCoursePageResponse.from(queryService.listSavedCourses(roomId, userId, page, limit)) ); } @@ -54,4 +69,4 @@ public CommonResponse getCourse( Long userId = SecurityUtils.currentUserIdOrThrow(); return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, coursePublicId, userId))); } -} +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/MeDateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/MeDateCourseController.java new file mode 100644 index 0000000..0729667 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/MeDateCourseController.java @@ -0,0 +1,28 @@ +package com.hufs.capstone.backend.course.api.controller; + +import com.hufs.capstone.backend.auth.security.SecurityUtils; +import com.hufs.capstone.backend.course.api.controller.swagger.MeDateCourseApi; +import com.hufs.capstone.backend.course.api.response.MyDateCoursePageResponse; +import com.hufs.capstone.backend.course.application.DateCourseQueryService; +import com.hufs.capstone.backend.global.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class MeDateCourseController implements MeDateCourseApi { + + private final DateCourseQueryService queryService; + + @Override + public CommonResponse listMyDateCourses( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok( + MyDateCoursePageResponse.from(queryService.listMySavedCourses(userId, page, limit)) + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index ac95ea2..fce87d5 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -1,15 +1,14 @@ package com.hufs.capstone.backend.course.api.controller.swagger; import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; -import com.hufs.capstone.backend.course.api.response.DateCourseBatchResponse; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; +import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; import com.hufs.capstone.backend.global.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; -import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -17,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @RequestMapping("/api/v1/rooms/{roomId}/date-courses") @@ -26,7 +26,7 @@ public interface DateCourseApi { @Operation( tags = {"Date course"}, summary = "데이트 코스 생성 API", - description = "방에 저장된 장소를 바탕으로 General/Trendy/Popular 3가지 코스를 생성하고 저장합니다." + description = "방에 저장된 장소를 바탕으로 General/Trendy/Popular 3가지 코스 후보를 생성합니다." ) @ApiResponse(responseCode = "201", description = "코스 생성 성공") @ResponseStatus(HttpStatus.CREATED) @@ -39,13 +39,28 @@ CommonResponse generateCourse( @Operation( tags = {"Date course"}, - summary = "데이트 코스 목록 조회 API", - description = "방에서 생성된 데이트 코스를 생성 배치(batchId) 단위로 최신순 조회합니다." + summary = "데이트 코스 저장 API", + description = "생성된 코스 후보 중 하나를 선택해 저장합니다." + ) + @ApiResponse(responseCode = "200", description = "저장 성공") + @PostMapping("/{coursePublicId}/save") + CommonResponse saveCourse( + @PathVariable String roomId, + @PathVariable String coursePublicId, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ); + + @Operation( + tags = {"Date course"}, + summary = "저장된 데이트 코스 목록 조회 API", + description = "방에서 멤버들이 저장한 데이트 코스를 최신순으로 페이지네이션 조회합니다." ) @ApiResponse(responseCode = "200", description = "OK") @GetMapping - CommonResponse> listCourses( - @PathVariable String roomId + CommonResponse listCourses( + @PathVariable String roomId, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit ); @Operation( @@ -59,4 +74,4 @@ CommonResponse getCourse( @PathVariable String roomId, @PathVariable String coursePublicId ); -} +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java new file mode 100644 index 0000000..8403ec4 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java @@ -0,0 +1,27 @@ +package com.hufs.capstone.backend.course.api.controller.swagger; + +import com.hufs.capstone.backend.course.api.response.MyDateCoursePageResponse; +import com.hufs.capstone.backend.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@RequestMapping("/api/v1/users/me/date-courses") +@SecurityRequirement(name = "bearer-jwt") +public interface MeDateCourseApi { + + @Operation( + tags = {"My date course"}, + summary = "내가 저장한 데이트 코스 목록 조회 API", + description = "현재 로그인한 사용자가 저장한 데이트 코스를 방 구분 없이 최신 저장 순으로 조회합니다." + ) + @ApiResponse(responseCode = "200", description = "OK") + @GetMapping + CommonResponse listMyDateCourses( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit + ); +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java index e3d51ec..5a2c6d6 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java @@ -5,11 +5,12 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.Instant; import java.util.List; public record DateCourseGenerationRequest( - @NotEmpty @Valid List categorySequence, + @NotEmpty @Size(max = 5) @Valid List categorySequence, @NotNull Instant plannedDateTime, @NotBlank String sigunguCode ) { diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java deleted file mode 100644 index e6177d3..0000000 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseBatchResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.hufs.capstone.backend.course.api.response; - -import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; -import java.time.Instant; -import java.util.List; - -public record DateCourseBatchResponse( - String generationBatchId, - Instant createdAt, - Instant plannedDateTime, - List courses -) { - - public static DateCourseBatchResponse from(DateCourseBatchResult result) { - return new DateCourseBatchResponse( - result.generationBatchId(), - result.createdAt(), - result.plannedDateTime(), - result.courses().stream().map(DateCourseResponse::from).toList() - ); - } -} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePageResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePageResponse.java new file mode 100644 index 0000000..f28c76c --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCoursePageResponse.java @@ -0,0 +1,23 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePageResult; +import java.util.List; + +public record DateCoursePageResponse( + List items, + int page, + int limit, + long totalElements, + int totalPages +) { + + public static DateCoursePageResponse from(DateCoursePageResult result) { + return new DateCoursePageResponse( + result.items().stream().map(DateCourseResponse::from).toList(), + result.page(), + result.limit(), + result.totalElements(), + result.totalPages() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java index 0ece97e..bb03b1d 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java @@ -12,7 +12,11 @@ public record DateCourseResponse( Instant plannedDateTime, Instant createdAt, List places, - List skippedSlotIndices + List skippedSlotIndices, + Long savedByUserId, + String savedByNickname, + String savedByProfileImageUrl, + Instant savedAt ) { public static DateCourseResponse from(DateCourseResult result) { @@ -23,7 +27,11 @@ public static DateCourseResponse from(DateCourseResult result) { result.plannedDateTime(), result.createdAt(), result.places().stream().map(DateCoursePlaceResponse::from).toList(), - result.skippedSlotIndices() + result.skippedSlotIndices(), + result.savedByUserId(), + result.savedByNickname(), + result.savedByProfileImageUrl(), + result.savedAt() ); } } diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCoursePageResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCoursePageResponse.java new file mode 100644 index 0000000..4fbbbb7 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCoursePageResponse.java @@ -0,0 +1,23 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.MyDateCoursePageResult; +import java.util.List; + +public record MyDateCoursePageResponse( + List items, + int page, + int limit, + long totalElements, + int totalPages +) { + + public static MyDateCoursePageResponse from(MyDateCoursePageResult result) { + return new MyDateCoursePageResponse( + result.items().stream().map(MyDateCourseResponse::from).toList(), + result.page(), + result.limit(), + result.totalElements(), + result.totalPages() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java new file mode 100644 index 0000000..0280902 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java @@ -0,0 +1,33 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.MyDateCourseResult; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.time.Instant; +import java.util.List; + +public record MyDateCourseResponse( + String publicId, + CourseMode mode, + String generationBatchId, + Instant plannedDateTime, + Instant savedAt, + String roomPublicId, + String roomName, + List places, + List skippedSlotIndices +) { + + public static MyDateCourseResponse from(MyDateCourseResult result) { + return new MyDateCourseResponse( + result.publicId(), + result.courseMode(), + result.generationBatchId(), + result.plannedDateTime(), + result.savedAt(), + result.roomPublicId(), + result.roomName(), + result.places().stream().map(DateCoursePlaceResponse::from).toList(), + result.skippedSlotIndices() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java index 4536aca..ca25ffa 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java @@ -7,7 +7,6 @@ import com.hufs.capstone.backend.link.domain.entity.Link; import com.hufs.capstone.backend.link.domain.entity.RoomLink; import com.hufs.capstone.backend.place.domain.entity.Place; -import java.math.BigDecimal; import java.time.Instant; import java.time.temporal.ChronoUnit; import org.springframework.stereotype.Component; @@ -15,7 +14,8 @@ @Component class CourseScorer { - private static final double MIN_DIST_KM = 0.001; + private static final double DIST_WEIGHT = 0.6; + private static final double MODE_WEIGHT = 0.4; double score( AvailableCandidate candidate, @@ -24,12 +24,12 @@ class CourseScorer { NormalizationContext ctx, Instant plannedDateTime ) { - double distFactor = distFactor(candidate, prev); + double distScore = distScore(candidate, prev); double modeWeight = modeWeight(candidate, mode, ctx, plannedDateTime); - return distFactor * modeWeight; + return DIST_WEIGHT * distScore + MODE_WEIGHT * modeWeight; } - private static double distFactor(AvailableCandidate candidate, AvailableCandidate prev) { + private static double distScore(AvailableCandidate candidate, AvailableCandidate prev) { if (prev == null) { return 1.0; } @@ -43,7 +43,7 @@ private static double distFactor(AvailableCandidate candidate, AvailableCandidat prevPlace.getLatitude(), prevPlace.getLongitude(), currPlace.getLatitude(), currPlace.getLongitude() ); - return 1.0 / Math.max(MIN_DIST_KM, dist); + return 1.0 / (1.0 + dist); } private static double modeWeight( diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java index c9bd2de..3f9789c 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; -import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationCommand; import com.hufs.capstone.backend.course.application.dto.DateCourseGenerationResult; import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; @@ -20,7 +19,6 @@ import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -34,14 +32,16 @@ public class DateCourseGenerationService { private final RoomAccessService roomAccessService; + private final DateCourseInputValidator inputValidator; private final AvailablePoolBuilder poolBuilder; private final CourseSelector courseSelector; private final DateCourseRepository dateCourseRepository; - private final DateCoursePlaceRepository dateCourseParaRepository; + private final DateCoursePlaceRepository dateCoursePlaceRepository; private final ObjectMapper objectMapper; @Transactional public DateCourseGenerationResult generate(DateCourseGenerationCommand command, Long userId) { + inputValidator.validate(command.sigunguCode(), command.categorySequence()); Room room = roomAccessService.requireMemberRoom(command.roomPublicId(), userId); AvailablePool pool = poolBuilder.build(room.getId(), command.categorySequence(), command.plannedDateTime(), command.sigunguCode()); @@ -59,6 +59,10 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, CourseSelectionResult selection = courseSelector.select( mode, command.categorySequence(), pool, globallyUsedIds, command.plannedDateTime()); + if (selection.pickedPlaces().isEmpty()) { + continue; + } + String skippedJson = serializeSkipped(selection.skippedSlotIndices()); DateCourse dateCourse = dateCourseRepository.save(DateCourse.create( UUID.randomUUID().toString(), @@ -76,11 +80,15 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, for (int i = 0; i < selection.pickedPlaces().size(); i++) { places.add(DateCoursePlace.create(dateCourse, selection.pickedPlaces().get(i), i)); } - dateCourseParaRepository.saveAll(places); + dateCoursePlaceRepository.saveAll(places); results.add(toResult(dateCourse, selection.pickedPlaces(), selection.skippedSlotIndices())); } + if (results.isEmpty()) { + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "생성 가능한 코스가 없습니다."); + } + return new DateCourseGenerationResult(batchId, results); } @@ -96,7 +104,11 @@ private static DateCourseResult toResult(DateCourse dateCourse, List dateCourse.getPlannedDateTime(), dateCourse.getCreatedAt(), placeResults, - skipped + skipped, + null, + null, + null, + null ); } @@ -123,7 +135,7 @@ private String serializeSlots(List slots) { try { return objectMapper.writeValueAsString(slots); } catch (JsonProcessingException e) { - return "[]"; + throw new BusinessException(ErrorCode.E500_INTERNAL, "카테고리 슬롯 직렬화에 실패했습니다."); } } @@ -131,7 +143,7 @@ private String serializeSkipped(List skipped) { try { return objectMapper.writeValueAsString(skipped); } catch (JsonProcessingException e) { - return "[]"; + throw new BusinessException(ErrorCode.E500_INTERNAL, "스킵 슬롯 직렬화에 실패했습니다."); } } } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java new file mode 100644 index 0000000..a40ed2d --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java @@ -0,0 +1,48 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.repository.PlaceCategoryRepository; +import com.hufs.capstone.backend.place.domain.repository.PlaceTagRepository; +import com.hufs.capstone.backend.region.domain.repository.RegionSigunguRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class DateCourseInputValidator { + + private final RegionSigunguRepository regionSigunguRepository; + private final PlaceCategoryRepository placeCategoryRepository; + private final PlaceTagRepository placeTagRepository; + + void validate(String sigunguCode, List slots) { + validateSigunguCode(sigunguCode); + for (CategorySlotCommand slot : slots) { + validateSlot(slot); + } + } + + private void validateSigunguCode(String sigunguCode) { + regionSigunguRepository.findActiveByCode(sigunguCode) + .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, + "유효하지 않은 시군구 코드입니다: " + sigunguCode)); + } + + private void validateSlot(CategorySlotCommand slot) { + PlaceCategory category = placeCategoryRepository.findByCode(slot.categoryCode()) + .filter(PlaceCategory::isActive) + .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, + "유효하지 않은 카테고리 코드입니다: " + slot.categoryCode())); + + if (!slot.isWildcard()) { + placeTagRepository.findByCategoryAndCode(category, slot.tagCode()) + .filter(tag -> tag.isActive()) + .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, + "유효하지 않은 태그 코드입니다: " + slot.tagCode())); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index 9c3b27f..953945f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -1,8 +1,12 @@ package com.hufs.capstone.backend.course.application; -import com.hufs.capstone.backend.course.application.dto.DateCourseBatchResult; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hufs.capstone.backend.course.application.dto.DateCoursePageResult; import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.application.dto.MyDateCoursePageResult; +import com.hufs.capstone.backend.course.application.dto.MyDateCourseResult; import com.hufs.capstone.backend.course.domain.entity.DateCourse; import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; @@ -13,58 +17,71 @@ import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; -import java.util.ArrayList; -import java.util.LinkedHashMap; +import com.hufs.capstone.backend.user.domain.entity.User; +import com.hufs.capstone.backend.user.domain.repository.UserRepository; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class DateCourseQueryService { + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + private final RoomAccessService roomAccessService; private final DateCourseRepository dateCourseRepository; - private final DateCoursePlaceRepository dateCourseParaRepository; + private final DateCoursePlaceRepository dateCoursePlaceRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; @Transactional(readOnly = true) - public List listBatches(String roomPublicId, Long userId) { + public DateCoursePageResult listSavedCourses(String roomPublicId, Long userId, Integer page, Integer limit) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + int normalizedPage = page == null ? DEFAULT_PAGE : page; + int normalizedLimit = limit == null ? DEFAULT_LIMIT : limit; + if (normalizedPage < 0) { + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be >= 0."); + } + if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + } - List courses = dateCourseRepository.findByRoomIdOrderByCreatedAtDesc(room.getId()); + Page coursePage = dateCourseRepository.findSavedByRoomIdOrderBySavedAtDesc( + room.getId(), PageRequest.of(normalizedPage, normalizedLimit)); + + List courses = coursePage.getContent(); if (courses.isEmpty()) { - return List.of(); + return new DateCoursePageResult(List.of(), normalizedPage, normalizedLimit, + coursePage.getTotalElements(), coursePage.getTotalPages()); } List courseIds = courses.stream().map(DateCourse::getId).toList(); - List allPlaces = dateCourseParaRepository.findWithRoomPlacesByCourseIdIn(courseIds); - + List allPlaces = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(courseIds); Map> placesByCourseId = allPlaces.stream() .collect(Collectors.groupingBy(dcp -> dcp.getDateCourse().getId())); - Map> coursesByBatch = new LinkedHashMap<>(); - for (DateCourse course : courses) { - coursesByBatch.computeIfAbsent(course.getGenerationBatchId(), k -> new ArrayList<>()).add(course); - } + Map userById = fetchUsers(courses.stream() + .map(DateCourse::getSavedByUserId).distinct().toList()); - return coursesByBatch.entrySet().stream() - .map(entry -> { - List batchCourses = entry.getValue(); - DateCourse first = batchCourses.get(0); - List courseResults = batchCourses.stream() - .map(c -> toCourseResult(c, placesByCourseId.getOrDefault(c.getId(), List.of()))) - .toList(); - return new DateCourseBatchResult( - entry.getKey(), - first.getCreatedAt(), - first.getPlannedDateTime(), - courseResults - ); - }) + List items = courses.stream() + .map(c -> toCourseResult(c, placesByCourseId.getOrDefault(c.getId(), List.of()), + userById.get(c.getSavedByUserId()))) .toList(); + + return new DateCoursePageResult(items, normalizedPage, normalizedLimit, + coursePage.getTotalElements(), coursePage.getTotalPages()); } @Transactional(readOnly = true) @@ -74,17 +91,60 @@ public DateCourseResult getCourse(String roomPublicId, String coursePublicId, Lo DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); - List places = dateCourseParaRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); - return toCourseResult(course, places); + List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); + User saver = course.getSavedByUserId() != null + ? userRepository.findByIdAndDeletedAtIsNull(course.getSavedByUserId()).orElse(null) + : null; + return toCourseResult(course, places, saver); + } + + @Transactional(readOnly = true) + public MyDateCoursePageResult listMySavedCourses(Long userId, Integer page, Integer limit) { + int normalizedPage = page == null ? DEFAULT_PAGE : page; + int normalizedLimit = limit == null ? DEFAULT_LIMIT : limit; + if (normalizedPage < 0) { + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be >= 0."); + } + if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + } + + Page coursePage = dateCourseRepository.findSavedByUserIdOrderBySavedAtDesc( + userId, PageRequest.of(normalizedPage, normalizedLimit)); + + List courses = coursePage.getContent(); + if (courses.isEmpty()) { + return new MyDateCoursePageResult(List.of(), normalizedPage, normalizedLimit, + coursePage.getTotalElements(), coursePage.getTotalPages()); + } + + List courseIds = courses.stream().map(DateCourse::getId).toList(); + List allPlaces = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(courseIds); + Map> placesByCourseId = allPlaces.stream() + .collect(Collectors.groupingBy(dcp -> dcp.getDateCourse().getId())); + + List items = courses.stream() + .map(c -> toMyResult(c, placesByCourseId.getOrDefault(c.getId(), List.of()))) + .toList(); + + return new MyDateCoursePageResult(items, normalizedPage, normalizedLimit, + coursePage.getTotalElements(), coursePage.getTotalPages()); + } + + private Map fetchUsers(Collection userIds) { + List nonNullIds = userIds.stream().filter(id -> id != null).toList(); + if (nonNullIds.isEmpty()) { + return Map.of(); + } + return userRepository.findByIdInAndDeletedAtIsNull(nonNullIds).stream() + .collect(Collectors.toMap(User::getId, Function.identity())); } - private static DateCourseResult toCourseResult(DateCourse course, List places) { + private DateCourseResult toCourseResult(DateCourse course, List places, User saver) { List placeResults = places.stream() .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) .toList(); - List skippedSlotIndices = parseSkipped(course.getSkippedSlotIndicesJson()); - return new DateCourseResult( course.getPublicId(), course.getCourseMode(), @@ -92,7 +152,30 @@ private static DateCourseResult toCourseResult(DateCourse course, List places) { + List placeResults = places.stream() + .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) + .toList(); + + Room room = course.getRoom(); + return new MyDateCourseResult( + course.getPublicId(), + course.getCourseMode(), + course.getGenerationBatchId(), + course.getPlannedDateTime(), + course.getSavedAt(), + room.getPublicId(), + room.getName(), + placeResults, + parseSkipped(course.getSkippedSlotIndicesJson()) ); } @@ -115,26 +198,16 @@ private static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequ ); } - private static List parseSkipped(String json) { + private List parseSkipped(String json) { if (json == null || json.isBlank()) { return List.of(); } try { - List result = new ArrayList<>(); - String trimmed = json.trim(); - if (trimmed.equals("[]")) { - return List.of(); - } - String inner = trimmed.substring(1, trimmed.length() - 1); - for (String part : inner.split(",")) { - String num = part.trim(); - if (!num.isEmpty()) { - result.add(Integer.parseInt(num)); - } - } - return result; + return objectMapper.readValue(json, new TypeReference>() { + }); } catch (Exception e) { + log.warn("Failed to parse skippedSlotIndicesJson: {}", json, e); return List.of(); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java new file mode 100644 index 0000000..ec0f6ba --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -0,0 +1,33 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DateCourseSaveService { + + private final RoomAccessService roomAccessService; + private final DateCourseRepository dateCourseRepository; + + @Transactional + public void save(String roomPublicId, String coursePublicId, Long userId) { + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + + DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); + + if (course.getSavedByUserId() != null) { + throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 코스입니다."); + } + + course.markAsSaved(userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java deleted file mode 100644 index 12ae4bf..0000000 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseBatchResult.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.hufs.capstone.backend.course.application.dto; - -import java.time.Instant; -import java.util.List; - -public record DateCourseBatchResult( - String generationBatchId, - Instant createdAt, - Instant plannedDateTime, - List courses -) { -} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePageResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePageResult.java new file mode 100644 index 0000000..8eafee9 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCoursePageResult.java @@ -0,0 +1,12 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.util.List; + +public record DateCoursePageResult( + List items, + int page, + int limit, + long totalElements, + int totalPages +) { +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java index 51dd10b..006da68 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java @@ -11,6 +11,10 @@ public record DateCourseResult( Instant plannedDateTime, Instant createdAt, List places, - List skippedSlotIndices + List skippedSlotIndices, + Long savedByUserId, + String savedByNickname, + String savedByProfileImageUrl, + Instant savedAt ) { } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCoursePageResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCoursePageResult.java new file mode 100644 index 0000000..9643af3 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCoursePageResult.java @@ -0,0 +1,12 @@ +package com.hufs.capstone.backend.course.application.dto; + +import java.util.List; + +public record MyDateCoursePageResult( + List items, + int page, + int limit, + long totalElements, + int totalPages +) { +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java new file mode 100644 index 0000000..efae4d0 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java @@ -0,0 +1,18 @@ +package com.hufs.capstone.backend.course.application.dto; + +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.time.Instant; +import java.util.List; + +public record MyDateCourseResult( + String publicId, + CourseMode courseMode, + String generationBatchId, + Instant plannedDateTime, + Instant savedAt, + String roomPublicId, + String roomName, + List places, + List skippedSlotIndices +) { +} \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java index af1f345..63c938c 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -25,7 +25,8 @@ indexes = { @Index(name = "idx_date_courses_room_id_created_at", columnList = "room_id, created_at"), @Index(name = "idx_date_courses_created_by_user_id_created_at", columnList = "created_by_user_id, created_at"), - @Index(name = "idx_date_courses_generation_batch_id", columnList = "generation_batch_id") + @Index(name = "idx_date_courses_generation_batch_id", columnList = "generation_batch_id"), + @Index(name = "idx_date_courses_saved_by_user_id_saved_at", columnList = "saved_by_user_id, saved_at") }, uniqueConstraints = { @UniqueConstraint(name = "uq_date_courses_public_id", columnNames = "public_id") @@ -63,6 +64,12 @@ public class DateCourse extends AuditableEntity { @Column(name = "skipped_slot_indices_json", columnDefinition = "text") private String skippedSlotIndicesJson; + @Column(name = "saved_by_user_id") + private Long savedByUserId; + + @Column(name = "saved_at") + private Instant savedAt; + private DateCourse( String publicId, Room room, @@ -103,4 +110,12 @@ public static DateCourse create( return new DateCourse(publicId, room, createdByUserId, courseMode, plannedDateTime, generationBatchId, sigunguCode, categorySequenceJson, skippedSlotIndicesJson); } + + public void markAsSaved(Long userId) { + if (this.savedByUserId != null) { + throw new IllegalStateException("이미 저장된 코스입니다."); + } + this.savedByUserId = userId; + this.savedAt = Instant.now(); + } } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java index bef99e7..80dcc81 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java @@ -25,7 +25,7 @@ @UniqueConstraint( name = "uq_date_course_places_course_order", columnNames = {"date_course_id", "sequence_order"} - ) + ) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index 0cd6001..d8a1ef3 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -3,6 +3,8 @@ import com.hufs.capstone.backend.course.domain.entity.DateCourse; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +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; @@ -13,6 +15,23 @@ public interface DateCourseRepository extends JpaRepository { Optional findByPublicIdAndRoomId(String publicId, Long roomId); + @Query(""" + SELECT dc FROM DateCourse dc + JOIN FETCH dc.room + WHERE dc.room.id = :roomId + AND dc.savedByUserId IS NOT NULL + ORDER BY dc.savedAt DESC + """) + Page findSavedByRoomIdOrderBySavedAtDesc(@Param("roomId") Long roomId, Pageable pageable); + + @Query(""" + SELECT dc FROM DateCourse dc + JOIN FETCH dc.room + WHERE dc.savedByUserId = :userId + ORDER BY dc.savedAt DESC + """) + Page findSavedByUserIdOrderBySavedAtDesc(@Param("userId") Long userId, Pageable pageable); + @Query(""" SELECT dc FROM DateCourse dc JOIN FETCH dc.room @@ -20,6 +39,4 @@ public interface DateCourseRepository extends JpaRepository { ORDER BY dc.createdAt DESC """) List findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId); - - List findByGenerationBatchId(String generationBatchId); -} +} \ No newline at end of file diff --git a/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java index be2f5ef..691a80b 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeCheckerTest.java @@ -6,7 +6,6 @@ import com.hufs.capstone.backend.place.application.BusinessHoursDisplayResolver; import java.time.Clock; import java.time.Instant; -import java.time.ZoneOffset; import org.junit.jupiter.api.Test; class BusinessHoursAtTimeCheckerTest { @@ -29,35 +28,35 @@ class BusinessHoursAtTimeCheckerTest { """; @Test - void openDuringBusinessHours_returnsTrue() { + void openDuringBusinessHoursReturnsTrue() { // 2026-05-12 Tuesday 15:00 KST = 06:00 UTC Instant at = Instant.parse("2026-05-12T06:00:00Z"); assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isTrue(); } @Test - void beforeOpeningTime_returnsFalse() { + void beforeOpeningTimeReturnsFalse() { // 2026-05-12 Tuesday 10:00 KST = 01:00 UTC (before 11:30) Instant at = Instant.parse("2026-05-12T01:00:00Z"); assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isFalse(); } @Test - void afterClosingTime_returnsFalse() { + void afterClosingTimeReturnsFalse() { // 2026-05-12 Tuesday 22:00 KST = 13:00 UTC (after 21:00) Instant at = Instant.parse("2026-05-12T13:00:00Z"); assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isFalse(); } @Test - void closingSoon_returnsTrue() { + void closingSoonReturnsTrue() { // 2026-05-12 Tuesday 20:45 KST = 11:45 UTC (within 30 min of 21:00) Instant at = Instant.parse("2026-05-12T11:45:00Z"); assertThat(checker.isOpenAt(WEEKLY_JSON, at)).isTrue(); } @Test - void open24Hours_alwaysReturnsTrue() { + void open24HoursAlwaysReturnsTrue() { String json = """ {"daily_hours":[ {"day":"월","raw":"24시간"},{"day":"화","raw":"24시간"},{"day":"수","raw":"24시간"}, @@ -71,7 +70,7 @@ void open24Hours_alwaysReturnsTrue() { } @Test - void dayOff_returnsFalse() { + void dayOffReturnsFalse() { String json = """ {"daily_hours":[ {"day":"화","raw":"정기휴무"}, @@ -84,12 +83,12 @@ void dayOff_returnsFalse() { } @Test - void nullJson_returnsFalse() { + void nullJsonReturnsFalse() { assertThat(checker.isOpenAt(null, Instant.now())).isFalse(); } @Test - void blankJson_returnsFalse() { + void blankJsonReturnsFalse() { assertThat(checker.isOpenAt("", Instant.now())).isFalse(); } } diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java index 175c8fe..9740f8f 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java @@ -24,41 +24,41 @@ class CourseScorerTest { private final CourseScorer scorer = new CourseScorer(); @Test - void general_noPrev_returnsOne() { + void generalNoPrevReturnsOne() { AvailableCandidate candidate = candidateWithCoords(37.5, 127.0); double score = scorer.score(candidate, null, CourseMode.GENERAL, noCtx(), Instant.now()); assertThat(score).isCloseTo(1.0, within(0.001)); } @Test - void general_withPrev_returnsInverseDistance() { + void generalWithPrevReturnsInverseDistance() { AvailableCandidate prev = candidateWithCoords(37.5665, 126.9780); // ~1 km away AvailableCandidate candidate = candidateWithCoords(37.5755, 126.9780); double score = scorer.score(candidate, prev, CourseMode.GENERAL, noCtx(), Instant.now()); - // dist ~1km → score ≈ 1/1 = 1.0 - assertThat(score).isCloseTo(1.0, within(0.2)); + // dist ~1km → distScore=0.5 → score = 0.6*0.5 + 0.4*1.0 = 0.7 + assertThat(score).isCloseTo(0.7, within(0.1)); } @Test - void general_distClamp_preventsInfinity() { + void generalDistClampPreventsInfinity() { AvailableCandidate prev = candidateWithCoords(37.5665, 126.9780); AvailableCandidate candidate = candidateWithCoords(37.5665, 126.9780); // same point, dist=0 double score = scorer.score(candidate, prev, CourseMode.GENERAL, noCtx(), Instant.now()); - // clamped to 1/0.001 = 1000 - assertThat(score).isCloseTo(1000.0, within(1.0)); + // dist=0 → distScore = 1/(1+0) = 1.0 → score = 0.6*1.0 + 0.4*1.0 = 1.0 (no infinity) + assertThat(score).isCloseTo(1.0, within(0.001)); } @Test - void trendy_daysSinceZero_isMax() { + void trendyDaysSinceZeroIsMax() { AvailableCandidate candidate = candidateWithCreatedAt(Instant.now()); double score = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); - // 1.0 + 0.5 * exp(0) = 1.5 - assertThat(score).isCloseTo(1.5, within(0.05)); + // modeWeight = 1.0 + 0.5*exp(0) = 1.5 → score = 0.6*1.0 + 0.4*1.5 = 1.2 + assertThat(score).isCloseTo(1.2, within(0.05)); } @Test - void trendy_daysSinceFar_approachesOne() { + void trendyDaysSinceFarApproachesOne() { Instant veryOld = Instant.now().minus(365 * 3L, ChronoUnit.DAYS); AvailableCandidate candidate = candidateWithCreatedAt(veryOld); double score = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); @@ -67,33 +67,33 @@ void trendy_daysSinceFar_approachesOne() { } @Test - void trendy_weightRange_between1and1point5() { + void trendyWeightRangeBetween1and1point5() { for (int days : new int[]{0, 7, 30, 90, 365}) { Instant savedAt = Instant.now().minus(days, ChronoUnit.DAYS); AvailableCandidate candidate = candidateWithCreatedAt(savedAt); double weight = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); - assertThat(weight).isBetween(1.0, 1.5); + assertThat(weight).isBetween(1.0, 1.2); } } @Test - void popular_noLink_returnsOne() { + void popularNoLinkReturnsOne() { AvailableCandidate candidate = candidateNoLink(); double score = scorer.score(candidate, null, CourseMode.POPULAR, noCtx(), Instant.now()); assertThat(score).isCloseTo(1.0, within(0.001)); } @Test - void popular_maxLikeCount_returnsOnePoint8() { + void popularMaxLikeCountReturnsOnePoint8() { NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.INSTAGRAM, 1000L)); AvailableCandidate candidate = candidateWithLikeCount(1000L, LinkSourceType.INSTAGRAM); double score = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); - // 1.0 + 0.8 * (1000/1000) = 1.8 - assertThat(score).isCloseTo(1.8, within(0.001)); + // modeWeight = 1.0 + 0.8*(1000/1000) = 1.8 → score = 0.6*1.0 + 0.4*1.8 = 1.32 + assertThat(score).isCloseTo(1.32, within(0.001)); } @Test - void popular_maxIsZero_returnsOne() { + void popularMaxIsZeroReturnsOne() { NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.INSTAGRAM, 0L)); AvailableCandidate candidate = candidateWithLikeCount(0L, LinkSourceType.INSTAGRAM); double score = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); @@ -101,12 +101,12 @@ void popular_maxIsZero_returnsOne() { } @Test - void popular_weightRange_between1and1point8() { + void popularWeightRangeBetween1and1point8() { NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.YOUTUBE, 500L)); for (long likes : new long[]{0, 100, 250, 500}) { AvailableCandidate candidate = candidateWithLikeCount(likes, LinkSourceType.YOUTUBE); double weight = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); - assertThat(weight).isBetween(1.0, 1.8); + assertThat(weight).isBetween(1.0, 1.32); } } diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java index 9785a77..81ba5e2 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java @@ -25,7 +25,7 @@ class CourseSelectorTest { private final CourseSelector selector = new CourseSelector(scorer); @Test - void firstSlot_noPrevious_distanceIgnored() { + void firstSlotNoPreviousDistanceIgnored() { AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); @@ -38,7 +38,7 @@ void firstSlot_noPrevious_distanceIgnored() { } @Test - void noMatchingCandidate_slotIsSkipped() { + void noMatchingCandidateSlotIsSkipped() { AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); // slot asks for CAFE but pool only has FOOD @@ -52,7 +52,7 @@ void noMatchingCandidate_slotIsSkipped() { } @Test - void wildcardSlot_matchesAllTagsInCategory() { + void wildcardSlotMatchesAllTagsInCategory() { AvailableCandidate cafe1 = candidate(1L, "CAFE", "BAKERY", 37.0, 127.0, Instant.now()); AvailableCandidate cafe2 = candidate(2L, "CAFE", "DESSERT", 37.01, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(cafe1, cafe2)); @@ -69,7 +69,7 @@ void wildcardSlot_matchesAllTagsInCategory() { } @Test - void globallyUsedIds_preventCrossCourseDuplication() { + void globallyUsedIdsPreventCrossCourseDuplication() { AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); @@ -87,7 +87,7 @@ void globallyUsedIds_preventCrossCourseDuplication() { } @Test - void sameCourseDuplication_prevented() { + void sameCourseDuplicationPrevented() { AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); // Two identical slots — only one candidate available @@ -104,7 +104,7 @@ void sameCourseDuplication_prevented() { } @Test - void popular_candidateWithoutLink_excluded() { + void popularCandidateWithoutLinkExcluded() { AvailableCandidate withLink = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailableCandidate noLink = candidateNoLink(2L, "FOOD", "KOREAN"); diff --git a/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java index 92c906b..1cde9e4 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java @@ -9,7 +9,7 @@ class HaversineTest { @Test - void samePoint_returnsZero() { + void samePointReturnsZero() { double km = Haversine.km( BigDecimal.valueOf(37.5665), BigDecimal.valueOf(126.9780), @@ -20,7 +20,7 @@ void samePoint_returnsZero() { } @Test - void approxOneKm_betweenNearbyPoints() { + void approxOneKmBetweenNearbyPoints() { // Seoul City Hall vs ~1km north double km = Haversine.km( BigDecimal.valueOf(37.5665), @@ -32,7 +32,7 @@ void approxOneKm_betweenNearbyPoints() { } @Test - void approxHundredKm_betweenSeoulAndSuwon() { + void approxHundredKmBetweenSeoulAndSuwon() { // Seoul to Suwon (~45 km by Haversine) double km = Haversine.km( BigDecimal.valueOf(37.5665), @@ -44,7 +44,7 @@ void approxHundredKm_betweenSeoulAndSuwon() { } @Test - void antipode_isApproxHalfEarthCircumference() { + void antipodeIsApproxHalfEarthCircumference() { // Seoul antipode is roughly in South Atlantic double km = Haversine.km( BigDecimal.valueOf(37.5665), From e9dd74987bf36fb194f306cb4708e170a3dc3e16 Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Fri, 29 May 2026 18:43:00 +0900 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20CourseScorerTest=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=80=EB=8F=99?= =?UTF-8?q?=EC=86=8C=EC=88=98=EC=A0=90=20=EC=98=A4=EC=B0=A8=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=EB=B2=94=EC=9C=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.4 × 1.5, 0.4 × 1.8 연산의 IEEE 754 반올림으로 이론 최댓값(1.2, 1.32)을 아주 미세하게 초과할 수 있어 isBetween 상한선을 각각 1.21, 1.33으로 조정 Co-Authored-By: Claude Sonnet 4.6 --- .../capstone/backend/course/application/CourseScorerTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java index 9740f8f..14b536d 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java @@ -72,7 +72,7 @@ void trendyWeightRangeBetween1and1point5() { Instant savedAt = Instant.now().minus(days, ChronoUnit.DAYS); AvailableCandidate candidate = candidateWithCreatedAt(savedAt); double weight = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); - assertThat(weight).isBetween(1.0, 1.2); + assertThat(weight).isBetween(1.0, 1.21); } } @@ -106,7 +106,7 @@ void popularWeightRangeBetween1and1point8() { for (long likes : new long[]{0, 100, 250, 500}) { AvailableCandidate candidate = candidateWithLikeCount(likes, LinkSourceType.YOUTUBE); double weight = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); - assertThat(weight).isBetween(1.0, 1.32); + assertThat(weight).isBetween(1.0, 1.33); } } From 5c354bc1680d296779ab3847bd7fd422fe005278 Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Mon, 1 Jun 2026 22:51:02 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20seed-local.sql=20PowerShell=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=ED=99=80=EB=8D=94=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker exec < 리다이렉트 방식은 PowerShell에서 동작하지 않아 Get-Content 파이프 방식으로 수정 - v_room_pid 플레이스홀더를 'INPUT ROOM_ID HERE'에서 'YOUR-ROOM-PUBLIC-ID'로 변경해 검증 조건과 일치시킴 Co-Authored-By: Claude Sonnet 4.6 --- scripts/seed-local.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/seed-local.sql b/scripts/seed-local.sql index fdfdb38..d3d44df 100644 --- a/scripts/seed-local.sql +++ b/scripts/seed-local.sql @@ -7,8 +7,8 @@ -- 2. Swagger: GET /api/v1/auth/dev/master-token → userId 확인 -- 3. Swagger: POST /api/v1/rooms → publicId 확인 -- 4. 아래 v_user_id, v_room_pid 값 수정 후 저장 --- 5. 터미널에서 실행: --- docker exec -i udidura-postgres psql -U udidura -d udidura < scripts/seed-local.sql +-- 5. 터미널에서 실행 (PowerShell): +-- Get-Content scripts/seed-local.sql | docker exec -i udidura-postgres psql -U udidura -d udidura -- -- 멱등 스크립트: ON CONFLICT DO NOTHING 사용, 중복 실행 안전 -- Docker 재시작 후에도 named volume(udidura_pg_data)이 데이터를 유지함 @@ -18,7 +18,7 @@ DO $$ DECLARE -- ★★★ 아래 두 값을 반드시 수정하세요 ★★★ v_user_id BIGINT := 0; -- ① GET /api/v1/auth/dev/master-token 응답의 userId - v_room_pid TEXT := 'INPUT ROOM_ID HERE'; -- ② POST /api/v1/rooms 응답의 publicId + v_room_pid TEXT := 'YOUR-ROOM-PUBLIC-ID'; -- ② POST /api/v1/rooms 응답의 publicId -- ★★★★★★★★★★★★★★★★★★★★★★★★★★ v_room_id BIGINT; From 29dc88a21f690f593ac76c934574bf65f4dbd649 Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Tue, 2 Jun 2026 16:25:04 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20Copilot=20AI=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94,?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=B2=98=EB=A6=AC,=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot pull-request-reviewer 리뷰(#4402047490) 지적 사항을 반영한다. 변경 사항: - DateCourseGenerationRequest: categorySequence 리스트 요소에 @NotNull 추가 (`List<@NotNull @Valid CategorySlotRequest>`) — null 요소 입력 시 NPE 방지 - DateCourse: sigungu_code 컬럼에 nullable = false 적용 및 create() 팩토리 메서드에 sigunguCode null/blank 검증 추가 — DB/도메인 무결성 강화 - DateCourseRepository: markAsSavedIfAbsent() JPQL 조건부 UPDATE 추가 (`WHERE saved_by_user_id IS NULL`) — 동시 저장 요청 시 레이스 컨디션 방지 - DateCourseSaveService: check-then-act 패턴을 원자적 쿼리로 교체 - CourseScorerTest: trendyWeightRange → trendyScoreRange, popularWeightRange → popularScoreRange 로 메서드명/변수명 수정 — score() 검증 의도 명확화 - seed-local.sql: POPULAR 코스 테스트용 mock Instagram likeCount 데이터 추가 (카테고리별 고/저 likeCount 링크 6개 + room_places.origin_room_link_id 업데이트) Co-Authored-By: Claude Sonnet 4.6 --- scripts/seed-local.sql | 82 ++++++++++++++++++- .../request/DateCourseGenerationRequest.java | 2 +- .../application/DateCourseSaveService.java | 6 +- .../course/domain/entity/DateCourse.java | 5 +- .../repository/DateCourseRepository.java | 12 +++ .../course/application/CourseScorerTest.java | 12 +-- 6 files changed, 106 insertions(+), 13 deletions(-) diff --git a/scripts/seed-local.sql b/scripts/seed-local.sql index d3d44df..4675c5d 100644 --- a/scripts/seed-local.sql +++ b/scripts/seed-local.sql @@ -45,6 +45,14 @@ DECLARE v_bh_expires TIMESTAMPTZ := '2027-12-31 00:00:00+00'; v_bh_json TEXT; + -- POPULAR 테스트용 link/room_link ID + v_rl_food_high BIGINT; + v_rl_food_low BIGINT; + v_rl_cafe_high BIGINT; + v_rl_cafe_low BIGINT; + v_rl_act_high BIGINT; + v_rl_act_low BIGINT; + BEGIN -- 입력 검증 IF v_user_id = 0 THEN @@ -259,5 +267,77 @@ BEGIN ) ON CONFLICT (kakao_place_id) DO NOTHING; - RAISE NOTICE '[seed] 완료 — 장소 11개, 영업시간 11개, room_places 삽입됨 (room: %)', v_room_pid; + -- ========================================================================= + -- POPULAR 코스 테스트용: links + room_links + origin_room_link_id 업데이트 + -- + -- 각 카테고리별 장소 2개에 Instagram mock likeCount를 부여한다. + -- FOOD: mock_food_001 → 1000 likes (POPULAR 당첨 예상) + -- mock_food_002 → 100 likes + -- CAFE: mock_cafe_001 → 900 likes (POPULAR 당첨 예상) + -- mock_cafe_002 → 50 likes + -- ACTIVITY: mock_act_001 → 800 likes (POPULAR 당첨 예상) + -- mock_act_002 → 30 likes + -- ========================================================================= + + -- links 삽입 + INSERT INTO links ( + original_url, normalized_url, + link_source_type, dispatch_status, status, + like_count, version, created_at, updated_at + ) VALUES + ('https://www.instagram.com/p/mock_food_high/', 'https://www.instagram.com/p/mock_food_high/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 1000, 0, v_now, v_now), + ('https://www.instagram.com/p/mock_food_low/', 'https://www.instagram.com/p/mock_food_low/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 100, 0, v_now, v_now), + ('https://www.instagram.com/p/mock_cafe_high/', 'https://www.instagram.com/p/mock_cafe_high/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 900, 0, v_now, v_now), + ('https://www.instagram.com/p/mock_cafe_low/', 'https://www.instagram.com/p/mock_cafe_low/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 50, 0, v_now, v_now), + ('https://www.instagram.com/p/mock_act_high/', 'https://www.instagram.com/p/mock_act_high/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 800, 0, v_now, v_now), + ('https://www.instagram.com/p/mock_act_low/', 'https://www.instagram.com/p/mock_act_low/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 30, 0, v_now, v_now) + ON CONFLICT (normalized_url) DO NOTHING; + + -- room_links 삽입 및 ID 조회 + INSERT INTO room_links (room_id, link_id, created_at, updated_at) + SELECT v_room_id, id, v_now, v_now FROM links + WHERE normalized_url IN ( + 'https://www.instagram.com/p/mock_food_high/', + 'https://www.instagram.com/p/mock_food_low/', + 'https://www.instagram.com/p/mock_cafe_high/', + 'https://www.instagram.com/p/mock_cafe_low/', + 'https://www.instagram.com/p/mock_act_high/', + 'https://www.instagram.com/p/mock_act_low/' + ) + ON CONFLICT (room_id, link_id) DO NOTHING; + + SELECT rl.id INTO v_rl_food_high FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_food_high/'; + SELECT rl.id INTO v_rl_food_low FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_food_low/'; + SELECT rl.id INTO v_rl_cafe_high FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_cafe_high/'; + SELECT rl.id INTO v_rl_cafe_low FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_cafe_low/'; + SELECT rl.id INTO v_rl_act_high FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_act_high/'; + SELECT rl.id INTO v_rl_act_low FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/mock_act_low/'; + + -- room_places.origin_room_link_id 업데이트 + UPDATE room_places SET origin_room_link_id = v_rl_food_high + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_food_001'); + UPDATE room_places SET origin_room_link_id = v_rl_food_low + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_food_002'); + UPDATE room_places SET origin_room_link_id = v_rl_cafe_high + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_cafe_001'); + UPDATE room_places SET origin_room_link_id = v_rl_cafe_low + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_cafe_002'); + UPDATE room_places SET origin_room_link_id = v_rl_act_high + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_act_001'); + UPDATE room_places SET origin_room_link_id = v_rl_act_low + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'mock_act_002'); + + RAISE NOTICE '[seed] 완료 — 장소 11개, 영업시간 11개, room_places 삽입, links 6개(POPULAR용) 추가됨 (room: %)', v_room_pid; END $$; diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java index 5a2c6d6..91eebba 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java @@ -10,7 +10,7 @@ import java.util.List; public record DateCourseGenerationRequest( - @NotEmpty @Size(max = 5) @Valid List categorySequence, + @NotEmpty @Size(max = 5) List<@NotNull @Valid CategorySlotRequest> categorySequence, @NotNull Instant plannedDateTime, @NotBlank String sigunguCode ) { diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java index ec0f6ba..b773b16 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -6,6 +6,7 @@ import com.hufs.capstone.backend.global.exception.ErrorCode; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; +import java.time.Instant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,10 +25,9 @@ public void save(String roomPublicId, String coursePublicId, Long userId) { DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); - if (course.getSavedByUserId() != null) { + int updated = dateCourseRepository.markAsSavedIfAbsent(course.getId(), userId, Instant.now()); + if (updated == 0) { throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 코스입니다."); } - - course.markAsSaved(userId); } } \ No newline at end of file diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java index 63c938c..bda46bc 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -55,7 +55,7 @@ public class DateCourse extends AuditableEntity { @Column(name = "generation_batch_id", nullable = false, length = 36) private String generationBatchId; - @Column(name = "sigungu_code", length = 5) + @Column(name = "sigungu_code", nullable = false, length = 5) private String sigunguCode; @Column(name = "category_sequence_json", columnDefinition = "text") @@ -104,7 +104,8 @@ public static DateCourse create( String skippedSlotIndicesJson ) { if (publicId == null || room == null || createdByUserId == null || courseMode == null - || plannedDateTime == null || generationBatchId == null) { + || plannedDateTime == null || generationBatchId == null + || sigunguCode == null || sigunguCode.isBlank()) { throw new IllegalArgumentException("DateCourse required values are missing."); } return new DateCourse(publicId, room, createdByUserId, courseMode, plannedDateTime, diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index d8a1ef3..a4e3e67 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -1,11 +1,13 @@ package com.hufs.capstone.backend.course.domain.repository; import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import java.time.Instant; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,6 +15,16 @@ public interface DateCourseRepository extends JpaRepository { Optional findByPublicId(String publicId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE DateCourse dc + SET dc.savedByUserId = :userId, dc.savedAt = :savedAt + WHERE dc.id = :id AND dc.savedByUserId IS NULL + """) + int markAsSavedIfAbsent(@Param("id") Long id, + @Param("userId") Long userId, + @Param("savedAt") Instant savedAt); + Optional findByPublicIdAndRoomId(String publicId, Long roomId); @Query(""" diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java index 14b536d..0f7b354 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseScorerTest.java @@ -67,12 +67,12 @@ void trendyDaysSinceFarApproachesOne() { } @Test - void trendyWeightRangeBetween1and1point5() { + void trendyScoreRangeBetween1and1point2() { for (int days : new int[]{0, 7, 30, 90, 365}) { Instant savedAt = Instant.now().minus(days, ChronoUnit.DAYS); AvailableCandidate candidate = candidateWithCreatedAt(savedAt); - double weight = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); - assertThat(weight).isBetween(1.0, 1.21); + double score = scorer.score(candidate, null, CourseMode.TRENDY, noCtx(), Instant.now()); + assertThat(score).isBetween(1.0, 1.21); } } @@ -101,12 +101,12 @@ void popularMaxIsZeroReturnsOne() { } @Test - void popularWeightRangeBetween1and1point8() { + void popularScoreRangeBetween1and1point33() { NormalizationContext ctx = new NormalizationContext(Map.of(LinkSourceType.YOUTUBE, 500L)); for (long likes : new long[]{0, 100, 250, 500}) { AvailableCandidate candidate = candidateWithLikeCount(likes, LinkSourceType.YOUTUBE); - double weight = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); - assertThat(weight).isBetween(1.0, 1.33); + double score = scorer.score(candidate, null, CourseMode.POPULAR, ctx, Instant.now()); + assertThat(score).isBetween(1.0, 1.33); } } From 17c7d4d8332c3c2ae0fceb6a11c28282d723c3fc Mon Sep 17 00:00:00 2001 From: Heonseop Ha Date: Tue, 2 Jun 2026 17:00:40 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20Copilot=20AI=202=EC=B0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B0=95=ED=99=94,=20=EC=BD=94=EB=93=9C=20=ED=92=88?= =?UTF-8?q?=EC=A7=88=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot pull-request-reviewer 2차 리뷰 지적 사항을 반영한다. 변경 사항: - HaversineTest: approxHundredKmBetweenSeoulAndSuwon → approxFortyFiveKmBetweenSeoulAndSuwon (메서드명이 실제 검증 거리 ~45km와 불일치하여 수정) - DateCoursePlace: create() 팩토리 메서드에 sequenceOrder >= 0 검사 추가 (정렬 키로 사용되는 필드의 도메인 불변식 명시) - CourseSelector: 선택 루프 후 O(n·m) pool 재조립 → O(n) 직접 구성으로 단순화 (List.contains/indexOf 대신 선택 시점에 pickedPlaces 직접 누적, 중복 방지용 picked Set 별도 유지) - BusinessHoursAtTimeChecker: java.util.Set FQN → import java.util.Set 추가 후 단순명 사용 (파일 내 다른 타입과 스타일 통일) Co-Authored-By: Claude Sonnet 4.6 --- .../application/BusinessHoursAtTimeChecker.java | 3 ++- .../course/application/CourseSelector.java | 16 ++++++---------- .../course/domain/entity/DateCoursePlace.java | 3 +++ .../course/application/HaversineTest.java | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java index 0760e7e..a9690a0 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java @@ -5,6 +5,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,7 +15,7 @@ public class BusinessHoursAtTimeChecker { private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); - private static final java.util.Set OPEN_STATUSES = java.util.Set.of( + private static final Set OPEN_STATUSES = Set.of( BusinessStatus.OPEN, BusinessStatus.OPEN_24_HOURS, BusinessStatus.CLOSING_SOON diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java index 0e4841e..d6682a6 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java @@ -35,7 +35,8 @@ CourseSelectionResult select( Instant plannedDateTime ) { NormalizationContext ctx = buildNormalizationContext(pool, mode); - List picked = new ArrayList<>(); + List pickedPlaces = new ArrayList<>(); + Set pickedIds = new HashSet<>(); List skipped = new ArrayList<>(); AvailableCandidate prev = null; @@ -44,7 +45,7 @@ CourseSelectionResult select( final AvailableCandidate prevForLambda = prev; Optional best = pool.forSlot(slot).stream() .filter(c -> !globallyUsedIds.contains(c.roomPlace().getId())) - .filter(c -> !picked.contains(c.roomPlace().getId())) + .filter(c -> !pickedIds.contains(c.roomPlace().getId())) .filter(c -> mode != CourseMode.POPULAR || c.roomPlace().getOriginRoomLink() != null) .max(Comparator.comparingDouble( c -> scorer.score(c, prevForLambda, mode, ctx, plannedDateTime) @@ -56,17 +57,12 @@ CourseSelectionResult select( } AvailableCandidate chosen = best.get(); - picked.add(chosen.roomPlace().getId()); + pickedPlaces.add(chosen.roomPlace()); + pickedIds.add(chosen.roomPlace().getId()); prev = chosen; } - List pickedPlaces = pool.all().stream() - .filter(c -> picked.contains(c.roomPlace().getId())) - .sorted(Comparator.comparingInt(c -> picked.indexOf(c.roomPlace().getId()))) - .map(AvailableCandidate::roomPlace) - .toList(); - - globallyUsedIds.addAll(picked); + globallyUsedIds.addAll(pickedIds); return new CourseSelectionResult(pickedPlaces, skipped); } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java index 80dcc81..5783a2c 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java @@ -52,6 +52,9 @@ public static DateCoursePlace create(DateCourse dateCourse, RoomPlace roomPlace, if (dateCourse == null || roomPlace == null || sequenceOrder == null) { throw new IllegalArgumentException("DateCoursePlace required values are missing."); } + if (sequenceOrder < 0) { + throw new IllegalArgumentException("DateCoursePlace required values are missing."); + } return new DateCoursePlace(dateCourse, roomPlace, sequenceOrder); } } diff --git a/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java index 1cde9e4..555c2af 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/HaversineTest.java @@ -32,7 +32,7 @@ void approxOneKmBetweenNearbyPoints() { } @Test - void approxHundredKmBetweenSeoulAndSuwon() { + void approxFortyFiveKmBetweenSeoulAndSuwon() { // Seoul to Suwon (~45 km by Haversine) double km = Haversine.km( BigDecimal.valueOf(37.5665), From 3dacb3e1e25743bf21abe7b0d7d2f224ea95f20b Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 17:22:42 +0900 Subject: [PATCH 07/14] chore: merge conflict resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원격 브랜치 기준으로 히스토리를 정리하고, 로컬 merge 시 반영한 seed/코스 저장 변경만 유지한다. Co-authored-by: Cursor --- scripts/seed-local.sql | 5 +++-- .../backend/course/application/DateCourseSaveService.java | 2 +- .../course/domain/repository/DateCourseRepository.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/seed-local.sql b/scripts/seed-local.sql index 4675c5d..c64d10f 100644 --- a/scripts/seed-local.sql +++ b/scripts/seed-local.sql @@ -7,8 +7,9 @@ -- 2. Swagger: GET /api/v1/auth/dev/master-token → userId 확인 -- 3. Swagger: POST /api/v1/rooms → publicId 확인 -- 4. 아래 v_user_id, v_room_pid 값 수정 후 저장 --- 5. 터미널에서 실행 (PowerShell): --- Get-Content scripts/seed-local.sql | docker exec -i udidura-postgres psql -U udidura -d udidura +-- 5. 터미널에서 실행: +-- bash: docker exec -i udidura-postgres psql -U udidura -d udidura < scripts/seed-local.sql +-- PowerShell: Get-Content scripts/seed-local.sql | docker exec -i udidura-postgres psql -U udidura -d udidura -- -- 멱등 스크립트: ON CONFLICT DO NOTHING 사용, 중복 실행 안전 -- Docker 재시작 후에도 named volume(udidura_pg_data)이 데이터를 유지함 diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java index b773b16..b390ad7 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -30,4 +30,4 @@ public void save(String roomPublicId, String coursePublicId, Long userId) { throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 코스입니다."); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index a4e3e67..9bbb4ea 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -51,4 +51,4 @@ int markAsSavedIfAbsent(@Param("id") Long id, ORDER BY dc.createdAt DESC """) List findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId); -} \ No newline at end of file +} From 29db3e0f378e0959f7c1ae899507b56cca203844 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 20:48:07 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20API=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/error-response.md | 87 ++++++++++++++++ docs/rest-api.md | 2 +- .../auth/api/controller/AuthController.java | 7 -- .../auth/api/request/LogoutRequest.java | 4 +- .../api/request/MobileExchangeRequest.java | 4 +- .../auth/api/request/RefreshRequest.java | 4 +- .../api/request/WebExchangeTicketRequest.java | 2 +- .../application/service/impl/PkceService.java | 6 +- .../security/RestAccessDeniedHandler.java | 2 +- .../RestAuthenticationEntryPoint.java | 2 +- .../api/request/CategorySlotRequest.java | 2 +- .../request/DateCourseGenerationRequest.java | 12 ++- .../application/DateCourseInputValidator.java | 51 ++++++++-- .../application/DateCourseQueryService.java | 28 +++--- .../backend/global/exception/ErrorCode.java | 29 +++--- .../exception/FieldValidationException.java | 32 ++++++ .../exception/GlobalExceptionHandler.java | 56 ++++++++++- .../global/response/FieldErrorDetail.java | 11 +++ .../request/CreateLinkAnalysisRequest.java | 4 +- .../request/OverrideLinkCandidateRequest.java | 34 +++---- .../request/SaveManualRoomPlaceRequest.java | 34 +++---- .../api/request/SaveRoomPlacesRequest.java | 7 +- .../LinkAnalysisAuthorizationService.java | 6 +- .../LinkAnalysisRequestService.java | 3 +- .../LinkAnalysisRequestWriteService.java | 24 ++--- .../LinkAnalysisStatusWriteService.java | 6 +- .../link/application/LinkUrlNormalizer.java | 15 ++- .../RoomLinkCandidateOverrideService.java | 5 +- .../application/RoomPlaceCommandService.java | 2 +- .../RoomPlaceCommandWriteService.java | 15 +-- .../request/UpdateRoomPlaceMemoRequest.java | 2 +- .../application/PlaceTaxonomyResolver.java | 6 +- .../RoomPlaceManagementService.java | 2 +- .../application/RoomPlaceQueryService.java | 11 ++- .../application/RoomPlaceStorageService.java | 8 +- .../ExternalPlaceCandidateSearchQuery.java | 7 +- .../impl/RegionQueryServiceImpl.java | 17 ++-- .../room/api/request/CreateRoomRequest.java | 10 +- .../room/api/request/JoinRoomRequest.java | 4 +- .../api/request/UpdateRoomNameRequest.java | 10 +- .../api/request/UpdateRoomPinRequest.java | 2 +- .../impl/RoomCommandServiceImpl.java | 5 +- .../backend/room/domain/RoomNamePolicy.java | 7 +- .../DateCourseInputValidatorTest.java | 99 +++++++++++++++++++ .../exception/GlobalExceptionHandlerTest.java | 52 ++++++++++ .../LinkAnalysisRequestServiceTest.java | 9 +- .../LinkConcurrencyIntegrationTest.java | 17 +++- ...oomPlaceCommandServiceIntegrationTest.java | 26 +++-- .../api/RegionControllerIntegrationTest.java | 6 +- .../RoomNameRequestValidationTest.java | 56 +++++++++++ .../application/RoomCommandServiceTest.java | 29 ++++-- .../application/RoomQueryServiceTest.java | 2 +- ...mpleteOnboardingRequestValidationTest.java | 24 ++++- .../UpdateNicknameRequestValidationTest.java | 12 ++- 54 files changed, 713 insertions(+), 206 deletions(-) create mode 100644 docs/error-response.md create mode 100644 src/main/java/com/hufs/capstone/backend/global/exception/FieldValidationException.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/room/api/request/RoomNameRequestValidationTest.java diff --git a/docs/error-response.md b/docs/error-response.md new file mode 100644 index 0000000..a2672e7 --- /dev/null +++ b/docs/error-response.md @@ -0,0 +1,87 @@ +# 에러 응답 계약 + +백엔드 오류 응답은 RFC 7807 `ProblemDetail` 형식을 사용한다. + +## 기본 구조 + +```json +{ + "title": "E400_VALIDATION", + "status": 400, + "detail": "입력값을 확인해 주세요.", + "instance": "/api/v1/rooms", + "code": "E400_VALIDATION", + "timestamp": "2026-06-03T00:00:00Z", + "fieldErrors": [ + { + "field": "name", + "message": "방 이름은 필수입니다.", + "rejectedValue": null + } + ] +} +``` + +`code`는 백엔드 `ErrorCode` 이름과 동일하다. `fieldErrors`는 입력 필드 오류가 있을 때만 포함된다. + +## 프론트 표시 기준 + +입력 필드 오류는 `fieldErrors`를 사용한다. + +- `fieldErrors`가 있으면 각 `field`에 해당하는 input 아래에 `message`를 표시한다. +- 이때 `detail`은 `"입력값을 확인해 주세요."` 같은 일반 안내 문구이며, 필드별 실제 문구는 `fieldErrors[].message`를 우선한다. +- Request DTO의 Bean Validation 메시지는 한국어로 내려온다. + +일반 오류는 `detail`을 사용한다. + +- `fieldErrors`가 없으면 `detail`을 toast, alert, modal 등 일반 오류 UI에 표시한다. +- 리소스 없음, 권한 없음, 중복 상태, 외부 API 실패, 서버 오류는 `detail` 중심으로 내려온다. + +## `fieldErrors`로 내려가는 오류 + +- `@Valid` Request DTO 검증 실패 +- 사용자 입력 필드의 필수값 누락, blank, 길이 초과, 숫자 범위 오류 +- enum, 날짜/시간, URL, path/query parameter 형식 오류 +- 시작 일시가 종료 일시보다 늦거나 같은 경우 +- 방 이름, 닉네임, 카테고리, 지역 코드, 장소 ID 목록 등 특정 input에 귀속 가능한 정책 위반 + +## `detail`로 내려가는 오류 + +- 존재하지 않는 리소스 +- 인증 필요, 권한 없음, 접근 불가 +- 이미 참여한 방, 이미 저장된 코스, 중복 생성 불가 +- 현재 상태상 수행할 수 없는 요청 +- 외부 API 실패 +- 서버 내부 오류 +- 특정 input 하나에 귀속하기 어려운 비즈니스 오류 + +## 예시 + +필드 오류: + +```json +{ + "title": "E400_VALIDATION", + "status": 400, + "detail": "입력값을 확인해 주세요.", + "code": "E400_VALIDATION", + "fieldErrors": [ + { + "field": "nickname", + "message": "닉네임은 최대 10자까지 가능합니다.", + "rejectedValue": "12345678901" + } + ] +} +``` + +비즈니스 오류: + +```json +{ + "title": "E409_CONFLICT", + "status": 409, + "detail": "이미 참여한 방입니다.", + "code": "E409_CONFLICT" +} +``` diff --git a/docs/rest-api.md b/docs/rest-api.md index fe7861f..50c34a2 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -19,7 +19,7 @@ - 리소스는 **복수형 명사** 위주로 표현한다 (`/api/v1/users`, `/api/v1/courses`). - HTTP 메서드: 조회 `GET`, 생성 `POST`, 수정 `PUT` 또는 `PATCH`, 삭제 `DELETE`. -- 응답·에러 형식은 애플리케이션 전역 규칙(`ProblemDetail`, 공통 응답 래퍼 등)을 따른다. +- 응답·에러 형식은 애플리케이션 전역 규칙(`ProblemDetail`, 공통 응답 래퍼 등)을 따른다. 에러 응답 계약은 [`error-response.md`](./error-response.md)를 따른다. ## 현재 엔드포인트 예시 diff --git a/src/main/java/com/hufs/capstone/backend/auth/api/controller/AuthController.java b/src/main/java/com/hufs/capstone/backend/auth/api/controller/AuthController.java index f605b52..fb3ecb5 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/api/controller/AuthController.java +++ b/src/main/java/com/hufs/capstone/backend/auth/api/controller/AuthController.java @@ -33,7 +33,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.security.web.csrf.CsrfTokenRepository; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @@ -101,9 +100,6 @@ public CommonResponse mobileRefresh( HttpServletRequest servletRequest, @RequestHeader(name = "X-Client-Platform", required = false) String clientPlatform ) { - if (!StringUtils.hasText(request.refreshToken())) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "리프레시 토큰이 필요합니다."); - } TokenPair rotated = tokenLifecycleService.rotate( request.refreshToken(), authLoginService.createAppClientContext(servletRequest.getHeader("User-Agent"), servletRequest.getRemoteAddr(), clientPlatform) @@ -136,9 +132,6 @@ public ResponseEntity logout( @Override public CommonResponse mobileLogout(@Valid @RequestBody LogoutRequest request) { - if (!StringUtils.hasText(request.refreshToken())) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "리프레시 토큰이 필요합니다."); - } tokenLifecycleService.revokeByRawToken(request.refreshToken(), RevokeReason.LOGOUT); return CommonResponse.okMessage("로그아웃되었습니다."); } diff --git a/src/main/java/com/hufs/capstone/backend/auth/api/request/LogoutRequest.java b/src/main/java/com/hufs/capstone/backend/auth/api/request/LogoutRequest.java index a0763f1..5bae350 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/api/request/LogoutRequest.java +++ b/src/main/java/com/hufs/capstone/backend/auth/api/request/LogoutRequest.java @@ -1,6 +1,8 @@ package com.hufs.capstone.backend.auth.api.request; +import jakarta.validation.constraints.NotBlank; + public record LogoutRequest( - String refreshToken + @NotBlank(message = "리프레시 토큰은 필수입니다.") String refreshToken ) { } diff --git a/src/main/java/com/hufs/capstone/backend/auth/api/request/MobileExchangeRequest.java b/src/main/java/com/hufs/capstone/backend/auth/api/request/MobileExchangeRequest.java index c99692a..7d71563 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/api/request/MobileExchangeRequest.java +++ b/src/main/java/com/hufs/capstone/backend/auth/api/request/MobileExchangeRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.NotBlank; public record MobileExchangeRequest( - @NotBlank String code, - @NotBlank String codeVerifier + @NotBlank(message = "모바일 인증 코드는 필수입니다.") String code, + @NotBlank(message = "코드 검증값(verifier)은 필수입니다.") String codeVerifier ) { } diff --git a/src/main/java/com/hufs/capstone/backend/auth/api/request/RefreshRequest.java b/src/main/java/com/hufs/capstone/backend/auth/api/request/RefreshRequest.java index 953b643..e6cdd08 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/api/request/RefreshRequest.java +++ b/src/main/java/com/hufs/capstone/backend/auth/api/request/RefreshRequest.java @@ -1,6 +1,8 @@ package com.hufs.capstone.backend.auth.api.request; +import jakarta.validation.constraints.NotBlank; + public record RefreshRequest( - String refreshToken + @NotBlank(message = "리프레시 토큰은 필수입니다.") String refreshToken ) { } diff --git a/src/main/java/com/hufs/capstone/backend/auth/api/request/WebExchangeTicketRequest.java b/src/main/java/com/hufs/capstone/backend/auth/api/request/WebExchangeTicketRequest.java index a4228aa..439bd60 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/api/request/WebExchangeTicketRequest.java +++ b/src/main/java/com/hufs/capstone/backend/auth/api/request/WebExchangeTicketRequest.java @@ -3,6 +3,6 @@ import jakarta.validation.constraints.NotBlank; public record WebExchangeTicketRequest( - @NotBlank String ticket + @NotBlank(message = "로그인 티켓은 필수입니다.") String ticket ) { } diff --git a/src/main/java/com/hufs/capstone/backend/auth/application/service/impl/PkceService.java b/src/main/java/com/hufs/capstone/backend/auth/application/service/impl/PkceService.java index e4fd690..6c18037 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/application/service/impl/PkceService.java +++ b/src/main/java/com/hufs/capstone/backend/auth/application/service/impl/PkceService.java @@ -14,13 +14,13 @@ public class PkceService { public void verify(String codeChallenge, String method, String codeVerifier) { if (!StringUtils.hasText(codeChallenge)) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "code challenge 값이 필요합니다."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "코드 챌린지는 필수입니다."); } if (!StringUtils.hasText(codeVerifier)) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "code verifier 값이 필요합니다."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "코드 검증값(verifier)은 필수입니다."); } if (!"S256".equalsIgnoreCase(method)) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "code challenge method는 S256만 지원합니다."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "코드 챌린지 방식은 S256만 지원합니다."); } String computed = s256(codeVerifier); if (!computed.equals(codeChallenge)) { diff --git a/src/main/java/com/hufs/capstone/backend/auth/security/RestAccessDeniedHandler.java b/src/main/java/com/hufs/capstone/backend/auth/security/RestAccessDeniedHandler.java index 0194ee1..2fb0dcc 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/security/RestAccessDeniedHandler.java +++ b/src/main/java/com/hufs/capstone/backend/auth/security/RestAccessDeniedHandler.java @@ -29,7 +29,7 @@ public void handle( ) throws IOException, ServletException { ProblemDetail detail = ProblemDetailFactory.create( ErrorCode.E403_FORBIDDEN, - accessDeniedException.getMessage(), + ErrorCode.E403_FORBIDDEN.getDefaultMessage(), null, URI.create(request.getRequestURI()) ); diff --git a/src/main/java/com/hufs/capstone/backend/auth/security/RestAuthenticationEntryPoint.java b/src/main/java/com/hufs/capstone/backend/auth/security/RestAuthenticationEntryPoint.java index 4218fb5..d0b8fdd 100644 --- a/src/main/java/com/hufs/capstone/backend/auth/security/RestAuthenticationEntryPoint.java +++ b/src/main/java/com/hufs/capstone/backend/auth/security/RestAuthenticationEntryPoint.java @@ -29,7 +29,7 @@ public void commence( ) throws IOException, ServletException { ProblemDetail detail = ProblemDetailFactory.create( ErrorCode.E401_UNAUTHORIZED, - authException.getMessage(), + ErrorCode.E401_UNAUTHORIZED.getDefaultMessage(), null, URI.create(request.getRequestURI()) ); diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java index f6bc657..1636783 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/CategorySlotRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record CategorySlotRequest( - @NotBlank String categoryCode, + @NotBlank(message = "카테고리 코드는 필수입니다.") String categoryCode, String tagCode ) { diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java index 91eebba..3f2cb1c 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseGenerationRequest.java @@ -10,16 +10,20 @@ import java.util.List; public record DateCourseGenerationRequest( - @NotEmpty @Size(max = 5) List<@NotNull @Valid CategorySlotRequest> categorySequence, - @NotNull Instant plannedDateTime, - @NotBlank String sigunguCode + @NotEmpty(message = "카테고리 순서는 필수입니다.") + @Size(max = 5, message = "카테고리 순서는 최대 5개까지 가능합니다.") + List<@NotNull(message = "카테고리 슬롯은 필수입니다.") @Valid CategorySlotRequest> categorySequence, + @NotNull(message = "시작 일시는 필수입니다.") Instant startDateTime, + @NotNull(message = "종료 일시는 필수입니다.") Instant endDateTime, + @NotBlank(message = "시/군/구 코드는 필수입니다.") String sigunguCode ) { public DateCourseGenerationCommand toCommand(String roomPublicId) { return new DateCourseGenerationCommand( roomPublicId, categorySequence.stream().map(CategorySlotRequest::toCommand).toList(), - plannedDateTime, + startDateTime, + endDateTime, sigunguCode ); } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java index a40ed2d..8aac485 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseInputValidator.java @@ -1,12 +1,12 @@ package com.hufs.capstone.backend.course.application; import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; import com.hufs.capstone.backend.place.domain.repository.PlaceCategoryRepository; import com.hufs.capstone.backend.place.domain.repository.PlaceTagRepository; import com.hufs.capstone.backend.region.domain.repository.RegionSigunguRepository; +import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,34 +15,65 @@ @RequiredArgsConstructor class DateCourseInputValidator { + private static final int MAX_CATEGORY_SEQUENCE_SIZE = 5; + private final RegionSigunguRepository regionSigunguRepository; private final PlaceCategoryRepository placeCategoryRepository; private final PlaceTagRepository placeTagRepository; - void validate(String sigunguCode, List slots) { + void validate(String sigunguCode, Instant startDateTime, Instant endDateTime, List slots) { validateSigunguCode(sigunguCode); + validateDateTimeRange(startDateTime, endDateTime); + validateSlots(slots); for (CategorySlotCommand slot : slots) { validateSlot(slot); } } private void validateSigunguCode(String sigunguCode) { + if (sigunguCode == null || sigunguCode.isBlank()) { + throw new FieldValidationException("sigunguCode", "시/군/구 코드는 필수입니다."); + } regionSigunguRepository.findActiveByCode(sigunguCode) - .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, - "유효하지 않은 시군구 코드입니다: " + sigunguCode)); + .orElseThrow(() -> new FieldValidationException( + "sigunguCode", "유효하지 않은 시/군/구 코드입니다.", sigunguCode)); + } + + private void validateDateTimeRange(Instant startDateTime, Instant endDateTime) { + if (startDateTime == null) { + throw new FieldValidationException("startDateTime", "시작 일시는 필수입니다."); + } + if (endDateTime == null) { + throw new FieldValidationException("endDateTime", "종료 일시는 필수입니다."); + } + if (!startDateTime.isBefore(endDateTime)) { + throw new FieldValidationException("startDateTime", "시작 일시는 종료 일시보다 이전이어야 합니다.", startDateTime); + } + } + + private void validateSlots(List slots) { + if (slots == null || slots.isEmpty()) { + throw new FieldValidationException("categorySequence", "카테고리 순서는 필수입니다."); + } + if (slots.size() > MAX_CATEGORY_SEQUENCE_SIZE) { + throw new FieldValidationException("categorySequence", "카테고리 순서는 최대 5개까지 가능합니다.", slots.size()); + } } private void validateSlot(CategorySlotCommand slot) { + if (slot == null || slot.categoryCode() == null || slot.categoryCode().isBlank()) { + throw new FieldValidationException("categorySequence[].categoryCode", "카테고리 코드는 필수입니다."); + } PlaceCategory category = placeCategoryRepository.findByCode(slot.categoryCode()) .filter(PlaceCategory::isActive) - .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, - "유효하지 않은 카테고리 코드입니다: " + slot.categoryCode())); + .orElseThrow(() -> new FieldValidationException( + "categorySequence[].categoryCode", "유효하지 않은 카테고리 코드입니다.", slot.categoryCode())); if (!slot.isWildcard()) { placeTagRepository.findByCategoryAndCode(category, slot.tagCode()) .filter(tag -> tag.isActive()) - .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, - "유효하지 않은 태그 코드입니다: " + slot.tagCode())); + .orElseThrow(() -> new FieldValidationException( + "categorySequence[].tagCode", "유효하지 않은 태그 코드입니다.", slot.tagCode())); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index 953945f..0a796f4 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -13,6 +13,7 @@ import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.place.domain.entity.Place; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import com.hufs.capstone.backend.room.application.RoomAccessService; @@ -20,6 +21,7 @@ import com.hufs.capstone.backend.user.domain.entity.User; import com.hufs.capstone.backend.user.domain.repository.UserRepository; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -52,10 +54,10 @@ public DateCoursePageResult listSavedCourses(String roomPublicId, Long userId, I int normalizedPage = page == null ? DEFAULT_PAGE : page; int normalizedLimit = limit == null ? DEFAULT_LIMIT : limit; if (normalizedPage < 0) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be >= 0."); + throw new FieldValidationException("page", "page는 0 이상이어야 합니다.", normalizedPage); } if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + throw new FieldValidationException("limit", "limit는 1~100 사이여야 합니다.", normalizedLimit); } Page coursePage = dateCourseRepository.findSavedByRoomIdOrderBySavedAtDesc( @@ -85,10 +87,10 @@ public DateCoursePageResult listSavedCourses(String roomPublicId, Long userId, I } @Transactional(readOnly = true) - public DateCourseResult getCourse(String roomPublicId, String coursePublicId, Long userId) { + public DateCourseResult getCourse(String roomPublicId, String dateCourseId, Long userId) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); - DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomId(dateCourseId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); @@ -103,10 +105,10 @@ public MyDateCoursePageResult listMySavedCourses(Long userId, Integer page, Inte int normalizedPage = page == null ? DEFAULT_PAGE : page; int normalizedLimit = limit == null ? DEFAULT_LIMIT : limit; if (normalizedPage < 0) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be >= 0."); + throw new FieldValidationException("page", "page는 0 이상이어야 합니다.", normalizedPage); } if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + throw new FieldValidationException("limit", "limit는 1~100 사이여야 합니다.", normalizedLimit); } Page coursePage = dateCourseRepository.findSavedByUserIdOrderBySavedAtDesc( @@ -142,14 +144,16 @@ private Map fetchUsers(Collection userIds) { private DateCourseResult toCourseResult(DateCourse course, List places, User saver) { List placeResults = places.stream() + .sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) .toList(); return new DateCourseResult( - course.getPublicId(), + course.getDateCourseId(), course.getCourseMode(), course.getGenerationBatchId(), - course.getPlannedDateTime(), + course.getStartDateTime(), + course.getEndDateTime(), course.getCreatedAt(), placeResults, parseSkipped(course.getSkippedSlotIndicesJson()), @@ -162,15 +166,17 @@ private DateCourseResult toCourseResult(DateCourse course, List private MyDateCourseResult toMyResult(DateCourse course, List places) { List placeResults = places.stream() + .sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) .toList(); Room room = course.getRoom(); return new MyDateCourseResult( - course.getPublicId(), + course.getDateCourseId(), course.getCourseMode(), course.getGenerationBatchId(), - course.getPlannedDateTime(), + course.getStartDateTime(), + course.getEndDateTime(), course.getSavedAt(), room.getPublicId(), room.getName(), @@ -210,4 +216,4 @@ private List parseSkipped(String json) { return List.of(); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java b/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java index 5863c5e..3bcdaca 100644 --- a/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java +++ b/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java @@ -5,20 +5,20 @@ @Getter public enum ErrorCode { - E400_VALIDATION(HttpStatus.BAD_REQUEST, "Request body validation failed."), - E400_BIND(HttpStatus.BAD_REQUEST, "Request binding failed."), - E400_CONSTRAINT(HttpStatus.BAD_REQUEST, "Constraint validation failed."), - E400_ILLEGAL_ARGUMENT(HttpStatus.BAD_REQUEST, "Invalid argument."), - E401_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Authentication is required."), - E401_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid token."), - E401_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Token has expired."), - E429_TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "Too many requests."), - E403_FORBIDDEN(HttpStatus.FORBIDDEN, "Access is denied."), - E404_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not found."), - E409_CONFLICT(HttpStatus.CONFLICT, "Business conflict occurred."), + E400_VALIDATION(HttpStatus.BAD_REQUEST, "입력값을 확인해 주세요."), + E400_BIND(HttpStatus.BAD_REQUEST, "입력값을 확인해 주세요."), + E400_CONSTRAINT(HttpStatus.BAD_REQUEST, "입력값을 확인해 주세요."), + E400_ILLEGAL_ARGUMENT(HttpStatus.BAD_REQUEST, "잘못된 요청값입니다."), + E401_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), + E401_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + E401_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."), + E429_TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + E403_FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + E404_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다."), + E409_CONFLICT(HttpStatus.CONFLICT, "요청을 처리할 수 없습니다."), E409_TOKEN_REUSE_DETECTED(HttpStatus.CONFLICT, "리프레시 토큰 재사용이 감지되었습니다."), - E502_EXTERNAL_API(HttpStatus.BAD_GATEWAY, "External API call failed."), - E500_INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error."); + E502_EXTERNAL_API(HttpStatus.BAD_GATEWAY, "외부 API 호출에 실패했습니다."), + E500_INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); private final HttpStatus httpStatus; private final String defaultMessage; @@ -29,6 +29,3 @@ public enum ErrorCode { } } - - - diff --git a/src/main/java/com/hufs/capstone/backend/global/exception/FieldValidationException.java b/src/main/java/com/hufs/capstone/backend/global/exception/FieldValidationException.java new file mode 100644 index 0000000..db3b249 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/global/exception/FieldValidationException.java @@ -0,0 +1,32 @@ +package com.hufs.capstone.backend.global.exception; + +import com.hufs.capstone.backend.global.response.FieldErrorDetail; +import java.util.List; + +public class FieldValidationException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "입력값을 확인해 주세요."; + + private final List fieldErrors; + + public FieldValidationException(String field, String message) { + this(List.of(FieldErrorDetail.of(field, message))); + } + + public FieldValidationException(String field, String message, Object rejectedValue) { + this(List.of(FieldErrorDetail.of(field, message, rejectedValue))); + } + + public FieldValidationException(List fieldErrors) { + this(DEFAULT_MESSAGE, fieldErrors); + } + + public FieldValidationException(String message, List fieldErrors) { + super(message == null || message.isBlank() ? DEFAULT_MESSAGE : message); + this.fieldErrors = List.copyOf(fieldErrors); + } + + public List getFieldErrors() { + return fieldErrors; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandler.java index 9b28fa2..6bfb666 100644 --- a/src/main/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandler.java @@ -10,13 +10,17 @@ import java.net.URI; import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @Slf4j @RestControllerAdvice @@ -29,7 +33,7 @@ public ResponseEntity handleMethodArgumentNotValid( .map(this::toFieldErrorDetail) .toList(); ProblemDetail body = ProblemDetailFactory.create( - ErrorCode.E400_VALIDATION, "요청 본문 검증에 실패했습니다.", details, requestUri(request)); + ErrorCode.E400_VALIDATION, ErrorCode.E400_VALIDATION.getDefaultMessage(), details, requestUri(request)); return ResponseEntity.status(body.getStatus()).body(body); } @@ -39,7 +43,7 @@ public ResponseEntity handleBindException(BindException ex, HttpS .map(this::toFieldErrorDetail) .toList(); ProblemDetail body = ProblemDetailFactory.create( - ErrorCode.E400_BIND, "요청 바인딩에 실패했습니다.", details, requestUri(request)); + ErrorCode.E400_BIND, ErrorCode.E400_BIND.getDefaultMessage(), details, requestUri(request)); return ResponseEntity.status(body.getStatus()).body(body); } @@ -50,10 +54,52 @@ public ResponseEntity handleConstraintViolation( .map(this::toFieldErrorDetail) .toList(); ProblemDetail body = ProblemDetailFactory.create( - ErrorCode.E400_CONSTRAINT, "제약 조건 검증에 실패했습니다.", details, requestUri(request)); + ErrorCode.E400_CONSTRAINT, ErrorCode.E400_CONSTRAINT.getDefaultMessage(), details, requestUri(request)); return ResponseEntity.status(body.getStatus()).body(body); } + @ExceptionHandler(FieldValidationException.class) + public ResponseEntity handleFieldValidation( + FieldValidationException ex, HttpServletRequest request) { + ProblemDetail body = ProblemDetailFactory.create( + ErrorCode.E400_VALIDATION, ex.getMessage(), ex.getFieldErrors(), requestUri(request)); + return ResponseEntity.status(body.getStatus()).body(body); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatch( + MethodArgumentTypeMismatchException ex, HttpServletRequest request) { + String field = ex.getName() != null ? ex.getName() : "unknown"; + FieldErrorDetail detail = FieldErrorDetail.of(field, "요청값 형식이 올바르지 않습니다.", ex.getValue()); + ProblemDetail body = ProblemDetailFactory.create( + ErrorCode.E400_VALIDATION, + ErrorCode.E400_VALIDATION.getDefaultMessage(), + List.of(detail), + requestUri(request)); + return ResponseEntity.status(body.getStatus()).body(body); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException ex, HttpServletRequest request) { + FieldErrorDetail detail = FieldErrorDetail.of(ex.getParameterName(), "필수 요청 파라미터입니다."); + ProblemDetail body = ProblemDetailFactory.create( + ErrorCode.E400_VALIDATION, + ErrorCode.E400_VALIDATION.getDefaultMessage(), + List.of(detail), + requestUri(request)); + return ResponseEntity.status(body.getStatus()).body(body); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, HttpServletRequest request) { + log.debug("HttpMessageNotReadable: {}", ex.getMessage()); + ProblemDetail body = ProblemDetailFactory.create( + ErrorCode.E400_ILLEGAL_ARGUMENT, "요청 본문 형식이 올바르지 않습니다.", null, requestUri(request)); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + private FieldErrorDetail toFieldErrorDetail(FieldError fe) { return new FieldErrorDetail( fe.getField(), @@ -90,14 +136,14 @@ public ResponseEntity handleBusiness(BusinessException ex, HttpSe @ExceptionHandler(ProcessingClientException.class) public ResponseEntity handleProcessing(ProcessingClientException ex, HttpServletRequest request) { log.warn("Processing(FastAPI private) 연동 실패: status={}", ex.getStatus(), ex); - ProblemDetail body = ProblemDetailFactory.create(ErrorCode.E502_EXTERNAL_API, ex.getMessage(), null, requestUri(request)); + ProblemDetail body = ProblemDetailFactory.create(ErrorCode.E502_EXTERNAL_API, null, null, requestUri(request)); return ResponseEntity.status(body.getStatus()).body(body); } @ExceptionHandler(KakaoLocalClientException.class) public ResponseEntity handleKakaoLocal(KakaoLocalClientException ex, HttpServletRequest request) { log.warn("Kakao Local API call failed: status={}", ex.getStatus(), ex); - ProblemDetail body = ProblemDetailFactory.create(ErrorCode.E502_EXTERNAL_API, ex.getMessage(), null, requestUri(request)); + ProblemDetail body = ProblemDetailFactory.create(ErrorCode.E502_EXTERNAL_API, null, null, requestUri(request)); return ResponseEntity.status(body.getStatus()).body(body); } diff --git a/src/main/java/com/hufs/capstone/backend/global/response/FieldErrorDetail.java b/src/main/java/com/hufs/capstone/backend/global/response/FieldErrorDetail.java index 74b6957..16a41c1 100644 --- a/src/main/java/com/hufs/capstone/backend/global/response/FieldErrorDetail.java +++ b/src/main/java/com/hufs/capstone/backend/global/response/FieldErrorDetail.java @@ -9,4 +9,15 @@ public record FieldErrorDetail(String field, String message, String rejectedValu public static FieldErrorDetail of(String field, String message) { return new FieldErrorDetail(field, message, null); } + + public static FieldErrorDetail of(String field, String message, Object rejectedValue) { + return new FieldErrorDetail(field, message, rejectedValueToString(rejectedValue)); + } + + private static String rejectedValueToString(Object value) { + if (value == null) { + return null; + } + return String.valueOf(value); + } } diff --git a/src/main/java/com/hufs/capstone/backend/link/api/request/CreateLinkAnalysisRequest.java b/src/main/java/com/hufs/capstone/backend/link/api/request/CreateLinkAnalysisRequest.java index a615de0..58947fa 100644 --- a/src/main/java/com/hufs/capstone/backend/link/api/request/CreateLinkAnalysisRequest.java +++ b/src/main/java/com/hufs/capstone/backend/link/api/request/CreateLinkAnalysisRequest.java @@ -5,7 +5,9 @@ import jakarta.validation.constraints.Size; public record CreateLinkAnalysisRequest( - @NotBlank @Size(max = 2048) String originalUrl, + @NotBlank(message = "URL은 필수입니다.") + @Size(max = 2048, message = "URL 길이가 너무 깁니다.") + String originalUrl, LinkSource source ) { } diff --git a/src/main/java/com/hufs/capstone/backend/link/api/request/OverrideLinkCandidateRequest.java b/src/main/java/com/hufs/capstone/backend/link/api/request/OverrideLinkCandidateRequest.java index bfefecd..1c446ec 100644 --- a/src/main/java/com/hufs/capstone/backend/link/api/request/OverrideLinkCandidateRequest.java +++ b/src/main/java/com/hufs/capstone/backend/link/api/request/OverrideLinkCandidateRequest.java @@ -9,41 +9,41 @@ import java.math.BigDecimal; public record OverrideLinkCandidateRequest( - @NotBlank - @Size(max = 100) + @NotBlank(message = "카카오 장소 ID는 필수입니다.") + @Size(max = 100, message = "카카오 장소 ID는 100자를 초과할 수 없습니다.") String kakaoPlaceId, - @NotBlank - @Size(max = 255) + @NotBlank(message = "장소 이름은 필수입니다.") + @Size(max = 255, message = "장소 이름은 255자를 초과할 수 없습니다.") String name, - @Size(max = 500) + @Size(max = 500, message = "지번 주소는 500자를 초과할 수 없습니다.") String address, - @Size(max = 500) + @Size(max = 500, message = "도로명 주소는 500자를 초과할 수 없습니다.") String roadAddress, - @NotNull - @DecimalMin("-90.0") - @DecimalMax("90.0") + @NotNull(message = "위도는 필수입니다.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90 이하여야 합니다.") BigDecimal latitude, - @NotNull - @DecimalMin("-180.0") - @DecimalMax("180.0") + @NotNull(message = "경도는 필수입니다.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180 이하여야 합니다.") BigDecimal longitude, - @Size(max = 500) + @Size(max = 500, message = "카테고리 이름은 500자를 초과할 수 없습니다.") String categoryName, - @Size(max = 50) + @Size(max = 50, message = "카테고리 그룹 코드는 50자를 초과할 수 없습니다.") String categoryGroupCode, - @Size(max = 100) + @Size(max = 100, message = "전화번호는 100자를 초과할 수 없습니다.") String phone, - @Size(max = 2048) - String placeUrl + @Size(max = 2048, message = "장소 URL은 2048자를 초과할 수 없습니다.") + String placeUrl ) { public PlaceSnapshot toSnapshot() { diff --git a/src/main/java/com/hufs/capstone/backend/link/api/request/SaveManualRoomPlaceRequest.java b/src/main/java/com/hufs/capstone/backend/link/api/request/SaveManualRoomPlaceRequest.java index 8396dc1..16ed4bd 100644 --- a/src/main/java/com/hufs/capstone/backend/link/api/request/SaveManualRoomPlaceRequest.java +++ b/src/main/java/com/hufs/capstone/backend/link/api/request/SaveManualRoomPlaceRequest.java @@ -9,41 +9,41 @@ import java.math.BigDecimal; public record SaveManualRoomPlaceRequest( - @NotBlank - @Size(max = 100) + @NotBlank(message = "카카오 장소 ID는 필수입니다.") + @Size(max = 100, message = "카카오 장소 ID는 100자를 초과할 수 없습니다.") String kakaoPlaceId, - @NotBlank - @Size(max = 255) + @NotBlank(message = "장소 이름은 필수입니다.") + @Size(max = 255, message = "장소 이름은 255자를 초과할 수 없습니다.") String name, - @Size(max = 500) + @Size(max = 500, message = "지번 주소는 500자를 초과할 수 없습니다.") String address, - @Size(max = 500) + @Size(max = 500, message = "도로명 주소는 500자를 초과할 수 없습니다.") String roadAddress, - @NotNull - @DecimalMin("-90.0") - @DecimalMax("90.0") + @NotNull(message = "위도는 필수입니다.") + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.") + @DecimalMax(value = "90.0", message = "위도는 90 이하여야 합니다.") BigDecimal latitude, - @NotNull - @DecimalMin("-180.0") - @DecimalMax("180.0") + @NotNull(message = "경도는 필수입니다.") + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.") + @DecimalMax(value = "180.0", message = "경도는 180 이하여야 합니다.") BigDecimal longitude, - @Size(max = 500) + @Size(max = 500, message = "카테고리 이름은 500자를 초과할 수 없습니다.") String categoryName, - @Size(max = 50) + @Size(max = 50, message = "카테고리 그룹 코드는 50자를 초과할 수 없습니다.") String categoryGroupCode, - @Size(max = 100) + @Size(max = 100, message = "전화번호는 100자를 초과할 수 없습니다.") String phone, - @Size(max = 2048) - String placeUrl + @Size(max = 2048, message = "장소 URL은 2048자를 초과할 수 없습니다.") + String placeUrl ) { public PlaceSnapshot toSnapshot() { diff --git a/src/main/java/com/hufs/capstone/backend/link/api/request/SaveRoomPlacesRequest.java b/src/main/java/com/hufs/capstone/backend/link/api/request/SaveRoomPlacesRequest.java index e9d98b8..47dc868 100644 --- a/src/main/java/com/hufs/capstone/backend/link/api/request/SaveRoomPlacesRequest.java +++ b/src/main/java/com/hufs/capstone/backend/link/api/request/SaveRoomPlacesRequest.java @@ -6,8 +6,9 @@ import java.util.List; public record SaveRoomPlacesRequest( - @NotEmpty - @Size(max = 20) - List<@NotBlank @Size(max = 100) String> kakaoPlaceIds + @NotEmpty(message = "저장할 장소는 필수입니다.") + @Size(max = 20, message = "저장할 장소는 최대 20개까지 가능합니다.") + List<@NotBlank(message = "카카오 장소 ID는 필수입니다.") + @Size(max = 100, message = "카카오 장소 ID는 100자를 초과할 수 없습니다.") String> kakaoPlaceIds ) { } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisAuthorizationService.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisAuthorizationService.java index 4703b64..ed15bb6 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisAuthorizationService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisAuthorizationService.java @@ -19,7 +19,7 @@ public class LinkAnalysisAuthorizationService { public LinkAnalysisRequest requireAnalysisRequest(Long userId, String roomId, Long analysisRequestId) { Room room = roomAccessService.requireMemberRoom(roomId, userId); LinkAnalysisRequest analysisRequest = linkAnalysisRequestRepository.findWithRoomAndLinkById(analysisRequestId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link analysis request not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크 분석 요청을 찾을 수 없습니다.")); requireSameRoom(room, analysisRequest); return analysisRequest; } @@ -27,14 +27,14 @@ public LinkAnalysisRequest requireAnalysisRequest(Long userId, String roomId, Lo public LinkAnalysisRequest requireAnalysisRequestForUpdate(Long userId, String roomId, Long analysisRequestId) { Room room = roomAccessService.requireMemberRoom(roomId, userId); LinkAnalysisRequest analysisRequest = linkAnalysisRequestRepository.findWithRoomAndLinkByIdForUpdate(analysisRequestId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link analysis request not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크 분석 요청을 찾을 수 없습니다.")); requireSameRoom(room, analysisRequest); return analysisRequest; } private static void requireSameRoom(Room room, LinkAnalysisRequest analysisRequest) { if (!room.getId().equals(analysisRequest.getRoom().getId())) { - throw new BusinessException(ErrorCode.E403_FORBIDDEN, "Link analysis request does not belong to this room."); + throw new BusinessException(ErrorCode.E403_FORBIDDEN, "해당 방의 링크 분석 요청이 아닙니다."); } } } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java index db99f06..84134cd 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.AnalyzeLinkCommand; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisRequestResult; import com.hufs.capstone.backend.link.domain.repository.LinkRepository; @@ -102,7 +103,7 @@ private LinkAnalysisRequestResult refreshLatest(LinkAnalysisRequestResult reques private static String requireRoomId(String roomId) { if (roomId == null || roomId.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "방 ID는 필수입니다."); + throw new FieldValidationException("roomId", "방 ID는 필수입니다."); } return roomId.trim(); } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestWriteService.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestWriteService.java index 4795e17..8639f43 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestWriteService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestWriteService.java @@ -74,9 +74,9 @@ public LinkAnalysisRequestResult retryWithinWriteTransaction(Long userId, String Room room = roomAccessService.requireMemberRoom(roomId, userId); LinkAnalysisRequest analysisRequest = linkAnalysisRequestRepository .findWithRoomAndLinkByIdForUpdate(analysisRequestId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link analysis request not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크 분석 요청을 찾을 수 없습니다.")); if (!analysisRequest.getRoom().getId().equals(room.getId())) { - throw new BusinessException(ErrorCode.E404_NOT_FOUND, "Link analysis request not found."); + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "링크 분석 요청을 찾을 수 없습니다."); } Link link = analysisRequest.getLink(); @@ -86,7 +86,7 @@ public LinkAnalysisRequestResult retryWithinWriteTransaction(Long userId, String resetFailedForRetry(link); } else if (link.getStatus() == LinkAnalysisStatus.DISPATCH_FAILED) { if (!recoverDispatchFailedForManualRetry(link)) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request changed while retrying."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도 중 링크 분석 요청이 변경되었습니다."); } } @@ -120,7 +120,7 @@ private Link persistNewLink(LinkUrlNormalizer.NormalizedUrl normalizedUrl) { throw new LinkDuplicateRaceException(normalizedUrl.normalizedUrl(), ex); } catch (DataAccessException ex) { log.error("Failed to save link. normalizedUrl={}", normalizedUrl.normalizedUrl(), ex); - throw new BusinessException(ErrorCode.E500_INTERNAL, "Failed to save link.", ex); + throw new BusinessException(ErrorCode.E500_INTERNAL, "링크 저장에 실패했습니다.", ex); } } @@ -145,7 +145,7 @@ private AnalysisRequestTarget findOrCreateAnalysisRequest( throw new LinkAnalysisRequestDuplicateRaceException(room.getPublicId(), link.getId(), ex); } catch (DataAccessException ex) { log.error("Failed to save link analysis request. roomId={}, linkId={}", room.getPublicId(), link.getId(), ex); - throw new BusinessException(ErrorCode.E500_INTERNAL, "Failed to save link analysis request.", ex); + throw new BusinessException(ErrorCode.E500_INTERNAL, "링크 분석 요청 저장에 실패했습니다.", ex); } } @@ -188,28 +188,28 @@ private boolean recoverDispatchFailedForManualRetry(Link link) { private void validateRetryable(Link link) { if (link.getStatus() == LinkAnalysisStatus.SUCCEEDED || link.getStatus() == LinkAnalysisStatus.PROCESSING) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request is not retryable."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도할 수 없는 링크 분석 요청입니다."); } if (link.getStatus() == LinkAnalysisStatus.REQUESTED) { if (isStaleRequestedWithoutJob(link)) { return; } - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request is not stale."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "만료되지 않은 링크 분석 요청입니다."); } if (link.getStatus() == LinkAnalysisStatus.DISPATCH_FAILED) { if (link.getProcessingJobId() == null) { return; } - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request is not retryable."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도할 수 없는 링크 분석 요청입니다."); } if (link.getStatus() == LinkAnalysisStatus.FAILED) { rejectInstagramCooldownIfActive(link, link.getNormalizedUrl()); if (Boolean.TRUE.equals(link.getRetryable())) { return; } - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request is not retryable."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도할 수 없는 링크 분석 요청입니다."); } - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request is not retryable."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도할 수 없는 링크 분석 요청입니다."); } private void resetFailedForRetry(Link link) { @@ -221,7 +221,7 @@ private void resetFailedForRetry(Link link) { Instant.now() ); if (updated != 1) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis request changed while retrying."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "재시도 중 링크 분석 요청이 변경되었습니다."); } } @@ -252,7 +252,7 @@ private void rejectInstagramCooldownIfActive(Link link, String canonicalUrl) { if (updatedAt.plusSeconds(cooldownSeconds).isAfter(Instant.now())) { throw new BusinessException( ErrorCode.E429_TOO_MANY_REQUESTS, - "Instagram analysis is cooling down. Please retry later." + "Instagram 분석이 쿨다운 중입니다. 잠시 후 다시 시도해주세요." ); } } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisStatusWriteService.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisStatusWriteService.java index 5a6b6d5..79cdf0b 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisStatusWriteService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisStatusWriteService.java @@ -56,7 +56,7 @@ public LinkAnalysisResult applySyncSnapshot( ) { for (int retry = 0; retry < MAX_CAS_RETRY; retry++) { Link current = linkRepository.findById(linkId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크를 찾을 수 없습니다.")); if (current.isTerminal()) { return linkAnalysisResultAssembler.from(current); @@ -70,7 +70,7 @@ public LinkAnalysisResult applySyncSnapshot( int updated = executeCasUpdate(current, plan); if (updated == 1) { Link refreshed = linkRepository.findById(linkId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크를 찾을 수 없습니다.")); if (plan.targetStatus() == LinkAnalysisStatus.SUCCEEDED) { linkCandidateSyncService.replaceCandidates( refreshed, @@ -84,7 +84,7 @@ public LinkAnalysisResult applySyncSnapshot( log.warn("CAS update conflict. Returning latest link analysis status. linkId={}, targetStatus={}", linkId, targetStatus); Link latest = linkRepository.findById(linkId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크를 찾을 수 없습니다.")); return linkAnalysisResultAssembler.from(latest); } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkUrlNormalizer.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkUrlNormalizer.java index 2e555fb..bfce530 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkUrlNormalizer.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkUrlNormalizer.java @@ -1,7 +1,6 @@ package com.hufs.capstone.backend.link.application; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.domain.LinkSourceTypeResolver; import java.net.URI; import java.net.URLDecoder; @@ -24,18 +23,18 @@ private LinkUrlNormalizer() { static NormalizedUrl normalize(String rawUrl) { if (rawUrl == null || rawUrl.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "URL은 필수입니다."); + throw new FieldValidationException("originalUrl", "URL은 필수입니다."); } String candidate = rawUrl.trim(); if (candidate.length() > MAX_URL_LENGTH) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "URL 길이가 너무 깁니다."); + throw new FieldValidationException("originalUrl", "URL 길이가 너무 깁니다.", rawUrl); } URI parsed; try { parsed = URI.create(candidate); } catch (IllegalArgumentException ex) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "URL 형식이 올바르지 않습니다.", ex); + throw new FieldValidationException("originalUrl", "URL 형식이 올바르지 않습니다.", rawUrl); } validateScheme(parsed.getScheme()); @@ -232,16 +231,16 @@ private static String lower(String value) { private static void validateScheme(String scheme) { if (scheme == null || scheme.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "URL 스킴은 필수입니다."); + throw new FieldValidationException("originalUrl", "URL 스킴은 필수입니다."); } if (!ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "http/https URL만 허용합니다."); + throw new FieldValidationException("originalUrl", "http/https URL만 허용합니다.", scheme); } } private static void validateHost(String host) { if (host == null || host.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "URL 호스트는 필수입니다."); + throw new FieldValidationException("originalUrl", "URL 호스트는 필수입니다."); } } diff --git a/src/main/java/com/hufs/capstone/backend/link/application/RoomLinkCandidateOverrideService.java b/src/main/java/com/hufs/capstone/backend/link/application/RoomLinkCandidateOverrideService.java index ac9da51..9eb149c 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/RoomLinkCandidateOverrideService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/RoomLinkCandidateOverrideService.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.RoomLinkCandidateOverrideResult; import com.hufs.capstone.backend.link.domain.entity.Link; import com.hufs.capstone.backend.link.domain.entity.LinkAnalysisRequest; @@ -36,13 +37,13 @@ public RoomLinkCandidateOverrideResult overrideCandidate( PlaceSnapshot snapshot ) { if (snapshot == null || !snapshot.hasKakaoPlaceId()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "Valid Kakao place snapshot is required."); + throw new FieldValidationException("kakaoPlaceId", "유효한 카카오 장소 스냅샷이 필요합니다."); } LinkAnalysisRequest analysisRequest = linkAnalysisAuthorizationService.requireAnalysisRequestForUpdate(userId, roomId, analysisRequestId); Link link = analysisRequest.getLink(); LinkCandidate candidate = linkCandidateRepository.findByIdAndLinkId(candidateId, link.getId()) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Link candidate not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "링크 후보를 찾을 수 없습니다.")); RoomLink roomLink = findOrCreateRoomLink(analysisRequest.getRoom(), link); RoomLinkCandidateOverride override = overrideRepository .findByRoomLinkIdAndLinkCandidateIdForUpdate(roomLink.getId(), candidate.getId()) diff --git a/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandService.java b/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandService.java index 95be866..f9f0311 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandService.java @@ -79,7 +79,7 @@ private RoomPlaceSaveResult executeWithRaceRetry( ); } } - throw new BusinessException(ErrorCode.E409_CONFLICT, "Room place save conflict occurred.", lastRace); + throw new BusinessException(ErrorCode.E409_CONFLICT, "방 장소 저장 충돌이 발생했습니다.", lastRace); } @FunctionalInterface diff --git a/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandWriteService.java b/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandWriteService.java index 0b8d4d9..7ca540e 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandWriteService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/RoomPlaceCommandWriteService.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.SaveManualRoomPlaceCommand; import com.hufs.capstone.backend.link.application.dto.SaveRoomPlacesCommand; import com.hufs.capstone.backend.link.domain.LinkAnalysisStatus; @@ -55,7 +56,7 @@ public RoomPlaceSaveResult saveRoomPlacesWithinTransaction( linkAnalysisAuthorizationService.requireAnalysisRequestForUpdate(userId, roomId, analysisRequestId); Link link = analysisRequest.getLink(); if (link.getStatus() != LinkAnalysisStatus.SUCCEEDED) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "Link analysis is not completed."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "링크 분석이 완료되지 않았습니다."); } Map candidatesByKakaoPlaceId = @@ -87,7 +88,7 @@ public RoomPlaceSaveResult saveManualRoomPlaceWithinTransaction( SaveManualRoomPlaceCommand command ) { if (command == null || command.snapshot() == null) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "Place snapshot is required."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "장소 스냅샷은 필수입니다."); } LinkAnalysisRequest analysisRequest = linkAnalysisAuthorizationService.requireAnalysisRequestForUpdate(userId, roomId, analysisRequestId); @@ -117,7 +118,7 @@ private RoomLink findOrCreateRoomLink(Room room, Link link) { throw new RoomLinkDuplicateRaceException(room.getPublicId(), link.getId(), ex); } catch (DataAccessException ex) { log.error("Room link save failed. roomId={}, linkId={}", room.getPublicId(), link.getId(), ex); - throw new BusinessException(ErrorCode.E500_INTERNAL, "Room link save failed.", ex); + throw new BusinessException(ErrorCode.E500_INTERNAL, "방 링크 저장에 실패했습니다.", ex); } } @@ -151,23 +152,23 @@ private static void validateCandidatesExist(Collection requested, Set !candidateKakaoPlaceIds.contains(kakaoPlaceId)) .toList(); if (!invalidIds.isEmpty()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "Requested place is not in link candidates."); + throw new FieldValidationException("kakaoPlaceIds", "요청한 장소가 링크 후보에 포함되어 있지 않습니다.", invalidIds); } } private static List normalizeAndValidate(List kakaoPlaceIds) { if (kakaoPlaceIds == null || kakaoPlaceIds.isEmpty()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "kakaoPlaceIds is required."); + throw new FieldValidationException("kakaoPlaceIds", "저장할 장소는 필수입니다."); } List normalized = kakaoPlaceIds.stream() .map(kakaoPlaceId -> kakaoPlaceId == null ? "" : kakaoPlaceId.trim()) .toList(); if (normalized.stream().anyMatch(String::isBlank)) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "kakaoPlaceIds must not contain blank values."); + throw new FieldValidationException("kakaoPlaceIds", "카카오 장소 ID는 필수입니다."); } LinkedHashSet distinct = new LinkedHashSet<>(normalized); if (distinct.size() != normalized.size()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "kakaoPlaceIds must not contain duplicates."); + throw new FieldValidationException("kakaoPlaceIds", "중복된 카카오 장소 ID를 포함할 수 없습니다."); } return List.copyOf(distinct); } diff --git a/src/main/java/com/hufs/capstone/backend/place/api/request/UpdateRoomPlaceMemoRequest.java b/src/main/java/com/hufs/capstone/backend/place/api/request/UpdateRoomPlaceMemoRequest.java index e18ea6a..afade27 100644 --- a/src/main/java/com/hufs/capstone/backend/place/api/request/UpdateRoomPlaceMemoRequest.java +++ b/src/main/java/com/hufs/capstone/backend/place/api/request/UpdateRoomPlaceMemoRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.Size; public record UpdateRoomPlaceMemoRequest( - @Size(max = 500) + @Size(max = 500, message = "메모는 500자를 초과할 수 없습니다.") String memo ) { } diff --git a/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java b/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java index 633cac6..cbd3cda 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java @@ -82,7 +82,7 @@ public ResolvedPlaceTaxonomy resolve(String kakaoCategoryGroupCode, String kakao PlaceTag fallbackTag = activeTags.stream() .filter(tag -> KakaoCategoryGroupPolicy.FALLBACK_TAG_CODE.equals(tag.getCode())) .findFirst() - .orElseThrow(() -> taxonomyConfigurationError("Missing fallback place tag: " + categoryCode + ".MISC")); + .orElseThrow(() -> taxonomyConfigurationError("폴백 장소 태그가 없습니다: " + categoryCode + ".MISC")); PlaceTag matchedTag; if (override == null) { matchedTag = matchTag(kakaoCategoryName, activeTags, fallbackTag); @@ -106,7 +106,7 @@ public ResolvedPlaceCategory resolveCategory(String kakaoCategoryGroupCode, Stri private PlaceCategory findCategory(String categoryCode) { return placeCategoryRepository.findByCode(categoryCode) - .orElseThrow(() -> taxonomyConfigurationError("Missing place category: " + categoryCode)); + .orElseThrow(() -> taxonomyConfigurationError("장소 카테고리가 없습니다: " + categoryCode)); } private List findActiveTags(PlaceCategory category) { @@ -120,7 +120,7 @@ private PlaceTag findOverrideTag(List activeTags, TaxonomyOverride ove .filter(tag -> override.tagCode().equals(tag.getCode())) .findFirst() .orElseThrow(() -> taxonomyConfigurationError( - "Missing override place tag: " + override.categoryCode() + "." + override.tagCode() + "오버라이드 장소 태그가 없습니다: " + override.categoryCode() + "." + override.tagCode() )); } diff --git a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceManagementService.java b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceManagementService.java index 2ff6923..672e574 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceManagementService.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceManagementService.java @@ -49,7 +49,7 @@ public void deleteRoomPlace(Long userId, String roomId, Long roomPlaceId) { private RoomPlace getRoomPlaceOrThrow(Long roomId, Long roomPlaceId) { return roomPlaceRepository.findByIdAndRoomId(roomPlaceId, roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Room place not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "방 장소를 찾을 수 없습니다.")); } private static String trimToNull(String value) { diff --git a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceQueryService.java b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceQueryService.java index e8a38e4..304980b 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceQueryService.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.place.application.dto.BusinessHoursResult; import com.hufs.capstone.backend.place.application.dto.MyRoomPlacePageResult; import com.hufs.capstone.backend.place.application.dto.MyRoomPlaceResult; @@ -100,10 +101,10 @@ public RoomPlacePageResult searchRoomPlaces( int normalizedPage = page == null ? DEFAULT_PAGE : page; int normalizedLimit = resolveLimit(limit, size); if (normalizedPage < 0) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be greater than or equal to 0."); + throw new FieldValidationException("page", "page는 0 이상이어야 합니다.", normalizedPage); } if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + throw new FieldValidationException("limit", "limit는 1~100 사이여야 합니다.", normalizedLimit); } RegionFilter regionFilter = regionQueryService.validateFilter(sidoCode, sigunguCode); Page result = roomPlaceRepository.searchRoomPlaces( @@ -140,7 +141,7 @@ public RoomPlacePageResult searchRoomPlaces( public RoomPlaceResult getRoomPlace(Long userId, String roomId, Long roomPlaceId) { Room room = roomAccessService.requireMemberRoom(roomId, userId); RoomPlace roomPlace = roomPlaceRepository.findByIdAndRoomId(roomPlaceId, room.getId()) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "Room place not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "방 장소를 찾을 수 없습니다.")); PlaceBusinessHours cache = placeBusinessHoursRepository.findByKakaoPlaceId(roomPlace.getKakaoPlaceId()) .orElse(null); Map> memosByRoomPlaceId = findMemoResults(List.of(roomPlace)); @@ -168,10 +169,10 @@ public MyRoomPlacePageResult searchMyRoomPlaces( int normalizedPage = page == null ? DEFAULT_PAGE : page; int normalizedLimit = resolveLimit(limit, size); if (normalizedPage < 0) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "page must be greater than or equal to 0."); + throw new FieldValidationException("page", "page는 0 이상이어야 합니다.", normalizedPage); } if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 100."); + throw new FieldValidationException("limit", "limit는 1~100 사이여야 합니다.", normalizedLimit); } RegionFilter regionFilter = regionQueryService.validateFilter(sidoCode, sigunguCode); Page result = roomPlaceRepository.searchMyRoomPlaces( diff --git a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceStorageService.java b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceStorageService.java index a2f8ec4..19effe5 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceStorageService.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/RoomPlaceStorageService.java @@ -157,19 +157,19 @@ private Place upsertPlace(PlaceSnapshot snapshot) { private static void validateSnapshot(PlaceSnapshot snapshot) { if (snapshot == null || snapshot.source() == null) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "Place snapshot is required."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "장소 스냅샷은 필수입니다."); } if (snapshot.externalPlaceId() == null || snapshot.externalPlaceId().isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "externalPlaceId is required."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "externalPlaceId는 필수입니다."); } if (!snapshot.hasKakaoPlaceId()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "kakaoPlaceId is required."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "kakaoPlaceId는 필수입니다."); } } private static void validateOriginRoomLink(RoomLink originRoomLink) { if (originRoomLink == null || originRoomLink.getLink() == null) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "Room place must be saved from a link."); + throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "방 장소는 링크를 통해 저장해야 합니다."); } } diff --git a/src/main/java/com/hufs/capstone/backend/place/application/dto/ExternalPlaceCandidateSearchQuery.java b/src/main/java/com/hufs/capstone/backend/place/application/dto/ExternalPlaceCandidateSearchQuery.java index e7cac37..ec89dba 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/dto/ExternalPlaceCandidateSearchQuery.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/dto/ExternalPlaceCandidateSearchQuery.java @@ -1,7 +1,6 @@ package com.hufs.capstone.backend.place.application.dto; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; public record ExternalPlaceCandidateSearchQuery( String keyword, @@ -21,11 +20,11 @@ public static ExternalPlaceCandidateSearchQuery of( ) { String normalizedKeyword = trimToNull(keyword); if (normalizedKeyword == null) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "keyword is required."); + throw new FieldValidationException("keyword", "검색어는 필수입니다."); } int normalizedLimit = limit == null ? DEFAULT_LIMIT : limit; if (normalizedLimit < 1 || normalizedLimit > MAX_LIMIT) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "limit must be between 1 and 15."); + throw new FieldValidationException("limit", "limit는 1~15 사이여야 합니다.", normalizedLimit); } return new ExternalPlaceCandidateSearchQuery( normalizedKeyword, diff --git a/src/main/java/com/hufs/capstone/backend/region/application/impl/RegionQueryServiceImpl.java b/src/main/java/com/hufs/capstone/backend/region/application/impl/RegionQueryServiceImpl.java index d9cab0d..1dd6e46 100644 --- a/src/main/java/com/hufs/capstone/backend/region/application/impl/RegionQueryServiceImpl.java +++ b/src/main/java/com/hufs/capstone/backend/region/application/impl/RegionQueryServiceImpl.java @@ -1,7 +1,6 @@ package com.hufs.capstone.backend.region.application.impl; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.region.application.RegionQueryService; import com.hufs.capstone.backend.region.application.dto.RegionFilter; import com.hufs.capstone.backend.region.application.dto.RegionOptionResult; @@ -46,7 +45,7 @@ public List getSidos() { public List getSigungus(String sidoCode) { String normalizedSidoCode = normalize(sidoCode); RegionSido sido = regionSidoRepository.findByCodeAndActiveTrue(normalizedSidoCode) - .orElseThrow(() -> invalidRegion("Invalid sidoCode.")); + .orElseThrow(() -> invalidRegion("sidoCode", "유효하지 않은 시/도 코드입니다.", sidoCode)); List sigungus = regionSigunguRepository.findActiveBySidoCode(sido.getCode()); List results = new ArrayList<>(sigungus.size() + 1); results.add(allOption()); @@ -70,17 +69,17 @@ public RegionFilter validateFilter(String sidoCode, String sigunguCode) { return new RegionFilter(null, null); } if (normalizedSidoCode == null) { - throw invalidRegion("sidoCode is required when sigunguCode is provided."); + throw invalidRegion("sidoCode", "시/군/구 코드가 있으면 시/도 코드는 필수입니다.", sidoCode); } RegionSido sido = regionSidoRepository.findByCodeAndActiveTrue(normalizedSidoCode) - .orElseThrow(() -> invalidRegion("Invalid sidoCode.")); + .orElseThrow(() -> invalidRegion("sidoCode", "유효하지 않은 시/도 코드입니다.", sidoCode)); if (normalizedSigunguCode == null) { return new RegionFilter(sido.getCode(), null); } RegionSigungu sigungu = regionSigunguRepository.findActiveByCode(normalizedSigunguCode) - .orElseThrow(() -> invalidRegion("Invalid sigunguCode.")); + .orElseThrow(() -> invalidRegion("sigunguCode", "유효하지 않은 시/군/구 코드입니다.", sigunguCode)); if (!sido.getCode().equals(sigungu.getSido().getCode())) { - throw invalidRegion("sidoCode and sigunguCode do not match."); + throw invalidRegion("sigunguCode", "시/도 코드와 시/군/구 코드가 일치하지 않습니다.", sigunguCode); } return new RegionFilter(sido.getCode(), sigungu.getCode()); } @@ -100,7 +99,7 @@ private static String normalize(String value) { return trimmed; } - private static BusinessException invalidRegion(String message) { - return new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, message); + private static FieldValidationException invalidRegion(String field, String message, Object rejectedValue) { + return new FieldValidationException(field, message, rejectedValue); } } diff --git a/src/main/java/com/hufs/capstone/backend/room/api/request/CreateRoomRequest.java b/src/main/java/com/hufs/capstone/backend/room/api/request/CreateRoomRequest.java index aac1c75..abe5a35 100644 --- a/src/main/java/com/hufs/capstone/backend/room/api/request/CreateRoomRequest.java +++ b/src/main/java/com/hufs/capstone/backend/room/api/request/CreateRoomRequest.java @@ -5,7 +5,15 @@ import jakarta.validation.constraints.Size; public record CreateRoomRequest( - @NotBlank @Size(max = RoomNamePolicy.MAX_LENGTH) String name + @NotBlank(message = "방 이름은 필수입니다.") + @Size(max = RoomNamePolicy.MAX_LENGTH, message = "방 이름은 20자를 초과할 수 없습니다.") + String name ) { + + public CreateRoomRequest { + if (name != null) { + name = name.trim(); + } + } } diff --git a/src/main/java/com/hufs/capstone/backend/room/api/request/JoinRoomRequest.java b/src/main/java/com/hufs/capstone/backend/room/api/request/JoinRoomRequest.java index 7340728..ed7dac2 100644 --- a/src/main/java/com/hufs/capstone/backend/room/api/request/JoinRoomRequest.java +++ b/src/main/java/com/hufs/capstone/backend/room/api/request/JoinRoomRequest.java @@ -4,7 +4,9 @@ import jakarta.validation.constraints.Size; public record JoinRoomRequest( - @NotBlank @Size(max = 32) String inviteCode + @NotBlank(message = "초대코드는 필수입니다.") + @Size(max = 32, message = "초대코드는 32자를 초과할 수 없습니다.") + String inviteCode ) { } diff --git a/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomNameRequest.java b/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomNameRequest.java index 0b77cf9..3dc11c4 100644 --- a/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomNameRequest.java +++ b/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomNameRequest.java @@ -5,6 +5,14 @@ import jakarta.validation.constraints.Size; public record UpdateRoomNameRequest( - @NotBlank @Size(max = RoomNamePolicy.MAX_LENGTH) String name + @NotBlank(message = "방 이름은 필수입니다.") + @Size(max = RoomNamePolicy.MAX_LENGTH, message = "방 이름은 20자를 초과할 수 없습니다.") + String name ) { + + public UpdateRoomNameRequest { + if (name != null) { + name = name.trim(); + } + } } diff --git a/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomPinRequest.java b/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomPinRequest.java index ed47cb6..cd526cf 100644 --- a/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomPinRequest.java +++ b/src/main/java/com/hufs/capstone/backend/room/api/request/UpdateRoomPinRequest.java @@ -3,6 +3,6 @@ import jakarta.validation.constraints.NotNull; public record UpdateRoomPinRequest( - @NotNull Boolean pinned + @NotNull(message = "핀 상태는 필수입니다.") Boolean pinned ) { } diff --git a/src/main/java/com/hufs/capstone/backend/room/application/impl/RoomCommandServiceImpl.java b/src/main/java/com/hufs/capstone/backend/room/application/impl/RoomCommandServiceImpl.java index 58d64f5..339a5f7 100644 --- a/src/main/java/com/hufs/capstone/backend/room/application/impl/RoomCommandServiceImpl.java +++ b/src/main/java/com/hufs/capstone/backend/room/application/impl/RoomCommandServiceImpl.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.application.RoomCommandService; import com.hufs.capstone.backend.room.application.RoomInviteCodeGenerator; @@ -60,7 +61,7 @@ public JoinRoomResult joinByInviteCode(Long userId, String inviteCode, String ip String normalizedInviteCode = requireInviteCode(inviteCode); Room room = roomRepository.findByInviteCodeForUpdate(normalizedInviteCode) - .orElseThrow(() -> new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "유효하지 않은 초대코드입니다.")); + .orElseThrow(() -> new FieldValidationException("inviteCode", "유효하지 않은 초대코드입니다.", inviteCode)); if (roomMemberRepository.existsByRoomAndUserId(room, userId)) { throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 참여한 방입니다."); @@ -138,7 +139,7 @@ private Room persistRoomWithUniqueInviteCode(Long userId, String roomName) { private static String requireInviteCode(String inviteCode) { if (inviteCode == null || inviteCode.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "초대코드는 필수입니다."); + throw new FieldValidationException("inviteCode", "초대코드는 필수입니다."); } return inviteCode.trim(); } diff --git a/src/main/java/com/hufs/capstone/backend/room/domain/RoomNamePolicy.java b/src/main/java/com/hufs/capstone/backend/room/domain/RoomNamePolicy.java index dda83b1..87ef662 100644 --- a/src/main/java/com/hufs/capstone/backend/room/domain/RoomNamePolicy.java +++ b/src/main/java/com/hufs/capstone/backend/room/domain/RoomNamePolicy.java @@ -1,7 +1,6 @@ package com.hufs.capstone.backend.room.domain; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; public final class RoomNamePolicy { @@ -12,11 +11,11 @@ private RoomNamePolicy() { public static String normalizeAndValidate(String roomName) { if (roomName == null || roomName.isBlank()) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "방 이름은 필수입니다."); + throw new FieldValidationException("name", "방 이름은 필수입니다."); } String normalized = roomName.trim(); if (normalized.length() > MAX_LENGTH) { - throw new BusinessException(ErrorCode.E400_ILLEGAL_ARGUMENT, "방 이름은 20자를 초과할 수 없습니다."); + throw new FieldValidationException("name", "방 이름은 20자를 초과할 수 없습니다.", normalized); } return normalized; } diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java new file mode 100644 index 0000000..802743e --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java @@ -0,0 +1,99 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.PlaceTag; +import com.hufs.capstone.backend.place.domain.repository.PlaceCategoryRepository; +import com.hufs.capstone.backend.place.domain.repository.PlaceTagRepository; +import com.hufs.capstone.backend.region.domain.entity.RegionSigungu; +import com.hufs.capstone.backend.region.domain.repository.RegionSigunguRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class DateCourseInputValidatorTest { + + private final RegionSigunguRepository regionRepository = mock(RegionSigunguRepository.class); + private final PlaceCategoryRepository categoryRepository = mock(PlaceCategoryRepository.class); + private final PlaceTagRepository tagRepository = mock(PlaceTagRepository.class); + private final DateCourseInputValidator validator = + new DateCourseInputValidator(regionRepository, categoryRepository, tagRepository); + + @Test + void startDateTimeMustBeBeforeEndDateTime() { + when(regionRepository.findActiveByCode("11680")).thenReturn(Optional.of(mock(RegionSigungu.class))); + + assertThatThrownBy(() -> validator.validate( + "11680", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T12:00:00Z"), + List.of(new CategorySlotCommand("FOOD", "KOREAN")) + )).isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("startDateTime"); + assertThat(error.message()).isEqualTo("시작 일시는 종료 일시보다 이전이어야 합니다."); + })); + } + + @Test + void categoryTagCombinationMustExist() { + PlaceCategory category = mock(PlaceCategory.class); + when(category.isActive()).thenReturn(true); + when(regionRepository.findActiveByCode("11680")).thenReturn(Optional.of(mock(RegionSigungu.class))); + when(categoryRepository.findByCode("FOOD")).thenReturn(Optional.of(category)); + when(tagRepository.findByCategoryAndCode(category, "KOREAN")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> validator.validate( + "11680", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + List.of(new CategorySlotCommand("FOOD", "KOREAN")) + )).isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("categorySequence[].tagCode"); + assertThat(error.message()).isEqualTo("유효하지 않은 태그 코드입니다."); + })); + } + + @Test + void validWildcardSlotPassesWithoutTagLookup() { + PlaceCategory category = mock(PlaceCategory.class); + when(category.isActive()).thenReturn(true); + when(regionRepository.findActiveByCode("11680")).thenReturn(Optional.of(mock(RegionSigungu.class))); + when(categoryRepository.findByCode("CAFE")).thenReturn(Optional.of(category)); + + validator.validate( + "11680", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + List.of(new CategorySlotCommand("CAFE", null)) + ); + } + + @Test + void validCategoryTagCombinationPasses() { + PlaceCategory category = mock(PlaceCategory.class); + PlaceTag tag = mock(PlaceTag.class); + when(category.isActive()).thenReturn(true); + when(tag.isActive()).thenReturn(true); + when(regionRepository.findActiveByCode("11680")).thenReturn(Optional.of(mock(RegionSigungu.class))); + when(categoryRepository.findByCode("FOOD")).thenReturn(Optional.of(category)); + when(tagRepository.findByCategoryAndCode(category, "KOREAN")).thenReturn(Optional.of(tag)); + + validator.validate( + "11680", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + List.of(new CategorySlotCommand("FOOD", "KOREAN")) + ); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..143cda2 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/global/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,52 @@ +package com.hufs.capstone.backend.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hufs.capstone.backend.global.response.FieldErrorDetail; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void fieldValidationExceptionReturnsFieldErrors() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/rooms"); + FieldValidationException exception = + new FieldValidationException("name", "방 이름은 필수입니다."); + + ResponseEntity response = handler.handleFieldValidation(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + ProblemDetail body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.getDetail()).isEqualTo("입력값을 확인해 주세요."); + assertThat(body.getProperties()).containsEntry("code", "E400_VALIDATION"); + assertThat(body.getInstance().toString()).isEqualTo("/api/v1/rooms"); + + Object fieldErrors = body.getProperties().get("fieldErrors"); + assertThat(fieldErrors).isInstanceOf(List.class); + assertThat((List) fieldErrors) + .singleElement() + .isEqualTo(FieldErrorDetail.of("name", "방 이름은 필수입니다.")); + } + + @Test + void businessExceptionReturnsDetailWithoutFieldErrors() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/rooms/join"); + BusinessException exception = new BusinessException(ErrorCode.E409_CONFLICT, "이미 참여한 방입니다."); + + ResponseEntity response = handler.handleBusiness(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + ProblemDetail body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.getDetail()).isEqualTo("이미 참여한 방입니다."); + assertThat(body.getProperties()).containsEntry("code", "E409_CONFLICT"); + assertThat(body.getProperties()).doesNotContainKey("fieldErrors"); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java b/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java index 8e5a67a..53355f6 100644 --- a/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java +++ b/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java @@ -10,6 +10,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.AnalyzeLinkCommand; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisRequestResult; import com.hufs.capstone.backend.link.domain.LinkAnalysisStatus; @@ -144,8 +145,12 @@ void requestLinkAnalysisShouldFailWhenRoomIdIsBlank() { " ", new AnalyzeLinkCommand("https://example.com/x", null) )) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("roomId"); + assertThat(error.message()).isEqualTo("방 ID는 필수입니다."); + })); } private static Link link(Long id, String processingJobId, LinkAnalysisStatus status) { diff --git a/src/test/java/com/hufs/capstone/backend/link/application/LinkConcurrencyIntegrationTest.java b/src/test/java/com/hufs/capstone/backend/link/application/LinkConcurrencyIntegrationTest.java index 1aa82d9..378b3ba 100644 --- a/src/test/java/com/hufs/capstone/backend/link/application/LinkConcurrencyIntegrationTest.java +++ b/src/test/java/com/hufs/capstone/backend/link/application/LinkConcurrencyIntegrationTest.java @@ -18,6 +18,7 @@ import com.hufs.capstone.backend.external.processing.dto.ProcessingJobResultResponse; import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.AnalyzeLinkCommand; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisResult; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisRequestResult; @@ -726,16 +727,24 @@ void shouldRejectSavingDuplicateRequestIdsAndInvalidCandidates() { analysisRequestId, new SaveRoomPlacesCommand(List.of("123456789", "123456789")) )) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("kakaoPlaceIds"); + assertThat(error.message()).isEqualTo("중복된 카카오 장소 ID를 포함할 수 없습니다."); + })); assertThatThrownBy(() -> roomPlaceCommandService.saveRoomPlaces( MEMBER_USER_ID, ROOM_A_PUBLIC_ID, analysisRequestId, new SaveRoomPlacesCommand(List.of("missing")) )) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("kakaoPlaceIds"); + assertThat(error.message()).isEqualTo("요청한 장소가 링크 후보에 포함되어 있지 않습니다."); + })); assertThat(roomPlaceRepository.countByRoomId(roomA.getId())).isZero(); } diff --git a/src/test/java/com/hufs/capstone/backend/place/application/RoomPlaceCommandServiceIntegrationTest.java b/src/test/java/com/hufs/capstone/backend/place/application/RoomPlaceCommandServiceIntegrationTest.java index a2eedd1..3162325 100644 --- a/src/test/java/com/hufs/capstone/backend/place/application/RoomPlaceCommandServiceIntegrationTest.java +++ b/src/test/java/com/hufs/capstone/backend/place/application/RoomPlaceCommandServiceIntegrationTest.java @@ -5,6 +5,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.domain.LinkSourceType; import com.hufs.capstone.backend.link.domain.entity.Link; import com.hufs.capstone.backend.link.domain.entity.RoomLink; @@ -407,7 +408,12 @@ void shouldNormalizeRegionAndFilterRoomPlacesBySidoAndSigungu() { 0, 20, null - )).isInstanceOf(BusinessException.class); + )).isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("sidoCode"); + assertThat(error.message()).isEqualTo("시/군/구 코드가 있으면 시/도 코드는 필수입니다."); + })); assertThatThrownBy(() -> roomPlaceQueryService.searchRoomPlaces( USER_ID, ROOM_PUBLIC_ID, @@ -419,7 +425,12 @@ void shouldNormalizeRegionAndFilterRoomPlacesBySidoAndSigungu() { 0, 20, null - )).isInstanceOf(BusinessException.class); + )).isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("sigunguCode"); + assertThat(error.message()).isEqualTo("시/도 코드와 시/군/구 코드가 일치하지 않습니다."); + })); } @Test @@ -657,11 +668,12 @@ void shouldFilterAndPageMyRoomPlacesLikeRoomPlaceList() { @Test void shouldFillMyRoomPlaceBusinessHoursStatusFromBulkCache() { saveExternalForTest(foodSnapshot("123456789", "Business Hours Place")); - PlaceBusinessHours cache = PlaceBusinessHours.create( - "123456789", - "https://place.map.kakao.com/123456789", - "Business Hours Place" - ); + PlaceBusinessHours cache = placeBusinessHoursRepository.findByKakaoPlaceId("123456789") + .orElseGet(() -> PlaceBusinessHours.create( + "123456789", + "https://place.map.kakao.com/123456789", + "Business Hours Place" + )); Instant fetchedAt = Instant.parse("2026-05-14T02:00:00Z"); Instant expiresAt = Instant.parse("2026-05-15T02:00:00Z"); cache.applyRemotePlace( diff --git a/src/test/java/com/hufs/capstone/backend/region/api/RegionControllerIntegrationTest.java b/src/test/java/com/hufs/capstone/backend/region/api/RegionControllerIntegrationTest.java index 9bf186b..3b4d367 100644 --- a/src/test/java/com/hufs/capstone/backend/region/api/RegionControllerIntegrationTest.java +++ b/src/test/java/com/hufs/capstone/backend/region/api/RegionControllerIntegrationTest.java @@ -50,6 +50,10 @@ void shouldReturnSigungusWithVirtualAllOption() throws Exception { @WithMockUser void shouldRejectInvalidSidoCode() throws Exception { mockMvc.perform(get("/api/v1/regions/sidos/99/sigungus")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("E400_VALIDATION")) + .andExpect(jsonPath("$.detail").value("입력값을 확인해 주세요.")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("sidoCode")) + .andExpect(jsonPath("$.fieldErrors[0].message").value("유효하지 않은 시/도 코드입니다.")); } } diff --git a/src/test/java/com/hufs/capstone/backend/room/api/request/RoomNameRequestValidationTest.java b/src/test/java/com/hufs/capstone/backend/room/api/request/RoomNameRequestValidationTest.java new file mode 100644 index 0000000..214a98b --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/room/api/request/RoomNameRequestValidationTest.java @@ -0,0 +1,56 @@ +package com.hufs.capstone.backend.room.api.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class RoomNameRequestValidationTest { + + private static Validator validator; + + @BeforeAll + static void initValidator() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Test + void createRoomNameShouldTrimBeforeValidation() { + CreateRoomRequest request = new CreateRoomRequest(" 12345678901234567890 "); + + Set> violations = validator.validate(request); + + assertThat(request.name()).isEqualTo("12345678901234567890"); + assertThat(violations).isEmpty(); + } + + @Test + void createRoomNameShouldReturnKoreanMessageWhenBlank() { + CreateRoomRequest request = new CreateRoomRequest(" "); + + Set> violations = validator.validate(request); + + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("name"); + assertThat(v.getMessage()).isEqualTo("방 이름은 필수입니다."); + }); + } + + @Test + void updateRoomNameShouldReturnKoreanMessageWhenTooLong() { + UpdateRoomNameRequest request = new UpdateRoomNameRequest("123456789012345678901"); + + Set> violations = validator.validate(request); + + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("name"); + assertThat(v.getMessage()).isEqualTo("방 이름은 20자를 초과할 수 없습니다."); + }); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/room/application/RoomCommandServiceTest.java b/src/test/java/com/hufs/capstone/backend/room/application/RoomCommandServiceTest.java index 52eefb5..3924a67 100644 --- a/src/test/java/com/hufs/capstone/backend/room/application/RoomCommandServiceTest.java +++ b/src/test/java/com/hufs/capstone/backend/room/application/RoomCommandServiceTest.java @@ -11,6 +11,7 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.room.api.response.CreateRoomResponse; import com.hufs.capstone.backend.room.application.dto.CreateRoomResult; import com.hufs.capstone.backend.room.application.dto.JoinRoomResult; @@ -108,8 +109,12 @@ void joinByInviteCodeShouldRejectWhenInviteCodeInvalid() { when(roomRepository.findByInviteCodeForUpdate("INVITE123456")).thenReturn(Optional.empty()); assertThatThrownBy(() -> roomCommandService.joinByInviteCode(USER_ID, "INVITE123456", "127.0.0.1")) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("inviteCode"); + assertThat(error.message()).isEqualTo("유효하지 않은 초대코드입니다."); + })); } @Test @@ -154,8 +159,12 @@ void renameRoomShouldTrimAndRename() { @Test void renameRoomShouldRejectBlankName() { assertThatThrownBy(() -> roomCommandService.renameRoom(USER_ID, "room-id", " ")) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("name"); + assertThat(error.message()).isEqualTo("방 이름은 필수입니다."); + })); } @Test @@ -163,8 +172,12 @@ void renameRoomShouldRejectTooLongName() { String tooLongName = "a".repeat(21); assertThatThrownBy(() -> roomCommandService.renameRoom(USER_ID, "room-id", tooLongName)) - .isInstanceOf(BusinessException.class) - .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()).isEqualTo(ErrorCode.E400_ILLEGAL_ARGUMENT)); + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors()) + .anySatisfy(error -> { + assertThat(error.field()).isEqualTo("name"); + assertThat(error.message()).isEqualTo("방 이름은 20자를 초과할 수 없습니다."); + })); } @Test @@ -172,7 +185,7 @@ void renameRoomShouldThrowWhenUserIsNotMember() { Room room = room("11111111-1111-1111-1111-111111111111"); when(roomAccessService.getRoomOrThrow(room.getPublicId())).thenReturn(room); when(roomAccessService.getMembershipOrThrow(room, USER_ID)) - .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "Room access denied.")); + .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "방 접근 권한이 없습니다.")); assertThatThrownBy(() -> roomCommandService.renameRoom(USER_ID, room.getPublicId(), "New Name")) .isInstanceOf(BusinessException.class) @@ -196,7 +209,7 @@ void updateRoomPinShouldThrowWhenUserIsNotMember() { Room room = room("11111111-1111-1111-1111-111111111111"); when(roomAccessService.getRoomOrThrow(room.getPublicId())).thenReturn(room); when(roomAccessService.getMembershipOrThrow(room, USER_ID)) - .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "Room access denied.")); + .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "방 접근 권한이 없습니다.")); assertThatThrownBy(() -> roomCommandService.updateRoomPin(USER_ID, room.getPublicId(), true)) .isInstanceOf(BusinessException.class) diff --git a/src/test/java/com/hufs/capstone/backend/room/application/RoomQueryServiceTest.java b/src/test/java/com/hufs/capstone/backend/room/application/RoomQueryServiceTest.java index ccbe2c9..a24e2a7 100644 --- a/src/test/java/com/hufs/capstone/backend/room/application/RoomQueryServiceTest.java +++ b/src/test/java/com/hufs/capstone/backend/room/application/RoomQueryServiceTest.java @@ -125,7 +125,7 @@ void getRoomDetailShouldThrowForbiddenForNonMember() { Room room = room("11111111-1111-1111-1111-111111111111", "Test Room"); when(roomAccessService.getRoomOrThrow(room.getPublicId())).thenReturn(room); when(roomAccessService.getMembershipOrThrow(room, USER_ID)) - .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "Room access denied.")); + .thenThrow(new BusinessException(ErrorCode.E403_FORBIDDEN, "방 접근 권한이 없습니다.")); assertThatThrownBy(() -> roomQueryService.getRoomDetail(USER_ID, room.getPublicId())) .isInstanceOf(BusinessException.class) diff --git a/src/test/java/com/hufs/capstone/backend/user/api/request/CompleteOnboardingRequestValidationTest.java b/src/test/java/com/hufs/capstone/backend/user/api/request/CompleteOnboardingRequestValidationTest.java index 94973f6..a8634f1 100644 --- a/src/test/java/com/hufs/capstone/backend/user/api/request/CompleteOnboardingRequestValidationTest.java +++ b/src/test/java/com/hufs/capstone/backend/user/api/request/CompleteOnboardingRequestValidationTest.java @@ -44,7 +44,11 @@ void shouldFailWhenNicknameIsBlank() { Set> violations = validator.validate(request); - assertThat(violations).anyMatch(v -> "nickname".equals(v.getPropertyPath().toString())); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("nickname"); + assertThat(v.getMessage()).isEqualTo("닉네임은 필수입니다."); + }); } @Test @@ -58,7 +62,11 @@ void shouldFailWhenNicknameExceedsTenCharacters() { Set> violations = validator.validate(request); - assertThat(violations).anyMatch(v -> "nickname".equals(v.getPropertyPath().toString())); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("nickname"); + assertThat(v.getMessage()).isEqualTo("닉네임은 최대 10자까지 가능합니다."); + }); } @Test @@ -72,7 +80,15 @@ void shouldFailWhenRequiredTermsAreNotAgreed() { Set> violations = validator.validate(request); - assertThat(violations).anyMatch(v -> "serviceTermsAgreed".equals(v.getPropertyPath().toString())); - assertThat(violations).anyMatch(v -> "privacyPolicyAgreed".equals(v.getPropertyPath().toString())); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("serviceTermsAgreed"); + assertThat(v.getMessage()).isEqualTo("서비스 이용약관 동의는 필수입니다."); + }); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("privacyPolicyAgreed"); + assertThat(v.getMessage()).isEqualTo("개인정보 수집 및 이용 동의는 필수입니다."); + }); } } diff --git a/src/test/java/com/hufs/capstone/backend/user/api/request/UpdateNicknameRequestValidationTest.java b/src/test/java/com/hufs/capstone/backend/user/api/request/UpdateNicknameRequestValidationTest.java index 30aefc0..b13d70a 100644 --- a/src/test/java/com/hufs/capstone/backend/user/api/request/UpdateNicknameRequestValidationTest.java +++ b/src/test/java/com/hufs/capstone/backend/user/api/request/UpdateNicknameRequestValidationTest.java @@ -34,7 +34,11 @@ void shouldFailWhenNicknameIsBlank() { Set> violations = validator.validate(request); - assertThat(violations).anyMatch(v -> "nickname".equals(v.getPropertyPath().toString())); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("nickname"); + assertThat(v.getMessage()).isEqualTo("닉네임은 필수입니다."); + }); } @Test @@ -43,6 +47,10 @@ void shouldFailWhenNicknameExceedsTenCharacters() { Set> violations = validator.validate(request); - assertThat(violations).anyMatch(v -> "nickname".equals(v.getPropertyPath().toString())); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("nickname"); + assertThat(v.getMessage()).isEqualTo("닉네임은 최대 10자까지 가능합니다."); + }); } } From cbf2f8075373f75d79903dcbe648238ae9f1fed5 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 20:48:40 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../api/controller/DateCourseController.java | 10 +- .../api/controller/swagger/DateCourseApi.java | 24 ++--- .../controller/swagger/MeDateCourseApi.java | 6 +- .../DateCourseCoordinateResponse.java | 19 ++++ .../api/response/DateCourseResponse.java | 16 +++- .../response/DateCourseResponseMapper.java | 24 +++++ .../api/response/MyDateCourseResponse.java | 18 ++-- .../application/AvailablePoolBuilder.java | 4 +- .../BusinessHoursAtTimeChecker.java | 4 +- .../CourseRecommendationStrategy.java | 23 +++++ .../course/application/CourseScorer.java | 12 +-- .../course/application/CourseSelector.java | 54 +++++------ .../DateCourseDuplicatePolicy.java | 67 +++++++++++++ .../DateCourseGenerationService.java | 31 ++++-- .../application/DateCourseSaveService.java | 18 +++- .../GeneralCourseRecommendationStrategy.java | 13 +++ .../PopularCourseRecommendationStrategy.java | 42 ++++++++ .../TrendyCourseRecommendationStrategy.java | 13 +++ .../dto/DateCourseGenerationCommand.java | 3 +- .../application/dto/DateCourseResult.java | 5 +- .../application/dto/MyDateCourseResult.java | 7 +- .../course/domain/entity/DateCourse.java | 37 ++++--- .../repository/DateCoursePlaceRepository.java | 24 +++++ .../repository/DateCourseRepository.java | 26 +++-- .../api/response/DateCourseResponseTest.java | 96 +++++++++++++++++++ .../DateCourseDuplicatePolicyTest.java | 83 ++++++++++++++++ 26 files changed, 563 insertions(+), 116 deletions(-) create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseCoordinateResponse.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseMapper.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/CourseRecommendationStrategy.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/GeneralCourseRecommendationStrategy.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/PopularCourseRecommendationStrategy.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/TrendyCourseRecommendationStrategy.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index 6f5d3b0..b2343e3 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -41,11 +41,11 @@ public CommonResponse generateCourse( @Override public CommonResponse saveCourse( @PathVariable String roomId, - @PathVariable String coursePublicId, + @PathVariable String dateCourseId, @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken ) { Long userId = SecurityUtils.currentUserIdOrThrow(); - saveService.save(roomId, coursePublicId, userId); + saveService.save(roomId, dateCourseId, userId); return CommonResponse.ok(null); } @@ -64,9 +64,9 @@ public CommonResponse listCourses( @Override public CommonResponse getCourse( @PathVariable String roomId, - @PathVariable String coursePublicId + @PathVariable String dateCourseId ) { Long userId = SecurityUtils.currentUserIdOrThrow(); - return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, coursePublicId, userId))); + return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, dateCourseId, userId))); } -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index fce87d5..e4dd63e 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -25,8 +25,9 @@ public interface DateCourseApi { @Operation( tags = {"Date course"}, - summary = "데이트 코스 생성 API", - description = "방에 저장된 장소를 바탕으로 General/Trendy/Popular 3가지 코스 후보를 생성합니다." + summary = "데이트 코스 추천 생성 API", + description = "방에 저장된 장소를 기반으로 GENERAL/TRENDY/POPULAR 코스 후보를 생성합니다. " + + "이미 저장된 코스와 동일한 장소 순서의 후보는 제외합니다." ) @ApiResponse(responseCode = "201", description = "코스 생성 성공") @ResponseStatus(HttpStatus.CREATED) @@ -40,20 +41,21 @@ CommonResponse generateCourse( @Operation( tags = {"Date course"}, summary = "데이트 코스 저장 API", - description = "생성된 코스 후보 중 하나를 선택해 저장합니다." + description = "생성된 코스 후보 중 하나를 선택해 저장합니다. " + + "이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다." ) @ApiResponse(responseCode = "200", description = "저장 성공") - @PostMapping("/{coursePublicId}/save") + @PostMapping("/{dateCourseId}/save") CommonResponse saveCourse( @PathVariable String roomId, - @PathVariable String coursePublicId, + @PathVariable String dateCourseId, @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken ); @Operation( tags = {"Date course"}, - summary = "저장된 데이트 코스 목록 조회 API", - description = "방에서 멤버들이 저장한 데이트 코스를 최신순으로 페이지네이션 조회합니다." + summary = "방 저장 데이트 코스 목록 조회 API", + description = "방 멤버가 저장한 데이트 코스를 최신 저장순으로 페이지 조회합니다." ) @ApiResponse(responseCode = "200", description = "OK") @GetMapping @@ -66,12 +68,12 @@ CommonResponse listCourses( @Operation( tags = {"Date course"}, summary = "데이트 코스 상세 조회 API", - description = "특정 코스의 장소 목록을 조회합니다." + description = "특정 데이트 코스의 장소 목록과 직선 polyline용 orderedCoordinates를 조회합니다." ) @ApiResponse(responseCode = "200", description = "OK") - @GetMapping("/{coursePublicId}") + @GetMapping("/{dateCourseId}") CommonResponse getCourse( @PathVariable String roomId, - @PathVariable String coursePublicId + @PathVariable String dateCourseId ); -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java index 8403ec4..42fbef9 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java @@ -15,8 +15,8 @@ public interface MeDateCourseApi { @Operation( tags = {"My date course"}, - summary = "내가 저장한 데이트 코스 목록 조회 API", - description = "현재 로그인한 사용자가 저장한 데이트 코스를 방 구분 없이 최신 저장 순으로 조회합니다." + summary = "내 저장 데이트 코스 목록 조회 API", + description = "현재 로그인한 사용자가 저장한 데이트 코스를 방 구분 없이 최신 저장순으로 페이지 조회합니다." ) @ApiResponse(responseCode = "200", description = "OK") @GetMapping @@ -24,4 +24,4 @@ CommonResponse listMyDateCourses( @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer limit ); -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseCoordinateResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseCoordinateResponse.java new file mode 100644 index 0000000..ee6d46c --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseCoordinateResponse.java @@ -0,0 +1,19 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import java.math.BigDecimal; + +public record DateCourseCoordinateResponse( + int sequenceOrder, + BigDecimal latitude, + BigDecimal longitude +) { + + public static DateCourseCoordinateResponse from(DateCoursePlaceResult place) { + return new DateCourseCoordinateResponse( + place.sequenceOrder(), + place.latitude(), + place.longitude() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java index bb03b1d..008e82f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java @@ -6,12 +6,14 @@ import java.util.List; public record DateCourseResponse( - String publicId, + String dateCourseId, CourseMode mode, String generationBatchId, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, Instant createdAt, List places, + List orderedCoordinates, List skippedSlotIndices, Long savedByUserId, String savedByNickname, @@ -21,12 +23,16 @@ public record DateCourseResponse( public static DateCourseResponse from(DateCourseResult result) { return new DateCourseResponse( - result.publicId(), + result.dateCourseId(), result.courseMode(), result.generationBatchId(), - result.plannedDateTime(), + result.startDateTime(), + result.endDateTime(), result.createdAt(), - result.places().stream().map(DateCoursePlaceResponse::from).toList(), + DateCourseResponseMapper.orderedPlaces(result.places()).stream() + .map(DateCoursePlaceResponse::from) + .toList(), + DateCourseResponseMapper.orderedCoordinates(result.places()), result.skippedSlotIndices(), result.savedByUserId(), result.savedByNickname(), diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseMapper.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseMapper.java new file mode 100644 index 0000000..465036b --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseMapper.java @@ -0,0 +1,24 @@ +package com.hufs.capstone.backend.course.api.response; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import java.util.Comparator; +import java.util.List; + +final class DateCourseResponseMapper { + + private DateCourseResponseMapper() { + } + + static List orderedPlaces(List places) { + return places.stream() + .sorted(Comparator.comparingInt(DateCoursePlaceResult::sequenceOrder)) + .toList(); + } + + static List orderedCoordinates(List places) { + return orderedPlaces(places).stream() + .filter(place -> place.latitude() != null && place.longitude() != null) + .map(DateCourseCoordinateResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java index 0280902..65b8a3f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java @@ -6,28 +6,34 @@ import java.util.List; public record MyDateCourseResponse( - String publicId, + String dateCourseId, CourseMode mode, String generationBatchId, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, Instant savedAt, String roomPublicId, String roomName, List places, + List orderedCoordinates, List skippedSlotIndices ) { public static MyDateCourseResponse from(MyDateCourseResult result) { return new MyDateCourseResponse( - result.publicId(), + result.dateCourseId(), result.courseMode(), result.generationBatchId(), - result.plannedDateTime(), + result.startDateTime(), + result.endDateTime(), result.savedAt(), result.roomPublicId(), result.roomName(), - result.places().stream().map(DateCoursePlaceResponse::from).toList(), + DateCourseResponseMapper.orderedPlaces(result.places()).stream() + .map(DateCoursePlaceResponse::from) + .toList(), + DateCourseResponseMapper.orderedCoordinates(result.places()), result.skippedSlotIndices() ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java index b5ac7ae..f6f460f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/AvailablePoolBuilder.java @@ -22,7 +22,7 @@ class AvailablePoolBuilder { private final PlaceBusinessHoursRepository placeBusinessHoursRepository; private final BusinessHoursAtTimeChecker businessHoursAtTimeChecker; - AvailablePool build(Long roomId, List slots, Instant plannedDateTime, String sigunguCode) { + AvailablePool build(Long roomId, List slots, Instant startDateTime, String sigunguCode) { Instant now = Instant.now(); List roomPlaces = candidateRepository.findCandidates(roomId, slots, now, sigunguCode); if (roomPlaces.isEmpty()) { @@ -44,7 +44,7 @@ AvailablePool build(Long roomId, List slots, Instant planne if (pbh == null || pbh.getBusinessHoursJson() == null) { return false; } - return businessHoursAtTimeChecker.isOpenAt(pbh.getBusinessHoursJson(), plannedDateTime); + return businessHoursAtTimeChecker.isOpenAt(pbh.getBusinessHoursJson(), startDateTime); }) .map(rp -> { PlaceBusinessHours pbh = businessHoursByKakaoId.get(rp.getKakaoPlaceId()); diff --git a/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java index a9690a0..84ef3a0 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/BusinessHoursAtTimeChecker.java @@ -23,8 +23,8 @@ public class BusinessHoursAtTimeChecker { private final BusinessHoursDisplayResolver resolver; - public boolean isOpenAt(String businessHoursJson, Instant plannedDateTime) { - ZonedDateTime at = plannedDateTime.atZone(SEOUL_ZONE); + public boolean isOpenAt(String businessHoursJson, Instant atDateTime) { + ZonedDateTime at = atDateTime.atZone(SEOUL_ZONE); BusinessStatus status = resolver.statusAt(businessHoursJson, at); return OPEN_STATUSES.contains(status); } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseRecommendationStrategy.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseRecommendationStrategy.java new file mode 100644 index 0000000..625a07f --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseRecommendationStrategy.java @@ -0,0 +1,23 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.NormalizationContext; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.util.Map; + +interface CourseRecommendationStrategy { + + CourseMode mode(); + + default boolean supports(CourseMode mode) { + return mode() == mode; + } + + default boolean isCandidateAllowed(AvailableCandidate candidate) { + return true; + } + + default NormalizationContext normalizationContext(AvailablePool pool) { + return new NormalizationContext(Map.of()); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java index ca25ffa..1401943 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseScorer.java @@ -22,10 +22,10 @@ class CourseScorer { AvailableCandidate prev, CourseMode mode, NormalizationContext ctx, - Instant plannedDateTime + Instant startDateTime ) { double distScore = distScore(candidate, prev); - double modeWeight = modeWeight(candidate, mode, ctx, plannedDateTime); + double modeWeight = modeWeight(candidate, mode, ctx, startDateTime); return DIST_WEIGHT * distScore + MODE_WEIGHT * modeWeight; } @@ -50,18 +50,18 @@ private static double modeWeight( AvailableCandidate candidate, CourseMode mode, NormalizationContext ctx, - Instant plannedDateTime + Instant startDateTime ) { return switch (mode) { case GENERAL -> 1.0; - case TRENDY -> trendyWeight(candidate, plannedDateTime); + case TRENDY -> trendyWeight(candidate, startDateTime); case POPULAR -> popularWeight(candidate, ctx); }; } - private static double trendyWeight(AvailableCandidate candidate, Instant plannedDateTime) { + private static double trendyWeight(AvailableCandidate candidate, Instant startDateTime) { Instant savedAt = candidate.roomPlace().getCreatedAt(); - long daysSince = Math.max(0L, ChronoUnit.DAYS.between(savedAt, plannedDateTime)); + long daysSince = Math.max(0L, ChronoUnit.DAYS.between(savedAt, startDateTime)); return 1.0 + 0.5 * Math.exp(-daysSince / 30.0); } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java index d6682a6..2eff857 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java @@ -5,36 +5,46 @@ import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; import com.hufs.capstone.backend.course.application.dto.NormalizationContext; import com.hufs.capstone.backend.course.domain.enums.CourseMode; -import com.hufs.capstone.backend.link.domain.LinkSourceType; -import com.hufs.capstone.backend.link.domain.entity.Link; -import com.hufs.capstone.backend.link.domain.entity.RoomLink; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; -import java.util.EnumMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor class CourseSelector { private final CourseScorer scorer; + private final List strategies; + + @Autowired + CourseSelector(CourseScorer scorer, List strategies) { + this.scorer = scorer; + this.strategies = strategies; + } + + CourseSelector(CourseScorer scorer) { + this(scorer, List.of( + new GeneralCourseRecommendationStrategy(), + new TrendyCourseRecommendationStrategy(), + new PopularCourseRecommendationStrategy() + )); + } CourseSelectionResult select( CourseMode mode, List slots, AvailablePool pool, Set globallyUsedIds, - Instant plannedDateTime + Instant startDateTime ) { - NormalizationContext ctx = buildNormalizationContext(pool, mode); + CourseRecommendationStrategy strategy = strategyFor(mode); + NormalizationContext ctx = strategy.normalizationContext(pool); List pickedPlaces = new ArrayList<>(); Set pickedIds = new HashSet<>(); List skipped = new ArrayList<>(); @@ -46,9 +56,9 @@ CourseSelectionResult select( Optional best = pool.forSlot(slot).stream() .filter(c -> !globallyUsedIds.contains(c.roomPlace().getId())) .filter(c -> !pickedIds.contains(c.roomPlace().getId())) - .filter(c -> mode != CourseMode.POPULAR || c.roomPlace().getOriginRoomLink() != null) + .filter(strategy::isCandidateAllowed) .max(Comparator.comparingDouble( - c -> scorer.score(c, prevForLambda, mode, ctx, plannedDateTime) + c -> scorer.score(c, prevForLambda, mode, ctx, startDateTime) )); if (best.isEmpty()) { @@ -66,23 +76,11 @@ CourseSelectionResult select( return new CourseSelectionResult(pickedPlaces, skipped); } - private static NormalizationContext buildNormalizationContext(AvailablePool pool, CourseMode mode) { - if (mode != CourseMode.POPULAR) { - return new NormalizationContext(Map.of()); - } - Map maxBySourceType = new EnumMap<>(LinkSourceType.class); - for (AvailableCandidate candidate : pool.all()) { - RoomLink originRoomLink = candidate.roomPlace().getOriginRoomLink(); - if (originRoomLink == null || originRoomLink.getLink() == null) { - continue; - } - Link link = originRoomLink.getLink(); - if (link.getLikeCount() == null) { - continue; - } - maxBySourceType.merge(link.getLinkSourceType(), link.getLikeCount(), Math::max); - } - return new NormalizationContext(maxBySourceType); + private CourseRecommendationStrategy strategyFor(CourseMode mode) { + return strategies.stream() + .filter(strategy -> strategy.supports(mode)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported course mode: " + mode)); } Set newGloballyUsedIds() { diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java new file mode 100644 index 0000000..cdcc143 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java @@ -0,0 +1,67 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class DateCourseDuplicatePolicy { + + private final DateCoursePlaceRepository dateCoursePlaceRepository; + + boolean existsSavedCourseWithSamePlaces(Long roomId, List places) { + return savedCourseSignatures(roomId).contains(roomPlaceSignature(places)); + } + + boolean existsSavedCourseWithSamePlacesExcluding( + Long roomId, + Long excludedCourseId, + List places + ) { + return savedCourseSignaturesExcluding(roomId, excludedCourseId) + .contains(dateCoursePlaceSignature(places)); + } + + private Set> savedCourseSignatures(Long roomId) { + return signatures(dateCoursePlaceRepository.findSavedPlacesByRoomId(roomId)); + } + + private Set> savedCourseSignaturesExcluding(Long roomId, Long excludedCourseId) { + return signatures(dateCoursePlaceRepository.findSavedPlacesByRoomIdExcludingCourseId(roomId, excludedCourseId)); + } + + private static Set> signatures(List places) { + Map> placesByCourseId = places.stream() + .collect(Collectors.groupingBy( + place -> place.getDateCourse().getId(), + LinkedHashMap::new, + Collectors.toCollection(ArrayList::new) + )); + + return placesByCourseId.values().stream() + .map(DateCourseDuplicatePolicy::dateCoursePlaceSignature) + .collect(Collectors.toSet()); + } + + private static List roomPlaceSignature(List places) { + return places.stream() + .map(RoomPlace::getId) + .toList(); + } + + private static List dateCoursePlaceSignature(List places) { + return places.stream() + .sorted(java.util.Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) + .map(place -> place.getRoomPlace().getId()) + .toList(); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java index 3f9789c..de9368b 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -20,6 +20,7 @@ import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; @@ -35,18 +36,21 @@ public class DateCourseGenerationService { private final DateCourseInputValidator inputValidator; private final AvailablePoolBuilder poolBuilder; private final CourseSelector courseSelector; + private final DateCourseDuplicatePolicy duplicatePolicy; private final DateCourseRepository dateCourseRepository; private final DateCoursePlaceRepository dateCoursePlaceRepository; private final ObjectMapper objectMapper; @Transactional public DateCourseGenerationResult generate(DateCourseGenerationCommand command, Long userId) { - inputValidator.validate(command.sigunguCode(), command.categorySequence()); + inputValidator.validate(command.sigunguCode(), command.startDateTime(), + command.endDateTime(), command.categorySequence()); Room room = roomAccessService.requireMemberRoom(command.roomPublicId(), userId); - AvailablePool pool = poolBuilder.build(room.getId(), command.categorySequence(), command.plannedDateTime(), command.sigunguCode()); + AvailablePool pool = poolBuilder.build(room.getId(), command.categorySequence(), + command.startDateTime(), command.sigunguCode()); if (pool.isEmpty()) { - throw new BusinessException(ErrorCode.E404_NOT_FOUND, "코스 생성 가능한 장소가 없습니다."); + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스 생성에 사용할 수 있는 장소가 없습니다."); } String batchId = UUID.randomUUID().toString(); @@ -56,12 +60,17 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, List results = new ArrayList<>(); for (CourseMode mode : List.of(CourseMode.GENERAL, CourseMode.TRENDY, CourseMode.POPULAR)) { + Set candidateUsedIds = new HashSet<>(globallyUsedIds); CourseSelectionResult selection = courseSelector.select( - mode, command.categorySequence(), pool, globallyUsedIds, command.plannedDateTime()); + mode, command.categorySequence(), pool, candidateUsedIds, command.startDateTime()); if (selection.pickedPlaces().isEmpty()) { continue; } + if (duplicatePolicy.existsSavedCourseWithSamePlaces(room.getId(), selection.pickedPlaces())) { + continue; + } + globallyUsedIds = candidateUsedIds; String skippedJson = serializeSkipped(selection.skippedSlotIndices()); DateCourse dateCourse = dateCourseRepository.save(DateCourse.create( @@ -69,7 +78,8 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, room, userId, mode, - command.plannedDateTime(), + command.startDateTime(), + command.endDateTime(), batchId, command.sigunguCode(), categorySequenceJson, @@ -86,7 +96,7 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, } if (results.isEmpty()) { - throw new BusinessException(ErrorCode.E404_NOT_FOUND, "생성 가능한 코스가 없습니다."); + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "생성할 수 있는 코스가 없습니다."); } return new DateCourseGenerationResult(batchId, results); @@ -98,10 +108,11 @@ private static DateCourseResult toResult(DateCourse dateCourse, List placeResults.add(toPlaceResult(pickedPlaces.get(i), i)); } return new DateCourseResult( - dateCourse.getPublicId(), + dateCourse.getDateCourseId(), dateCourse.getCourseMode(), dateCourse.getGenerationBatchId(), - dateCourse.getPlannedDateTime(), + dateCourse.getStartDateTime(), + dateCourse.getEndDateTime(), dateCourse.getCreatedAt(), placeResults, skipped, @@ -135,7 +146,7 @@ private String serializeSlots(List slots) { try { return objectMapper.writeValueAsString(slots); } catch (JsonProcessingException e) { - throw new BusinessException(ErrorCode.E500_INTERNAL, "카테고리 슬롯 직렬화에 실패했습니다."); + throw new BusinessException(ErrorCode.E500_INTERNAL, "카테고리 순서 직렬화에 실패했습니다."); } } @@ -143,7 +154,7 @@ private String serializeSkipped(List skipped) { try { return objectMapper.writeValueAsString(skipped); } catch (JsonProcessingException e) { - throw new BusinessException(ErrorCode.E500_INTERNAL, "스킵 슬롯 직렬화에 실패했습니다."); + throw new BusinessException(ErrorCode.E500_INTERNAL, "건너뛴 슬롯 직렬화에 실패했습니다."); } } } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java index b390ad7..6d9bc90 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -1,12 +1,15 @@ package com.hufs.capstone.backend.course.application; import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; import java.time.Instant; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,17 +20,24 @@ public class DateCourseSaveService { private final RoomAccessService roomAccessService; private final DateCourseRepository dateCourseRepository; + private final DateCoursePlaceRepository dateCoursePlaceRepository; + private final DateCourseDuplicatePolicy duplicatePolicy; @Transactional - public void save(String roomPublicId, String coursePublicId, Long userId) { + public void save(String roomPublicId, String dateCourseId, Long userId) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); - DateCourse course = dateCourseRepository.findByPublicIdAndRoomId(coursePublicId, room.getId()) - .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomId(dateCourseId, room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.")); + + List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); + if (duplicatePolicy.existsSavedCourseWithSamePlacesExcluding(room.getId(), course.getId(), places)) { + throw new BusinessException(ErrorCode.E409_CONFLICT, "동일한 데이트 코스가 이미 저장되어 있습니다."); + } int updated = dateCourseRepository.markAsSavedIfAbsent(course.getId(), userId, Instant.now()); if (updated == 0) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 코스입니다."); + throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 데이트 코스입니다."); } } } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/GeneralCourseRecommendationStrategy.java b/src/main/java/com/hufs/capstone/backend/course/application/GeneralCourseRecommendationStrategy.java new file mode 100644 index 0000000..bcc0d5f --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/GeneralCourseRecommendationStrategy.java @@ -0,0 +1,13 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import org.springframework.stereotype.Component; + +@Component +class GeneralCourseRecommendationStrategy implements CourseRecommendationStrategy { + + @Override + public CourseMode mode() { + return CourseMode.GENERAL; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/PopularCourseRecommendationStrategy.java b/src/main/java/com/hufs/capstone/backend/course/application/PopularCourseRecommendationStrategy.java new file mode 100644 index 0000000..b5d1d08 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/PopularCourseRecommendationStrategy.java @@ -0,0 +1,42 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.NormalizationContext; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.link.domain.LinkSourceType; +import com.hufs.capstone.backend.link.domain.entity.Link; +import com.hufs.capstone.backend.link.domain.entity.RoomLink; +import java.util.EnumMap; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +class PopularCourseRecommendationStrategy implements CourseRecommendationStrategy { + + @Override + public CourseMode mode() { + return CourseMode.POPULAR; + } + + @Override + public boolean isCandidateAllowed(AvailableCandidate candidate) { + return candidate.roomPlace().getOriginRoomLink() != null; + } + + @Override + public NormalizationContext normalizationContext(AvailablePool pool) { + Map maxBySourceType = new EnumMap<>(LinkSourceType.class); + for (AvailableCandidate candidate : pool.all()) { + RoomLink originRoomLink = candidate.roomPlace().getOriginRoomLink(); + if (originRoomLink == null || originRoomLink.getLink() == null) { + continue; + } + Link link = originRoomLink.getLink(); + if (link.getLikeCount() == null) { + continue; + } + maxBySourceType.merge(link.getLinkSourceType(), link.getLikeCount(), Math::max); + } + return new NormalizationContext(maxBySourceType); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/TrendyCourseRecommendationStrategy.java b/src/main/java/com/hufs/capstone/backend/course/application/TrendyCourseRecommendationStrategy.java new file mode 100644 index 0000000..6e32f5d --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/TrendyCourseRecommendationStrategy.java @@ -0,0 +1,13 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import org.springframework.stereotype.Component; + +@Component +class TrendyCourseRecommendationStrategy implements CourseRecommendationStrategy { + + @Override + public CourseMode mode() { + return CourseMode.TRENDY; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java index 55c8ba4..08e54c7 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseGenerationCommand.java @@ -6,7 +6,8 @@ public record DateCourseGenerationCommand( String roomPublicId, List categorySequence, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, String sigunguCode ) { } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java index 006da68..cdcbe8f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java @@ -5,10 +5,11 @@ import java.util.List; public record DateCourseResult( - String publicId, + String dateCourseId, CourseMode courseMode, String generationBatchId, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, Instant createdAt, List places, List skippedSlotIndices, diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java index efae4d0..ff77cb5 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java @@ -5,14 +5,15 @@ import java.util.List; public record MyDateCourseResult( - String publicId, + String dateCourseId, CourseMode courseMode, String generationBatchId, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, Instant savedAt, String roomPublicId, String roomName, List places, List skippedSlotIndices ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java index bda46bc..77ef6bc 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -29,14 +29,14 @@ @Index(name = "idx_date_courses_saved_by_user_id_saved_at", columnList = "saved_by_user_id, saved_at") }, uniqueConstraints = { - @UniqueConstraint(name = "uq_date_courses_public_id", columnNames = "public_id") + @UniqueConstraint(name = "uq_date_courses_date_course_id", columnNames = "date_course_id") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class DateCourse extends AuditableEntity { - @Column(name = "public_id", nullable = false, length = 36) - private String publicId; + @Column(name = "date_course_id", nullable = false, length = 36) + private String dateCourseId; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "room_id", nullable = false) @@ -49,8 +49,11 @@ public class DateCourse extends AuditableEntity { @Column(name = "course_mode", nullable = false, length = 20) private CourseMode courseMode; - @Column(name = "planned_date_time", nullable = false) - private Instant plannedDateTime; + @Column(name = "start_date_time", nullable = false) + private Instant startDateTime; + + @Column(name = "end_date_time", nullable = false) + private Instant endDateTime; @Column(name = "generation_batch_id", nullable = false, length = 36) private String generationBatchId; @@ -71,21 +74,23 @@ public class DateCourse extends AuditableEntity { private Instant savedAt; private DateCourse( - String publicId, + String dateCourseId, Room room, Long createdByUserId, CourseMode courseMode, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, String generationBatchId, String sigunguCode, String categorySequenceJson, String skippedSlotIndicesJson ) { - this.publicId = publicId; + this.dateCourseId = dateCourseId; this.room = room; this.createdByUserId = createdByUserId; this.courseMode = courseMode; - this.plannedDateTime = plannedDateTime; + this.startDateTime = startDateTime; + this.endDateTime = endDateTime; this.generationBatchId = generationBatchId; this.sigunguCode = sigunguCode; this.categorySequenceJson = categorySequenceJson; @@ -93,22 +98,26 @@ private DateCourse( } public static DateCourse create( - String publicId, + String dateCourseId, Room room, Long createdByUserId, CourseMode courseMode, - Instant plannedDateTime, + Instant startDateTime, + Instant endDateTime, String generationBatchId, String sigunguCode, String categorySequenceJson, String skippedSlotIndicesJson ) { - if (publicId == null || room == null || createdByUserId == null || courseMode == null - || plannedDateTime == null || generationBatchId == null + if (dateCourseId == null || room == null || createdByUserId == null || courseMode == null + || startDateTime == null || endDateTime == null || generationBatchId == null || sigunguCode == null || sigunguCode.isBlank()) { throw new IllegalArgumentException("DateCourse required values are missing."); } - return new DateCourse(publicId, room, createdByUserId, courseMode, plannedDateTime, + if (!startDateTime.isBefore(endDateTime)) { + throw new IllegalArgumentException("DateCourse startDateTime must be before endDateTime."); + } + return new DateCourse(dateCourseId, room, createdByUserId, courseMode, startDateTime, endDateTime, generationBatchId, sigunguCode, categorySequenceJson, skippedSlotIndicesJson); } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java index 3183deb..cc279b7 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java @@ -20,4 +20,28 @@ public interface DateCoursePlaceRepository extends JpaRepository findWithRoomPlacesByCourseIdIn(@Param("courseIds") List courseIds); + + @Query(""" + SELECT dcp FROM DateCoursePlace dcp + JOIN FETCH dcp.dateCourse dc + JOIN FETCH dcp.roomPlace rp + WHERE dc.room.id = :roomId + AND dc.savedByUserId IS NOT NULL + ORDER BY dc.id ASC, dcp.sequenceOrder ASC + """) + List findSavedPlacesByRoomId(@Param("roomId") Long roomId); + + @Query(""" + SELECT dcp FROM DateCoursePlace dcp + JOIN FETCH dcp.dateCourse dc + JOIN FETCH dcp.roomPlace rp + WHERE dc.room.id = :roomId + AND dc.savedByUserId IS NOT NULL + AND dc.id <> :excludedCourseId + ORDER BY dc.id ASC, dcp.sequenceOrder ASC + """) + List findSavedPlacesByRoomIdExcludingCourseId( + @Param("roomId") Long roomId, + @Param("excludedCourseId") Long excludedCourseId + ); } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index 9bbb4ea..4b7f419 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -2,7 +2,6 @@ import com.hufs.capstone.backend.course.domain.entity.DateCourse; import java.time.Instant; -import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -13,8 +12,6 @@ public interface DateCourseRepository extends JpaRepository { - Optional findByPublicId(String publicId); - @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE DateCourse dc @@ -25,30 +22,31 @@ int markAsSavedIfAbsent(@Param("id") Long id, @Param("userId") Long userId, @Param("savedAt") Instant savedAt); - Optional findByPublicIdAndRoomId(String publicId, Long roomId); + Optional findByDateCourseIdAndRoomId(String dateCourseId, Long roomId); - @Query(""" + @Query(value = """ SELECT dc FROM DateCourse dc JOIN FETCH dc.room WHERE dc.room.id = :roomId AND dc.savedByUserId IS NOT NULL ORDER BY dc.savedAt DESC + """, + countQuery = """ + SELECT COUNT(dc) FROM DateCourse dc + WHERE dc.room.id = :roomId + AND dc.savedByUserId IS NOT NULL """) Page findSavedByRoomIdOrderBySavedAtDesc(@Param("roomId") Long roomId, Pageable pageable); - @Query(""" + @Query(value = """ SELECT dc FROM DateCourse dc JOIN FETCH dc.room WHERE dc.savedByUserId = :userId ORDER BY dc.savedAt DESC + """, + countQuery = """ + SELECT COUNT(dc) FROM DateCourse dc + WHERE dc.savedByUserId = :userId """) Page findSavedByUserIdOrderBySavedAtDesc(@Param("userId") Long userId, Pageable pageable); - - @Query(""" - SELECT dc FROM DateCourse dc - JOIN FETCH dc.room - WHERE dc.room.id = :roomId - ORDER BY dc.createdAt DESC - """) - List findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId); } diff --git a/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java new file mode 100644 index 0000000..a5bcf59 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java @@ -0,0 +1,96 @@ +package com.hufs.capstone.backend.course.api.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.application.dto.MyDateCourseResult; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DateCourseResponseTest { + + @Test + void placesAndCoordinatesAreOrderedBySequenceOrder() { + DateCourseResult result = new DateCourseResult( + "course-1", + CourseMode.GENERAL, + "batch-1", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + Instant.parse("2026-06-03T11:00:00Z"), + List.of( + place(2, "third", "37.3", "127.3"), + place(0, "first", "37.1", "127.1"), + place(1, "second", null, null) + ), + List.of(), + null, + null, + null, + null + ); + + DateCourseResponse response = DateCourseResponse.from(result); + + assertThat(response.places()) + .extracting(DateCoursePlaceResponse::sequenceOrder) + .containsExactly(0, 1, 2); + assertThat(response.orderedCoordinates()) + .extracting(DateCourseCoordinateResponse::sequenceOrder) + .containsExactly(0, 2); + assertThat(response.orderedCoordinates().get(0).latitude()) + .isEqualByComparingTo("37.1"); + assertThat(response.orderedCoordinates().get(1).longitude()) + .isEqualByComparingTo("127.3"); + } + + @Test + void myDateCourseResponseIncludesOrderedCoordinates() { + MyDateCourseResult result = new MyDateCourseResult( + "course-1", + CourseMode.GENERAL, + "batch-1", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + Instant.parse("2026-06-03T15:00:00Z"), + "room-1", + "room", + List.of( + place(1, "second", "37.2", "127.2"), + place(0, "first", "37.1", "127.1") + ), + List.of() + ); + + MyDateCourseResponse response = MyDateCourseResponse.from(result); + + assertThat(response.places()) + .extracting(DateCoursePlaceResponse::sequenceOrder) + .containsExactly(0, 1); + assertThat(response.orderedCoordinates()) + .extracting(DateCourseCoordinateResponse::sequenceOrder) + .containsExactly(0, 1); + } + + private static DateCoursePlaceResult place(int sequenceOrder, String name, String latitude, String longitude) { + return new DateCoursePlaceResult( + 1L + sequenceOrder, + 10L + sequenceOrder, + "kakao-" + sequenceOrder, + name, + "address", + "roadAddress", + latitude == null ? null : new BigDecimal(latitude), + longitude == null ? null : new BigDecimal(longitude), + "FOOD", + "Food", + "KOREAN", + "Korean", + sequenceOrder + ); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java new file mode 100644 index 0000000..9e46e57 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java @@ -0,0 +1,83 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DateCourseDuplicatePolicyTest { + + private final DateCoursePlaceRepository repository = mock(DateCoursePlaceRepository.class); + private final DateCourseDuplicatePolicy policy = new DateCourseDuplicatePolicy(repository); + + @Test + void sameRoomPlaceOrderIsDuplicate() { + List savedPlaces = List.of( + dateCoursePlace(10L, 0, 100L), + dateCoursePlace(10L, 1, 200L) + ); + when(repository.findSavedPlacesByRoomId(1L)).thenReturn(savedPlaces); + + boolean duplicate = policy.existsSavedCourseWithSamePlaces(1L, List.of( + roomPlace(100L), + roomPlace(200L) + )); + + assertThat(duplicate).isTrue(); + } + + @Test + void samePlacesWithDifferentOrderAreNotDuplicate() { + List savedPlaces = List.of( + dateCoursePlace(10L, 0, 100L), + dateCoursePlace(10L, 1, 200L) + ); + when(repository.findSavedPlacesByRoomId(1L)).thenReturn(savedPlaces); + + boolean duplicate = policy.existsSavedCourseWithSamePlaces(1L, List.of( + roomPlace(200L), + roomPlace(100L) + )); + + assertThat(duplicate).isFalse(); + } + + @Test + void excludingCurrentCourseStillDetectsOtherSavedDuplicate() { + List savedPlaces = List.of( + dateCoursePlace(10L, 0, 100L), + dateCoursePlace(10L, 1, 200L) + ); + when(repository.findSavedPlacesByRoomIdExcludingCourseId(1L, 20L)).thenReturn(savedPlaces); + + boolean duplicate = policy.existsSavedCourseWithSamePlacesExcluding(1L, 20L, List.of( + dateCoursePlace(20L, 0, 100L), + dateCoursePlace(20L, 1, 200L) + )); + + assertThat(duplicate).isTrue(); + } + + private static DateCoursePlace dateCoursePlace(Long courseId, int sequenceOrder, Long roomPlaceId) { + DateCourse course = mock(DateCourse.class); + when(course.getId()).thenReturn(courseId); + DateCoursePlace dateCoursePlace = mock(DateCoursePlace.class); + RoomPlace roomPlace = roomPlace(roomPlaceId); + when(dateCoursePlace.getDateCourse()).thenReturn(course); + when(dateCoursePlace.getSequenceOrder()).thenReturn(sequenceOrder); + when(dateCoursePlace.getRoomPlace()).thenReturn(roomPlace); + return dateCoursePlace; + } + + private static RoomPlace roomPlace(Long id) { + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + return roomPlace; + } +} From 9fde2dca124b5cb7a955e7ce428466eae5b51a14 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 20:54:40 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20impo?= =?UTF-8?q?rt=20=EC=A0=9C=EA=B1=B0=20(checkstyle=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/link/application/LinkAnalysisRequestService.java | 2 -- .../link/application/LinkAnalysisRequestServiceTest.java | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java index 84134cd..e01f649 100644 --- a/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java +++ b/src/main/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestService.java @@ -1,7 +1,5 @@ package com.hufs.capstone.backend.link.application; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.AnalyzeLinkCommand; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisRequestResult; diff --git a/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java b/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java index 53355f6..ab42ab9 100644 --- a/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java +++ b/src/test/java/com/hufs/capstone/backend/link/application/LinkAnalysisRequestServiceTest.java @@ -8,8 +8,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.hufs.capstone.backend.global.exception.BusinessException; -import com.hufs.capstone.backend.global.exception.ErrorCode; import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.link.application.dto.AnalyzeLinkCommand; import com.hufs.capstone.backend.link.application.dto.LinkAnalysisRequestResult; From 089a7945124eadedc2706ca13826c6fba54bfbec Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 22:37:12 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EC=84=A4=EC=A0=95=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/DateCourseController.java | 10 +++ .../api/controller/swagger/DateCourseApi.java | 14 +++- .../controller/swagger/MeDateCourseApi.java | 2 +- .../application/DateCourseQueryService.java | 19 +++++ .../repository/RoomPlaceRepository.java | 17 ++++ .../DateCourseQueryServiceTest.java | 77 +++++++++++++++++++ 6 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index b2343e3..07c1c60 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -10,7 +10,9 @@ import com.hufs.capstone.backend.course.application.DateCourseQueryService; import com.hufs.capstone.backend.course.application.DateCourseSaveService; import com.hufs.capstone.backend.global.response.CommonResponse; +import com.hufs.capstone.backend.region.api.response.RegionOptionResponse; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -26,6 +28,14 @@ public class DateCourseController implements DateCourseApi { private final DateCourseSaveService saveService; private final DateCourseQueryService queryService; + @Override + public CommonResponse> listCourseGenerationSigungus(@PathVariable String roomId) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok(queryService.listCourseGenerationSigungus(roomId, userId).stream() + .map(RegionOptionResponse::from) + .toList()); + } + @Override public CommonResponse generateCourse( @PathVariable String roomId, diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index e4dd63e..9ff02d5 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -5,10 +5,12 @@ import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; import com.hufs.capstone.backend.global.response.CommonResponse; +import com.hufs.capstone.backend.region.api.response.RegionOptionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; +import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,6 +25,16 @@ @SecurityRequirement(name = "bearer-jwt") public interface DateCourseApi { + @Operation( + tags = {"Date course"}, + summary = "코스 생성용 방 시/군/구 필터 목록 조회 API", + description = "방에 저장된 장소에 실제로 존재하는 시/군/구 옵션만 반환합니다. " + + "가상 전체(ALL) 옵션은 포함하지 않습니다." + ) + @ApiResponse(responseCode = "200", description = "OK") + @GetMapping("/sigungus") + CommonResponse> listCourseGenerationSigungus(@PathVariable String roomId); + @Operation( tags = {"Date course"}, summary = "데이트 코스 추천 생성 API", @@ -68,7 +80,7 @@ CommonResponse listCourses( @Operation( tags = {"Date course"}, summary = "데이트 코스 상세 조회 API", - description = "특정 데이트 코스의 장소 목록과 직선 polyline용 orderedCoordinates를 조회합니다." + description = "특정 데이트 코스의 장소 목록과 직선 경로(polyline) 렌더링용 orderedCoordinates를 조회합니다." ) @ApiResponse(responseCode = "200", description = "OK") @GetMapping("/{dateCourseId}") diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java index 42fbef9..6e7d5be 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/MeDateCourseApi.java @@ -18,7 +18,7 @@ public interface MeDateCourseApi { summary = "내 저장 데이트 코스 목록 조회 API", description = "현재 로그인한 사용자가 저장한 데이트 코스를 방 구분 없이 최신 저장순으로 페이지 조회합니다." ) - @ApiResponse(responseCode = "200", description = "OK") + @ApiResponse(responseCode = "200", description = "성공") @GetMapping CommonResponse listMyDateCourses( @RequestParam(required = false) Integer page, diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index 0a796f4..edb6e54 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -16,6 +16,8 @@ import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.place.domain.entity.Place; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; +import com.hufs.capstone.backend.region.application.dto.RegionOptionResult; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; import com.hufs.capstone.backend.user.domain.entity.User; @@ -26,6 +28,7 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -45,9 +48,25 @@ public class DateCourseQueryService { private final RoomAccessService roomAccessService; private final DateCourseRepository dateCourseRepository; private final DateCoursePlaceRepository dateCoursePlaceRepository; + private final RoomPlaceRepository roomPlaceRepository; private final UserRepository userRepository; private final ObjectMapper objectMapper; + @Transactional(readOnly = true) + public List listCourseGenerationSigungus(String roomPublicId, Long userId) { + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + List options = + roomPlaceRepository.findDistinctSigunguOptionsByRoomId(room.getId()); + return IntStream.range(0, options.size()) + .mapToObj(index -> new RegionOptionResult( + options.get(index).getCode(), + options.get(index).getName(), + index + 1, + false + )) + .toList(); + } + @Transactional(readOnly = true) public DateCoursePageResult listSavedCourses(String roomPublicId, Long userId, Integer page, Integer limit) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); diff --git a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java index 67f4286..28662eb 100644 --- a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java +++ b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java @@ -1,6 +1,7 @@ package com.hufs.capstone.backend.place.domain.repository; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -42,6 +43,16 @@ select count(rp) long countByRoomId(Long roomId); + @Query(""" + select distinct rp.sigunguCode as code, rp.sigunguName as name + from RoomPlace rp + where rp.room.id = :roomId + and rp.sigunguCode is not null + and rp.sigunguCode <> '' + order by rp.sigunguCode asc + """) + List findDistinctSigunguOptionsByRoomId(@Param("roomId") Long roomId); + long deleteByRoomId(Long roomId); @Modifying(flushAutomatically = true, clearAutomatically = true) @@ -53,4 +64,10 @@ select count(rp) """) int clearOriginRoomLinkByOriginRoomLinkId(@Param("roomLinkId") Long roomLinkId); + interface RoomPlaceSigunguOption { + + String getCode(); + + String getName(); + } } diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java new file mode 100644 index 0000000..146f93d --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java @@ -0,0 +1,77 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; +import com.hufs.capstone.backend.region.application.dto.RegionOptionResult; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import com.hufs.capstone.backend.user.domain.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DateCourseQueryServiceTest { + + private static final String ROOM_PUBLIC_ID = "room-public-id"; + private static final Long USER_ID = 100L; + + private final RoomAccessService roomAccessService = mock(RoomAccessService.class); + private final DateCourseRepository dateCourseRepository = mock(DateCourseRepository.class); + private final DateCoursePlaceRepository dateCoursePlaceRepository = mock(DateCoursePlaceRepository.class); + private final RoomPlaceRepository roomPlaceRepository = mock(RoomPlaceRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final ObjectMapper objectMapper = mock(ObjectMapper.class); + private final DateCourseQueryService service = new DateCourseQueryService( + roomAccessService, + dateCourseRepository, + dateCoursePlaceRepository, + roomPlaceRepository, + userRepository, + objectMapper + ); + + @Test + void listCourseGenerationSigungusReturnsOnlyRoomPlaceSigungus() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(10L); + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(roomPlaceRepository.findDistinctSigunguOptionsByRoomId(10L)).thenReturn(List.of( + sigungu("11110", "Jongno-gu"), + sigungu("11680", "Gangnam-gu") + )); + + List results = service.listCourseGenerationSigungus(ROOM_PUBLIC_ID, USER_ID); + + assertThat(results).extracting(RegionOptionResult::code) + .containsExactly("11110", "11680"); + assertThat(results).extracting(RegionOptionResult::name) + .containsExactly("Jongno-gu", "Gangnam-gu"); + assertThat(results).extracting(RegionOptionResult::displayOrder) + .containsExactly(1, 2); + assertThat(results).extracting(RegionOptionResult::all) + .containsExactly(false, false); + verify(roomAccessService).requireMemberRoom(ROOM_PUBLIC_ID, USER_ID); + verify(roomPlaceRepository).findDistinctSigunguOptionsByRoomId(10L); + } + + private static RoomPlaceRepository.RoomPlaceSigunguOption sigungu(String code, String name) { + return new RoomPlaceRepository.RoomPlaceSigunguOption() { + + @Override + public String getCode() { + return code; + } + + @Override + public String getName() { + return name; + } + }; + } +} From fb091618a59c4d6bc6c18ff4df623ec94c1e7f38 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 22:48:34 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=9E=A5=EC=86=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20roomPlaceId=20=ED=95=84=EC=88=98=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DateCourseGenerationService.java | 22 +----- .../application/DateCoursePlaceMapper.java | 42 +++++++++++ .../application/DateCourseQueryService.java | 25 +------ .../course/domain/entity/DateCoursePlace.java | 4 ++ .../api/response/DateCourseResponseTest.java | 40 +++++++++++ .../DateCoursePlaceMapperTest.java | 70 +++++++++++++++++++ 6 files changed, 159 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapper.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapperTest.java diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java index de9368b..08a80fe 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -15,7 +15,6 @@ import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; -import com.hufs.capstone.backend.place.domain.entity.Place; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; @@ -105,7 +104,7 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, private static DateCourseResult toResult(DateCourse dateCourse, List pickedPlaces, List skipped) { List placeResults = new ArrayList<>(); for (int i = 0; i < pickedPlaces.size(); i++) { - placeResults.add(toPlaceResult(pickedPlaces.get(i), i)); + placeResults.add(DateCoursePlaceMapper.toPlaceResult(pickedPlaces.get(i), i)); } return new DateCourseResult( dateCourse.getDateCourseId(), @@ -123,25 +122,6 @@ private static DateCourseResult toResult(DateCourse dateCourse, List ); } - private static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequenceOrder) { - Place place = roomPlace.getPlace(); - return new DateCoursePlaceResult( - roomPlace.getId(), - place.getId(), - place.getKakaoPlaceId(), - place.getName(), - place.getAddress(), - place.getRoadAddress(), - place.getLatitude(), - place.getLongitude(), - place.getServiceCategory().getCode(), - place.getServiceCategory().getName(), - place.getServiceTag().getCode(), - place.getServiceTag().getName(), - sequenceOrder - ); - } - private String serializeSlots(List slots) { try { return objectMapper.writeValueAsString(slots); diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapper.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapper.java new file mode 100644 index 0000000..d3c0b5e --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapper.java @@ -0,0 +1,42 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; + +final class DateCoursePlaceMapper { + + private DateCoursePlaceMapper() { + } + + static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequenceOrder) { + if (roomPlace == null) { + throw new BusinessException(ErrorCode.E500_INTERNAL, "데이트 코스 장소의 RoomPlace 참조가 없습니다."); + } + Long roomPlaceId = roomPlace.getId(); + if (roomPlaceId == null) { + throw new BusinessException(ErrorCode.E500_INTERNAL, "데이트 코스 장소의 roomPlaceId가 없습니다."); + } + Place place = roomPlace.getPlace(); + if (place == null) { + throw new BusinessException(ErrorCode.E500_INTERNAL, "데이트 코스 장소의 Place 참조가 없습니다."); + } + return new DateCoursePlaceResult( + roomPlaceId, + place.getId(), + place.getKakaoPlaceId(), + place.getName(), + place.getAddress(), + place.getRoadAddress(), + place.getLatitude(), + place.getLongitude(), + place.getServiceCategory().getCode(), + place.getServiceCategory().getName(), + place.getServiceTag().getCode(), + place.getServiceTag().getName(), + sequenceOrder + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index edb6e54..22b8f4c 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -14,8 +14,6 @@ import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; import com.hufs.capstone.backend.global.exception.FieldValidationException; -import com.hufs.capstone.backend.place.domain.entity.Place; -import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; import com.hufs.capstone.backend.region.application.dto.RegionOptionResult; import com.hufs.capstone.backend.room.application.RoomAccessService; @@ -164,7 +162,7 @@ private Map fetchUsers(Collection userIds) { private DateCourseResult toCourseResult(DateCourse course, List places, User saver) { List placeResults = places.stream() .sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) - .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) + .map(dcp -> DateCoursePlaceMapper.toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) .toList(); return new DateCourseResult( @@ -186,7 +184,7 @@ private DateCourseResult toCourseResult(DateCourse course, List private MyDateCourseResult toMyResult(DateCourse course, List places) { List placeResults = places.stream() .sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) - .map(dcp -> toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) + .map(dcp -> DateCoursePlaceMapper.toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) .toList(); Room room = course.getRoom(); @@ -204,25 +202,6 @@ private MyDateCourseResult toMyResult(DateCourse course, List p ); } - private static DateCoursePlaceResult toPlaceResult(RoomPlace roomPlace, int sequenceOrder) { - Place place = roomPlace.getPlace(); - return new DateCoursePlaceResult( - roomPlace.getId(), - place.getId(), - place.getKakaoPlaceId(), - place.getName(), - place.getAddress(), - place.getRoadAddress(), - place.getLatitude(), - place.getLongitude(), - place.getServiceCategory().getCode(), - place.getServiceCategory().getName(), - place.getServiceTag().getCode(), - place.getServiceTag().getName(), - sequenceOrder - ); - } - private List parseSkipped(String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java index 5783a2c..5b4128b 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCoursePlace.java @@ -25,6 +25,10 @@ @UniqueConstraint( name = "uq_date_course_places_course_order", columnNames = {"date_course_id", "sequence_order"} + ), + @UniqueConstraint( + name = "uq_date_course_places_course_room_place", + columnNames = {"date_course_id", "room_place_id"} ) } ) diff --git a/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java index a5bcf59..7a0de61 100644 --- a/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java @@ -5,6 +5,7 @@ import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; import com.hufs.capstone.backend.course.application.dto.DateCourseResult; import com.hufs.capstone.backend.course.application.dto.MyDateCourseResult; +import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; import com.hufs.capstone.backend.course.domain.enums.CourseMode; import java.math.BigDecimal; import java.time.Instant; @@ -39,6 +40,12 @@ void placesAndCoordinatesAreOrderedBySequenceOrder() { assertThat(response.places()) .extracting(DateCoursePlaceResponse::sequenceOrder) .containsExactly(0, 1, 2); + assertThat(response.places()) + .extracting(DateCoursePlaceResponse::roomPlaceId) + .containsExactly(1L, 2L, 3L); + assertThat(response.places()) + .extracting(DateCoursePlaceResponse::name) + .containsExactly("first", "second", "third"); assertThat(response.orderedCoordinates()) .extracting(DateCourseCoordinateResponse::sequenceOrder) .containsExactly(0, 2); @@ -48,6 +55,36 @@ void placesAndCoordinatesAreOrderedBySequenceOrder() { .isEqualByComparingTo("127.3"); } + @Test + void generationResponsePreservesRoomPlaceIdOnEveryPlace() { + DateCourseGenerationResponse response = DateCourseGenerationResponse.from( + new com.hufs.capstone.backend.course.application.dto.DateCourseGenerationResult( + "batch-1", + List.of( + new DateCourseResult( + "course-1", + CourseMode.GENERAL, + "batch-1", + Instant.parse("2026-06-03T12:00:00Z"), + Instant.parse("2026-06-03T14:00:00Z"), + Instant.parse("2026-06-03T11:00:00Z"), + List.of(place(0, "first", "37.1", "127.1")), + List.of(), + null, + null, + null, + null + ) + ) + ) + ); + + assertThat(response.courses()).hasSize(1); + assertThat(response.courses().get(0).places()) + .extracting(DateCoursePlaceResponse::roomPlaceId) + .containsExactly(1L); + } + @Test void myDateCourseResponseIncludesOrderedCoordinates() { MyDateCourseResult result = new MyDateCourseResult( @@ -71,6 +108,9 @@ void myDateCourseResponseIncludesOrderedCoordinates() { assertThat(response.places()) .extracting(DateCoursePlaceResponse::sequenceOrder) .containsExactly(0, 1); + assertThat(response.places()) + .extracting(DateCoursePlaceResponse::roomPlaceId) + .containsExactly(1L, 2L); assertThat(response.orderedCoordinates()) .extracting(DateCourseCoordinateResponse::sequenceOrder) .containsExactly(0, 1); diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapperTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapperTest.java new file mode 100644 index 0000000..c695d65 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCoursePlaceMapperTest.java @@ -0,0 +1,70 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.PlaceTag; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class DateCoursePlaceMapperTest { + + @Test + void mapsRoomPlaceIdAndDisplayFields() { + RoomPlace roomPlace = roomPlace(42L, place()); + + DateCoursePlaceResult result = DateCoursePlaceMapper.toPlaceResult(roomPlace, 1); + + assertThat(result.roomPlaceId()).isEqualTo(42L); + assertThat(result.sequenceOrder()).isEqualTo(1); + assertThat(result.name()).isEqualTo("Test Place"); + assertThat(result.address()).isEqualTo("Seoul"); + assertThat(result.categoryCode()).isEqualTo("FOOD"); + assertThat(result.tagCode()).isEqualTo("KOREAN"); + } + + @Test + void rejectsRoomPlaceWithoutId() { + RoomPlace roomPlace = roomPlace(null, place()); + + assertThatThrownBy(() -> DateCoursePlaceMapper.toPlaceResult(roomPlace, 0)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("roomPlaceId"); + } + + private static RoomPlace roomPlace(Long id, Place place) { + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + when(roomPlace.getPlace()).thenReturn(place); + return roomPlace; + } + + private static Place place() { + PlaceCategory category = mock(PlaceCategory.class); + when(category.getCode()).thenReturn("FOOD"); + when(category.getName()).thenReturn("Food"); + + PlaceTag tag = mock(PlaceTag.class); + when(tag.getCode()).thenReturn("KOREAN"); + when(tag.getName()).thenReturn("Korean"); + + Place place = mock(Place.class); + when(place.getId()).thenReturn(99L); + when(place.getKakaoPlaceId()).thenReturn("kakao-1"); + when(place.getName()).thenReturn("Test Place"); + when(place.getAddress()).thenReturn("Seoul"); + when(place.getRoadAddress()).thenReturn("Seoul Road"); + when(place.getLatitude()).thenReturn(new BigDecimal("37.5")); + when(place.getLongitude()).thenReturn(new BigDecimal("127.0")); + when(place.getServiceCategory()).thenReturn(category); + when(place.getServiceTag()).thenReturn(tag); + return place; + } +} From 895a6648efae1ba63fffb3d7e61ddaed326b9690 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 23:12:23 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EC=84=A4=EC=A0=95=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?API=202=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/DateCourseController.java | 15 ++++- .../api/controller/swagger/DateCourseApi.java | 19 ++++-- .../application/DateCourseQueryService.java | 29 +++++++++- .../repository/RoomPlaceRepository.java | 26 ++++++++- .../DateCourseQueryServiceTest.java | 58 ++++++++++++++----- 5 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index 07c1c60..99a2b04 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -29,9 +29,20 @@ public class DateCourseController implements DateCourseApi { private final DateCourseQueryService queryService; @Override - public CommonResponse> listCourseGenerationSigungus(@PathVariable String roomId) { + public CommonResponse> listCourseGenerationSidos(@PathVariable String roomId) { Long userId = SecurityUtils.currentUserIdOrThrow(); - return CommonResponse.ok(queryService.listCourseGenerationSigungus(roomId, userId).stream() + return CommonResponse.ok(queryService.listCourseGenerationSidos(roomId, userId).stream() + .map(RegionOptionResponse::from) + .toList()); + } + + @Override + public CommonResponse> listCourseGenerationSigungus( + @PathVariable String roomId, + @PathVariable String sidoCode + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok(queryService.listCourseGenerationSigungus(roomId, sidoCode, userId).stream() .map(RegionOptionResponse::from) .toList()); } diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index 9ff02d5..f0f6c8a 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -25,15 +25,26 @@ @SecurityRequirement(name = "bearer-jwt") public interface DateCourseApi { + @Operation( + tags = {"Date course"}, + summary = "코스 생성용 방 시/도 필터 목록 조회 API", + description = "방에 저장된 장소에 실제로 존재하는 시/도 옵션만 반환합니다." + ) + @ApiResponse(responseCode = "200", description = "OK") + @GetMapping("/sidos") + CommonResponse> listCourseGenerationSidos(@PathVariable String roomId); + @Operation( tags = {"Date course"}, summary = "코스 생성용 방 시/군/구 필터 목록 조회 API", - description = "방에 저장된 장소에 실제로 존재하는 시/군/구 옵션만 반환합니다. " - + "가상 전체(ALL) 옵션은 포함하지 않습니다." + description = "선택한 시/도에 속하며 방에 저장된 장소에 실제로 존재하는 시/군/구 옵션만 반환합니다." ) @ApiResponse(responseCode = "200", description = "OK") - @GetMapping("/sigungus") - CommonResponse> listCourseGenerationSigungus(@PathVariable String roomId); + @GetMapping("/sidos/{sidoCode}/sigungus") + CommonResponse> listCourseGenerationSigungus( + @PathVariable String roomId, + @PathVariable String sidoCode + ); @Operation( tags = {"Date course"}, diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index 22b8f4c..b00dc34 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -51,10 +51,33 @@ public class DateCourseQueryService { private final ObjectMapper objectMapper; @Transactional(readOnly = true) - public List listCourseGenerationSigungus(String roomPublicId, Long userId) { + public List listCourseGenerationSidos(String roomPublicId, Long userId) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); - List options = - roomPlaceRepository.findDistinctSigunguOptionsByRoomId(room.getId()); + return toRegionOptions(roomPlaceRepository.findDistinctSidoOptionsByRoomId(room.getId())); + } + + @Transactional(readOnly = true) + public List listCourseGenerationSigungus(String roomPublicId, String sidoCode, Long userId) { + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + String normalizedSidoCode = requireSidoSavedInRoom(room.getId(), sidoCode); + return toRegionOptions( + roomPlaceRepository.findDistinctSigunguOptionsByRoomIdAndSidoCode(room.getId(), normalizedSidoCode) + ); + } + + private String requireSidoSavedInRoom(Long roomId, String sidoCode) { + if (sidoCode == null || sidoCode.isBlank()) { + throw new FieldValidationException("sidoCode", "시/도 코드는 필수입니다.", sidoCode); + } + String normalized = sidoCode.trim(); + if (!roomPlaceRepository.existsByRoomIdAndSidoCode(roomId, normalized)) { + throw new FieldValidationException( + "sidoCode", "이 방에 저장된 시/도가 아닙니다.", sidoCode); + } + return normalized; + } + + private static List toRegionOptions(List options) { return IntStream.range(0, options.size()) .mapToObj(index -> new RegionOptionResult( options.get(index).getCode(), diff --git a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java index 28662eb..da71879 100644 --- a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java +++ b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java @@ -43,15 +43,37 @@ select count(rp) long countByRoomId(Long roomId); + @Query(""" + select distinct rp.sidoCode as code, rp.sidoName as name + from RoomPlace rp + where rp.room.id = :roomId + and rp.sidoCode is not null + and rp.sidoCode <> '' + order by rp.sidoCode asc + """) + List findDistinctSidoOptionsByRoomId(@Param("roomId") Long roomId); + @Query(""" select distinct rp.sigunguCode as code, rp.sigunguName as name from RoomPlace rp where rp.room.id = :roomId + and rp.sidoCode = :sidoCode and rp.sigunguCode is not null and rp.sigunguCode <> '' order by rp.sigunguCode asc """) - List findDistinctSigunguOptionsByRoomId(@Param("roomId") Long roomId); + List findDistinctSigunguOptionsByRoomIdAndSidoCode( + @Param("roomId") Long roomId, + @Param("sidoCode") String sidoCode + ); + + @Query(""" + select case when count(rp) > 0 then true else false end + from RoomPlace rp + where rp.room.id = :roomId + and rp.sidoCode = :sidoCode + """) + boolean existsByRoomIdAndSidoCode(@Param("roomId") Long roomId, @Param("sidoCode") String sidoCode); long deleteByRoomId(Long roomId); @@ -64,7 +86,7 @@ select count(rp) """) int clearOriginRoomLinkByOriginRoomLinkId(@Param("roomLinkId") Long roomLinkId); - interface RoomPlaceSigunguOption { + interface RoomPlaceRegionOption { String getCode(); diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java index 146f93d..b463ad6 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseQueryServiceTest.java @@ -1,6 +1,7 @@ package com.hufs.capstone.backend.course.application; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -8,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.FieldValidationException; import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; import com.hufs.capstone.backend.region.application.dto.RegionOptionResult; import com.hufs.capstone.backend.room.application.RoomAccessService; @@ -37,31 +39,57 @@ class DateCourseQueryServiceTest { ); @Test - void listCourseGenerationSigungusReturnsOnlyRoomPlaceSigungus() { + void listCourseGenerationSidosReturnsOnlyRoomPlaceSidos() { Room room = mock(Room.class); when(room.getId()).thenReturn(10L); when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); - when(roomPlaceRepository.findDistinctSigunguOptionsByRoomId(10L)).thenReturn(List.of( - sigungu("11110", "Jongno-gu"), - sigungu("11680", "Gangnam-gu") + when(roomPlaceRepository.findDistinctSidoOptionsByRoomId(10L)).thenReturn(List.of( + region("11", "Seoul"), + region("41", "Gyeonggi"), + region("50", "Jeju") )); - List results = service.listCourseGenerationSigungus(ROOM_PUBLIC_ID, USER_ID); + List results = service.listCourseGenerationSidos(ROOM_PUBLIC_ID, USER_ID); + + assertThat(results).extracting(RegionOptionResult::code) + .containsExactly("11", "41", "50"); + verify(roomPlaceRepository).findDistinctSidoOptionsByRoomId(10L); + } + + @Test + void listCourseGenerationSigungusReturnsOnlyRoomPlaceSigungusUnderSido() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(10L); + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(roomPlaceRepository.existsByRoomIdAndSidoCode(10L, "11")).thenReturn(true); + when(roomPlaceRepository.findDistinctSigunguOptionsByRoomIdAndSidoCode(10L, "11")) + .thenReturn(List.of( + region("11110", "Jongno-gu"), + region("11680", "Gangnam-gu") + )); + + List results = service.listCourseGenerationSigungus(ROOM_PUBLIC_ID, "11", USER_ID); assertThat(results).extracting(RegionOptionResult::code) .containsExactly("11110", "11680"); - assertThat(results).extracting(RegionOptionResult::name) - .containsExactly("Jongno-gu", "Gangnam-gu"); - assertThat(results).extracting(RegionOptionResult::displayOrder) - .containsExactly(1, 2); - assertThat(results).extracting(RegionOptionResult::all) - .containsExactly(false, false); - verify(roomAccessService).requireMemberRoom(ROOM_PUBLIC_ID, USER_ID); - verify(roomPlaceRepository).findDistinctSigunguOptionsByRoomId(10L); + verify(roomPlaceRepository).findDistinctSigunguOptionsByRoomIdAndSidoCode(10L, "11"); + } + + @Test + void listCourseGenerationSigungusRejectsSidoNotSavedInRoom() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(10L); + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(roomPlaceRepository.existsByRoomIdAndSidoCode(10L, "41")).thenReturn(false); + + assertThatThrownBy(() -> service.listCourseGenerationSigungus(ROOM_PUBLIC_ID, "41", USER_ID)) + .isInstanceOf(FieldValidationException.class) + .satisfies(ex -> assertThat(((FieldValidationException) ex).getFieldErrors().get(0).message()) + .isEqualTo("이 방에 저장된 시/도가 아닙니다.")); } - private static RoomPlaceRepository.RoomPlaceSigunguOption sigungu(String code, String name) { - return new RoomPlaceRepository.RoomPlaceSigunguOption() { + private static RoomPlaceRepository.RoomPlaceRegionOption region(String code, String name) { + return new RoomPlaceRepository.RoomPlaceRegionOption() { @Override public String getCode() { From a091d24067b219bd09e75dbb18d8ee2000455542 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Wed, 3 Jun 2026 23:43:21 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=BD=94=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/DateCourseController.java | 4 +++- .../api/controller/swagger/DateCourseApi.java | 4 +++- .../api/request/DateCourseSaveRequest.java | 18 +++++++++++++++ .../api/response/DateCourseResponse.java | 2 ++ .../api/response/MyDateCourseResponse.java | 2 ++ .../DateCourseGenerationService.java | 1 + .../application/DateCourseQueryService.java | 2 ++ .../application/DateCourseSaveService.java | 7 ++++-- .../application/dto/DateCourseResult.java | 1 + .../application/dto/MyDateCourseResult.java | 1 + .../course/domain/DateCourseNamePolicy.java | 22 +++++++++++++++++++ .../course/domain/entity/DateCourse.java | 11 +++------- .../repository/DateCourseRepository.java | 13 +++++++---- .../api/response/DateCourseResponseTest.java | 6 +++++ .../DateCourseInputValidatorTest.java | 1 + .../domain/DateCourseNamePolicyTest.java | 22 +++++++++++++++++++ 16 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java create mode 100644 src/main/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicy.java create mode 100644 src/test/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicyTest.java diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index 99a2b04..d72c8dc 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -3,6 +3,7 @@ import com.hufs.capstone.backend.auth.security.SecurityUtils; import com.hufs.capstone.backend.course.api.controller.swagger.DateCourseApi; import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; +import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; @@ -63,10 +64,11 @@ public CommonResponse generateCourse( public CommonResponse saveCourse( @PathVariable String roomId, @PathVariable String dateCourseId, + @Valid @RequestBody DateCourseSaveRequest request, @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken ) { Long userId = SecurityUtils.currentUserIdOrThrow(); - saveService.save(roomId, dateCourseId, userId); + saveService.save(roomId, dateCourseId, request.courseName(), userId); return CommonResponse.ok(null); } diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index f0f6c8a..e38395e 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -1,6 +1,7 @@ package com.hufs.capstone.backend.course.api.controller.swagger; import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; +import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; @@ -64,7 +65,7 @@ CommonResponse generateCourse( @Operation( tags = {"Date course"}, summary = "데이트 코스 저장 API", - description = "생성된 코스 후보 중 하나를 선택해 저장합니다. " + description = "생성된 코스 후보 중 하나를 선택해 저장합니다." + "이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다." ) @ApiResponse(responseCode = "200", description = "저장 성공") @@ -72,6 +73,7 @@ CommonResponse generateCourse( CommonResponse saveCourse( @PathVariable String roomId, @PathVariable String dateCourseId, + @Valid @RequestBody DateCourseSaveRequest request, @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken ); diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java new file mode 100644 index 0000000..479ebc2 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java @@ -0,0 +1,18 @@ +package com.hufs.capstone.backend.course.api.request; + +import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record DateCourseSaveRequest( + @NotBlank(message = "데이트 코스 이름은 필수입니다.") + @Size(max = DateCourseNamePolicy.MAX_LENGTH, message = "데이트 코스 이름은 20자를 초과할 수 없습니다.") + String courseName +) { + + public DateCourseSaveRequest { + if (courseName != null) { + courseName = courseName.trim(); + } + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java index 008e82f..cda006f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/DateCourseResponse.java @@ -7,6 +7,7 @@ public record DateCourseResponse( String dateCourseId, + String courseName, CourseMode mode, String generationBatchId, Instant startDateTime, @@ -24,6 +25,7 @@ public record DateCourseResponse( public static DateCourseResponse from(DateCourseResult result) { return new DateCourseResponse( result.dateCourseId(), + result.courseName(), result.courseMode(), result.generationBatchId(), result.startDateTime(), diff --git a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java index 65b8a3f..e2ffdbd 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/response/MyDateCourseResponse.java @@ -7,6 +7,7 @@ public record MyDateCourseResponse( String dateCourseId, + String courseName, CourseMode mode, String generationBatchId, Instant startDateTime, @@ -22,6 +23,7 @@ public record MyDateCourseResponse( public static MyDateCourseResponse from(MyDateCourseResult result) { return new MyDateCourseResponse( result.dateCourseId(), + result.courseName(), result.courseMode(), result.generationBatchId(), result.startDateTime(), diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java index 08a80fe..9e9ab5e 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -108,6 +108,7 @@ private static DateCourseResult toResult(DateCourse dateCourse, List } return new DateCourseResult( dateCourse.getDateCourseId(), + dateCourse.getCourseName(), dateCourse.getCourseMode(), dateCourse.getGenerationBatchId(), dateCourse.getStartDateTime(), diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index b00dc34..1d9ce73 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -190,6 +190,7 @@ private DateCourseResult toCourseResult(DateCourse course, List return new DateCourseResult( course.getDateCourseId(), + course.getCourseName(), course.getCourseMode(), course.getGenerationBatchId(), course.getStartDateTime(), @@ -213,6 +214,7 @@ private MyDateCourseResult toMyResult(DateCourse course, List p Room room = course.getRoom(); return new MyDateCourseResult( course.getDateCourseId(), + course.getCourseName(), course.getCourseMode(), course.getGenerationBatchId(), course.getStartDateTime(), diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java index 6d9bc90..1ce58dc 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -1,5 +1,6 @@ package com.hufs.capstone.backend.course.application; +import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy; import com.hufs.capstone.backend.course.domain.entity.DateCourse; import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; @@ -24,8 +25,9 @@ public class DateCourseSaveService { private final DateCourseDuplicatePolicy duplicatePolicy; @Transactional - public void save(String roomPublicId, String dateCourseId, Long userId) { + public void save(String roomPublicId, String dateCourseId, String courseName, Long userId) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + String normalizedName = DateCourseNamePolicy.normalizeAndValidate(courseName); DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomId(dateCourseId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.")); @@ -35,7 +37,8 @@ public void save(String roomPublicId, String dateCourseId, Long userId) { throw new BusinessException(ErrorCode.E409_CONFLICT, "동일한 데이트 코스가 이미 저장되어 있습니다."); } - int updated = dateCourseRepository.markAsSavedIfAbsent(course.getId(), userId, Instant.now()); + int updated = dateCourseRepository.markAsSavedIfAbsent( + course.getId(), userId, Instant.now(), normalizedName); if (updated == 0) { throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 데이트 코스입니다."); } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java index cdcbe8f..ddb60b0 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/DateCourseResult.java @@ -6,6 +6,7 @@ public record DateCourseResult( String dateCourseId, + String courseName, CourseMode courseMode, String generationBatchId, Instant startDateTime, diff --git a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java index ff77cb5..3d4824b 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/dto/MyDateCourseResult.java @@ -6,6 +6,7 @@ public record MyDateCourseResult( String dateCourseId, + String courseName, CourseMode courseMode, String generationBatchId, Instant startDateTime, diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicy.java b/src/main/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicy.java new file mode 100644 index 0000000..d8b4e02 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicy.java @@ -0,0 +1,22 @@ +package com.hufs.capstone.backend.course.domain; + +import com.hufs.capstone.backend.global.exception.FieldValidationException; + +public final class DateCourseNamePolicy { + + public static final int MAX_LENGTH = 20; + + private DateCourseNamePolicy() { + } + + public static String normalizeAndValidate(String courseName) { + if (courseName == null || courseName.isBlank()) { + throw new FieldValidationException("courseName", "데이트 코스 이름은 필수입니다."); + } + String normalized = courseName.trim(); + if (normalized.length() > MAX_LENGTH) { + throw new FieldValidationException("courseName", "데이트 코스 이름은 20자를 초과할 수 없습니다.", normalized); + } + return normalized; + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java index 77ef6bc..07444fa 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -38,6 +38,9 @@ public class DateCourse extends AuditableEntity { @Column(name = "date_course_id", nullable = false, length = 36) private String dateCourseId; + @Column(name = "course_name", length = 20) + private String courseName; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "room_id", nullable = false) private Room room; @@ -120,12 +123,4 @@ public static DateCourse create( return new DateCourse(dateCourseId, room, createdByUserId, courseMode, startDateTime, endDateTime, generationBatchId, sigunguCode, categorySequenceJson, skippedSlotIndicesJson); } - - public void markAsSaved(Long userId) { - if (this.savedByUserId != null) { - throw new IllegalStateException("이미 저장된 코스입니다."); - } - this.savedByUserId = userId; - this.savedAt = Instant.now(); - } } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index 4b7f419..224dd9b 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -15,12 +15,17 @@ public interface DateCourseRepository extends JpaRepository { @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE DateCourse dc - SET dc.savedByUserId = :userId, dc.savedAt = :savedAt + SET dc.savedByUserId = :userId, + dc.savedAt = :savedAt, + dc.courseName = :courseName WHERE dc.id = :id AND dc.savedByUserId IS NULL """) - int markAsSavedIfAbsent(@Param("id") Long id, - @Param("userId") Long userId, - @Param("savedAt") Instant savedAt); + int markAsSavedIfAbsent( + @Param("id") Long id, + @Param("userId") Long userId, + @Param("savedAt") Instant savedAt, + @Param("courseName") String courseName + ); Optional findByDateCourseIdAndRoomId(String dateCourseId, Long roomId); diff --git a/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java index 7a0de61..57931db 100644 --- a/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/api/response/DateCourseResponseTest.java @@ -18,6 +18,7 @@ class DateCourseResponseTest { void placesAndCoordinatesAreOrderedBySequenceOrder() { DateCourseResult result = new DateCourseResult( "course-1", + "강남 데이트", CourseMode.GENERAL, "batch-1", Instant.parse("2026-06-03T12:00:00Z"), @@ -53,6 +54,7 @@ void placesAndCoordinatesAreOrderedBySequenceOrder() { .isEqualByComparingTo("37.1"); assertThat(response.orderedCoordinates().get(1).longitude()) .isEqualByComparingTo("127.3"); + assertThat(response.courseName()).isEqualTo("강남 데이트"); } @Test @@ -63,6 +65,7 @@ void generationResponsePreservesRoomPlaceIdOnEveryPlace() { List.of( new DateCourseResult( "course-1", + null, CourseMode.GENERAL, "batch-1", Instant.parse("2026-06-03T12:00:00Z"), @@ -83,12 +86,14 @@ void generationResponsePreservesRoomPlaceIdOnEveryPlace() { assertThat(response.courses().get(0).places()) .extracting(DateCoursePlaceResponse::roomPlaceId) .containsExactly(1L); + assertThat(response.courses().get(0).courseName()).isNull(); } @Test void myDateCourseResponseIncludesOrderedCoordinates() { MyDateCourseResult result = new MyDateCourseResult( "course-1", + "마이 코스", CourseMode.GENERAL, "batch-1", Instant.parse("2026-06-03T12:00:00Z"), @@ -114,6 +119,7 @@ void myDateCourseResponseIncludesOrderedCoordinates() { assertThat(response.orderedCoordinates()) .extracting(DateCourseCoordinateResponse::sequenceOrder) .containsExactly(0, 1); + assertThat(response.courseName()).isEqualTo("마이 코스"); } private static DateCoursePlaceResult place(int sequenceOrder, String name, String latitude, String longitude) { diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java index 802743e..eb2d71d 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseInputValidatorTest.java @@ -96,4 +96,5 @@ void validCategoryTagCombinationPasses() { List.of(new CategorySlotCommand("FOOD", "KOREAN")) ); } + } diff --git a/src/test/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicyTest.java b/src/test/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicyTest.java new file mode 100644 index 0000000..980e516 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/domain/DateCourseNamePolicyTest.java @@ -0,0 +1,22 @@ +package com.hufs.capstone.backend.course.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import org.junit.jupiter.api.Test; + +class DateCourseNamePolicyTest { + + @Test + void normalizesTrimmedName() { + assertThat(DateCourseNamePolicy.normalizeAndValidate(" 강남 데이트 ")) + .isEqualTo("강남 데이트"); + } + + @Test + void rejectsBlankName() { + assertThatThrownBy(() -> DateCourseNamePolicy.normalizeAndValidate(" ")) + .isInstanceOf(FieldValidationException.class); + } +}