diff --git a/build.gradle b/build.gradle index c9dc3509..3c63b9b6 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' + // AWS SDK v2 for S3 + implementation platform('software.amazon.awssdk:bom:2.21.0') + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:auth' + // 스웨거 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/pinHouse/server/core/config/S3Config.java b/src/main/java/com/pinHouse/server/core/config/S3Config.java new file mode 100644 index 00000000..243e2890 --- /dev/null +++ b/src/main/java/com/pinHouse/server/core/config/S3Config.java @@ -0,0 +1,38 @@ +package com.pinHouse.server.core.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +/** + * AWS S3 클라이언트 설정 클래스 + */ +@Configuration +public class S3Config { + + @Value("${aws.s3.region}") + private String region; + + @Value("${aws.s3.access-key}") + private String accessKey; + + @Value("${aws.s3.secret-key}") + private String secretKey; + + /** + * S3Client Bean 생성 + */ + @Bean + public S3Client s3Client() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/core/exception/code/DiagnosisErrorCode.java b/src/main/java/com/pinHouse/server/core/exception/code/DiagnosisErrorCode.java index 3d0e837e..c0a3bb60 100644 --- a/src/main/java/com/pinHouse/server/core/exception/code/DiagnosisErrorCode.java +++ b/src/main/java/com/pinHouse/server/core/exception/code/DiagnosisErrorCode.java @@ -12,7 +12,7 @@ public enum DiagnosisErrorCode implements ErrorCode { // ======================== // 400 Bad Request // ======================== - BAD_REQUEST_TYPE(400_000,HttpStatus.BAD_REQUEST,"진단 입력 파라미터가 잘못되었습니다"); + BAD_REQUEST_TYPE(400_000, HttpStatus.BAD_REQUEST, "진단 입력 파라미터가 잘못되었습니다"), // ======================== // 401 Unauthorized @@ -25,6 +25,7 @@ public enum DiagnosisErrorCode implements ErrorCode { // ======================== // 404 Not Found // ======================== + NOT_FOUND_DIAGNOSIS(404_001, HttpStatus.NOT_FOUND, "진단 기록을 찾을 수 없습니다"); /** diff --git a/src/main/java/com/pinHouse/server/core/exception/code/ImageErrorCode.java b/src/main/java/com/pinHouse/server/core/exception/code/ImageErrorCode.java new file mode 100644 index 00000000..f0f38bad --- /dev/null +++ b/src/main/java/com/pinHouse/server/core/exception/code/ImageErrorCode.java @@ -0,0 +1,44 @@ +package com.pinHouse.server.core.exception.code; + +import com.pinHouse.server.core.response.response.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 이미지 관련 예외처리 클래스입니다. + */ +@Getter +@RequiredArgsConstructor +public enum ImageErrorCode implements ErrorCode { + + // ======================== + // 400 Bad Request + // ======================== + INVALID_FILE_TYPE(400_301, HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 형식입니다. (jpg, jpeg, png, gif만 가능)"), + INVALID_FILE_EXTENSION(400_302, HttpStatus.BAD_REQUEST, "파일 확장자가 유효하지 않습니다."), + FILE_SIZE_EXCEEDED(400_303, HttpStatus.BAD_REQUEST, "파일 크기가 5MB를 초과합니다."), + INVALID_FILE_NAME(400_304, HttpStatus.BAD_REQUEST, "파일명이 유효하지 않습니다."), + + // ======================== + // 500 Internal Server Error + // ======================== + S3_PRESIGNED_URL_GENERATION_FAILED(500_301, HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 URL 생성에 실패했습니다."), + S3_CLIENT_ERROR(500_302, HttpStatus.INTERNAL_SERVER_ERROR, "S3 서버와 통신 중 오류가 발생했습니다."); + + /** + * 에러 코드 (고유값) + */ + private final Integer code; + + /** + * HTTP 상태 코드 + */ + private final HttpStatus httpStatus; + + /** + * 에러 메시지 + */ + private final String message; + +} diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisRecommendationGroup.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisRecommendationGroup.java new file mode 100644 index 00000000..695d8222 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisRecommendationGroup.java @@ -0,0 +1,25 @@ +package com.pinHouse.server.platform.diagnostic.diagnosis.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "[응답][진단] 추천 그룹", description = "추천 임대 유형을 공고 유형별로 묶은 정보") +public record DiagnosisRecommendationGroup( + + @Schema(description = "공고 유형", example = "통합공공임대") + String noticeType, + + @Schema(description = "추천 공급 유형 리스트", example = "[\"청년 특별공급\", \"신혼부부 특별공급\"]") + List supplyTypes +) { + + public static DiagnosisRecommendationGroup of(String noticeType, List supplyTypes) { + return DiagnosisRecommendationGroup.builder() + .noticeType(noticeType) + .supplyTypes(supplyTypes) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponseV2.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponseV2.java new file mode 100644 index 00000000..af5deb54 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponseV2.java @@ -0,0 +1,59 @@ +package com.pinHouse.server.platform.diagnostic.diagnosis.application.dto; + +import com.pinHouse.server.platform.diagnostic.rule.domain.entity.EvaluationContext; +import com.pinHouse.server.platform.diagnostic.rule.domain.entity.SupplyRentalCandidate; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Builder +@Schema(name = "[응답][진단] 청약 진단 결과 v2", description = "추천 임대주택을 공고 유형별로 그룹화한 응답") +public record DiagnosisResponseV2( + + @Schema(description = "최종 자격 여부 (추천 임대주택이 있는지 여부)", example = "true") + boolean eligible, + + @Schema(description = "최종 요약 메시지", example = "추천 임대주택이 있습니다") + String decisionMessage, + + @Schema(description = "추천 임대주택 후보 그룹", example = "[{\"noticeType\":\"통합공공임대\",\"supplyTypes\":[\"청년 특별공급\",\"신혼부부 특별공급\"]}]") + List recommended +) { + + public static DiagnosisResponseV2 from(EvaluationContext context) { + List candidates = context.getCurrentCandidates(); + + if (candidates.isEmpty()) { + return DiagnosisResponseV2.builder() + .eligible(false) + .decisionMessage("모든 조건 미충족") + .recommended(List.of()) + .build(); + } + + Map> grouped = new LinkedHashMap<>(); + + for (SupplyRentalCandidate candidate : candidates) { + String noticeType = candidate.noticeType().getValue(); + String supplyType = candidate.supplyType().getValue(); + grouped.computeIfAbsent(noticeType, k -> new ArrayList<>()); + if (!grouped.get(noticeType).contains(supplyType)) { + grouped.get(noticeType).add(supplyType); + } + } + + List groups = grouped.entrySet().stream() + .map(e -> DiagnosisRecommendationGroup.of(e.getKey(), e.getValue())) + .toList(); + + return DiagnosisResponseV2.builder() + .eligible(true) + .decisionMessage("추천 임대주택이 있습니다") + .recommended(groups) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java index ac1b7827..c93e038a 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java @@ -3,6 +3,7 @@ import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponseV2; import com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; import com.pinHouse.server.platform.diagnostic.diagnosis.domain.repository.DiagnosisJpaRepository; import com.pinHouse.server.platform.diagnostic.rule.domain.entity.EvaluationContext; @@ -58,6 +59,17 @@ public DiagnosisResponse diagnose(UUID userId, DiagnosisRequest request) { return DiagnosisResponse.from(context); } + @Override + @Transactional + public DiagnosisResponseV2 diagnoseV2(UUID userId, DiagnosisRequest request) { + User user = userService.loadUser(userId); + var diagnosis = Diagnosis.of(user, request); + Diagnosis entity = repository.save(diagnosis); + + EvaluationContext context = ruleChain.evaluateAll(entity); + return DiagnosisResponseV2.from(context); + } + /** * 나의 최근 청약진단 상세 조회 (입력 정보 + 결과) * @param userId 유저ID @@ -86,4 +98,20 @@ public DiagnosisDetailResponse getDiagnoseDetail(UUID userId) { return DiagnosisDetailResponse.from(context); } + @Override + @Transactional(readOnly = true) + public DiagnosisResponseV2 getDiagnoseSummaryV2(UUID userId) { + User user = userService.loadUser(userId); + + Diagnosis diagnosis = repository.findTopByUserOrderByCreatedAtDesc(user) + .orElse(null); + + if (diagnosis == null) { + return null; + } + + EvaluationContext context = ruleChain.evaluateAll(diagnosis); + return DiagnosisResponseV2.from(context); + } + } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java index 8dd998cc..21b6d4c5 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java @@ -3,6 +3,7 @@ import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponseV2; import java.util.UUID; @@ -11,7 +12,13 @@ public interface DiagnosisUseCase { /// 청약 진단하기 (결과만 반환) DiagnosisResponse diagnose(UUID userId, DiagnosisRequest request); + /// 청약 진단하기 v2 (그룹화 응답) + DiagnosisResponseV2 diagnoseV2(UUID userId, DiagnosisRequest request); + /// 최근 진단 상세 조회하기 (입력 정보 + 결과) DiagnosisDetailResponse getDiagnoseDetail(UUID userId); + /// 청약 진단 결과 v2 (추천 그룹화) + DiagnosisResponseV2 getDiagnoseSummaryV2(UUID userId); + } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java index 7b589ffa..fd46daca 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java @@ -4,6 +4,7 @@ import com.pinHouse.server.core.response.response.ApiResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponseV2; import com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; import com.pinHouse.server.platform.diagnostic.diagnosis.presentation.swagger.DiagnosisApiSpec; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; @@ -13,7 +14,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/v1/diagnosis") +@RequestMapping({"/v1/diagnosis", "/v2/diagnosis"}) @RequiredArgsConstructor public class DiagnosisApi implements DiagnosisApiSpec { @@ -36,6 +37,20 @@ public ApiResponse diagnosis(@AuthenticationPrincipal Princip return ApiResponse.ok(response); } + /** + * 청약 진단 v2 (추천 그룹화 응답) + */ + @PostMapping(path = "", params = "v=2") + public ApiResponse diagnosisV2(@AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody DiagnosisRequest request) { + + /// 서비스 + DiagnosisResponseV2 response = service.diagnoseV2(principalDetails.getId(), request); + + /// 리턴 + return ApiResponse.ok(response); + } + /** * 최근 진단 결과 상세 조회 (입력 정보 + 결과) * @@ -52,4 +67,14 @@ public ApiResponse getLatestDiagnosis(@AuthenticationPr /// 리턴 return ApiResponse.ok(response); } + + /** + * 최근 진단 결과 v2 (추천 그룹화) + */ + @GetMapping(path = "/latest", params = "v=2") + @CheckLogin + public ApiResponse getLatestDiagnosisV2(@AuthenticationPrincipal PrincipalDetails principalDetails) { + DiagnosisResponseV2 response = service.getDiagnoseSummaryV2(principalDetails.getId()); + return ApiResponse.ok(response); + } } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java index 09f06535..b0c604ee 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java @@ -4,6 +4,7 @@ import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponseV2; import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -20,10 +21,23 @@ public interface DiagnosisApiSpec { ApiResponse diagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody DiagnosisRequest requestDTO); + @Operation( + summary = "청약 진단 API v2", + description = "청약 진단을 수행하고 추천 결과를 공고 유형별로 그룹화한 v2 응답을 반환합니다." + ) + ApiResponse diagnosisV2(@AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody DiagnosisRequest requestDTO); + @Operation( summary = "최근 진단 결과 상세 조회 API", description = "사용자의 최근 진단 결과를 입력 정보와 함께 상세하게 조회합니다." ) ApiResponse getLatestDiagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails); + @Operation( + summary = "최근 진단 결과 조회 API v2", + description = "추천 임대주택을 공고 유형별로 그룹화하여 반환합니다." + ) + ApiResponse getLatestDiagnosisV2(@AuthenticationPrincipal PrincipalDetails principalDetails); + } diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java index 66d53f90..64b424ce 100644 --- a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java @@ -12,6 +12,9 @@ public record HomeNoticeListResponse( @Schema(description = "공통 지역", example = "성남시") String region, + @Schema(description = "목록 설명/출처", example = "진단 기반 추천") + String title, + @Schema(description = "공고 목록") List content, diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryPageResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryPageResponse.java new file mode 100644 index 00000000..5e2abb8b --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryPageResponse.java @@ -0,0 +1,52 @@ +package com.pinHouse.server.platform.home.application.dto; + +import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "[응답][홈] 통합 검색 카테고리별 페이징 결과", description = "카테고리별 공고 검색 결과와 현재 페이지 정보를 반환합니다.") +public record HomeSearchCategoryPageResponse( + + @Schema(description = "검색 카테고리", example = "NOTICE") + HomeSearchCategoryType category, + + @Schema(description = "현재 페이지 (1부터 시작)", example = "1") + int page, + + @Schema(description = "공고 결과") + List content, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + + public static HomeSearchCategoryPageResponse notice( + int page, + List notices, + boolean hasNext + ) { + return HomeSearchCategoryPageResponse.builder() + .category(HomeSearchCategoryType.NOTICE) + .page(page) + .content(notices) + .hasNext(hasNext) + .build(); + } + + public static HomeSearchCategoryPageResponse of( + HomeSearchCategoryType category, + int page, + List content, + boolean hasNext + ) { + return HomeSearchCategoryPageResponse.builder() + .category(category) + .page(page) + .content(content) + .hasNext(hasNext) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryType.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryType.java new file mode 100644 index 00000000..3ff0f108 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchCategoryType.java @@ -0,0 +1,35 @@ +package com.pinHouse.server.platform.home.application.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.pinHouse.server.core.exception.code.CommonErrorCode; +import com.pinHouse.server.core.response.response.CustomException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum HomeSearchCategoryType { + NOTICE("NOTICE"), + COMPLEX("COMPLEX"), + TARGET_GROUP("TARGET_GROUP"), + REGION("REGION"), + HOUSE_TYPE("HOUSE_TYPE"); + + private final String value; + + @JsonCreator + public static HomeSearchCategoryType from(String value) { + for (HomeSearchCategoryType type : values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + throw new CustomException(CommonErrorCode.BAD_PARAMETER); + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchOverviewResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchOverviewResponse.java new file mode 100644 index 00000000..60b9d325 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchOverviewResponse.java @@ -0,0 +1,26 @@ +package com.pinHouse.server.platform.home.application.dto; + +import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(name = "[응답][홈] 통합 검색 미리보기", description = "홈 통합 검색 섹션별 상위 결과(공고)를 반환합니다.") +public record HomeSearchOverviewResponse( + + @Schema(description = "공고명 검색 결과 (5개 미리보기)") + HomeSearchSectionResponse notices, + + @Schema(description = "단지명 검색 결과 (5개 미리보기, 공고 기준)") + HomeSearchSectionResponse complexes, + + @Schema(description = "모집대상 검색 결과 (5개 미리보기, 공고 기준)") + HomeSearchSectionResponse targetGroups, + + @Schema(description = "지역 검색 결과 (5개 미리보기, 공고 기준)") + HomeSearchSectionResponse regions, + + @Schema(description = "주택유형 검색 결과 (5개 미리보기, 공고 기준)") + HomeSearchSectionResponse houseTypes +) { +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchSectionResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchSectionResponse.java new file mode 100644 index 00000000..4588eb1f --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeSearchSectionResponse.java @@ -0,0 +1,25 @@ +package com.pinHouse.server.platform.home.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(name = "[응답][홈] 통합 검색 섹션", description = "홈 통합 검색 섹션별 결과입니다.") +public record HomeSearchSectionResponse( + + @Schema(description = "결과 목록") + List content, + + @Schema(description = "다음 페이지 존재 여부", example = "false") + boolean hasNext +) { + + public static HomeSearchSectionResponse of(List content, boolean hasNext) { + return HomeSearchSectionResponse.builder() + .content(content) + .hasNext(hasNext) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java b/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java index 0b251199..27abf549 100644 --- a/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java +++ b/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java @@ -7,6 +7,10 @@ import com.pinHouse.server.platform.Location; import com.pinHouse.server.platform.home.application.dto.HomeNoticeListResponse; import com.pinHouse.server.platform.home.application.dto.HomeNoticeResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryPageResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryType; +import com.pinHouse.server.platform.home.application.dto.HomeSearchOverviewResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchSectionResponse; import com.pinHouse.server.platform.home.application.dto.NoticeCountResponse; import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; import com.pinHouse.server.platform.housing.complex.domain.entity.ComplexDocument; @@ -14,13 +18,19 @@ import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; 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.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; 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.dto.PopularKeywordResponse; +import com.pinHouse.server.platform.search.domain.entity.SearchKeywordScope; import com.pinHouse.server.platform.search.application.usecase.NoticeSearchUseCase; +import com.pinHouse.server.platform.search.application.usecase.SearchKeywordUseCase; +import com.pinHouse.server.core.exception.code.DiagnosisErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -36,7 +46,6 @@ import java.time.ZonedDateTime; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; /** * 홈 화면 서비스 @@ -51,7 +60,9 @@ public class HomeService implements HomeUseCase { private final ComplexDocumentRepository complexRepository; private final LikeQueryUseCase likeService; private final NoticeSearchUseCase noticeSearchService; + private final SearchKeywordUseCase searchKeywordService; private final PinPointUseCase pinPointService; + private final DiagnosisUseCase diagnosisService; /** * 마감임박공고 조회 (PinPoint 지역 기반) @@ -105,11 +116,12 @@ public HomeNoticeListResponse getDeadlineApproachingNotices( // 최종 응답 생성 (region + content + 페이징 정보) return HomeNoticeListResponse.builder() - .region(county) - .content(content) - .hasNext(page.hasNext()) - .totalElements(page.getTotalElements()) - .build(); + .region(county) + .title(null) + .content(content) + .hasNext(page.hasNext()) + .totalElements(page.getTotalElements()) + .build(); } /** @@ -181,21 +193,108 @@ private NoticeListRequest.Region extractRegionFromAddress(String address) { } /** - * 통합 검색 (공고 제목 및 타겟 그룹 기반) - * - NoticeSearchUseCase를 그대로 활용 + * 홈 통합 검색 (섹션별 5개 미리보기) */ + private static final int HOME_SEARCH_PREVIEW_LIMIT = 5; + private static final int HOME_SEARCH_CATEGORY_PAGE_SIZE = 5; + + @Override + public HomeSearchOverviewResponse searchHomeOverview(String keyword, UUID userId) { + searchKeywordService.recordSearch(keyword, SearchKeywordScope.HOME); + + // 좋아요 ID 캐싱 + List likedIds = likedIds(userId); + + // 공고 검색 (기존 검색 로직 재사용) + SliceResponse notices = noticeSearchService.searchNotices( + keyword, + 1, + HOME_SEARCH_PREVIEW_LIMIT, + NoticeSearchSortType.LATEST, + NoticeSearchFilterType.ALL, + userId + ); + + Pageable pageable = PageRequest.of(0, HOME_SEARCH_PREVIEW_LIMIT); + + // 단지명 → 공고 + var complexSlice = complexRepository.searchByName(keyword, pageable); + var complexNotices = toNoticeResponsesFromComplexes(complexSlice.getContent(), likedIds); + + // 모집대상 → 공고 + var targetGroupSlice = noticeRepository.searchNoticesByTargetGroup(keyword, pageable); + var targetNotices = toNoticeResponses(targetGroupSlice.getContent(), likedIds); + + // 지역 → 공고 + var regionSlice = noticeRepository.searchNoticesByRegion(keyword, pageable); + var regionNotices = toNoticeResponses(regionSlice.getContent(), likedIds); + + // 주택유형 → 공고 + var houseTypeSlice = noticeRepository.searchNoticesByHouseType(keyword, pageable); + var houseTypeNotices = toNoticeResponses(houseTypeSlice.getContent(), likedIds); + + return HomeSearchOverviewResponse.builder() + .notices(HomeSearchSectionResponse.of(notices.content(), notices.hasNext())) + .complexes(HomeSearchSectionResponse.of(complexNotices, complexSlice.hasNext())) + .targetGroups(HomeSearchSectionResponse.of(targetNotices, targetGroupSlice.hasNext())) + .regions(HomeSearchSectionResponse.of(regionNotices, regionSlice.hasNext())) + .houseTypes(HomeSearchSectionResponse.of(houseTypeNotices, houseTypeSlice.hasNext())) + .build(); + } + @Override - public SliceResponse searchNoticesIntegrated( + public HomeSearchCategoryPageResponse searchHomeByCategory( + HomeSearchCategoryType category, String keyword, int page, - int size, - NoticeSearchSortType sortType, - NoticeSearchFilterType status, UUID userId ) { - return noticeSearchService.searchNotices(keyword, page, size, sortType, status, userId); + searchKeywordService.recordSearch(keyword, SearchKeywordScope.HOME); + Pageable pageable = PageRequest.of(page - 1, HOME_SEARCH_CATEGORY_PAGE_SIZE); + List likedIds = likedIds(userId); + + switch (category) { + case NOTICE -> { + var notices = noticeSearchService.searchNotices( + keyword, + page, + HOME_SEARCH_CATEGORY_PAGE_SIZE, + NoticeSearchSortType.LATEST, + NoticeSearchFilterType.ALL, + userId + ); + return HomeSearchCategoryPageResponse.notice(page, notices.content(), notices.hasNext()); + } + case COMPLEX -> { + var complexes = complexRepository.searchByName(keyword, pageable); + List content = toNoticeResponsesFromComplexes(complexes.getContent(), likedIds); + return HomeSearchCategoryPageResponse.of(HomeSearchCategoryType.COMPLEX, page, content, complexes.hasNext()); + } + case TARGET_GROUP -> { + var targets = noticeRepository.searchNoticesByTargetGroup(keyword, pageable); + List content = toNoticeResponses(targets.getContent(), likedIds); + return HomeSearchCategoryPageResponse.of(HomeSearchCategoryType.TARGET_GROUP, page, content, targets.hasNext()); + } + case REGION -> { + var regions = noticeRepository.searchNoticesByRegion(keyword, pageable); + List content = toNoticeResponses(regions.getContent(), likedIds); + return HomeSearchCategoryPageResponse.of(HomeSearchCategoryType.REGION, page, content, regions.hasNext()); + } + case HOUSE_TYPE -> { + var houseTypes = noticeRepository.searchNoticesByHouseType(keyword, pageable); + List content = toNoticeResponses(houseTypes.getContent(), likedIds); + return HomeSearchCategoryPageResponse.of(HomeSearchCategoryType.HOUSE_TYPE, page, content, houseTypes.hasNext()); + } + default -> throw new IllegalStateException("Unexpected value: " + category); + } } + @Override + public List getHomePopularKeywords(int limit) { + return searchKeywordService.getPopularKeywords(limit, SearchKeywordScope.HOME); + } + + /** * 핀포인트 기준 최대 이동 시간 내 공고 개수 조회 * - 거리 기반 근사 계산을 통해 최대 이동 시간 내 단지를 찾고 @@ -247,4 +346,129 @@ public NoticeCountResponse getNoticeCountWithinTravelTime(String pinPointId, int return NoticeCountResponse.from(uniqueNoticeCount); } + + /** + * 진단 기반 추천 공고 조회 + * - 사용자의 최근 청약 진단 결과를 기반으로 맞춤형 공고를 추천 + * - 진단 결과의 availableRentalTypes를 NoticeDocument.supplyType으로 매핑하여 필터링 + * - 마감임박순으로 정렬 (applyEnd ASC) + * - 모든 공고 상태 포함 (모집중 + 마감) + */ + @Override + public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( + SliceRequest sliceRequest, + UUID userId + ) { + // 1. 진단 결과 조회 + DiagnosisDetailResponse diagnosis = diagnosisService.getDiagnoseDetail(userId); + + // 2. 진단 기록 없음 처리 + if (diagnosis == null) { + log.warn("진단 기록이 없습니다 - userId={}", userId); + throw new CustomException(DiagnosisErrorCode.NOT_FOUND_DIAGNOSIS); + } + + // 3. 추천 임대주택 유형 추출 + List availableRentalTypes = diagnosis.availableRentalTypes(); + + // 4. 자격 없는 경우 빈 응답 반환 + if (availableRentalTypes == null || + availableRentalTypes.isEmpty() || + availableRentalTypes.contains("해당 없음")) { + log.info("추천 가능한 임대주택이 없습니다 - userId={}", userId); + return HomeNoticeListResponse.builder() + .region(null) + .title("진단 기반 추천") + .content(List.of()) + .hasNext(false) + .totalElements(0L) + .build(); + } + + // 5. 진단 결과 → 공고 supplyType 매핑 + List targetSupplyTypes = availableRentalTypes.stream() + .filter(type -> type != null && !type.isBlank()) + .distinct() + .toList(); + + // 진단 결과에 매핑될 공고 유형이 없는 경우 빈 응답 반환 + if (targetSupplyTypes.isEmpty()) { + log.info("진단 결과에 매핑 가능한 주택 유형이 없습니다 - userId={}", userId); + return HomeNoticeListResponse.builder() + .region(null) + .title("진단 기반 추천") + .content(List.of()) + .hasNext(false) + .totalElements(0L) + .build(); + } + + log.debug("진단 기반 필터링 - rentalTypes={}, supplyTypes={}", + availableRentalTypes, targetSupplyTypes); + + // 6. 페이징 설정 (마감임박순) + Sort sort = Sort.by(Sort.Order.asc("applyEnd"), Sort.Order.asc("noticeId")); + Pageable pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), sort); + + // 7. Repository 조회 + Page page = noticeRepository.findRecommendedNoticesByDiagnosis( + targetSupplyTypes, + pageable + ); + + // 8. 좋아요 상태 조회 + List likedNoticeIds = likeService.getLikeNoticeIds(userId); + + // 9. DTO 변환 + List content = page.getContent().stream() + .map(notice -> { + boolean isLiked = likedNoticeIds.contains(notice.getId()); + return HomeNoticeResponse.from(notice, isLiked); + }) + .toList(); + + // 10. 최종 응답 + return HomeNoticeListResponse.builder() + .region(null) + .title("진단 기반 추천") + .content(content) + .hasNext(page.hasNext()) + .totalElements(page.getTotalElements()) + .build(); + } + + private List likedIds(UUID userId) { + return userId == null ? List.of() : likeService.getLikeNoticeIds(userId); + } + + private List toNoticeResponses(List notices, List likedIds) { + return notices.stream() + .map(n -> NoticeSearchResultResponse.from(n, likedIds != null && likedIds.contains(n.getId()))) + .toList(); + } + + private List toNoticeResponsesFromComplexes(List complexes, List likedIds) { + List noticeIds = complexes.stream() + .map(ComplexDocument::getNoticeId) + .filter(id -> id != null && !id.isBlank()) + .distinct() + .limit(HOME_SEARCH_PREVIEW_LIMIT + 1L) + .toList(); + + List notices = noticeRepository.findByIdIn(noticeIds); + + return noticeIds.stream() + .map(id -> notices.stream().filter(n -> id.equals(n.getId())).findFirst().orElse(null)) + .filter(n -> n != null) + .limit(HOME_SEARCH_PREVIEW_LIMIT) + .map(n -> NoticeSearchResultResponse.from(n, likedIds != null && likedIds.contains(n.getId()))) + .toList(); + } + + private String buildAddress(String city, String county) { + if (city == null && county == null) return null; + if (city == null) return county; + if (county == null) return city; + return city + " " + county; + } } diff --git a/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java b/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java index 9013b832..be737b33 100644 --- a/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java @@ -3,13 +3,15 @@ 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.dto.HomeNoticeListResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryPageResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryType; +import com.pinHouse.server.platform.home.application.dto.HomeSearchOverviewResponse; import com.pinHouse.server.platform.home.application.dto.NoticeCountResponse; import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; -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.dto.PopularKeywordResponse; import java.util.UUID; +import java.util.List; /** * 홈 화면 Use Case @@ -30,30 +32,44 @@ HomeNoticeListResponse getDeadlineApproachingNotices( ); /** - * 통합 검색 (공고 제목 및 타겟 그룹 기반) - * @param keyword 검색 키워드 - * @param page 페이지 번호 (1부터 시작) - * @param size 페이지 크기 - * @param sortType 정렬 방식 (LATEST: 최신공고순, END: 마감임박순) - * @param status 공고 상태 (ALL: 전체, RECRUITING: 모집중) - * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) - * @return 검색 결과 (무한 스크롤 응답) + * 핀포인트 기준 최대 이동 시간 내 공고 개수 조회 + * @param pinPointId 핀포인트 ID (기준 위치) + * @param maxTime 최대 이동 시간 (분) + * @param userId 사용자 ID (PinPoint 소유권 검증용) + * @return 공고 개수 */ - SliceResponse searchNoticesIntegrated( + NoticeCountResponse getNoticeCountWithinTravelTime(String pinPointId, int maxTime, UUID userId); + + /** + * 진단 기반 추천 공고 조회 + * 사용자의 최근 청약 진단 결과를 기반으로 맞춤형 공고를 추천 + * + * @param sliceRequest 페이징 정보 (page, offSet) + * @param userId 사용자 ID (진단 결과 조회 및 좋아요 정보용) + * @return 진단 기반 추천 공고 목록 (마감임박순 정렬, 모든 공고 상태 포함) + */ + HomeNoticeListResponse getRecommendedNoticesByDiagnosis( + SliceRequest sliceRequest, + UUID userId + ); + + /** + * 홈 통합 검색 미리보기 (섹션별 5개) + */ + HomeSearchOverviewResponse searchHomeOverview(String keyword, UUID userId); + + /** + * 홈 통합 검색 카테고리별 조회 + */ + HomeSearchCategoryPageResponse searchHomeByCategory( + HomeSearchCategoryType category, String keyword, int page, - int size, - NoticeSearchSortType sortType, - NoticeSearchFilterType status, UUID userId ); /** - * 핀포인트 기준 최대 이동 시간 내 공고 개수 조회 - * @param pinPointId 핀포인트 ID (기준 위치) - * @param maxTime 최대 이동 시간 (분) - * @param userId 사용자 ID (PinPoint 소유권 검증용) - * @return 공고 개수 + * 홈 인기 검색어 조회 */ - NoticeCountResponse getNoticeCountWithinTravelTime(String pinPointId, int maxTime, UUID userId); + List getHomePopularKeywords(int limit); } diff --git a/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java b/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java index 80b1254b..caf463c5 100644 --- a/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java @@ -3,14 +3,14 @@ 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.dto.HomeNoticeListResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryPageResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryType; +import com.pinHouse.server.platform.home.application.dto.HomeSearchOverviewResponse; import com.pinHouse.server.platform.home.application.dto.NoticeCountResponse; import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; import com.pinHouse.server.platform.home.presentation.swagger.HomeApiSpec; -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.dto.PopularKeywordResponse; import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import java.util.UUID; +import java.util.List; /** * 홈 화면 API @@ -57,31 +58,52 @@ public ApiResponse getDeadlineApproachingNotices( } /** - * 통합 검색 (공고 제목 및 타겟 그룹 기반) - * GET /v1/home/search?q=키워드&page=1&offSet=20&sortType=LATEST&status=ALL + * 홈 통합 검색 미리보기 (섹션별 5개) + * GET /v1/home/search/overview?q=키워드 */ @Override - @GetMapping("/search") - public ApiResponse> searchNoticesIntegrated( + @GetMapping("/search/overview") + public ApiResponse searchOverview( @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; + HomeSearchOverviewResponse response = homeService.searchHomeOverview(q, userId); + return ApiResponse.ok(response); + } - // 서비스 호출 - SliceResponse response = homeService.searchNoticesIntegrated( + /** + * 홈 통합 검색 카테고리별 조회 (더보기) + * GET /v1/home/search/category?type=NOTICE&q=키워드&page=1&offSet=20 + */ + @Override + @GetMapping("/search/category") + public ApiResponse searchByCategory( + @RequestParam HomeSearchCategoryType type, + @RequestParam String q, + @RequestParam(defaultValue = "1") int page, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + UUID userId = (principalDetails != null) ? principalDetails.getId() : null; + HomeSearchCategoryPageResponse response = homeService.searchHomeByCategory( + type, q, - sliceRequest.page(), - sliceRequest.offSet(), - sortType, - status, + page, userId ); + return ApiResponse.ok(response); + } + /** + * 홈 인기 검색어 조회 + * GET /v1/home/search/popular?limit=10 + */ + @Override + @GetMapping("/search/popular") + public ApiResponse> getHomePopularKeywords( + @RequestParam(defaultValue = "10") int limit + ) { + List response = homeService.getHomePopularKeywords(limit); return ApiResponse.ok(response); } @@ -110,4 +132,28 @@ public ApiResponse getNoticeCountWithinTravelTime( return ApiResponse.ok(response); } + + /** + * 진단 기반 추천 공고 조회 + * GET /v1/home/recommended-notices?page=1&offSet=20 + * 로그인 필수 + */ + @Override + @CheckLogin + @GetMapping("/recommended-notices") + public ApiResponse getRecommendedNoticesByDiagnosis( + SliceRequest sliceRequest, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + // @CheckLogin에 의해 principalDetails는 항상 non-null + UUID userId = principalDetails.getId(); + + // 서비스 호출 + HomeNoticeListResponse response = homeService.getRecommendedNoticesByDiagnosis( + sliceRequest, + userId + ); + + return ApiResponse.ok(response); + } } diff --git a/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java b/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java index c0a7012d..699052d2 100644 --- a/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java @@ -2,15 +2,18 @@ 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.dto.HomeNoticeListResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryPageResponse; +import com.pinHouse.server.platform.home.application.dto.HomeSearchCategoryType; +import com.pinHouse.server.platform.home.application.dto.HomeSearchOverviewResponse; import com.pinHouse.server.platform.home.application.dto.NoticeCountResponse; -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.dto.PopularKeywordResponse; 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.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestParam; @@ -19,7 +22,7 @@ public interface HomeApiSpec { @Operation( - summary = "마감임박공고 조회 API (PinPoint 지역 기반) - 로그인 필수", + summary = "마감임박공고 조회 API", description = "PinPoint의 지역을 기반으로 마감임박순으로 정렬된 공고 목록을 조회하는 API 입니다. " + "PinPoint의 주소에서 광역 단위(시/도)를 추출하여 해당 지역의 모집중인 공고만 조회합니다. " + "본인의 PinPoint만 사용 가능하며, 다른 사용자의 PinPoint ID를 사용하면 400 에러가 발생합니다. " + @@ -36,28 +39,44 @@ ApiResponse getDeadlineApproachingNotices( ); @Operation( - summary = "통합 검색 API", - description = "공고 제목 및 타겟 그룹을 기반으로 검색하는 통합 검색 API 입니다. " + - "키워드를 입력하면 공고 제목과 모집 대상에서 검색하여 결과를 반환합니다. " + - "정렬 방식과 공고 상태 필터를 적용할 수 있으며, 로그인한 사용자의 경우 좋아요 정보가 포함됩니다." + summary = "홈 통합 검색 미리보기", + description = "공고명, 단지명, 모집대상, 지역, 주택유형을 대상으로 상위 5개씩 미리보기 결과를 반환합니다." ) - ApiResponse> searchNoticesIntegrated( + ApiResponse searchOverview( @Parameter(description = "검색 키워드", example = "청년") @RequestParam String q, - SliceRequest sliceRequest, + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + @Operation( + summary = "홈 통합 검색 카테고리별 조회", + description = "홈 통합 검색의 더보기 API로, 카테고리별로 별도 페이징하여 결과를 반환합니다." + ) + ApiResponse searchByCategory( + @Parameter(description = "카테고리 타입", example = "NOTICE") + @RequestParam HomeSearchCategoryType type, - @Parameter(description = "정렬 방식 (LATEST: 최신공고순, END: 마감임박순)", example = "LATEST") - @RequestParam(required = false, defaultValue = "LATEST") NoticeSearchSortType sortType, + @Parameter(description = "검색 키워드", example = "청년") + @RequestParam String q, - @Parameter(description = "공고 상태 (ALL: 전체, RECRUITING: 모집중)", example = "ALL") - @RequestParam(required = false, defaultValue = "ALL") NoticeSearchFilterType status, + @Parameter(description = "페이지 (1부터 시작)", example = "1") + @RequestParam(defaultValue = "1") int page, @AuthenticationPrincipal PrincipalDetails principalDetails ); @Operation( - summary = "핀포인트 기준 공고 개수 조회 API - 로그인 필수", + summary = "홈 인기 검색어 조회", + description = "홈 통합 검색에서 많이 검색된 키워드를 조회합니다." + ) + ApiResponse> getHomePopularKeywords( + @Parameter(description = "조회 개수", example = "10") + @RequestParam(defaultValue = "10") int limit + ); + + @Operation( + summary = "핀포인트 기준 공고 개수 조회 API", description = "핀포인트를 기준으로 최대 이동 시간(분) 내에 위치한 공고의 개수를 조회하는 API 입니다. " + "대중교통 평균 속도(15km/h)를 기준으로 반경을 계산하여 해당 범위 내 단지들의 고유 공고 개수를 반환합니다. " + "본인의 PinPoint만 사용 가능하며, 다른 사용자의 PinPoint ID를 사용하면 400 에러가 발생합니다." @@ -71,4 +90,35 @@ ApiResponse getNoticeCountWithinTravelTime( @AuthenticationPrincipal PrincipalDetails principalDetails ); + + @Operation( + summary = "진단 기반 추천 공고 조회 API", + description = "사용자의 최근 청약 진단 결과를 기반으로 추천 공고를 조회하는 API입니다. " + + "진단 결과의 신청가능한 임대주택 유형(availableRentalTypes)을 기준으로 공고를 필터링하며, " + + "마감임박순으로 정렬됩니다. 모든 공고 상태(모집중 + 마감)를 포함합니다. " + + "진단 기록이 없는 경우 404 에러가 발생합니다. " + + "진단 결과 자격이 없는 경우(해당 없음) 200 OK와 함께 빈 리스트를 반환합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공 (진단 결과에 맞는 공고가 없어도 200 반환)", + content = @Content(schema = @Schema(implementation = HomeNoticeListResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "진단 기록을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패 (로그인 필요)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ApiResponse getRecommendedNoticesByDiagnosis( + SliceRequest sliceRequest, + + @AuthenticationPrincipal PrincipalDetails principalDetails + ); } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java index b4503463..a30ed40d 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java @@ -19,4 +19,13 @@ public interface CustomComplexDocumentRepository { * @return 정렬된 단지 목록 (각 단지의 unitTypes도 정렬됨) */ List findSortedComplexesWithUnitTypes(String noticeId, UnitTypeSortType sortType); + + /** + * 단지명 텍스트 검색 (무한 스크롤) + * + * @param keyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 단지 결과 슬라이스 + */ + org.springframework.data.domain.Slice searchByName(String keyword, org.springframework.data.domain.Pageable pageable); } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java index 54d39f1a..1bf4a982 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java @@ -9,6 +9,7 @@ import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -110,6 +111,21 @@ public List findSortedComplexesWithUnitTypes(String noticeId, U return complexes; } + @Override + public org.springframework.data.domain.Slice searchByName(String keyword, org.springframework.data.domain.Pageable pageable) { + Criteria criteria = Criteria.where("name").regex(keyword, "i"); + Query query = new Query(criteria).with(pageable); + + int limit = pageable.getPageSize(); + query.limit(limit + 1); + + List complexes = mongoTemplate.find(query, ComplexDocument.class); + boolean hasNext = complexes.size() > limit; + List content = hasNext ? complexes.subList(0, limit) : complexes; + + return new org.springframework.data.domain.SliceImpl<>(content, pageable, hasNext); + } + /** * 정렬 기준 생성 (Tie-break 규칙 포함) * diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java index 3a25f828..1b672488 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java @@ -64,4 +64,47 @@ Page findDeadlineApproachingNoticesByRegionAndCounty( Instant now ); + /** + * 진단 결과 기반 추천 공고 조회 + * 사용자의 청약 진단 결과를 바탕으로 신청 가능한 공고를 조회 + * + * @param supplyTypes 공급 유형 리스트 (진단 결과에서 매핑된 값) + * @param pageable 페이징 및 정렬 정보 (마감임박순 권장) + * @return 추천 공고 목록 + */ + Page findRecommendedNoticesByDiagnosis( + java.util.List supplyTypes, + Pageable pageable + ); + + /** + * 모집대상 텍스트 검색 (중복 제거) + */ + org.springframework.data.domain.Slice searchTargetGroups(String keyword, Pageable pageable); + + /** + * 지역 텍스트 검색 (도시/시군구 조합, 중복 제거) + */ + org.springframework.data.domain.Slice searchRegions(String keyword, Pageable pageable); + + /** + * 주택유형 텍스트 검색 (중복 제거) + */ + org.springframework.data.domain.Slice searchHouseTypes(String keyword, Pageable pageable); + + /** + * 모집대상 공고 검색 (Slice) + */ + org.springframework.data.domain.Slice searchNoticesByTargetGroup(String keyword, Pageable pageable); + + /** + * 지역 공고 검색 (Slice) + */ + org.springframework.data.domain.Slice searchNoticesByRegion(String keyword, Pageable pageable); + + /** + * 주택유형 공고 검색 (Slice) + */ + org.springframework.data.domain.Slice searchNoticesByHouseType(String keyword, Pageable pageable); + } diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java index 52b140f6..01789813 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java @@ -6,6 +6,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -205,4 +208,144 @@ public Page findDeadlineApproachingNoticesByRegionAndCounty( return new PageImpl<>(notices, pageable, count); } + @Override + public Page findRecommendedNoticesByDiagnosis( + java.util.List supplyTypes, + Pageable pageable + ) { + Criteria criteria = new Criteria(); + + /// supplyType 필터링 (진단 결과에서 매핑된 주택 유형) + if (supplyTypes != null && !supplyTypes.isEmpty()) { + criteria.and("supplyType").in(supplyTypes); + } + + /// 모집 상태 필터링 없음 (모든 공고 포함) + /// 정렬은 Pageable의 Sort로 처리 (마감임박순 권장) + + Query query = new Query(criteria).with(pageable); + + /// 실행 및 Page 응답 구성 + List notices = mongoTemplate.find(query, NoticeDocument.class); + long count = mongoTemplate.count(Query.of(query).limit(-1).skip(-1), NoticeDocument.class); + + return new PageImpl<>(notices, pageable, count); + } + + @Override + public org.springframework.data.domain.Slice searchTargetGroups(String keyword, Pageable pageable) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where("targetGroup").regex(keyword, "i")), + Aggregation.unwind("targetGroup"), + Aggregation.match(Criteria.where("targetGroup").regex(keyword, "i")), + Aggregation.group("targetGroup"), + Aggregation.sort(Sort.by(Sort.Order.asc("_id"))), + Aggregation.skip((long) pageable.getPageNumber() * pageable.getPageSize()), + Aggregation.limit(pageable.getPageSize() + 1) + ); + + List results = mongoTemplate.aggregate(aggregation, "notices", StringAggregationResult.class) + .getMappedResults() + .stream() + .map(StringAggregationResult::getId) + .toList(); + + return toSlice(results, pageable); + } + + @Override + public org.springframework.data.domain.Slice searchRegions(String keyword, Pageable pageable) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(new Criteria().orOperator( + Criteria.where("city").regex(keyword, "i"), + Criteria.where("county").regex(keyword, "i") + )), + Aggregation.project("city", "county") + .andExpression("concat(city, ' ', county)").as("region"), + Aggregation.group("region"), + Aggregation.sort(Sort.by(Sort.Order.asc("_id"))), + Aggregation.skip((long) pageable.getPageNumber() * pageable.getPageSize()), + Aggregation.limit(pageable.getPageSize() + 1) + ); + + List results = mongoTemplate.aggregate(aggregation, "notices", StringAggregationResult.class) + .getMappedResults() + .stream() + .map(StringAggregationResult::getId) + .toList(); + + return toSlice(results, pageable); + } + + @Override + public org.springframework.data.domain.Slice searchHouseTypes(String keyword, Pageable pageable) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where("houseType").regex(keyword, "i")), + Aggregation.group("houseType"), + Aggregation.sort(Sort.by(Sort.Order.asc("_id"))), + Aggregation.skip((long) pageable.getPageNumber() * pageable.getPageSize()), + Aggregation.limit(pageable.getPageSize() + 1) + ); + + List results = mongoTemplate.aggregate(aggregation, "notices", StringAggregationResult.class) + .getMappedResults() + .stream() + .map(StringAggregationResult::getId) + .toList(); + + return toSlice(results, pageable); + } + + @Override + public org.springframework.data.domain.Slice searchNoticesByTargetGroup(String keyword, Pageable pageable) { + Criteria criteria = Criteria.where("targetGroup").regex(keyword, "i"); + return findNoticeSlice(criteria, pageable); + } + + @Override + public org.springframework.data.domain.Slice searchNoticesByRegion(String keyword, Pageable pageable) { + Criteria criteria = new Criteria().orOperator( + Criteria.where("city").regex(keyword, "i"), + Criteria.where("county").regex(keyword, "i") + ); + return findNoticeSlice(criteria, pageable); + } + + @Override + public org.springframework.data.domain.Slice searchNoticesByHouseType(String keyword, Pageable pageable) { + Criteria criteria = Criteria.where("houseType").regex(keyword, "i"); + return findNoticeSlice(criteria, pageable); + } + + private org.springframework.data.domain.Slice findNoticeSlice(Criteria criteria, Pageable pageable) { + Query query = new Query(criteria).with(pageable); + int limit = pageable.getPageSize(); + query.limit(limit + 1); + + List notices = mongoTemplate.find(query, NoticeDocument.class); + boolean hasNext = notices.size() > limit; + List content = hasNext ? notices.subList(0, limit) : notices; + + return new SliceImpl<>(content, pageable, hasNext); + } + + private org.springframework.data.domain.Slice toSlice(List values, Pageable pageable) { + int size = pageable.getPageSize(); + boolean hasNext = values.size() > size; + List content = hasNext ? values.subList(0, size) : values; + return new SliceImpl<>(content, pageable, hasNext); + } + + private static class StringAggregationResult { + private String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + } diff --git a/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlRequest.java b/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlRequest.java new file mode 100644 index 00000000..2df70181 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlRequest.java @@ -0,0 +1,22 @@ +package com.pinHouse.server.platform.image.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Presigned URL 생성 요청 DTO + */ +@Schema(name = "[요청][이미지] Presigned URL 생성 요청") +public record PresignedUrlRequest( + + @NotBlank(message = "파일명은 필수 입력값입니다") + @Schema(description = "업로드할 파일명", example = "profile.jpg", required = true) + String fileName, + + @NotBlank(message = "Content-Type은 필수 입력값입니다") + @Pattern(regexp = "^image/(jpeg|jpg|png|gif)$", message = "지원하지 않는 이미지 형식입니다") + @Schema(description = "파일 Content-Type", example = "image/jpeg", required = true) + String contentType +) { +} diff --git a/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlResponse.java b/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlResponse.java new file mode 100644 index 00000000..ac129c49 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/application/dto/PresignedUrlResponse.java @@ -0,0 +1,27 @@ +package com.pinHouse.server.platform.image.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Presigned URL 생성 응답 DTO + */ +@Schema(name = "[응답][이미지] Presigned URL 생성 응답") +public record PresignedUrlResponse( + + @Schema(description = "클라이언트가 PUT 요청할 Presigned URL", example = "https://pinhouse-images-local.s3.ap-northeast-2.amazonaws.com/profile/...") + String presignedUrl, + + @Schema(description = "User.profileImage에 저장할 최종 이미지 URL", example = "https://pinhouse-images-local.s3.ap-northeast-2.amazonaws.com/profile/a1b2c3d4.../f8a9b0c1....jpg") + String imageUrl, + + @Schema(description = "Presigned URL 만료 시간 (초)", example = "600") + int expiresIn +) { + + /** + * 정적 팩토리 메서드 + */ + public static PresignedUrlResponse of(String presignedUrl, String imageUrl, int expiresIn) { + return new PresignedUrlResponse(presignedUrl, imageUrl, expiresIn); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/image/application/service/ImageService.java b/src/main/java/com/pinHouse/server/platform/image/application/service/ImageService.java new file mode 100644 index 00000000..6cccd619 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/application/service/ImageService.java @@ -0,0 +1,117 @@ +package com.pinHouse.server.platform.image.application.service; + +import com.pinHouse.server.core.exception.code.ImageErrorCode; +import com.pinHouse.server.core.response.response.CustomException; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlRequest; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlResponse; +import com.pinHouse.server.platform.image.application.usecase.ImageUseCase; +import com.pinHouse.server.platform.image.domain.entity.ImageType; +import com.pinHouse.server.platform.image.external.PresignedUrlGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * 이미지 업로드 서비스 구현체 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageService implements ImageUseCase { + + private final PresignedUrlGenerator presignedUrlGenerator; + + @Value("${aws.s3.presigned-url.expiration-minutes:10}") + private int expirationMinutes; + + /** + * 프로필 이미지 업로드를 위한 Presigned URL 생성 + */ + @Override + public PresignedUrlResponse generatePresignedUrl(PresignedUrlRequest request, UUID userId) { + + // 1. Content-Type 검증 + validateContentType(request.contentType()); + + // 2. 파일 확장자 추출 및 검증 + String extension = extractExtension(request.fileName()); + validateExtension(extension); + + // 3. Object Key 생성: profile/{userId}/{uuid}.{ext} + String objectKey = generateObjectKey(ImageType.PROFILE, userId, extension); + + // 4. Presigned URL 생성 + String presignedUrl = presignedUrlGenerator.generatePutPresignedUrl(objectKey, request.contentType()); + + // 5. Public URL 생성 + String publicUrl = presignedUrlGenerator.getPublicUrl(objectKey); + + // 6. Response 반환 + int expiresInSeconds = expirationMinutes * 60; + + log.info("Presigned URL 생성 완료: userId={}, fileName={}, objectKey={}", userId, request.fileName(), objectKey); + + return PresignedUrlResponse.of(presignedUrl, publicUrl, expiresInSeconds); + } + + // ================= + // 내부 로직 + // ================= + + /** + * Content-Type 검증 + */ + private void validateContentType(String contentType) { + List allowedContentTypes = List.of("image/jpeg", "image/jpg", "image/png", "image/gif"); + + if (!allowedContentTypes.contains(contentType.toLowerCase())) { + log.warn("지원하지 않는 Content-Type: {}", contentType); + throw new CustomException(ImageErrorCode.INVALID_FILE_TYPE); + } + } + + /** + * 파일 확장자 추출 + */ + private String extractExtension(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new CustomException(ImageErrorCode.INVALID_FILE_NAME); + } + + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex <= 0 || lastDotIndex == fileName.length() - 1) { + log.warn("파일 확장자 추출 실패: fileName={}", fileName); + throw new CustomException(ImageErrorCode.INVALID_FILE_EXTENSION); + } + + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } + + /** + * 파일 확장자 검증 + */ + private void validateExtension(String extension) { + List allowedExtensions = List.of("jpg", "jpeg", "png", "gif"); + + if (!allowedExtensions.contains(extension)) { + log.warn("지원하지 않는 확장자: {}", extension); + throw new CustomException(ImageErrorCode.INVALID_FILE_EXTENSION); + } + } + + /** + * Object Key 생성: {imageType}/{userId}/{uuid}.{extension} + */ + private String generateObjectKey(ImageType imageType, UUID userId, String extension) { + String uniqueId = UUID.randomUUID().toString(); + return String.format("%s/%s/%s.%s", + imageType.getPath(), + userId.toString(), + uniqueId, + extension); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/image/application/usecase/ImageUseCase.java b/src/main/java/com/pinHouse/server/platform/image/application/usecase/ImageUseCase.java new file mode 100644 index 00000000..d459a364 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/application/usecase/ImageUseCase.java @@ -0,0 +1,21 @@ +package com.pinHouse.server.platform.image.application.usecase; + +import com.pinHouse.server.platform.image.application.dto.PresignedUrlRequest; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlResponse; + +import java.util.UUID; + +/** + * 이미지 업로드 UseCase 인터페이스 + */ +public interface ImageUseCase { + + /** + * 프로필 이미지 업로드를 위한 Presigned URL 생성 + * + * @param request 파일 메타데이터 (fileName, contentType) + * @param userId 인증된 사용자 ID + * @return Presigned URL 정보 (업로드 URL, 최종 이미지 URL, 만료시간) + */ + PresignedUrlResponse generatePresignedUrl(PresignedUrlRequest request, UUID userId); +} diff --git a/src/main/java/com/pinHouse/server/platform/image/domain/entity/ImageType.java b/src/main/java/com/pinHouse/server/platform/image/domain/entity/ImageType.java new file mode 100644 index 00000000..2e40fe95 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/domain/entity/ImageType.java @@ -0,0 +1,22 @@ +package com.pinHouse.server.platform.image.domain.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 이미지 타입 열거형 + */ +@Getter +@RequiredArgsConstructor +public enum ImageType { + + /** + * 프로필 이미지 + */ + PROFILE("profile"); + + /** + * S3 Object Key의 prefix로 사용되는 경로 + */ + private final String path; +} diff --git a/src/main/java/com/pinHouse/server/platform/image/external/PresignedUrlGenerator.java b/src/main/java/com/pinHouse/server/platform/image/external/PresignedUrlGenerator.java new file mode 100644 index 00000000..7ae27912 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/external/PresignedUrlGenerator.java @@ -0,0 +1,25 @@ +package com.pinHouse.server.platform.image.external; + +/** + * Presigned URL 생성 인터페이스 + * 테스트 가능성을 위한 의존성 역전 + */ +public interface PresignedUrlGenerator { + + /** + * PUT 요청을 위한 Presigned URL 생성 + * + * @param objectKey S3 Object Key (예: profile/{userId}/{uuid}.jpg) + * @param contentType Content-Type (예: image/jpeg) + * @return Presigned URL (클라이언트가 PUT 요청할 URL) + */ + String generatePutPresignedUrl(String objectKey, String contentType); + + /** + * Public URL 생성 + * + * @param objectKey S3 Object Key + * @return Public URL (User.profileImage에 저장할 최종 URL) + */ + String getPublicUrl(String objectKey); +} diff --git a/src/main/java/com/pinHouse/server/platform/image/external/S3PresignedUrlGenerator.java b/src/main/java/com/pinHouse/server/platform/image/external/S3PresignedUrlGenerator.java new file mode 100644 index 00000000..7a3c207a --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/external/S3PresignedUrlGenerator.java @@ -0,0 +1,81 @@ +package com.pinHouse.server.platform.image.external; + +import com.pinHouse.server.core.exception.code.ImageErrorCode; +import com.pinHouse.server.core.response.response.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; + +/** + * AWS S3 Presigned URL 생성 구현체 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class S3PresignedUrlGenerator implements PresignedUrlGenerator { + + private final S3Client s3Client; + + @Value("${aws.s3.bucket}") + private String bucketName; + + @Value("${aws.s3.presigned-url.expiration-minutes:10}") + private int expirationMinutes; + + /** + * PUT 요청을 위한 Presigned URL 생성 + */ + @Override + public String generatePutPresignedUrl(String objectKey, String contentType) { + try (S3Presigner presigner = S3Presigner.builder() + .region(s3Client.serviceClientConfiguration().region()) + .build()) { + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .putObjectRequest(putObjectRequest) + .signatureDuration(Duration.ofMinutes(expirationMinutes)) + .build(); + + PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest); + + String presignedUrl = presignedRequest.url().toString(); + + log.info("Presigned URL 생성 성공: objectKey={}, expiresIn={}분", objectKey, expirationMinutes); + + return presignedUrl; + + } catch (S3Exception e) { + log.error("S3 Presigned URL 생성 실패: objectKey={}, error={}", objectKey, e.getMessage()); + throw new CustomException(ImageErrorCode.S3_PRESIGNED_URL_GENERATION_FAILED); + } catch (Exception e) { + log.error("S3 Presigned URL 생성 중 예외 발생: objectKey={}, error={}", objectKey, e.getMessage()); + throw new CustomException(ImageErrorCode.S3_CLIENT_ERROR); + } + } + + /** + * Public URL 생성 + */ + @Override + public String getPublicUrl(String objectKey) { + return String.format("https://%s.s3.%s.amazonaws.com/%s", + bucketName, + s3Client.serviceClientConfiguration().region().id(), + objectKey); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/image/presentation/ImageApi.java b/src/main/java/com/pinHouse/server/platform/image/presentation/ImageApi.java new file mode 100644 index 00000000..2ac3ff82 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/presentation/ImageApi.java @@ -0,0 +1,41 @@ +package com.pinHouse.server.platform.image.presentation; + +import com.pinHouse.server.core.aop.CheckLogin; +import com.pinHouse.server.core.response.response.ApiResponse; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlRequest; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlResponse; +import com.pinHouse.server.platform.image.application.usecase.ImageUseCase; +import com.pinHouse.server.platform.image.presentation.swagger.ImageApiSpec; +import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 이미지 업로드 API 컨트롤러 + */ +@RestController +@RequestMapping("/v1/images") +@RequiredArgsConstructor +public class ImageApi implements ImageApiSpec { + + private final ImageUseCase imageUseCase; + + /** + * Presigned URL 생성 엔드포인트 + */ + @PostMapping("/presigned-url") + @CheckLogin + @Override + public ApiResponse generatePresignedUrl( + @RequestBody @Valid PresignedUrlRequest request, + @AuthenticationPrincipal PrincipalDetails principalDetails) { + + var response = imageUseCase.generatePresignedUrl(request, principalDetails.getId()); + return ApiResponse.ok(response); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/image/presentation/swagger/ImageApiSpec.java b/src/main/java/com/pinHouse/server/platform/image/presentation/swagger/ImageApiSpec.java new file mode 100644 index 00000000..057283bf --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/image/presentation/swagger/ImageApiSpec.java @@ -0,0 +1,73 @@ +package com.pinHouse.server.platform.image.presentation.swagger; + +import com.pinHouse.server.core.response.response.ApiResponse; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlRequest; +import com.pinHouse.server.platform.image.application.dto.PresignedUrlResponse; +import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * 이미지 업로드 API Swagger 명세 + */ +@Tag(name = "Image API", description = "이미지 업로드 API") +public interface ImageApiSpec { + + @Operation( + summary = "Presigned URL 생성", + description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다. " + + "생성된 presignedUrl로 클라이언트가 직접 S3에 PUT 요청을 보내 이미지를 업로드합니다. " + + "파일 크기 전달은 필요 없으며, 파일명과 Content-Type만 보내면 됩니다.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Presigned URL 생성 요청", + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = PresignedUrlRequest.class), + examples = @ExampleObject( + name = "요청 예시", + value = """ + { + "fileName": "profile.jpg", + "contentType": "image/jpeg" + } + """ + ) + ) + ) + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Presigned URL 생성 성공", + content = @Content(schema = @Schema(implementation = PresignedUrlResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파일 타입/이름 오류)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "S3 서버 오류", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ApiResponse generatePresignedUrl( + @RequestBody @Valid PresignedUrlRequest request, + @AuthenticationPrincipal PrincipalDetails principalDetails + ); +} diff --git a/src/main/java/com/pinHouse/server/platform/search/application/service/SearchKeywordService.java b/src/main/java/com/pinHouse/server/platform/search/application/service/SearchKeywordService.java index e2ebc420..c4bf3f54 100644 --- a/src/main/java/com/pinHouse/server/platform/search/application/service/SearchKeywordService.java +++ b/src/main/java/com/pinHouse/server/platform/search/application/service/SearchKeywordService.java @@ -4,6 +4,7 @@ import com.pinHouse.server.platform.search.application.dto.SearchSuggestionResponse; import com.pinHouse.server.platform.search.application.usecase.SearchKeywordUseCase; import com.pinHouse.server.platform.search.domain.entity.SearchKeyword; +import com.pinHouse.server.platform.search.domain.entity.SearchKeywordScope; import com.pinHouse.server.platform.search.domain.repository.SearchKeywordRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +38,12 @@ public class SearchKeywordService implements SearchKeywordUseCase { @Override @Transactional public void recordSearch(String keyword) { + recordSearch(keyword, SearchKeywordScope.GENERAL); + } + + @Override + @Transactional + public void recordSearch(String keyword, SearchKeywordScope scope) { if (keyword == null || keyword.trim().isEmpty()) { return; } @@ -50,10 +57,12 @@ public void recordSearch(String keyword) { try { // MongoDB atomic upsert를 사용한 카운트 증가 - Query query = new Query(Criteria.where("keyword").is(normalizedKeyword)); + Query query = new Query(Criteria.where("keyword").is(normalizedKeyword) + .and("scope").is(scope)); Update update = new Update() .inc("count", 1) .set("lastSearchedAt", Instant.now()) + .set("scope", scope) .setOnInsert("firstSearchedAt", Instant.now()); mongoTemplate.upsert(query, update, SearchKeyword.class); @@ -71,8 +80,14 @@ public void recordSearch(String keyword) { @Override @Transactional(readOnly = true) public List getPopularKeywords(int limit) { + return getPopularKeywords(limit, SearchKeywordScope.GENERAL); + } + + @Override + @Transactional(readOnly = true) + public List getPopularKeywords(int limit, SearchKeywordScope scope) { Pageable pageable = PageRequest.of(0, limit); - List keywords = repository.findAllByOrderByCountDescLastSearchedAtDesc(pageable); + List keywords = repository.findAllByScopeOrderByCountDescLastSearchedAtDesc(scope, pageable); return PopularKeywordResponse.from(keywords); } } diff --git a/src/main/java/com/pinHouse/server/platform/search/application/usecase/SearchKeywordUseCase.java b/src/main/java/com/pinHouse/server/platform/search/application/usecase/SearchKeywordUseCase.java index c30b3ffd..6e14fefa 100644 --- a/src/main/java/com/pinHouse/server/platform/search/application/usecase/SearchKeywordUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/search/application/usecase/SearchKeywordUseCase.java @@ -2,6 +2,7 @@ import com.pinHouse.server.platform.search.application.dto.PopularKeywordResponse; import com.pinHouse.server.platform.search.application.dto.SearchSuggestionResponse; +import com.pinHouse.server.platform.search.domain.entity.SearchKeywordScope; import java.util.List; @@ -16,10 +17,20 @@ public interface SearchKeywordUseCase { */ void recordSearch(String keyword); + /** + * 검색 키워드 기록 (스코프 구분) + */ + void recordSearch(String keyword, SearchKeywordScope scope); + /** * 인기 검색어 조회 * @param limit 조회할 검색어 개수 * @return 인기 검색어 목록 */ List getPopularKeywords(int limit); + + /** + * 스코프별 인기 검색어 조회 + */ + List getPopularKeywords(int limit, SearchKeywordScope scope); } diff --git a/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeyword.java b/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeyword.java index 97c30c77..4afeff79 100644 --- a/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeyword.java +++ b/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeyword.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -16,6 +18,9 @@ @Getter @NoArgsConstructor @Document(collection = "search_keywords") +@CompoundIndexes({ + @CompoundIndex(name = "idx_scope_keyword_unique", def = "{'scope': 1, 'keyword': 1}", unique = true) +}) public class SearchKeyword { @Id @@ -24,9 +29,15 @@ public class SearchKeyword { /** * 검색 키워드 (정규화됨: 소문자, 공백 제거) */ - @Indexed(unique = true) + @Indexed private String keyword; + /** + * 검색 영역 (기본: GENERAL) + */ + @Indexed + private SearchKeywordScope scope; + /** * 검색 횟수 */ @@ -45,8 +56,9 @@ public class SearchKeyword { private Instant firstSearchedAt; @Builder - public SearchKeyword(String keyword, Long count, Instant lastSearchedAt, Instant firstSearchedAt) { + public SearchKeyword(String keyword, SearchKeywordScope scope, Long count, Instant lastSearchedAt, Instant firstSearchedAt) { this.keyword = keyword; + this.scope = scope; this.count = count; this.lastSearchedAt = lastSearchedAt; this.firstSearchedAt = firstSearchedAt; @@ -55,10 +67,11 @@ public SearchKeyword(String keyword, Long count, Instant lastSearchedAt, Instant /** * 새로운 검색 키워드 생성 */ - public static SearchKeyword create(String keyword) { + public static SearchKeyword create(String keyword, SearchKeywordScope scope) { Instant now = Instant.now(); return SearchKeyword.builder() .keyword(normalizeKeyword(keyword)) + .scope(scope) .count(1L) .lastSearchedAt(now) .firstSearchedAt(now) diff --git a/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeywordScope.java b/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeywordScope.java new file mode 100644 index 00000000..d4d9e618 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/search/domain/entity/SearchKeywordScope.java @@ -0,0 +1,6 @@ +package com.pinHouse.server.platform.search.domain.entity; + +public enum SearchKeywordScope { + GENERAL, + HOME +} diff --git a/src/main/java/com/pinHouse/server/platform/search/domain/repository/SearchKeywordRepository.java b/src/main/java/com/pinHouse/server/platform/search/domain/repository/SearchKeywordRepository.java index ad1de918..d1be5844 100644 --- a/src/main/java/com/pinHouse/server/platform/search/domain/repository/SearchKeywordRepository.java +++ b/src/main/java/com/pinHouse/server/platform/search/domain/repository/SearchKeywordRepository.java @@ -1,6 +1,7 @@ package com.pinHouse.server.platform.search.domain.repository; import com.pinHouse.server.platform.search.domain.entity.SearchKeyword; +import com.pinHouse.server.platform.search.domain.entity.SearchKeywordScope; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; @@ -17,14 +18,14 @@ public interface SearchKeywordRepository extends MongoRepository findByKeyword(String keyword); + Optional findByKeywordAndScope(String keyword, SearchKeywordScope scope); /** * 검색 횟수 기준으로 정렬하여 상위 N개 조회 * @param pageable 페이징 정보 (정렬 포함) * @return 인기 검색어 목록 */ - List findAllByOrderByCountDescLastSearchedAtDesc(Pageable pageable); + List findAllByScopeOrderByCountDescLastSearchedAtDesc(SearchKeywordScope scope, Pageable pageable); /** * 키워드가 특정 접두어로 시작하는 검색어 조회 (자동완성용) @@ -32,5 +33,5 @@ public interface SearchKeywordRepository extends MongoRepository findByKeywordStartingWithOrderByCountDescLastSearchedAtDesc(String prefix, Pageable pageable); + List findByKeywordStartingWithAndScopeOrderByCountDescLastSearchedAtDesc(String prefix, SearchKeywordScope scope, Pageable pageable); }