Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
Comment on lines +70 to +71
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

정규식 특수 문자 이스케이프 필요

추출된 county 값이 NoticeDocumentRepositoryImpl의 line 196에서 정규식 패턴에 직접 사용됩니다. 만약 주소에서 추출된 county에 정규식 메타 문자가 포함되어 있으면 의도하지 않은 매칭이나 ReDoS 취약점이 발생할 수 있습니다.

Repository 레이어에서 Pattern.quote()를 사용하여 이스케이프 처리하는 것을 권장합니다 (NoticeDocumentRepositoryImpl.java의 다른 리뷰 코멘트 참조).

🤖 Prompt for AI Agents
In
@src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java
around lines 70-71, The extracted county string from
extractCountyFromAddress(pinPoint.getAddress()) may contain regex metacharacters
and is later used in a Pattern in NoticeDocumentRepositoryImpl; escape it before
usage by quoting the value with Pattern.quote() (either wrap the county here in
HomeService before passing to the repository or update the repository method
that consumes the county to call Pattern.quote(county) when building the
regex/Pattern) so no unescaped user/address data is interpolated into regexes.


// 현재 시각 (한국 시간 기준)
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);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find SliceRequest file
fd -t f -i slicerequest.java

Repository: PinHouse/PinHouse_BE

Length of output: 147


🏁 Script executed:

cat -n src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.java

Repository: 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.java

Repository: 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 -100

Repository: 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 -80

Repository: 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 -120

Repository: PinHouse/PinHouse_BE

Length of output: 3188


API 문서와 코드 로직의 페이징 규칙 불일치 해결 필수

SliceRequest의 Swagger 문서에서는 "페이지 번호 (0부터 시작)"이라고 명시했으나, 실제 코드는 sliceRequest.page() - 1로 1-based 입력을 가정하고 있습니다. 이로 인한 문제점:

  1. 문서 오류: SliceRequest 스키마에서 "0부터 시작"이라고 했으나, HomeApi 주석과 코드 로직은 page=1을 첫 번째 페이지로 취급
  2. 검증 부재: SliceRequest에 @Min(1) 등의 검증이 없어서, 클라이언트가 page=0을 전송하면 -1이 되어 PageRequest.of()에서 오류 발생
  3. 클라이언트 혼란: API 문서를 신뢰하고 page=0을 보내면 실패함

해결책:

  • SliceRequest의 Swagger 문서를 "페이지 번호 (1부터 시작)"으로 수정
  • page 필드에 @Min(value = 1) 검증 추가
🤖 Prompt for AI Agents
In
@src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java
around line 78, The code treats SliceRequest.page() as 1-based (HomeService uses
PageRequest.of(sliceRequest.page() - 1, ...)) while the Swagger schema says
pages are 0-based; fix by making the API contract 1-based and enforcing it:
update the SliceRequest Swagger/Schema description to say "페이지 번호 (1부터 시작)" and
add validation on the page field (e.g., @Min(1)) in the SliceRequest class so
clients cannot pass 0 or negative values that would cause PageRequest.of() to
receive a negative index; keep the existing subtraction in HomeService
(PageRequest.of(sliceRequest.page() - 1, ...)) so existing logic remains correct
once validation and docs are aligned.


// 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
);
}
Loading
Loading