-
Notifications
You must be signed in to change notification settings - Fork 0
✨feat : 홈 관련 기능 구현 #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/\uD648-\uAD00\uB828-\uAE30\uB2A5-\uAD6C\uD604"
Changes from all commits
4a0d2cc
02e159f
ba8fbed
af54c30
78a5306
74d363a
0c2a612
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| package com.pinHouse.server.platform.home.application.service; | ||
|
|
||
| import com.pinHouse.server.core.exception.code.PinPointErrorCode; | ||
| import com.pinHouse.server.core.response.response.CustomException; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceRequest; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceResponse; | ||
| import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; | ||
| import com.pinHouse.server.platform.housing.notice.domain.entity.NoticeDocument; | ||
| import com.pinHouse.server.platform.housing.notice.domain.repository.NoticeDocumentRepository; | ||
| import com.pinHouse.server.platform.like.application.usecase.LikeQueryUseCase; | ||
| import com.pinHouse.server.platform.pinPoint.application.usecase.PinPointUseCase; | ||
| import com.pinHouse.server.platform.pinPoint.domain.entity.PinPoint; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; | ||
| import com.pinHouse.server.platform.search.application.usecase.NoticeSearchUseCase; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.domain.Sort; | ||
| import org.springframework.data.domain.SliceImpl; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.Instant; | ||
| import java.time.ZoneId; | ||
| import java.time.ZonedDateTime; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * 홈 화면 서비스 | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class HomeService implements HomeUseCase { | ||
|
|
||
| private final NoticeDocumentRepository noticeRepository; | ||
| private final LikeQueryUseCase likeService; | ||
| private final NoticeSearchUseCase noticeSearchService; | ||
| private final PinPointUseCase pinPointService; | ||
|
|
||
| /** | ||
| * 마감임박공고 조회 (PinPoint 지역 기반) | ||
| * - PinPoint의 address에서 광역 단위와 시/군/구를 추출하여 해당 지역의 마감임박 공고를 조회 | ||
| */ | ||
| @Override | ||
| public SliceResponse<NoticeListResponse> getDeadlineApproachingNotices( | ||
| String pinpointId, | ||
| SliceRequest sliceRequest, | ||
| UUID userId | ||
| ) { | ||
| // PinPoint 소유자 검증 | ||
| boolean isOwner = pinPointService.checkPinPoint(pinpointId, userId); | ||
| if (!isOwner) { | ||
| log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", pinpointId, userId); | ||
| throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); | ||
| } | ||
|
|
||
| // PinPoint 조회 | ||
| PinPoint pinPoint = pinPointService.loadPinPoint(pinpointId); | ||
|
|
||
| // PinPoint의 address에서 광역 단위(Region)와 시/군/구 추출 | ||
| NoticeListRequest.Region region = extractRegionFromAddress(pinPoint.getAddress()); | ||
| String county = extractCountyFromAddress(pinPoint.getAddress()); | ||
|
|
||
| // 현재 시각 (한국 시간 기준) | ||
| Instant now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toInstant(); | ||
|
|
||
| // 페이징 설정 (마감임박순 정렬) | ||
| Sort sort = Sort.by(Sort.Order.asc("applyEnd"), Sort.Order.asc("noticeId")); | ||
| Pageable pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), sort); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find SliceRequest file
fd -t f -i slicerequest.javaRepository: PinHouse/PinHouse_BE Length of output: 147 🏁 Script executed: cat -n src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.javaRepository: PinHouse/PinHouse_BE Length of output: 597 🏁 Script executed: # Check HomeService line 78 with context
sed -n '70,85p' src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.javaRepository: PinHouse/PinHouse_BE Length of output: 788 🏁 Script executed: # Search for tests related to HomeService or SliceRequest
find . -type f -name "*HomeServiceTest*" -o -name "*SliceRequestTest*" 2>/dev/null | head -20
# Also search for other usages of SliceRequest
rg "SliceRequest" --type java -B 2 -A 2 | head -100Repository: PinHouse/PinHouse_BE Length of output: 11714 🏁 Script executed: # Check HomeApi to see how SliceRequest is documented
cat -n src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java | head -80Repository: PinHouse/PinHouse_BE Length of output: 3702 🏁 Script executed: # Check HomeApiSpec for any additional documentation
cat -n src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java | head -120Repository: PinHouse/PinHouse_BE Length of output: 3188 API 문서와 코드 로직의 페이징 규칙 불일치 해결 필수
해결책:
🤖 Prompt for AI Agents |
||
|
|
||
| // Repository에서 직접 조회 | ||
| Page<NoticeDocument> page = noticeRepository.findDeadlineApproachingNoticesByRegionAndCounty( | ||
| region.getFullName(), | ||
| county, | ||
| pageable, | ||
| now | ||
| ); | ||
|
|
||
| // 좋아요 상태 조회 | ||
| List<String> likedNoticeIds = likeService.getLikeNoticeIds(userId); | ||
|
|
||
| // DTO 변환 | ||
| List<NoticeListResponse> content = page.getContent().stream() | ||
| .map(notice -> { | ||
| boolean isLiked = likedNoticeIds.contains(notice.getId()); | ||
| return NoticeListResponse.from(notice, isLiked); | ||
| }) | ||
| .toList(); | ||
|
|
||
| return SliceResponse.from(new SliceImpl<>(content, pageable, page.hasNext()), page.getTotalElements()); | ||
| } | ||
|
|
||
| /** | ||
| * 주소에서 시/군/구를 추출 | ||
| * @param address 전체 주소 (예: "경기도 성남시 분당구 정자동" 또는 "서울특별시 강남구 역삼동") | ||
| * @return 시/군/구 이름 (예: "성남시", "강남구") | ||
| */ | ||
| private String extractCountyFromAddress(String address) { | ||
| if (address == null || address.isBlank()) { | ||
| return null; | ||
| } | ||
|
|
||
| // 공백으로 주소를 분리 | ||
| String[] parts = address.trim().split("\\s+"); | ||
|
|
||
| // 광역 단위를 제외한 나머지 부분에서 시/군/구를 찾기 | ||
| for (int i = 1; i < parts.length; i++) { | ||
| String part = parts[i]; | ||
| // "시", "군", "구"로 끝나는 첫 번째 토큰을 반환 | ||
| if (part.endsWith("시") || part.endsWith("군") || part.endsWith("구")) { | ||
| return part; | ||
| } | ||
| } | ||
|
|
||
| // 시/군/구를 찾지 못한 경우 null 반환 (광역시 등의 경우) | ||
| log.debug("주소에서 시/군/구를 추출할 수 없습니다. address={}", address); | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * 주소에서 광역 단위(시/도)를 추출 | ||
| * @param address 전체 주소 (예: "서울특별시 강남구 역삼동" 또는 "서울 강남구 역삼동") | ||
| * @return Region enum | ||
| */ | ||
| private NoticeListRequest.Region extractRegionFromAddress(String address) { | ||
| if (address == null || address.isBlank()) { | ||
| throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); | ||
| } | ||
|
|
||
| // 1차: 정확한 fullName으로 매칭 시도 | ||
| for (NoticeListRequest.Region region : NoticeListRequest.Region.values()) { | ||
| if (address.startsWith(region.getFullName())) { | ||
| return region; | ||
| } | ||
| } | ||
|
|
||
| // 2차: 약칭으로 매칭 시도 (예: "서울" -> "서울특별시") | ||
| if (address.startsWith("서울")) return NoticeListRequest.Region.SEOUL; | ||
| if (address.startsWith("부산")) return NoticeListRequest.Region.BUSAN; | ||
| if (address.startsWith("대구")) return NoticeListRequest.Region.DAEGU; | ||
| if (address.startsWith("인천")) return NoticeListRequest.Region.INCHEON; | ||
| if (address.startsWith("광주")) return NoticeListRequest.Region.GWANGJU; | ||
| if (address.startsWith("대전")) return NoticeListRequest.Region.DAEJEON; | ||
| if (address.startsWith("울산")) return NoticeListRequest.Region.ULSAN; | ||
| if (address.startsWith("세종")) return NoticeListRequest.Region.SEJONG; | ||
| if (address.startsWith("경기")) return NoticeListRequest.Region.GYEONGGI; | ||
| if (address.startsWith("강원")) return NoticeListRequest.Region.GANGWON; | ||
| if (address.startsWith("충북") || address.startsWith("충청북")) return NoticeListRequest.Region.CHUNGBUK; | ||
| if (address.startsWith("충남") || address.startsWith("충청남")) return NoticeListRequest.Region.CHUNGNAM; | ||
| if (address.startsWith("전북") || address.startsWith("전라북")) return NoticeListRequest.Region.JEONBUK; | ||
| if (address.startsWith("전남") || address.startsWith("전라남")) return NoticeListRequest.Region.JEONNAM; | ||
| if (address.startsWith("경북") || address.startsWith("경상북")) return NoticeListRequest.Region.GYEONGBUK; | ||
| if (address.startsWith("경남") || address.startsWith("경상남")) return NoticeListRequest.Region.GYEONGNAM; | ||
| if (address.startsWith("제주")) return NoticeListRequest.Region.JEJU; | ||
|
|
||
| // 매칭되는 Region을 찾지 못한 경우 | ||
| log.warn("주소에서 광역 단위를 추출할 수 없습니다. address={}", address); | ||
| throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); | ||
| } | ||
|
|
||
| /** | ||
| * 통합 검색 (공고 제목 및 타겟 그룹 기반) | ||
| * - NoticeSearchUseCase를 그대로 활용 | ||
| */ | ||
| @Override | ||
| public SliceResponse<NoticeSearchResultResponse> searchNoticesIntegrated( | ||
| String keyword, | ||
| int page, | ||
| int size, | ||
| NoticeSearchSortType sortType, | ||
| NoticeSearchFilterType status, | ||
| UUID userId | ||
| ) { | ||
| return noticeSearchService.searchNotices(keyword, page, size, sortType, status, userId); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package com.pinHouse.server.platform.home.application.usecase; | ||
|
|
||
| import com.pinHouse.server.core.response.response.pageable.SliceRequest; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceResponse; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; | ||
|
|
||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * 홈 화면 Use Case | ||
| */ | ||
| public interface HomeUseCase { | ||
|
|
||
| /** | ||
| * 마감임박공고 조회 (PinPoint 지역 기반) | ||
| * @param pinpointId PinPoint ID (해당 지역의 공고를 조회) | ||
| * @param sliceRequest 페이징 정보 | ||
| * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) | ||
| * @return 해당 지역의 마감임박순으로 정렬된 공고 목록 | ||
| */ | ||
| SliceResponse<NoticeListResponse> getDeadlineApproachingNotices( | ||
| String pinpointId, | ||
| SliceRequest sliceRequest, | ||
| UUID userId | ||
| ); | ||
|
|
||
| /** | ||
| * 통합 검색 (공고 제목 및 타겟 그룹 기반) | ||
| * @param keyword 검색 키워드 | ||
| * @param page 페이지 번호 (1부터 시작) | ||
| * @param size 페이지 크기 | ||
| * @param sortType 정렬 방식 (LATEST: 최신공고순, END: 마감임박순) | ||
| * @param status 공고 상태 (ALL: 전체, RECRUITING: 모집중) | ||
| * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) | ||
| * @return 검색 결과 (무한 스크롤 응답) | ||
| */ | ||
| SliceResponse<NoticeSearchResultResponse> searchNoticesIntegrated( | ||
| String keyword, | ||
| int page, | ||
| int size, | ||
| NoticeSearchSortType sortType, | ||
| NoticeSearchFilterType status, | ||
| UUID userId | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package com.pinHouse.server.platform.home.presentation; | ||
|
|
||
| import com.pinHouse.server.core.aop.CheckLogin; | ||
| import com.pinHouse.server.core.response.response.ApiResponse; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceRequest; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceResponse; | ||
| import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; | ||
| import com.pinHouse.server.platform.home.presentation.swagger.HomeApiSpec; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; | ||
| import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * 홈 화면 API | ||
| */ | ||
| @Slf4j | ||
| @RestController | ||
| @RequestMapping("/v1/home") | ||
| @RequiredArgsConstructor | ||
| public class HomeApi implements HomeApiSpec { | ||
|
|
||
| private final HomeUseCase homeService; | ||
|
|
||
| /** | ||
| * 마감임박공고 조회 (PinPoint 지역 기반) | ||
| * GET /v1/home/deadline-approaching?pinpointId=xxx&page=1&offSet=20 | ||
| * 로그인 필수 | ||
| */ | ||
| @Override | ||
| @CheckLogin | ||
| @GetMapping("/notice") | ||
| public ApiResponse<SliceResponse<NoticeListResponse>> getDeadlineApproachingNotices( | ||
| @RequestParam String pinpointId, | ||
| SliceRequest sliceRequest, | ||
| @AuthenticationPrincipal PrincipalDetails principalDetails | ||
| ) { | ||
| // @CheckLogin에 의해 principalDetails는 항상 non-null | ||
| UUID userId = principalDetails.getId(); | ||
|
|
||
| // 서비스 호출 | ||
| SliceResponse<NoticeListResponse> response = homeService.getDeadlineApproachingNotices( | ||
| pinpointId, | ||
| sliceRequest, | ||
| userId | ||
| ); | ||
|
|
||
| return ApiResponse.ok(response); | ||
| } | ||
|
|
||
| /** | ||
| * 통합 검색 (공고 제목 및 타겟 그룹 기반) | ||
| * GET /v1/home/search?q=키워드&page=1&offSet=20&sortType=LATEST&status=ALL | ||
| */ | ||
| @Override | ||
| @GetMapping("/search") | ||
| public ApiResponse<SliceResponse<NoticeSearchResultResponse>> searchNoticesIntegrated( | ||
| @RequestParam String q, | ||
| SliceRequest sliceRequest, | ||
| @RequestParam(required = false, defaultValue = "LATEST") NoticeSearchSortType sortType, | ||
| @RequestParam(required = false, defaultValue = "ALL") NoticeSearchFilterType status, | ||
| @AuthenticationPrincipal PrincipalDetails principalDetails | ||
| ) { | ||
| // 로그인하지 않은 경우 userId는 null | ||
| UUID userId = (principalDetails != null) ? principalDetails.getId() : null; | ||
|
|
||
| // 서비스 호출 | ||
| SliceResponse<NoticeSearchResultResponse> response = homeService.searchNoticesIntegrated( | ||
| q, | ||
| sliceRequest.page(), | ||
| sliceRequest.offSet(), | ||
| sortType, | ||
| status, | ||
| userId | ||
| ); | ||
|
|
||
| return ApiResponse.ok(response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package com.pinHouse.server.platform.home.presentation.swagger; | ||
|
|
||
| import com.pinHouse.server.core.response.response.ApiResponse; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceRequest; | ||
| import com.pinHouse.server.core.response.response.pageable.SliceResponse; | ||
| import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; | ||
| import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; | ||
| import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
|
|
||
| @Tag(name = "홈 API", description = "홈 화면 관련 API 입니다") | ||
| public interface HomeApiSpec { | ||
|
|
||
| @Operation( | ||
| summary = "마감임박공고 조회 API (PinPoint 지역 기반) - 로그인 필수", | ||
| description = "PinPoint의 지역을 기반으로 마감임박순으로 정렬된 공고 목록을 조회하는 API 입니다. " + | ||
| "PinPoint의 주소에서 광역 단위(시/도)를 추출하여 해당 지역의 모집중인 공고만 조회합니다. " + | ||
| "본인의 PinPoint만 사용 가능하며, 다른 사용자의 PinPoint ID를 사용하면 400 에러가 발생합니다. " + | ||
| "좋아요 정보가 포함됩니다." | ||
| ) | ||
| ApiResponse<SliceResponse<NoticeListResponse>> getDeadlineApproachingNotices( | ||
| @Parameter(description = "PinPoint ID", example = "83ec36ce-8fc1-4f62-8983-397c2729fc22") | ||
| @RequestParam String pinpointId, | ||
|
|
||
| SliceRequest sliceRequest, | ||
|
|
||
| @AuthenticationPrincipal PrincipalDetails principalDetails | ||
| ); | ||
|
|
||
| @Operation( | ||
| summary = "통합 검색 API", | ||
| description = "공고 제목 및 타겟 그룹을 기반으로 검색하는 통합 검색 API 입니다. " + | ||
| "키워드를 입력하면 공고 제목과 모집 대상에서 검색하여 결과를 반환합니다. " + | ||
| "정렬 방식과 공고 상태 필터를 적용할 수 있으며, 로그인한 사용자의 경우 좋아요 정보가 포함됩니다." | ||
| ) | ||
| ApiResponse<SliceResponse<NoticeSearchResultResponse>> searchNoticesIntegrated( | ||
| @Parameter(description = "검색 키워드", example = "청년") | ||
| @RequestParam String q, | ||
|
|
||
| SliceRequest sliceRequest, | ||
|
|
||
| @Parameter(description = "정렬 방식 (LATEST: 최신공고순, END: 마감임박순)", example = "LATEST") | ||
| @RequestParam(required = false, defaultValue = "LATEST") NoticeSearchSortType sortType, | ||
|
|
||
| @Parameter(description = "공고 상태 (ALL: 전체, RECRUITING: 모집중)", example = "ALL") | ||
| @RequestParam(required = false, defaultValue = "ALL") NoticeSearchFilterType status, | ||
|
|
||
| @AuthenticationPrincipal PrincipalDetails principalDetails | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
정규식 특수 문자 이스케이프 필요
추출된
county값이NoticeDocumentRepositoryImpl의 line 196에서 정규식 패턴에 직접 사용됩니다. 만약 주소에서 추출된 county에 정규식 메타 문자가 포함되어 있으면 의도하지 않은 매칭이나 ReDoS 취약점이 발생할 수 있습니다.Repository 레이어에서
Pattern.quote()를 사용하여 이스케이프 처리하는 것을 권장합니다 (NoticeDocumentRepositoryImpl.java의 다른 리뷰 코멘트 참조).🤖 Prompt for AI Agents