diff --git a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java index 8f40614..3661da7 100644 --- a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java +++ b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java @@ -1,13 +1,16 @@ package DiffLens.back_end.domain.library.controller; +import DiffLens.back_end.domain.library.dto.LibraryRequestDto; import DiffLens.back_end.domain.library.dto.LibraryResponseDTO; +import DiffLens.back_end.domain.library.service.LibraryService; +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "라이브러리 API") @RestController @@ -15,11 +18,134 @@ @RequiredArgsConstructor public class LibraryController { - @GetMapping - @Operation(summary = "라이브러리 목록 조회 ( 미구현 )") - public ApiResponse libraryList( /* 파라미터 추가 필요 */ ){ - LibraryResponseDTO.ListResult result = new LibraryResponseDTO.ListResult(); - return ApiResponse.onSuccess(result); - } + private final LibraryService libraryService; + private final CurrentUserService currentUserService; + @GetMapping + @Operation(summary = "라이브러리 목록 조회", description = """ + + ## 개요 + 인증된 사용자가 생성한 라이브러리 목록을 조회하는 API입니다. + + ## 응답 + - libraries : 라이브러리 목록 배열 + - library_id : 라이브러리 ID + - library_name : 라이브러리 이름 + - tags : 라이브러리 분류 태그 리스트 + - panel_count : 저장된 패널 개수 + - created_at : 생성 시간 + - page_info : 페이징 정보 (현재는 null, 추후 구현 예정) + + ## 권한 + 본인이 생성한 라이브러리만 조회할 수 있습니다. + + ## 정렬 + 최근 생성된 순서로 정렬됩니다. + + """) + public ApiResponse libraryList() { + Member member = currentUserService.getCurrentUser(); + LibraryResponseDTO.ListResult result = libraryService.getLibrariesByMember(member); + return ApiResponse.onSuccess(result); + } + + @PostMapping + @Operation(summary = "라이브러리 생성", description = """ + + ## 개요 + 검색 기록을 기반으로 라이브러리를 생성하는 API입니다. + + ## Request Body + - search_history_id : 저장할 검색 기록의 ID (필수) + - library_name : 라이브러리 이름 (필수) + - tags : 라이브러리 분류 태그 리스트 (필수) + - panel_ids : 저장할 패널 ID 리스트 (선택, null이면 검색 기록의 모든 패널 저장) + + ## 응답 + - library_id : 생성된 라이브러리 ID + - library_name : 라이브러리 이름 + - search_history_id : 연결된 검색 기록 ID + - panel_count : 저장된 패널 개수 + - created_at : 생성 시간 + + ## 권한 + 본인의 검색 기록만 라이브러리로 저장할 수 있습니다. + + """) + public ApiResponse createLibrary( + @RequestBody @Valid LibraryRequestDto.Create request) { + Member member = currentUserService.getCurrentUser(); + LibraryService.LibraryCreateResult createResult = libraryService.createLibrary(request, member); + + LibraryResponseDTO.CreateResult result = LibraryResponseDTO.CreateResult.from( + createResult.getLibrary(), + request.getSearchHistoryId(), + createResult.getPanelCount()); + return ApiResponse.onSuccess(result); + } + + @GetMapping("/{libraryId}") + @Operation(summary = "특정 라이브러리 상세 조회", description = """ + ## 개요 + 특정 라이브러리의 상세 정보를 조회하는 API입니다. + + ## Path Parameters + - libraryId : 조회할 라이브러리 ID + + ## 응답 데이터 + - 라이브러리 기본 정보 (이름, 태그, 생성일, 수정일) + - 포함된 패널들의 상세 정보 + - 연결된 검색기록들 -> 혹시 몰라 추가한 로직으로 필요 없다고 판단될 시 안쓰셔도 됩니다. + - 패널 통계 정보 (성별, 연령대, 거주지 분포) -> 혹시 몰라 추가한 로직으로 필요 없다고 판단될 시 안쓰셔도 됩니다. + + ## 권한 + 본인이 생성한 라이브러리만 조회할 수 있습니다. + """) + public ApiResponse getLibraryDetail(@PathVariable Long libraryId) { + Member member = currentUserService.getCurrentUser(); + LibraryResponseDTO.LibraryDetail result = libraryService.getLibraryDetail(libraryId, member); + return ApiResponse.onSuccess(result); + } + + @PutMapping("/{libraryId}/search-histories/{searchHistoryId}") + @Operation(summary = "기존 라이브러리에 새로운 검색기록 추가(병합)", description = """ + + ## 개요 + 기존 라이브러리에 검색기록을 추가하는 API입니다. + 라이브러리의 패널 ID와 검색기록의 패널 ID를 병합하여 중복을 제거합니다. + + ## Path Parameters + - libraryId : 라이브러리 ID + - searchHistoryId : 추가할 검색기록 ID + + ## 응답 + - library_id : 라이브러리 ID + - library_name : 라이브러리 이름 + - search_history_id : 추가된 검색기록 ID + - panel_count : 새로 추가된 패널 개수 + - panel_ids : 병합된 전체 패널 ID 리스트 + - created_at : 생성 시간 + + ## 권한 + 본인의 라이브러리와 검색기록만 사용할 수 있습니다. + + ## 예시 + - 라이브러리 1번에 패널 ID [1, 2, 3]이 있음 + - 검색기록 2번에 패널 ID [3, 4, 5]가 있음 + - 결과: 라이브러리 1번에 패널 ID [1, 2, 3, 4, 5]가 됨 + + """) + public ApiResponse addSearchHistoryToLibrary( + @PathVariable Long libraryId, + @PathVariable Long searchHistoryId) { + Member member = currentUserService.getCurrentUser(); + LibraryService.LibraryCreateResult createResult = libraryService.addSearchHistoryToLibrary(libraryId, + searchHistoryId, member); + + LibraryResponseDTO.CreateResult result = LibraryResponseDTO.CreateResult.from( + createResult.getLibrary(), + searchHistoryId, + createResult.getPanelCount()); + return ApiResponse.onSuccess(result); + } } diff --git a/src/main/java/DiffLens/back_end/domain/library/dto/LibraryRequestDto.java b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryRequestDto.java new file mode 100644 index 0000000..83a6a4c --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryRequestDto.java @@ -0,0 +1,36 @@ +package DiffLens.back_end.domain.library.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class LibraryRequestDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Create { + + @NotNull(message = "검색 기록 ID는 필수입니다") + @JsonProperty("search_history_id") + private Long searchHistoryId; + + @NotBlank(message = "라이브러리 이름은 필수입니다") + @JsonProperty("library_name") + private String libraryName; + + @NotNull(message = "태그는 필수입니다") + private List tags; + + @JsonProperty("panel_ids") + private List panelIds; // 선택적: null이면 SearchHistory의 panelIds 사용 + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/library/dto/LibraryResponseDTO.java b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryResponseDTO.java index c3be2bc..534f809 100644 --- a/src/main/java/DiffLens/back_end/domain/library/dto/LibraryResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/library/dto/LibraryResponseDTO.java @@ -1,5 +1,6 @@ package DiffLens.back_end.domain.library.dto; +import DiffLens.back_end.domain.library.entity.Library; import DiffLens.back_end.global.dto.ResponsePageDTO; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -15,45 +16,233 @@ public class LibraryResponseDTO { @Builder @NoArgsConstructor @AllArgsConstructor - public static class ListResult{ + public static class ListResult { - private List panels; + private List libraries; @JsonProperty("page_info") - private ResponsePageDTO.PageInfo pageInfo; + private ResponsePageDTO.CursorPageInfo cursorPageInfo; @Getter @Builder @NoArgsConstructor @AllArgsConstructor - public static class Panel { + public static class LibraryItem { - @JsonProperty("search_id") - private Long searchId; + @JsonProperty("library_id") + private Long libraryId; - private String name; + @JsonProperty("library_name") + private String libraryName; - @JsonProperty("total_count") - private Integer totalCount; + private List tags; + + @JsonProperty("panel_count") + private Integer panelCount; + + @JsonProperty("panel_ids") + private List panelIds; + + @JsonProperty("created_at") + private String createdAt; + + public static LibraryItem from(Library library, int panelCount) { + return LibraryItem.builder() + .libraryId(library.getId()) + .libraryName(library.getLibraryName()) + .tags(library.getTags()) + .panelCount(panelCount) + .panelIds(library.getPanelIds()) + .createdAt(library.getCreatedDate().toString()) + .build(); + } + } + + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateResult { + + @JsonProperty("library_id") + private Long libraryId; + + @JsonProperty("library_name") + private String libraryName; + + private List tags; + + @JsonProperty("search_history_id") + private Long searchHistoryId; + + @JsonProperty("panel_count") + private Integer panelCount; + + @JsonProperty("panel_ids") + private List panelIds; + + @JsonProperty("created_at") + private String createdAt; + + public static CreateResult from(Library library, Long searchHistoryId, int panelCount) { + return CreateResult.builder() + .libraryId(library.getId()) + .libraryName(library.getLibraryName()) + .tags(library.getTags()) + .searchHistoryId(searchHistoryId) + .panelCount(panelCount) + .panelIds(library.getPanelIds()) + .createdAt(library.getCreatedDate() != null ? library.getCreatedDate().toString() : null) + .build(); + } + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LibraryDetail { + + @JsonProperty("library_id") + private Long libraryId; + + @JsonProperty("library_name") + private String libraryName; + + private List tags; + + @JsonProperty("panel_count") + private Integer panelCount; + + @JsonProperty("panel_ids") + private List panelIds; + + @JsonProperty("panels") + private List panels; + + @JsonProperty("search_histories") + private List searchHistories; + + @JsonProperty("statistics") + private Statistics statistics; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("updated_at") + private String updatedAt; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PanelInfo { + @JsonProperty("panel_id") + private String panelId; + + @JsonProperty("gender") + private String gender; + + @JsonProperty("age") + private Integer age; + + @JsonProperty("age_group") + private String ageGroup; + + @JsonProperty("residence") + private String residence; + + @JsonProperty("marital_status") + private String maritalStatus; + + @JsonProperty("children_count") + private Integer childrenCount; + + @JsonProperty("occupation") + private String occupation; + + @JsonProperty("profile_summary") + private String profileSummary; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SearchHistoryInfo { + @JsonProperty("search_history_id") + private Long searchHistoryId; + + @JsonProperty("content") + private String content; + + @JsonProperty("date") + private String date; + + @JsonProperty("panel_count") + private Integer panelCount; @JsonProperty("created_at") private String createdAt; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Statistics { + @JsonProperty("total_panels") + private Integer totalPanels; + + @JsonProperty("gender_distribution") + private GenderDistribution genderDistribution; - @JsonProperty("data_type") - private String dataType; + @JsonProperty("age_group_distribution") + private AgeGroupDistribution ageGroupDistribution; - private List tags; + @JsonProperty("residence_distribution") + private ResidenceDistribution residenceDistribution; @Getter @Builder @NoArgsConstructor @AllArgsConstructor - public static class Tag { - private String key; - private String value; + public static class GenderDistribution { + private Integer male; + private Integer female; + private Integer none; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AgeGroupDistribution { + @JsonProperty("20대") + private Integer twenties; + @JsonProperty("30대") + private Integer thirties; + @JsonProperty("40대") + private Integer forties; + @JsonProperty("50대") + private Integer fifties; + @JsonProperty("60대+") + private Integer sixtiesPlus; } - } + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResidenceDistribution { + private Integer seoul; + private Integer gyeonggi; + private Integer busan; + private Integer other; + } + } } } diff --git a/src/main/java/DiffLens/back_end/domain/library/entity/Library.java b/src/main/java/DiffLens/back_end/domain/library/entity/Library.java index 1f5d1d9..d95c2ec 100644 --- a/src/main/java/DiffLens/back_end/domain/library/entity/Library.java +++ b/src/main/java/DiffLens/back_end/domain/library/entity/Library.java @@ -1,15 +1,23 @@ package DiffLens.back_end.domain.library.entity; -import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.util.List; @Entity @Getter +@Setter @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Library { +@EntityListeners(AuditingEntityListener.class) +public class Library extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -18,12 +26,16 @@ public class Library { @Column(nullable = false, length = 50) private String libraryName; - @Column(nullable = false, length = 10) - private String tag; + @Column(nullable = false, columnDefinition = "text[]") + private List tags; - // 연관관계 - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "history_id") - private SearchHistory history; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(nullable = false, columnDefinition = "text[] DEFAULT '{}'::text[]") + @Builder.Default + private List panelIds = List.of(); + // 연관관계 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; } diff --git a/src/main/java/DiffLens/back_end/domain/library/entity/LibraryPanel.java b/src/main/java/DiffLens/back_end/domain/library/entity/LibraryPanel.java index b79d381..69ea73a 100644 --- a/src/main/java/DiffLens/back_end/domain/library/entity/LibraryPanel.java +++ b/src/main/java/DiffLens/back_end/domain/library/entity/LibraryPanel.java @@ -1,6 +1,7 @@ package DiffLens.back_end.domain.library.entity; import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -12,7 +13,7 @@ @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class LibraryPanel { +public class LibraryPanel extends BaseEntity { // 복합키 @EmbeddedId diff --git a/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibrary.java b/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibrary.java new file mode 100644 index 0000000..f7989b0 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibrary.java @@ -0,0 +1,29 @@ +package DiffLens.back_end.domain.library.entity; + +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@EntityListeners(AuditingEntityListener.class) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SearchHistoryLibrary extends BaseEntity { + + @EmbeddedId + private SearchHistoryLibraryKey id; + + @MapsId("libraryId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "library_id", insertable = false, updatable = false) + private Library library; + + @MapsId("historyId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "history_id", insertable = false, updatable = false) + private SearchHistory history; +} diff --git a/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibraryKey.java b/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibraryKey.java new file mode 100644 index 0000000..952702b --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/entity/SearchHistoryLibraryKey.java @@ -0,0 +1,23 @@ +package DiffLens.back_end.domain.library.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +@Embeddable +public class SearchHistoryLibraryKey implements Serializable { + @Column(name = "history_id") + private Long historyId; + + @Column(name = "library_id") + private Long libraryId; +} diff --git a/src/main/java/DiffLens/back_end/domain/library/repository/LibraryPanelRepository.java b/src/main/java/DiffLens/back_end/domain/library/repository/LibraryPanelRepository.java new file mode 100644 index 0000000..a606e9e --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/repository/LibraryPanelRepository.java @@ -0,0 +1,18 @@ +package DiffLens.back_end.domain.library.repository; + +import DiffLens.back_end.domain.library.entity.LibraryPanel; +import DiffLens.back_end.domain.library.entity.LibraryPanelKey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface LibraryPanelRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM LibraryPanel lp WHERE lp.library.id = :libraryId") + void deleteByLibraryId(@Param("libraryId") Long libraryId); + + @Query("SELECT COUNT(lp) FROM LibraryPanel lp WHERE lp.library.id = :libraryId") + int countByLibraryId(@Param("libraryId") Long libraryId); +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/library/repository/LibraryRepository.java b/src/main/java/DiffLens/back_end/domain/library/repository/LibraryRepository.java new file mode 100644 index 0000000..33b6adc --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/repository/LibraryRepository.java @@ -0,0 +1,11 @@ +package DiffLens.back_end.domain.library.repository; + +import DiffLens.back_end.domain.library.entity.Library; +import DiffLens.back_end.domain.members.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LibraryRepository extends JpaRepository { + List findByMemberOrderByCreatedDateDesc(Member member); +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/library/repository/SearchHistoryLibraryRepository.java b/src/main/java/DiffLens/back_end/domain/library/repository/SearchHistoryLibraryRepository.java new file mode 100644 index 0000000..7c931f6 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/repository/SearchHistoryLibraryRepository.java @@ -0,0 +1,20 @@ +package DiffLens.back_end.domain.library.repository; + +import DiffLens.back_end.domain.library.entity.SearchHistoryLibrary; +import DiffLens.back_end.domain.library.entity.SearchHistoryLibraryKey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface SearchHistoryLibraryRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM SearchHistoryLibrary shl WHERE shl.library.id = :libraryId") + void deleteByLibraryId(@Param("libraryId") Long libraryId); + + @Query("SELECT shl FROM SearchHistoryLibrary shl WHERE shl.library.id = :libraryId") + List findByLibraryId(@Param("libraryId") Long libraryId); +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java b/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java new file mode 100644 index 0000000..3879087 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/library/service/LibraryService.java @@ -0,0 +1,313 @@ +package DiffLens.back_end.domain.library.service; + +import DiffLens.back_end.domain.library.dto.LibraryRequestDto; +import DiffLens.back_end.domain.library.dto.LibraryResponseDTO; +import DiffLens.back_end.domain.library.entity.Library; +import DiffLens.back_end.domain.library.entity.LibraryPanel; +import DiffLens.back_end.domain.library.entity.LibraryPanelKey; +import DiffLens.back_end.domain.library.entity.SearchHistoryLibrary; +import DiffLens.back_end.domain.library.entity.SearchHistoryLibraryKey; +import DiffLens.back_end.domain.library.repository.LibraryPanelRepository; +import DiffLens.back_end.domain.library.repository.LibraryRepository; +import DiffLens.back_end.domain.library.repository.SearchHistoryLibraryRepository; +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.repository.SearchHistoryRepository; +import DiffLens.back_end.global.responses.code.status.error.ErrorStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class LibraryService { + + private final LibraryRepository libraryRepository; + private final LibraryPanelRepository libraryPanelRepository; + private final SearchHistoryLibraryRepository searchHistoryLibraryRepository; + private final SearchHistoryRepository searchHistoryRepository; + private final PanelRepository panelRepository; + + @Transactional + public LibraryCreateResult createLibrary(LibraryRequestDto.Create request, Member member) { + + // 1. SearchHistory 검증 + SearchHistory history = searchHistoryRepository.findById(request.getSearchHistoryId()) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + + // 2. 권한 검증 - 본인의 검색 기록만 라이브러리로 저장 가능 + if (!history.getMember().getId().equals(member.getId())) { + throw new ErrorHandler(ErrorStatus.FORBIDDEN); + } + + // 3. Library 생성 (SearchHistory 참조 제거) + // 패널 ID 결정: 요청에 있으면 사용, 없으면 검색기록의 패널 ID 사용 + List panelIds = request.getPanelIds() != null + ? request.getPanelIds() + : history.getPanelIds(); + + Library library = Library.builder() + .libraryName(request.getLibraryName()) + .tags(request.getTags()) + .panelIds(panelIds != null ? panelIds : List.of()) + .member(member) + .build(); + + library = libraryRepository.save(library); + + // 4. Library-SearchHistory 다대다 관계 생성 + SearchHistoryLibrary searchHistoryLibrary = SearchHistoryLibrary.builder() + .id(new SearchHistoryLibraryKey(history.getId(), library.getId())) + .library(library) + .history(history) + .build(); + + searchHistoryLibraryRepository.save(searchHistoryLibrary); + + // 5. Panel 관계 생성 + int panelCount = 0; + if (panelIds != null && !panelIds.isEmpty()) { + createLibraryPanels(library, panelIds); + panelCount = panelIds.size(); + } + + return new LibraryCreateResult(library, panelCount); + } + + @Transactional + public LibraryCreateResult addSearchHistoryToLibrary(Long libraryId, Long searchHistoryId, Member member) { + // 1. 라이브러리 조회 및 권한 검증 + Library library = libraryRepository.findById(libraryId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + + if (!library.getMember().getId().equals(member.getId())) { + throw new ErrorHandler(ErrorStatus.FORBIDDEN); + } + + // 2. 검색기록 조회 및 권한 검증 + SearchHistory searchHistory = searchHistoryRepository.findById(searchHistoryId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + + if (!searchHistory.getMember().getId().equals(member.getId())) { + throw new ErrorHandler(ErrorStatus.FORBIDDEN); + } + + // 3. 패널 ID 병합 (중복 제거) + List existingPanelIds = library.getPanelIds(); + List newPanelIds = searchHistory.getPanelIds(); + List mergedPanelIds = Stream.concat( + existingPanelIds.stream(), + newPanelIds.stream()).distinct().collect(Collectors.toList()); + + // 4. 새로운 패널들만 LibraryPanel에 추가 + List newPanelsToAdd = newPanelIds.stream() + .filter(panelId -> !existingPanelIds.contains(panelId)) + .collect(Collectors.toList()); + + int addedPanelCount = 0; + if (!newPanelsToAdd.isEmpty()) { + createLibraryPanels(library, newPanelsToAdd); + addedPanelCount = newPanelsToAdd.size(); + } + + // 5. Library 엔티티의 panelIds 업데이트 + // 기존 엔티티의 panelIds만 업데이트 (AuditingEntityListener가 updatedAt 자동 관리) + library.setPanelIds(mergedPanelIds); + library = libraryRepository.save(library); + + // 6. SearchHistoryLibrary 관계 생성 (존재하지 않을 때만) + SearchHistoryLibraryKey searchHistoryLibraryKey = new SearchHistoryLibraryKey(searchHistoryId, + libraryId); + boolean relationshipExists = searchHistoryLibraryRepository.existsById(searchHistoryLibraryKey); + + if (!relationshipExists) { + SearchHistoryLibrary searchHistoryLibrary = SearchHistoryLibrary.builder() + .id(searchHistoryLibraryKey) + .library(library) + .history(searchHistory) + .build(); + searchHistoryLibraryRepository.save(searchHistoryLibrary); + } + + return new LibraryCreateResult(library, addedPanelCount); + } + + @Transactional(readOnly = true) + public LibraryResponseDTO.ListResult getLibrariesByMember(Member member) { + List libraries = libraryRepository.findByMemberOrderByCreatedDateDesc(member); + + List libraryItems = libraries.stream() + .map(library -> { + int panelCount = libraryPanelRepository.countByLibraryId(library.getId()); + return LibraryResponseDTO.ListResult.LibraryItem.from(library, panelCount); + }) + .toList(); + + return LibraryResponseDTO.ListResult.builder() + .libraries(libraryItems) + .cursorPageInfo(null) // 페이징이 필요하면 추후 구현 + .build(); + } + + @Transactional(readOnly = true) + public LibraryResponseDTO.LibraryDetail getLibraryDetail(Long libraryId, Member member) { + // 1. 라이브러리 조회 및 권한 검증 + Library library = libraryRepository.findById(libraryId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + + if (!library.getMember().getId().equals(member.getId())) { + throw new ErrorHandler(ErrorStatus.FORBIDDEN); + } + + // 2. 패널 정보 조회 + List panels = panelRepository.findByIdList(library.getPanelIds()); + List panelInfos = panels.stream() + .map(panel -> LibraryResponseDTO.LibraryDetail.PanelInfo.builder() + .panelId(panel.getId()) + .gender(panel.getGender() != null ? panel.getGender().toString() : null) + .age(panel.getAge()) + .ageGroup(panel.getAgeGroup()) + .residence(panel.getResidence()) + .maritalStatus(panel.getMaritalStatus()) + .childrenCount(panel.getChildrenCount()) + .occupation(panel.getOccupation()) + .profileSummary(panel.getProfileSummary()) + .build()) + .toList(); + + // 3. 연결된 검색기록 조회 + List searchHistoryLibraries = searchHistoryLibraryRepository + .findByLibraryId(libraryId); + List searchHistoryInfos = searchHistoryLibraries + .stream() + .map(shl -> { + SearchHistory history = shl.getHistory(); + return LibraryResponseDTO.LibraryDetail.SearchHistoryInfo.builder() + .searchHistoryId(history.getId()) + .content(history.getContent()) + .date(history.getDate() != null ? history.getDate().toString() + : null) + .panelCount(history.getPanelIds() != null + ? history.getPanelIds().size() + : 0) + .createdAt(history.getCreatedDate() != null + ? history.getCreatedDate().toString() + : null) + .build(); + }) + .toList(); + + // 4. 통계 정보 생성 + LibraryResponseDTO.LibraryDetail.Statistics statistics = createStatistics(panels); + + return LibraryResponseDTO.LibraryDetail.builder() + .libraryId(library.getId()) + .libraryName(library.getLibraryName()) + .tags(library.getTags()) + .panelCount(panels.size()) + .panelIds(library.getPanelIds()) + .panels(panelInfos) + .searchHistories(searchHistoryInfos) + .statistics(statistics) + .createdAt(library.getCreatedDate() != null ? library.getCreatedDate().toString() + : null) + .updatedAt(library.getUpdatedAt() != null ? library.getUpdatedAt().toString() : null) + .build(); + } + + // 추후 혹시 라이브러리 상세 페이지에서 간단한 통계 디자인 생길 것 대비해 작성해둠 + private LibraryResponseDTO.LibraryDetail.Statistics createStatistics(List panels) { + // 성별 분포 + long maleCount = panels.stream() + .filter(p -> p.getGender() != null && p.getGender().toString().equals("MALE")).count(); + long femaleCount = panels.stream() + .filter(p -> p.getGender() != null && p.getGender().toString().equals("FEMALE")) + .count(); + long noneCount = panels.stream() + .filter(p -> p.getGender() != null && p.getGender().toString().equals("NONE")).count(); + + LibraryResponseDTO.LibraryDetail.Statistics.GenderDistribution genderDistribution = LibraryResponseDTO.LibraryDetail.Statistics.GenderDistribution + .builder() + .male((int) maleCount) + .female((int) femaleCount) + .none((int) noneCount) + .build(); + + // 연령대 분포 + long twenties = panels.stream().filter(p -> "20대".equals(p.getAgeGroup())).count(); + long thirties = panels.stream().filter(p -> "30대".equals(p.getAgeGroup())).count(); + long forties = panels.stream().filter(p -> "40대".equals(p.getAgeGroup())).count(); + long fifties = panels.stream().filter(p -> "50대".equals(p.getAgeGroup())).count(); + long sixtiesPlus = panels.stream().filter(p -> p.getAgeGroup() != null && + (p.getAgeGroup().contains("60") || p.getAgeGroup().contains("70") + || p.getAgeGroup().contains("80"))) + .count(); + + LibraryResponseDTO.LibraryDetail.Statistics.AgeGroupDistribution ageGroupDistribution = LibraryResponseDTO.LibraryDetail.Statistics.AgeGroupDistribution + .builder() + .twenties((int) twenties) + .thirties((int) thirties) + .forties((int) forties) + .fifties((int) fifties) + .sixtiesPlus((int) sixtiesPlus) + .build(); + + // 거주지 분포 + long seoul = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("서울")) + .count(); + long gyeonggi = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("경기")) + .count(); + long busan = panels.stream().filter(p -> p.getResidence() != null && p.getResidence().contains("부산")) + .count(); + long other = panels.size() - seoul - gyeonggi - busan; + + LibraryResponseDTO.LibraryDetail.Statistics.ResidenceDistribution residenceDistribution = LibraryResponseDTO.LibraryDetail.Statistics.ResidenceDistribution + .builder() + .seoul((int) seoul) + .gyeonggi((int) gyeonggi) + .busan((int) busan) + .other((int) other) + .build(); + + return LibraryResponseDTO.LibraryDetail.Statistics.builder() + .totalPanels(panels.size()) + .genderDistribution(genderDistribution) + .ageGroupDistribution(ageGroupDistribution) + .residenceDistribution(residenceDistribution) + .build(); + } + + private void createLibraryPanels(Library library, List panelIds) { + List panels = panelRepository.findByIdList(panelIds); + + if (panels.size() != panelIds.size()) { + throw new ErrorHandler(ErrorStatus.BAD_REQUEST); + // TODO: 커스텀 에러 메시지 추가 시 활용 - "존재하지 않는 패널 ID가 있습니다" + } + + List libraryPanels = panels.stream() + .map(panel -> LibraryPanel.builder() + .id(new LibraryPanelKey(panel.getId(), library.getId())) + .library(library) + .panel(panel) + .build()) + .toList(); + + libraryPanelRepository.saveAll(libraryPanels); + } + + // 라이브러리 생성 결과를 담는 내부 클래스 + @lombok.Getter + @lombok.AllArgsConstructor + public static class LibraryCreateResult { + private final Library library; + private final int panelCount; + } +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java index aa3de53..f0b1363 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java @@ -9,6 +9,7 @@ import DiffLens.back_end.global.responses.code.status.error.AuthStatus; import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; import DiffLens.back_end.global.security.JwtTokenProvider; +import DiffLens.back_end.global.security.Role; import lombok.Builder; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -74,7 +75,7 @@ public TokenResponseDTO login(Object request) { // 멤버 유무 판단 후 create 메서드 호출 Member member = memberRepository.findByEmail(userInfo.getEmail()) - .orElseGet(() -> createMember(userInfo)); + .orElseGet(() -> createMember(userInfo, body)); return jwtTokenProvider.createToken(member); } @@ -84,14 +85,16 @@ public TokenResponseDTO login(Object request) { protected abstract SocialUserInfo getUserInfo(String accessToken); // member 생성 후 저장 - protected Member createMember(SocialUserInfo userInfo) { + protected Member createMember(SocialUserInfo userInfo, AuthRequestDTO.SocialLogin request) { Member member = Member.builder() .email(userInfo.getEmail()) .name(userInfo.getName()) .loginType(getLoginType()) .password(null) .loginType(userInfo.getLoginType()) + .role(Role.ROLE_USER) // .roles(Set.of(UserType.USER)) + .plan(request.getPlan()) .build(); return memberRepository.save(member); } diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthAdminStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthAdminStrategy.java new file mode 100644 index 0000000..a2497a1 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthAdminStrategy.java @@ -0,0 +1,57 @@ +package DiffLens.back_end.domain.members.auth.strategy.implement; + +import DiffLens.back_end.domain.members.auth.strategy.interfaces.AuthStrategy; +import DiffLens.back_end.domain.members.dto.auth.AuthRequestDTO; +import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.domain.members.repository.MemberRepository; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import DiffLens.back_end.global.security.JwtTokenProvider; +import DiffLens.back_end.global.security.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthAdminStrategy implements AuthStrategy { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Override + public Boolean signUp(AuthRequestDTO.SignUp request) { + + // 요청 정보 추출 + String email = request.getEmail(); + LoginType loginType = request.getLoginType(); + + // 이미 존재하는 사용자 + if(memberRepository.existsByEmail(email)) { + throw new ErrorHandler(AuthStatus.ALREADY_EXISTS); + } + + // 유저 객체 생성 + Member member = Member.builder() + .email(email) + .name(request.getName()) + .loginType(loginType) + .password(passwordEncoder.encode(request.getPassword())) + .role(Role.ROLE_ADMIN) + .plan(request.getPlan()) + .build(); + + // 유저 저장 + memberRepository.save(member); + return true; + + } + + @Override + public TokenResponseDTO login(Object request) { + throw new ErrorHandler(AuthStatus.NOT_SUPPLYING); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java index 8942bff..73ca333 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java @@ -3,12 +3,15 @@ import DiffLens.back_end.domain.members.auth.strategy.interfaces.AuthStrategy; import DiffLens.back_end.domain.members.dto.auth.AuthRequestDTO; import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; +import DiffLens.back_end.domain.members.entity.Agreement; import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.entity.Onboarding; import DiffLens.back_end.domain.members.enums.LoginType; import DiffLens.back_end.domain.members.repository.MemberRepository; import DiffLens.back_end.global.responses.code.status.error.AuthStatus; import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; import DiffLens.back_end.global.security.JwtTokenProvider; +import DiffLens.back_end.global.security.Role; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -39,8 +42,17 @@ public Boolean signUp(AuthRequestDTO.SignUp request) { .name(request.getName()) .loginType(loginType) .password(passwordEncoder.encode(request.getPassword())) + .role(Role.ROLE_USER) + .plan(request.getPlan()) .build(); + // Onboarding 생성 + Onboarding onboarding = Onboarding.builder() + .job(request.getJob()) + .industry(request.getIndustry()) + .build(); + onboarding.setMember(member); // 양방향 관계 편의 메서드로 연결 + // 유저 저장 memberRepository.save(member); return true; diff --git a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java index a20b168..b712871 100644 --- a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java +++ b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java @@ -59,7 +59,7 @@ public ApiResponse localLogin(@RequestBody @Valid Auth } @PostMapping("/login/google") - @Operation(summary = "로그인 ( 구글 )", + @Operation(summary = "로그인 ( 구글 )", hidden = true, description = """ ## 개요 diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java index ef5718b..1c66f34 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java @@ -1,6 +1,9 @@ package DiffLens.back_end.domain.members.dto.auth; +import DiffLens.back_end.domain.members.enums.Industry; +import DiffLens.back_end.domain.members.enums.Job; import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.domain.members.enums.PlanEnum; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -17,6 +20,7 @@ public class AuthRequestDTO { @AllArgsConstructor public static class SignUp { + // 로그인 정보 @Schema(description = "email 형식으로 입력해야 합니다.") @NotBlank(message = "이메일은 필수 입력 항목입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") @@ -36,6 +40,19 @@ public static class SignUp { @NotNull(message = "로그인 타입은 필수 항목입니다.") private LoginType loginType; + // 플랜 + @Schema(description = "플랜 유형") + @NotNull(message = "플랜 유형은 필수 항목입니다.") + private PlanEnum plan; + + // 온보딩 + @Schema(description = "직무") + @NotNull(message = "직무는 필수 항목입니다.") + private Job job; + + @Schema(description = "엄종") + @NotNull(message = "엄종은 필수 항목입니다.") + private Industry industry; } @@ -71,6 +88,9 @@ public static class SocialLogin implements LoginRequest { @NotNull(message = "로그인 타입은 필수 항목입니다.") private LoginType loginType; + @NotNull(message = "플랜 유형은 필수 항목입니다.") + private PlanEnum plan; + } public interface LoginRequest { diff --git a/src/main/java/DiffLens/back_end/domain/members/entity/Agreement.java b/src/main/java/DiffLens/back_end/domain/members/entity/Agreement.java index 52893eb..f261253 100644 --- a/src/main/java/DiffLens/back_end/domain/members/entity/Agreement.java +++ b/src/main/java/DiffLens/back_end/domain/members/entity/Agreement.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; import lombok.*; -@Entity +//@Entity @Getter @Builder @AllArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/members/entity/Member.java b/src/main/java/DiffLens/back_end/domain/members/entity/Member.java index 448373b..3572da1 100644 --- a/src/main/java/DiffLens/back_end/domain/members/entity/Member.java +++ b/src/main/java/DiffLens/back_end/domain/members/entity/Member.java @@ -1,7 +1,9 @@ package DiffLens.back_end.domain.members.entity; import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.domain.members.enums.PlanEnum; import DiffLens.back_end.global.entity.BaseEntity; +import DiffLens.back_end.global.security.Role; import jakarta.persistence.*; import lombok.*; import org.springframework.security.core.GrantedAuthority; @@ -39,25 +41,34 @@ public class Member extends BaseEntity implements UserDetails { @Column(nullable = true) private String profileImage; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PlanEnum plan; + // 연관관계 // 양방향 - @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - private Agreement agreement; +// @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) +// private Agreement agreement; // 양방향 @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private Onboarding onboarding; - // 단방향 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "plan_id") - private Plan plan; + // 연관관계 편의 메서드 + public void setOnboarding(Onboarding onboarding) { + this.onboarding = onboarding; + } + // Security 관련 @Override public Collection getAuthorities() { - return List.of(); + return List.of(role); } @Override diff --git a/src/main/java/DiffLens/back_end/domain/members/entity/Onboarding.java b/src/main/java/DiffLens/back_end/domain/members/entity/Onboarding.java index 142f3af..0a4aa4c 100644 --- a/src/main/java/DiffLens/back_end/domain/members/entity/Onboarding.java +++ b/src/main/java/DiffLens/back_end/domain/members/entity/Onboarding.java @@ -32,4 +32,11 @@ public class Onboarding extends BaseEntity { @JoinColumn(name = "member_id", nullable = false) private Member member; + public void setMember(Member member) { + this.member = member; + if (member.getOnboarding() != this) { + member.setOnboarding(this); + } + } + } diff --git a/src/main/java/DiffLens/back_end/domain/members/entity/Plan.java b/src/main/java/DiffLens/back_end/domain/members/entity/Plan.java index 95ecfe4..f8ac405 100644 --- a/src/main/java/DiffLens/back_end/domain/members/entity/Plan.java +++ b/src/main/java/DiffLens/back_end/domain/members/entity/Plan.java @@ -13,7 +13,6 @@ public class Plan extends BaseEntity { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 50) diff --git a/src/main/java/DiffLens/back_end/domain/members/enums/Industry.java b/src/main/java/DiffLens/back_end/domain/members/enums/Industry.java index 434aa7d..a981f98 100644 --- a/src/main/java/DiffLens/back_end/domain/members/enums/Industry.java +++ b/src/main/java/DiffLens/back_end/domain/members/enums/Industry.java @@ -1,10 +1,28 @@ package DiffLens.back_end.domain.members.enums; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * 업종 */ +@Getter +@AllArgsConstructor public enum Industry { - IT, FINANCE, EDUCATION + IT_SOFTWARE("IT·인터넷·소프트웨어"), + ELECTRONICS_MANUFACTURING("전자·제조·기계"), + FINANCE_INSURANCE("금융·보험·핀테크"), + DISTRIBUTION_FOOD("유통·소비재·식품"), + CULTURE_MEDIA("문화·미디어·엔터테인먼트"), + MEDICAL_BIO("의료·제약·바이오"), + EDUCATION_EDUTECH("교육·에듀테크"), + PUBLIC_ADMIN("공공·비영리·행정"), + CONSTRUCTION_INFRA("건설·부동산·인프라"), + ENERGY_ENVIRONMENT("에너지·환경·화학"), + TOURISM_TRAVEL("관광·여행·항공"), + ETC("기타 산업군"); + private final String krValue; } + diff --git a/src/main/java/DiffLens/back_end/domain/members/enums/Job.java b/src/main/java/DiffLens/back_end/domain/members/enums/Job.java index 833e9f7..b761f14 100644 --- a/src/main/java/DiffLens/back_end/domain/members/enums/Job.java +++ b/src/main/java/DiffLens/back_end/domain/members/enums/Job.java @@ -1,10 +1,29 @@ package DiffLens.back_end.domain.members.enums; +import lombok.AllArgsConstructor; +import lombok.Getter; + /** * 직무 */ +@Getter +@AllArgsConstructor public enum Job { - ETC + MANAGEMENT("경영/기획/전략"), + MARKETING("마케팅/광고/홍보"), + SALES("영업/고객관리"), + IT("IT/개발/데이터"), + DESIGN("디자인/미디어"), + PRODUCTION("생산/제조/품질"), + RESEARCH("연구/R&D"), + EDUCATION("교육/컨설팅"), + MEDICAL("의료/보건/복지"), + FINANCE("금융/회계/법률"), + SERVICE("서비스/유통"), + ETC_FREELANCER("기타/프리랜서") + ; + + private final String krValue; } diff --git a/src/main/java/DiffLens/back_end/domain/members/enums/LoginType.java b/src/main/java/DiffLens/back_end/domain/members/enums/LoginType.java index 6fa58f8..9b8d312 100644 --- a/src/main/java/DiffLens/back_end/domain/members/enums/LoginType.java +++ b/src/main/java/DiffLens/back_end/domain/members/enums/LoginType.java @@ -7,7 +7,6 @@ @RequiredArgsConstructor public enum LoginType { - GENERAL("로컬", "LOCAL" ), GOOGLE("구글", "GOOGLE" ), // KAKAO, diff --git a/src/main/java/DiffLens/back_end/domain/members/enums/PlanEnum.java b/src/main/java/DiffLens/back_end/domain/members/enums/PlanEnum.java new file mode 100644 index 0000000..a68e5f7 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/enums/PlanEnum.java @@ -0,0 +1,18 @@ +package DiffLens.back_end.domain.members.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PlanEnum { + + PERSONAL(1L, "개인", 100000), + BUSINESS(2L, "비즈니스", 500000) + ; + + private final Long id; + private final String name; + private final Integer price; + +} diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/PlanRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/PlanRepository.java new file mode 100644 index 0000000..e325194 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/repository/PlanRepository.java @@ -0,0 +1,7 @@ +package DiffLens.back_end.domain.members.repository; + +import DiffLens.back_end.domain.members.entity.Plan; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PlanRepository extends JpaRepository { +} diff --git a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java index 3f16a43..b0c6373 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java +++ b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java @@ -2,6 +2,7 @@ import DiffLens.back_end.domain.panel.dto.PanelRequestDTO; import DiffLens.back_end.domain.panel.dto.PanelResponseDTO; +import DiffLens.back_end.domain.panel.service.PanelService; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,18 +16,32 @@ @RequiredArgsConstructor public class PanelController { + private final PanelService panelService; + @GetMapping("/{panelId}") - @Operation(summary = "특정 패널 상세 조회 ( 미구현 )") - public ApiResponse details(@PathVariable("panelId") Long panelId) { - PanelResponseDTO.PanelDetails result = new PanelResponseDTO.PanelDetails(); + @Operation(summary = "특정 패널 상세 조회", description = """ + ## 개요 + 특정 패널의 상세 정보를 조회하는 API입니다. + + ## 응답 데이터 + - 패널의 기본 정보 (성별, 나이, 거주지 등) + - 패널의 속성 정보 (기기, 해시태그 등) + - 연결된 RawData 정보 + + ## 권한 + 인증된 사용자만 접근 가능합니다. + """) + public ApiResponse details(@PathVariable("panelId") String panelId) { + PanelResponseDTO.PanelDetails result = panelService.getPanelDetails(panelId); return ApiResponse.onSuccess(result); } @PostMapping("/compare") @Operation(summary = "패널 그룹 비교 분석 ( 미구현 ) ") - public ApiResponse groupCompare(@RequestBody @Valid PanelRequestDTO.GroupAnal request) { + public ApiResponse groupCompare( + @RequestBody @Valid PanelRequestDTO.GroupAnal request) { PanelResponseDTO.GroupCompare result = new PanelResponseDTO.GroupCompare(); - return ApiResponse.onSuccess(result); + return ApiResponse.onSuccess(result); } } diff --git a/src/main/java/DiffLens/back_end/domain/panel/dto/PanelResponseDTO.java b/src/main/java/DiffLens/back_end/domain/panel/dto/PanelResponseDTO.java index dd3ad19..f02aae5 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/dto/PanelResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/panel/dto/PanelResponseDTO.java @@ -19,9 +19,6 @@ public static class PanelDetails { @JsonProperty("panel_detail") private PanelDetail panelDetail; - @JsonProperty("similar_panels") - private List similarPanels; - @Getter @Builder @NoArgsConstructor @@ -69,27 +66,9 @@ public static class BasicInfo { public static class Attribute { private String key; private String value; // 문자열 or 배열 모두 가능 - private String unit; } } - @Getter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class SimilarPanel { - - @JsonProperty("panel_id") - private String panelId; - - @JsonProperty("display_name") - private String displayName; - - private List tags; - - @JsonProperty("match_score") - private Integer matchScore; - } } @Getter @@ -135,8 +114,6 @@ public static class FeatureComparison { @JsonProperty("b_value") private Double bValue; - - private String unit; } @Getter @@ -184,8 +161,6 @@ public static class BasicComparison { @JsonProperty("b_value") private Double bValue; - - private String unit; } } diff --git a/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java b/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java index c99d2b3..1dbb949 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java +++ b/src/main/java/DiffLens/back_end/domain/panel/entity/Panel.java @@ -1,12 +1,15 @@ package DiffLens.back_end.domain.panel.entity; +import DiffLens.back_end.domain.rawData.entity.RawData; import DiffLens.back_end.domain.search.enums.filters.Gender; +import DiffLens.back_end.global.entity.BaseEntity; import jakarta.persistence.*; import jakarta.validation.constraints.Min; import lombok.*; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.util.ArrayList; import java.util.List; @Entity @@ -14,53 +17,104 @@ @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Panel { +public class Panel extends BaseEntity { @Id @Column(length = 50) private String id; - @JdbcTypeCode(SqlTypes.JSON) - @Column(nullable = false) - private Object rawData; - @Enumerated(EnumType.STRING) - @Column(nullable = false) private Gender gender; - @Column(nullable = false, length = 20) + @Column + @Min(0) + private Integer age; + + @Column(length = 20) private String ageGroup; - @Column(nullable = false, length = 100) + @Column(length = 4) + private Integer birthYear; + + @Column(length = 100) private String residence; - @Column(nullable = false, length = 10) - private String martialStatus; + @Column(length = 50) + private String maritalStatus; - @Column(nullable = false) + @Column @Min(0) private Integer childrenCount; - @Column(nullable = false, length = 50) + @Column(length = 10) + private String familySize; + + @Column(length = 50) private String education; - @Column(nullable = false, length = 200) + @Column(length = 200) private String occupation; + @Column(length = 100) + private String job; + + @Column(length = 50) + private String personalIncome; + + @Column(length = 50) + private String householdIncome; + @JdbcTypeCode(SqlTypes.ARRAY) - @Column(nullable = false, columnDefinition = "text[] DEFAULT '{}'::text[]") - private List devices; + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List electronicDevices = new ArrayList<>(); + + @Column(length = 100) + private String phoneBrand; + + @Column(length = 100) + private String phoneModel; - @Column(nullable = false, columnDefinition = "TEXT") + @Column(length = 20) + private String carOwnership; + + @Column(length = 50) + private String carBrand; + + @Column(length = 100) + private String carModel; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List smokingExperience = new ArrayList<>(); + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List cigaretteBrands = new ArrayList<>(); + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List eCigarette = new ArrayList<>(); + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List drinkingExperience = new ArrayList<>(); + + @Column(columnDefinition = "TEXT") private String profileSummary; @JdbcTypeCode(SqlTypes.VECTOR) @Column(columnDefinition = "vector(4096)") - private float[] embedding; // float[] 써야한다고 함... + @Basic(fetch = FetchType.LAZY) + private float[] embedding = new float[4096]; // float[] 써야한다고 함... @JdbcTypeCode(SqlTypes.ARRAY) - @Column(nullable = false, columnDefinition = "text[] DEFAULT '{}'::text[]") - private List hashTags; - + @Column(columnDefinition = "text[] DEFAULT '{}'::text[]") + private List hashTags = new ArrayList<>(); + + // 연관관계 + @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JoinColumn(name = "id") // FK 컬럼 이름을 Panel PK와 동일하게 + @MapsId + private RawData rawData; } diff --git a/src/main/java/DiffLens/back_end/domain/panel/repository/PanelRepository.java b/src/main/java/DiffLens/back_end/domain/panel/repository/PanelRepository.java new file mode 100644 index 0000000..26edd0f --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/panel/repository/PanelRepository.java @@ -0,0 +1,98 @@ +package DiffLens.back_end.domain.panel.repository; + +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PanelRepository extends JpaRepository { + + /** + * embedding 제외하여 조회 -> DTO 반환 + */ + @Query(""" + SELECT new DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO( + p.id, + p.gender, + p.age, + p.ageGroup, + p.birthYear, + p.residence, + p.maritalStatus, + p.childrenCount, + p.familySize, + p.education, + p.occupation, + p.job, + p.personalIncome, + p.householdIncome, + p.electronicDevices, + p.phoneBrand, + p.phoneModel, + p.carOwnership, + p.carBrand, + p.carModel, + p.smokingExperience, + p.cigaretteBrands, + p.eCigarette, + p.drinkingExperience, + p.profileSummary, + p.hashTags, + p.rawData + ) + FROM Panel p + WHERE p.id IN :ids + """) + List findPanelsWithRawDataByIds(@Param("ids") List ids); + + @Query(""" + SELECT new DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO( + p.id, + p.gender, + p.age, + p.ageGroup, + p.birthYear, + p.residence, + p.maritalStatus, + p.childrenCount, + p.familySize, + p.education, + p.occupation, + p.job, + p.personalIncome, + p.householdIncome, + p.electronicDevices, + p.phoneBrand, + p.phoneModel, + p.carOwnership, + p.carBrand, + p.carModel, + p.smokingExperience, + p.cigaretteBrands, + p.eCigarette, + p.drinkingExperience, + p.profileSummary, + p.hashTags, + p.rawData + ) + FROM Panel p + WHERE p.id IN :ids + """) + Page findPanelsWithRawDataByIdsInPage(@Param("ids") List ids, Pageable pageable); + + @Query( + """ + SELECT p FROM Panel p + WHERE p.id IN :ids + """ + ) + List findByIdList(@Param("ids") List ids); + + Page findByIdIn(List ids, Pageable pageable); + +} diff --git a/src/main/java/DiffLens/back_end/domain/panel/repository/projection/PanelWithRawDataDTO.java b/src/main/java/DiffLens/back_end/domain/panel/repository/projection/PanelWithRawDataDTO.java new file mode 100644 index 0000000..4a0dbca --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/panel/repository/projection/PanelWithRawDataDTO.java @@ -0,0 +1,79 @@ +package DiffLens.back_end.domain.panel.repository.projection; + +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.rawData.entity.RawData; +import DiffLens.back_end.domain.search.enums.filters.Gender; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +/** + * 패널 전체 조회 시 Hibernate와 PostgreSQL의 vector 타입 파싱 문제로 인해 + * Panel의 embedding만 제외하고 조회하도록 하는 DTO + */ +@Getter +@AllArgsConstructor +@ToString +public class PanelWithRawDataDTO { + private String id; + private Gender gender; + private Integer age; + private String ageGroup; + private Integer birthYear; + private String residence; + private String maritalStatus; + private Integer childrenCount; + private String familySize; + private String education; + private String occupation; + private String job; + private String personalIncome; + private String householdIncome; + private List electronicDevices; + private String phoneBrand; + private String phoneModel; + private String carOwnership; + private String carBrand; + private String carModel; + private List smokingExperience; + private List cigaretteBrands; + private List eCigarette; + private List drinkingExperience; + private String profileSummary; + private List hashTags; + private RawData rawData; + + public static PanelWithRawDataDTO fromEntity(Panel panel) { + return new PanelWithRawDataDTO( + panel.getId(), + panel.getGender(), + panel.getAge(), + panel.getAgeGroup(), + panel.getBirthYear(), + panel.getResidence(), + panel.getMaritalStatus(), + panel.getChildrenCount(), + panel.getFamilySize(), + panel.getEducation(), + panel.getOccupation(), + panel.getJob(), + panel.getPersonalIncome(), + panel.getHouseholdIncome(), + panel.getElectronicDevices(), + panel.getPhoneBrand(), + panel.getPhoneModel(), + panel.getCarOwnership(), + panel.getCarBrand(), + panel.getCarModel(), + panel.getSmokingExperience(), + panel.getCigaretteBrands(), + panel.getECigarette(), + panel.getDrinkingExperience(), + panel.getProfileSummary(), + panel.getHashTags(), + panel.getRawData() + ); + } +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/panel/service/PanelService.java b/src/main/java/DiffLens/back_end/domain/panel/service/PanelService.java new file mode 100644 index 0000000..382a4d7 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/panel/service/PanelService.java @@ -0,0 +1,160 @@ +package DiffLens.back_end.domain.panel.service; + +import DiffLens.back_end.domain.panel.dto.PanelResponseDTO; +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.rawData.entity.RawData; +import DiffLens.back_end.global.responses.code.status.error.ErrorStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PanelService { + + private final PanelRepository panelRepository; + + @Transactional(readOnly = true) + public PanelResponseDTO.PanelDetails getPanelDetails(String panelId) { + // 1. 패널 조회 + Panel panel = panelRepository.findById(panelId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.BAD_REQUEST)); + + // 2. RawData 조회 (OneToOne 관계로 자동 로딩) + RawData rawData = panel.getRawData(); + + // 3. PanelDetail 생성 + PanelResponseDTO.PanelDetails.PanelDetail panelDetail = PanelResponseDTO.PanelDetails.PanelDetail + .builder() + .panelId(panel.getId()) + .summary(panel.getProfileSummary()) + .basicInfo(PanelResponseDTO.PanelDetails.PanelDetail.BasicInfo.builder() + .gender(panel.getGender() != null ? panel.getGender().toString() : null) + .residence(panel.getResidence()) + .maritalStatus(panel.getMaritalStatus()) + .childrenCount(panel.getChildrenCount()) + .occupation(panel.getOccupation()) + .build()) + .attributes(createAttributes(panel, rawData)) + .build(); + + return PanelResponseDTO.PanelDetails.builder() + .panelDetail(panelDetail) + .build(); + } + + private List createAttributes(Panel panel, + RawData rawData) { + return List.of( + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("age") + .value(panel.getAge() != null ? panel.getAge().toString() : null) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("age_group") + .value(panel.getAgeGroup()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("birth_year") + .value(panel.getBirthYear() != null ? panel.getBirthYear().toString() + : null) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("residence") + .value(panel.getResidence()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("marital_status") + .value(panel.getMaritalStatus()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("children_count") + .value(panel.getChildrenCount() != null + ? panel.getChildrenCount().toString() + : null) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("family_size") + .value(panel.getFamilySize()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("education") + .value(panel.getEducation()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("occupation") + .value(panel.getOccupation()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("job") + .value(panel.getJob()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("personal_income") + .value(panel.getPersonalIncome()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("household_income") + .value(panel.getHouseholdIncome()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("electronic_devices") + .value(panel.getElectronicDevices().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("phone_brand") + .value(panel.getPhoneBrand()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("phone_model") + .value(panel.getPhoneModel()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("car_ownership") + .value(panel.getCarOwnership()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("car_brand") + .value(panel.getCarBrand()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("car_model") + .value(panel.getCarModel()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("smoking_experience") + .value(panel.getSmokingExperience().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("cigarette_brands") + .value(panel.getCigaretteBrands().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("e_cigarette") + .value(panel.getECigarette().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("drinking_experience") + .value(panel.getDrinkingExperience().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("hash_tags") + .value(panel.getHashTags().toString()) + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("raw_data") + .value(rawData != null ? rawData.getJson().toString() + : "No raw data available") + .build(), + PanelResponseDTO.PanelDetails.PanelDetail.Attribute.builder() + .key("embedding_dimension") + .value(panel.getEmbedding() != null + ? String.valueOf(panel.getEmbedding().length) + : "No embedding") + .build()); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/panel/util/ReflectionUtil.java b/src/main/java/DiffLens/back_end/domain/panel/util/ReflectionUtil.java new file mode 100644 index 0000000..935d71a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/panel/util/ReflectionUtil.java @@ -0,0 +1,18 @@ +package DiffLens.back_end.domain.panel.util; + +import java.lang.reflect.Method; + +public class ReflectionUtil { + + public static Object getFieldValue(Object target, String fieldName) { + try { + String methodName = "get" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + Method getter = target.getClass().getMethod(methodName); + return getter.invoke(target); + } catch (Exception e) { + return null; // 없는 필드나 접근 불가한 경우 null 반환 + } + } + + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/controller/RawDataController.java b/src/main/java/DiffLens/back_end/domain/rawData/controller/RawDataController.java new file mode 100644 index 0000000..cdcc14e --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/controller/RawDataController.java @@ -0,0 +1,47 @@ +package DiffLens.back_end.domain.rawData.controller; + +import DiffLens.back_end.domain.rawData.service.RawDataUploadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "관리자용 - 문제 시 문의바람") +@Slf4j +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class RawDataController { + + private final RawDataUploadService rawDataPanelService; + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping(value = "/raw-data/upload", consumes = "multipart/form-data") + @Operation(summary = "원천데이터로.json 으로 Panel과 RawData 저장") + public ResponseEntity uploadJsonFile(@RequestParam("file") MultipartFile file) { + try { + // 파일 이름 확인 + String fileName = file.getOriginalFilename(); + log.info("Uploaded file: " + fileName); + + // JSON 내용 읽기 + String json = new String(file.getBytes()); + + // rawData 저장 + rawDataPanelService.uploadRawData(json); + + // 필요하면 파싱해서 객체로 변환 + // ObjectMapper mapper = new ObjectMapper(); + // Map data = mapper.readValue(json, Map.class); + + return ResponseEntity.ok("File uploaded successfully: " + fileName); + } catch (Exception e) { + return ResponseEntity.internalServerError().body("Error reading file: " + e.getMessage()); + } + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/converter/JsonToDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/converter/JsonToDTO.java new file mode 100644 index 0000000..1ae3e29 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/converter/JsonToDTO.java @@ -0,0 +1,28 @@ +package DiffLens.back_end.domain.rawData.converter; + +import DiffLens.back_end.domain.rawData.dto.PanelDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +@AllArgsConstructor +@NoArgsConstructor +@Component +public class JsonToDTO implements RawDataConverter { + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public PanelDTO convert(Object rawJson) { + try { + String jsonString = rawJson instanceof String ? + (String) rawJson : + objectMapper.writeValueAsString(rawJson); + return objectMapper.readValue(jsonString, PanelDTO.class); + } catch (Exception e) { + throw new RuntimeException("Failed to convert RawData JSON to PanelDTO", e); + } + + } +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/converter/RawDataConverter.java b/src/main/java/DiffLens/back_end/domain/rawData/converter/RawDataConverter.java new file mode 100644 index 0000000..d25fab7 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/converter/RawDataConverter.java @@ -0,0 +1,5 @@ +package DiffLens.back_end.domain.rawData.converter; + +public interface RawDataConverter { + T convert(V rawJson); +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/ConsumptionDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/ConsumptionDTO.java new file mode 100644 index 0000000..2d0704a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/ConsumptionDTO.java @@ -0,0 +1,30 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ConsumptionDTO { + @JsonProperty("OTT개수") + private String ottCount; + + @JsonProperty("전통시장") + private String traditionalMarket; + + @JsonProperty("설선물") + private String holidayGift; + + @JsonProperty("소비만족") + private String consumptionSatisfaction; + + @JsonProperty("배송서비스") + private String deliveryService; + + @JsonProperty("주요지출") + private String mainExpense; + + @JsonProperty("포인트관심") + private String pointInterest; +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/DigitalDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/DigitalDTO.java new file mode 100644 index 0000000..25eb8fa --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/DigitalDTO.java @@ -0,0 +1,21 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DigitalDTO { + @JsonProperty("자주쓰는앱") + private String frequentlyUsedApp; + + @JsonProperty("AI챗봇") + private String aiChatbot; + + @JsonProperty("AI활용") + private String aiUsage; + + @JsonProperty("개인정보보호") + private String privacyProtection; +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/EnvironmentDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/EnvironmentDTO.java new file mode 100644 index 0000000..7b4ae39 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/EnvironmentDTO.java @@ -0,0 +1,15 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class EnvironmentDTO { + @JsonProperty("버리기아까운물건") + private String itemNotThrownAway; + + @JsonProperty("비닐절감") + private String reducePlastic; +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/HealthDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/HealthDTO.java new file mode 100644 index 0000000..0f6b1e3 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/HealthDTO.java @@ -0,0 +1,30 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class HealthDTO { + @JsonProperty("체력관리") + private String fitness; + + @JsonProperty("피부상태") + private String skinCondition; + + @JsonProperty("땀불편") + private String sweatIssue; + + @JsonProperty("다이어트") + private String diet; + + @JsonProperty("야식방법") + private String lateSnackMethod; + + @JsonProperty("여름간식") + private String summerSnack; + + @JsonProperty("초콜릿섭취") + private String chocolateIntake; +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/LifestyleDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/LifestyleDTO.java new file mode 100644 index 0000000..0757685 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/LifestyleDTO.java @@ -0,0 +1,54 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LifestyleDTO { + @JsonProperty("겨울방학") + private String winterVacation; + + @JsonProperty("반려동물") + private String petOwnership; + + @JsonProperty("이사스트레스") + private String movingStress; + + @JsonProperty("스트레스원인") + private String stressCause; + + @JsonProperty("해외여행") + private String overseasTravel; + + @JsonProperty("여름걱정") + private String summerConcern; + + @JsonProperty("알람설정") + private String alarmSetting; + + @JsonProperty("혼밥빈도") + private String soloMealFrequency; + + @JsonProperty("노년행복") + private String seniorHappiness; + + @JsonProperty("라이프스타일") + private String lifestyleType; + + @JsonProperty("여행스타일") + private String travelStyle; + + @JsonProperty("여름패션") + private String summerFashion; + + @JsonProperty("비대처") + private String rainResponse; + + @JsonProperty("갤러리사진") + private String galleryPhoto; + + @JsonProperty("물놀이장소") + private String waterPlayLocation; +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/PanelDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/PanelDTO.java new file mode 100644 index 0000000..04b5945 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/PanelDTO.java @@ -0,0 +1,119 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +import lombok.Getter; +import lombok.Setter; + +import static java.util.Map.entry; + +@Getter +@Setter +public class PanelDTO { + @JsonProperty("panel_id") + private String panelId; + + @JsonProperty("성별") + private String gender; + + @JsonProperty("나이") + private Integer age; + + @JsonProperty("연령대") + private String ageGroup; + + @JsonProperty("출생년도") + private Integer birthYear; + + @JsonProperty("거주지역") + private String residence; + + @JsonProperty("결혼여부") + private String maritalStatus; + + @JsonProperty("자녀수") + private Integer childrenCount; + + @JsonProperty("가족수") + private String familySize; + + @JsonProperty("최종학력") + private String education; + + @JsonProperty("직업") + private String occupation; + + @JsonProperty("직무") + private String job; + + @JsonProperty("개인소득") + private String personalIncome; + + @JsonProperty("가구소득") + private String householdIncome; + + @JsonProperty("보유전자제품") + private List ownedElectronics; + + @JsonProperty("휴대폰브랜드") + private String phoneBrand; + + @JsonProperty("휴대폰모델") + private String phoneModel; + + @JsonProperty("차량보유") + private String hasCar; + + @JsonProperty("차량브랜드") + private String carBrand; + + @JsonProperty("차량모델") + private String carModel; + + @JsonProperty("흡연경험") + private List smokingExperience; + + @JsonProperty("담배브랜드") + private List cigaretteBrands; + + @JsonProperty("전자담배") + private List eCigarettes; + + @JsonProperty("음주경험") + private List drinkingExperience; + + @JsonProperty("설문응답") + private SurveyResponseDTO surveyResponse; + + // 한글 컬럼명 → 실제 필드명 매핑 + public static final Map columnMapping = Map.ofEntries( + entry("panel_id", "id"), + entry("성별", "gender"), + entry("나이", "age"), + entry("연령대", "ageGroup"), + entry("출생년도", "birthYear"), + entry("거주지역", "residence"), + entry("결혼여부", "maritalStatus"), + entry("자녀수", "childrenCount"), + entry("가족수", "familySize"), + entry("최종학력", "education"), + entry("직업", "occupation"), + entry("직무", "job"), + entry("개인소득", "personalIncome"), + entry("가구소득", "householdIncome"), + entry("보유전자제품", "electronicDevices"), + entry("휴대폰브랜드", "phoneBrand"), + entry("휴대폰모델", "phoneModel"), + entry("차량보유", "carOwnership"), + entry("차량브랜드", "carBrand"), + entry("차량모델", "carModel"), + entry("흡연경험", "smokingExperience"), + entry("담배브랜드", "cigaretteBrands"), + entry("전자담배", "eCigarette"), + entry("음주경험", "drinkingExperience"), + entry("설문응답", "profileSummary") + ); + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/dto/SurveyResponseDTO.java b/src/main/java/DiffLens/back_end/domain/rawData/dto/SurveyResponseDTO.java new file mode 100644 index 0000000..8551118 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/dto/SurveyResponseDTO.java @@ -0,0 +1,24 @@ +package DiffLens.back_end.domain.rawData.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SurveyResponseDTO { + @JsonProperty("건강생활") + private HealthDTO health; + + @JsonProperty("소비습관") + private ConsumptionDTO consumption; + + @JsonProperty("라이프스타일") + private LifestyleDTO lifestyle; + + @JsonProperty("디지털인식") + private DigitalDTO digital; + + @JsonProperty("환경의식") + private EnvironmentDTO environment; +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/rawData/entity/RawData.java b/src/main/java/DiffLens/back_end/domain/rawData/entity/RawData.java new file mode 100644 index 0000000..1b2748b --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/entity/RawData.java @@ -0,0 +1,23 @@ +package DiffLens.back_end.domain.rawData.entity; + +import DiffLens.back_end.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RawData extends BaseEntity { + + @Id + private String id; // Panel과 동일한 ID를 PK로 사용 + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false) + @Setter + private Object json; +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/rawData/repository/RawDataRepository.java b/src/main/java/DiffLens/back_end/domain/rawData/repository/RawDataRepository.java new file mode 100644 index 0000000..67a9796 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/repository/RawDataRepository.java @@ -0,0 +1,17 @@ +package DiffLens.back_end.domain.rawData.repository; + +import DiffLens.back_end.domain.rawData.entity.RawData; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RawDataRepository extends JpaRepository { + + + List findAllByIdIn(List panelIds); + + Page findAllByIdIn(List panelIds, Pageable pageable); + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataService.java b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataService.java new file mode 100644 index 0000000..8b10545 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataService.java @@ -0,0 +1,13 @@ +package DiffLens.back_end.domain.rawData.service; + +import DiffLens.back_end.domain.rawData.dto.PanelDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface RawDataService { + PanelDTO getRawDataDTO(String panelId); + List getRawDataDTOList(List panelId); + Page getRawDataDTOList(List panelId, Pageable pageable); +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataServiceImpl.java b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataServiceImpl.java new file mode 100644 index 0000000..b62ce9d --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataServiceImpl.java @@ -0,0 +1,86 @@ +package DiffLens.back_end.domain.rawData.service; + +import DiffLens.back_end.domain.rawData.converter.RawDataConverter; +import DiffLens.back_end.domain.rawData.dto.PanelDTO; +import DiffLens.back_end.domain.rawData.entity.RawData; +import DiffLens.back_end.domain.rawData.repository.RawDataRepository; +import DiffLens.back_end.global.responses.code.status.error.RawDataStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RawDataServiceImpl implements RawDataService { + + private final RawDataRepository rawDataRepository; + private final RawDataConverter rawDataConverter; + + /** + * + * RawData를 pane_id로 조회하여 매핑된 DTO 객체를 반환합니다. + * + * @param panelId Panel 식별자 + * @return Json -> 클래스 매핑된 DTO + */ + @Override + @Transactional(readOnly = true) + public PanelDTO getRawDataDTO(String panelId) { + + RawData rawData = rawDataRepository.findById(panelId) + .orElseThrow(() -> new ErrorHandler(RawDataStatus.RAW_DATA_NOT_FOUND)); + + Object json = rawData.getJson(); + + return rawDataConverter.convert(json); + } + + /** + * + * panelId 목록으로 List를 반환합니다. + * + */ + @Override + @Transactional(readOnly = true) + public List getRawDataDTOList(List panelIds) { + List rawDataList = rawDataRepository.findAllByIdIn(panelIds); + return getPanelDTOS(rawDataList); + } + + /** + * + * panelId 목록으로 Page를 반환합니다. + * + */ + @Override + @Transactional(readOnly = true) + public Page getRawDataDTOList(List panelIds, Pageable pageable) { + Page rawDataPage = rawDataRepository.findAllByIdIn(panelIds, pageable); + List panelDTOList = getPanelDTOS(rawDataPage.getContent()); + return new PageImpl<>(panelDTOList, pageable, rawDataPage.getTotalElements()); + } + + /** + * + * List 목록을 List로 변환하여 반환합니다. + * + * @param rawDataList + * @return + */ + private List getPanelDTOS(List rawDataList) { + if (rawDataList.isEmpty()) { + throw new ErrorHandler(RawDataStatus.RAW_DATA_NOT_FOUND); + } + return rawDataList.stream() + .map(RawData::getJson) + .map(rawDataConverter::convert) + .toList(); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadService.java b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadService.java new file mode 100644 index 0000000..122f8db --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadService.java @@ -0,0 +1,9 @@ +package DiffLens.back_end.domain.rawData.service; + +import java.io.IOException; + +public interface RawDataUploadService { + + void uploadRawData(String json) throws IOException; + +} diff --git a/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadServiceImpl.java b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadServiceImpl.java new file mode 100644 index 0000000..2c60efe --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/rawData/service/RawDataUploadServiceImpl.java @@ -0,0 +1,122 @@ +package DiffLens.back_end.domain.rawData.service; + +import DiffLens.back_end.domain.rawData.dto.PanelDTO; +import DiffLens.back_end.domain.search.enums.filters.Gender; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class RawDataUploadServiceImpl implements RawDataUploadService { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + + @Override + @Transactional + public void uploadRawData(String json) throws IOException { + + objectMapper.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()); + + // 1. 기존 RawData 삭제 + jdbcTemplate.update("TRUNCATE TABLE raw_data CASCADE"); + + // 2. 기존 Panel ID 조회 + List existingPanelIds = jdbcTemplate.queryForList("SELECT id FROM panel", String.class); + Set existingIds = new HashSet<>(existingPanelIds); + + // 3. JSON -> PanelDTO + List panelDTOList = objectMapper.readValue(json, new TypeReference>() {}); + + // 4. RawData bulk insert (jsonb) + String rawDataSql = "INSERT INTO raw_data (id, json) VALUES (?, ?::jsonb)"; + List rawDataBatch = new ArrayList<>(); + for (PanelDTO dto : panelDTOList) { + rawDataBatch.add(new Object[]{ + dto.getPanelId(), + objectMapper.writeValueAsString(dto) + }); + } + jdbcTemplate.batchUpdate(rawDataSql, rawDataBatch); + + // 5. Panel bulk insert + String panelSql = "INSERT INTO panel (" + + "id, gender, age, age_group, birth_year, residence, marital_status, children_count, family_size, education, occupation, job, " + + "personal_income, household_income, electronic_devices, phone_brand, phone_model, car_ownership, car_brand, car_model, " + + "smoking_experience, cigarette_brands, e_cigarette, drinking_experience, profile_summary, embedding, hash_tags" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + List panelBatch = new ArrayList<>(); + for (PanelDTO dto : panelDTOList) { + if (!existingIds.contains(dto.getPanelId())) { + + String[] devicesArray = dto.getOwnedElectronics() != null + ? dto.getOwnedElectronics().toArray(new String[0]) + : new String[0]; + + String[] smokingArray = dto.getSmokingExperience() != null + ? dto.getSmokingExperience().toArray(new String[0]) + : new String[0]; + + String[] cigBrands = dto.getCigaretteBrands() != null + ? dto.getCigaretteBrands().toArray(new String[0]) + : new String[0]; + + String[] eCig = dto.getECigarettes() != null + ? dto.getECigarettes().toArray(new String[0]) + : new String[0]; + + String[] drinking = dto.getDrinkingExperience() != null + ? dto.getDrinkingExperience().toArray(new String[0]) + : new String[0]; + + String profileJson = dto.getSurveyResponse() != null + ? objectMapper.writeValueAsString(dto.getSurveyResponse()) + : null; + + float[] embedding = new float[4096]; // 기본값 0.0f + String[] hashTags = new String[0]; + + panelBatch.add(new Object[]{ + dto.getPanelId(), + Gender.fromRawDataValue(dto.getGender()).name(), + dto.getAge(), + dto.getAgeGroup(), + dto.getBirthYear(), + dto.getResidence(), + dto.getMaritalStatus(), + dto.getChildrenCount(), + dto.getFamilySize(), + dto.getEducation(), + dto.getOccupation(), + dto.getJob(), + dto.getPersonalIncome(), + dto.getHouseholdIncome(), + devicesArray, + dto.getPhoneBrand(), + dto.getPhoneModel(), + dto.getHasCar(), + dto.getCarBrand(), + dto.getCarModel(), + smokingArray, + cigBrands, + eCig, + drinking, + profileJson, + embedding, + hashTags + }); + } + } + + jdbcTemplate.batchUpdate(panelSql, panelBatch); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java index 97c7025..1206cf4 100644 --- a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java +++ b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java @@ -2,6 +2,8 @@ import DiffLens.back_end.domain.search.dto.SearchRequestDTO; import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.service.interfaces.SearchHistoryService; +import DiffLens.back_end.domain.search.service.interfaces.SearchService; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,29 +17,61 @@ @RequiredArgsConstructor public class SearchController { + private final SearchService naturalServiceService; + private final SearchService existingSearchService; + private final SearchHistoryService searchHistoryService; + @PostMapping - @Operation(summary = "자연어 검색 ( 미구현 )") + @Operation(summary = "자연어 검색 ( ai 연동 전 )", + description = """ + + ## 개요 + 자연어 검색 API 입니다. + + ## request body + - 검색 모드와 필터 항목들은 노션에 정리하여 올리겠습니다. + - 필터에는 Filter Code 를 넣어주세요. ex) 101, 203, 305 ... + + ## 응답 + 검색 결과에 개별응답은 포함하지 않았습니다. 개별 응답 데이터 API를 조회해야 합니다. + + """) public ApiResponse naturalLanguage(@RequestBody @Valid SearchRequestDTO.NaturalLanguage request) { - SearchResponseDTO.SearchResult result = new SearchResponseDTO.SearchResult(); // 임시 result + SearchResponseDTO.SearchResult result = naturalServiceService.search(request); return ApiResponse.onSuccess(result); } @PostMapping("/refine") - @Operation(summary = "기존 검색 결과 기반 재검색 ( 미구현 )") + @Operation(summary = "기존 검색 결과 기반 재검색 ( 미구현 )", description = "아직 구현 전이지만 아마 자연어 검색과 같은 형태로 반환될 듯 싶습니다.") public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.ExistingSearchResult request) { SearchResponseDTO.SearchResult result = new SearchResponseDTO.SearchResult(); // 임시 result return ApiResponse.onSuccess(result); } @GetMapping("/{searchId}/each-responses") - @Operation(summary = "개별 응답 데이터 ( 미구현 )") - public ApiResponse eachResponses(@PathVariable("searchId") Long searchId, Integer pageNum, Integer size){ - SearchResponseDTO.EachResponses result = new SearchResponseDTO.EachResponses(); + @Operation(summary = "개별 응답 데이터 ( 완료 )", + description = """ + + ## 개요 + 개별 응답 데이터 조회 API 입니다. + 페이징 문제로 인해 검색 API와 분리하였습니다. + + ## 요청값 + - searchId : 검색결과 ID. 검색 API 에서 받은 식별자 값(searchId)를 넣으면 됩니다. + - page : 페이지 번호입니다. 1부터 시작입니다. + - size : 한 페이지 크기입니다. + + ## 응답 + 현재 피그마에 나와있는대로 구현했습니다. + + """) + public ApiResponse eachResponses(@PathVariable("searchId") Long searchId, @RequestParam("page") Integer page, @RequestParam("size") Integer size){ + SearchResponseDTO.EachResponses result = searchHistoryService.getEachResponses(searchId, page, size); return ApiResponse.onSuccess(result); } @GetMapping("/recommended") - @Operation(summary = "맞춤 검색 추천 ( 미구현 )") + @Operation(summary = "맞춤 검색 추천 ( 미구현 )", description = "요청 형태 확정 후 구현하겠습니다.") public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.SearchFilters request) { return ApiResponse.onSuccess(null); } diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java new file mode 100644 index 0000000..f8f9f59 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/converter/ChartDtoConverter.java @@ -0,0 +1,49 @@ +package DiffLens.back_end.domain.search.converter; + +import DiffLens.back_end.domain.search.dto.ChartDTO; +import DiffLens.back_end.domain.search.entity.Chart; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class ChartDtoConverter implements SearchDtoConverter, List> { + + @Override + public List requestToDto(Void response, List charts) { + return charts.stream() + .map(this::chartToDto) + .toList(); + } + + private ChartDTO.Graph chartToDto(Chart chart) { + return ChartDTO.Graph.builder() + .chartId("") + .reason(chart.getReason()) + .chartType(chart.getChartType().name()) + .title(chart.getTitle()) + .xAxis(chart.getXAxis()) + .yAxis(chart.getYAxis()) + .dataPoints(getDataPoints(chart)) + .build(); + } + + private List getDataPoints(Chart chart) { + List dataPoints = new ArrayList<>(); + List labels = chart.getLabels(); + List values = chart.getValues(); + for(int i = 0 ; i < labels.size() ; i++) { + dataPoints.add(getDataPoint(labels.get(i), values.get(i))); + } + return dataPoints; + } + + private ChartDTO.DataPoint getDataPoint(String label, Integer value) { + return ChartDTO.DataPoint.builder() + .label(label) + .value(value) + .build(); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/FilterDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/FilterDtoConverter.java new file mode 100644 index 0000000..988096b --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/converter/FilterDtoConverter.java @@ -0,0 +1,37 @@ +package DiffLens.back_end.domain.search.converter; + +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO.SearchResult.AppliedFilter; +import DiffLens.back_end.domain.search.entity.Filter; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.service.interfaces.FilterService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class FilterDtoConverter implements SearchDtoConverter, SearchHistory>{ + + private final FilterService filterService; + + @Override + public List requestToDto(FastNaturalSearchResponseDTO.SearchResult response, SearchHistory searchHistory) { + + List filterIdList = searchHistory.getSearchFilter().getFilters(); + List filters = filterService.findFilters(filterIdList); + return filters.stream() + .map(this::filterToDto) + .toList(); + + } + + private AppliedFilter filterToDto(Filter filter) { + return AppliedFilter.builder() + .key(filter.getType()) + .displayValue(filter.getDisplayValue()) + .build(); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/PanelResponseConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/PanelResponseConverter.java new file mode 100644 index 0000000..0a7c941 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/converter/PanelResponseConverter.java @@ -0,0 +1,18 @@ +package DiffLens.back_end.domain.search.converter; + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.search.dto.SearchPanelDTO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class PanelResponseConverter implements SearchDtoConverter>{ + @Override + public SearchPanelDTO.PanelData requestToDto(FastNaturalSearchResponseDTO.SearchResult response, List info) { + // TODO : 개별 응답데이터 처리 로직 작성 + return new SearchPanelDTO.PanelData(); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/SearchDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/SearchDtoConverter.java new file mode 100644 index 0000000..be943a2 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/converter/SearchDtoConverter.java @@ -0,0 +1,12 @@ +package DiffLens.back_end.domain.search.converter; + +/** + * 검색 관련 DTO에 관한 converter + * + * T : 클라이언트 -> 서버 DTO + * R : fast api -> spring boot 웅덥 DTO + * S : 반환할 DTO + */ +public interface SearchDtoConverter { + public S requestToDto(R response, T info); +} diff --git a/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java b/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java new file mode 100644 index 0000000..34cb5d5 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/converter/SummaryDtoConverter.java @@ -0,0 +1,38 @@ +package DiffLens.back_end.domain.search.converter; + + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +public class SummaryDtoConverter implements SearchDtoConverter> { + + @Override + public SearchResponseDTO.SearchResult.Summary requestToDto(FastNaturalSearchResponseDTO.SearchResult response, List panelList) { + + return SearchResponseDTO.SearchResult.Summary.builder() + .totalRespondents(panelList.size()) + .averageAge(getAgeAvg(panelList)) + .dataCaptureDate(getCurrentDate()) + .confidenceLevel(response.getAccuracy().intValue()) + .build(); + } + + private Double getAgeAvg(List panelList) { + // TODO : 나이 평균 계산 + return 1.0; + } + + private String getCurrentDate() { + LocalDate now = LocalDate.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + return now.format(formatter); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchPanelDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchPanelDTO.java index cc49748..e99c256 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchPanelDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchPanelDTO.java @@ -20,7 +20,7 @@ public static class PanelData { private List records; @JsonProperty("page_info") - private ResponsePageDTO.PageInfo pageInfo; + private ResponsePageDTO.CursorPageInfo cursorPageInfo; } @Getter diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java index 64f6c32..cdbf760 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchRequestDTO.java @@ -1,6 +1,6 @@ package DiffLens.back_end.domain.search.dto; -import com.fasterxml.jackson.annotation.JsonProperty; +import DiffLens.back_end.domain.search.enums.mode.QuestionMode; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Getter; @@ -16,10 +16,10 @@ public class SearchRequestDTO { public static class NaturalLanguage{ @NotBlank private String question; - @NotBlank - private String mode; @NotNull - private SearchFilters filters; + private QuestionMode mode; + @NotNull + private List filters; } // 기존 검색 결과 기반 재검색 @@ -38,15 +38,17 @@ public static class ExistingSearchResult{ @Getter @Setter public static class SearchFilters{ - private Integer count; - private String gender; - @JsonProperty(value = "age_group") - private List ageGroup; - private List region; - @JsonProperty(value = "martial_status") - private List martialStatus; // 결혼상태 - private String children; - private List occupation;// 직업 +// private Integer count; +// private String gender; + private List filters; // age_group:TWENTY +// private Respondent respondent; +// @JsonProperty(value = "age_group") +// private List ageGroup; +// private List region; +// @JsonProperty(value = "martial_status") +// private List martialStatus; // 결혼상태 +// private Children children; +// private List occupation;// 직업 // TODO : 추후 필터 추가 예정... diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java index 1eadf84..d564e32 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java @@ -1,5 +1,7 @@ package DiffLens.back_end.domain.search.dto; +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.dto.ResponsePageDTO; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,8 +29,8 @@ public static class SearchResult { private List charts; - @JsonProperty("panel_data") - private SearchPanelDTO.PanelData panelData; +// @JsonProperty("panel_data") // 개별 API로 분리 +// private SearchPanelDTO.PanelData panelData; // 중간배열 @Getter @@ -70,12 +72,43 @@ public static class AppliedFilter { @NoArgsConstructor public static class EachResponses { - private List keys; - private List> values; - private Integer page; - private Integer size; + private List keys; // columns + private List values; // row에 들어가는 값 목록 + + @JsonProperty("page_info") + private ResponsePageDTO.OffsetLimitPageInfo pageInfo; } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ResponseValues{ + @JsonProperty("respondent_id") + private String respondentId; + private String gender; + private String age; + private String residence; + @JsonProperty("personal_income") + private String personalIncome; + @JsonProperty("concordance_rate") + private String concordanceRate; // 일치율 + + public static ResponseValues fromPanelDTO(PanelWithRawDataDTO panel, String concordanceRate) { + return ResponseValues.builder() + .respondentId(panel.getId()) + .gender(panel.getGender().getDisplayValue()) + .age(panel.getAge() != null ? panel.getAge().toString() : null) + .residence(panel.getResidence()) + .personalIncome(panel.getPersonalIncome()) + .concordanceRate(concordanceRate) + .build(); + } + + } + + + } diff --git a/src/main/java/DiffLens/back_end/domain/search/entity/Chart.java b/src/main/java/DiffLens/back_end/domain/search/entity/Chart.java new file mode 100644 index 0000000..cc27fc6 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/entity/Chart.java @@ -0,0 +1,68 @@ +package DiffLens.back_end.domain.search.entity; + +import DiffLens.back_end.domain.search.enums.chart.ChartType; +import DiffLens.back_end.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString +public class Chart extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String reason; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChartType chartType; + + @Column(nullable = false, length = 50) + private String panelColumn; + + @Column(nullable = false, length = 50) + private String title; + + @Column(nullable = false, length = 50) + private String xAxis; + + @Column(nullable = false, length = 50) + private String yAxis; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(nullable = false, columnDefinition = "text[]") + private List labels = new ArrayList<>(); + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(nullable = false, columnDefinition = "text[]") + private List values = new ArrayList<>(); + + + // 연관관계 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "history_id") + private SearchHistory searchHistory; + + // 연관관계 편의 메서드 + public void setSearchHistory(SearchHistory searchHistory) { + this.searchHistory = searchHistory; + + if (searchHistory != null && !searchHistory.getCharts().contains(this)) { + searchHistory.getCharts().add(this); + } + } + + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/entity/Filter.java b/src/main/java/DiffLens/back_end/domain/search/entity/Filter.java index d3c5952..a7dcb56 100644 --- a/src/main/java/DiffLens/back_end/domain/search/entity/Filter.java +++ b/src/main/java/DiffLens/back_end/domain/search/entity/Filter.java @@ -1,5 +1,6 @@ package DiffLens.back_end.domain.search.entity; +import DiffLens.back_end.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -8,7 +9,7 @@ @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Filter { +public class Filter extends BaseEntity { @Id private Long id; // 자동생성 X @@ -17,6 +18,9 @@ public class Filter { private String type; @Column(nullable = false, length = 100) - private String value; + private String displayValue; + + @Column(nullable = true, length = 200) + private String rawDataValue; } diff --git a/src/main/java/DiffLens/back_end/domain/search/entity/SearchHistory.java b/src/main/java/DiffLens/back_end/domain/search/entity/SearchHistory.java index 90d02d2..38418ee 100644 --- a/src/main/java/DiffLens/back_end/domain/search/entity/SearchHistory.java +++ b/src/main/java/DiffLens/back_end/domain/search/entity/SearchHistory.java @@ -8,6 +8,7 @@ import org.hibernate.type.SqlTypes; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; @Entity @@ -28,13 +29,39 @@ public class SearchHistory extends BaseEntity { private String content; @JdbcTypeCode(SqlTypes.ARRAY) - @Column(nullable = false, columnDefinition = "text[] DEFAULT '{}'::text[]") + @Column(columnDefinition = "text[] NOT NULL DEFAULT '{}'") private List panelIds; + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(columnDefinition = "float[] NOT NULL DEFAULT '{}'") + private List concordanceRate; // 일치율 + // 연관관계 + @OneToOne(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, mappedBy = "searchHistory") + private SearchFilter searchFilter; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") + @Setter private Member member; + @OneToMany(mappedBy = "searchHistory", cascade = CascadeType.ALL, orphanRemoval = true) + private List charts = new ArrayList<>(); + + public void setFilter(SearchFilter searchFilter) { + this.searchFilter = searchFilter; + } + + // 연관관계 편의 메서드 + public void addChart(Chart chart) { + charts.add(chart); + chart.setSearchHistory(this); + } + + public void removeChart(Chart chart) { + charts.remove(chart); + chart.setSearchHistory(null); + } + } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java b/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java index d128da3..c38c725 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/chart/ChartType.java @@ -1,4 +1,16 @@ package DiffLens.back_end.domain.search.enums.chart; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum ChartType { + + BAR, + PIE + ; + + + } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/AgeGroup.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/AgeGroup.java index 84dde6f..091a408 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/AgeGroup.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/AgeGroup.java @@ -7,14 +7,15 @@ @AllArgsConstructor public enum AgeGroup{ - TWENTY(1L, "20-29"), - THIRTY(2L, "30-39"), - FORTY(3L, "40-49"), - FIFTY(4L, "50-59"), - SIXTY_PLUS(5L, "60-69") + TWENTY(1L, "20-29세","20대"), + THIRTY(2L, "30-39세", "30대"), + FORTY(3L, "40-49세","40대"), + FIFTY(4L, "50-59세","50대"), + SIXTY_PLUS(5L, "60세 이상", "60대 이상"), ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값 } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Children.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Children.java index e7dc918..d34822b 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Children.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Children.java @@ -3,13 +3,17 @@ import lombok.AllArgsConstructor; import lombok.Getter; +// 자녀유무 @Getter @AllArgsConstructor public enum Children { + EXIST(1L, "있음", "true"), + NOT_EXIST(2L, "없음", "false") ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값 -} +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/FilterKey.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/FilterKey.java index e50bcde..6768c32 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/FilterKey.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/FilterKey.java @@ -3,9 +3,11 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +// 필터 모음 @Getter @AllArgsConstructor public enum FilterKey { @@ -15,7 +17,8 @@ public enum FilterKey { GENDER(300L, "gender", "성별", Gender.class), MARTIAL_STATUS(400L, "martial_status", "결혼상태", MartialStatus.class), OCCUPATION(500L, "occupation", "직업", Occupation.class), - RESIDENCE(600L, "residence", "거주지", Residence.class) + RESIDENCE(600L, "residence", "거주지", Residence.class), + RESPONDENT(700L, "respondent", "응답자수", Respondent.class), // 추가예정 ; @@ -29,4 +32,40 @@ public static List getFilterList(){ return Arrays.asList(FilterKey.values()); } + public static FilterKey findByKeyEn(String keyEn){ + return Arrays.stream(values()) + .filter(f -> f.getKeyEn().equals(keyEn)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid keyEn: " + keyEn)); + } + + + public static List> getEnumList(){ + return new ArrayList>(Arrays.asList(FilterKey.values())); + } + + public static FilterKey findByEnumValue(String enumNameOrRawValue) { + for (FilterKey filterKey : FilterKey.values()) { + Class> enumClass = filterKey.getValueEnum(); + for (Enum enumConst : enumClass.getEnumConstants()) { + // enum 이름 또는 rawDataValue 비교 + try { + // "name()" 비교 + if (enumConst.name().equals(enumNameOrRawValue)) { + return filterKey; + } + // "rawDataValue" 비교 (만약 enum이 getRawDataValue() 메서드를 가지고 있다면) + Object rawDataValue = enumClass.getMethod("getRawDataValue").invoke(enumConst); + if (rawDataValue != null && rawDataValue.toString().equals(enumNameOrRawValue)) { + return filterKey; + } + } catch (Exception e) { + // getRawDataValue 없으면 무시 + } + } + } + throw new IllegalArgumentException("No FilterKey found for enum value: " + enumNameOrRawValue); + } + + } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Gender.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Gender.java index e4103a2..a5efb38 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Gender.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Gender.java @@ -3,17 +3,30 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; + @Getter @AllArgsConstructor public enum Gender { - MALE(1L, "남자"), - FEMALE(2L, "여자"), - NONE(3L, "알수없음") + MALE(1L, "남성", "남성"), + FEMALE(2L, "여성", "여성"), + NONE(3L, "기타", null) ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값 + + public static Gender fromRawDataValue(String rawDataValue) { + if (rawDataValue == null) { + return NONE; + } + return Arrays.stream(values()) + .filter(g -> rawDataValue.equals(g.getRawDataValue())) + .findFirst() + .orElse(NONE); + } } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/MartialStatus.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/MartialStatus.java index 7473491..96616e6 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/MartialStatus.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/MartialStatus.java @@ -3,13 +3,32 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; + @Getter @AllArgsConstructor public enum MartialStatus { + NOT_MARRIED(1L, "미혼", "미혼"), + MARRIED(2L, "기혼", "기혼"), + DIVORCE(3L, "이혼", "이혼"), + DEATH(4L, "사별", "사별"), + NONE(5L, "기타", null) + ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값rivate final String value; + + public static MartialStatus fromRawDataValue(String rawDataValue) { + if (rawDataValue == null) { + return NONE; + } + return Arrays.stream(values()) + .filter(m -> rawDataValue.equals(m.getRawDataValue())) + .findFirst() + .orElse(NONE); + } } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Occupation.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Occupation.java index d55105b..302134a 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Occupation.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Occupation.java @@ -3,13 +3,35 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; + +// 직업 @Getter @AllArgsConstructor public enum Occupation { + STUDENT(1L, "학생", "학생"), + BUSINESS(2L, "직장인", "직장인"), + CEO(3L, "자영업", "자영업"), // 자영업자 + JUBU(4L, "주부", "주부"), // 주부 + BAEKSU(5L, "무직", "무직"), // 무직 + ETC(6L, "기타", "기타"), + NONE(7L, "알수없음", null) + ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값 + + public static Occupation fromRawDataValue(String rawDataValue) { + if (rawDataValue == null) { + return NONE; + } + return Arrays.stream(values()) + .filter(g -> rawDataValue.equals(g.getRawDataValue())) + .findFirst() + .orElse(NONE); + } } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Residence.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Residence.java index 07281ed..07571f6 100644 --- a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Residence.java +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Residence.java @@ -7,9 +7,28 @@ @AllArgsConstructor public enum Residence { + SEOUL(1L, "서울", "서울"), + GYEONGGI(2L, "제주", "제주"), + INCHEON(3L, "인천", "인천"), + BUSAN(4L, "부산", "부산"), + DAEGU(5L, "대구", "대구"), + GWANGJU(6L, "광주", "광주"), + DAEJEON(7L, "대전", "대전"), + ULSAN(8L, "울산", "울산"), + SEJONG(9L, "세종", "세종"), + GANGWON(10L, "강원", "강원"), + CHUNG_NORTH(11L, "충북", "충북"), + CHUNG_SOUTH(12L, "충남", "충남"), + JUN_NORTH(13L, "전북", "전북"), + JUN_SOUTH(14L, "전남", "전남"), + GYEONG_NORTH(15L, "경북", "경북"), + GYEONG_SOUTH(16L, "경남", "경남"), + JEJU(17L, "제주", "제주"), + ; private final Long id; // 내부 시드 ID ( 상대적 ) - private final String value; + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값 } diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/filters/Respondent.java b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Respondent.java new file mode 100644 index 0000000..3de3c0b --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/enums/filters/Respondent.java @@ -0,0 +1,22 @@ +package DiffLens.back_end.domain.search.enums.filters; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Respondent { + + T5(1L, "50명", "50"), + H1(2L, "100명", "100"), + H3(3L, "300명", "300"), + H5(3L, "500명", "500"), + TH1(3L, "1000명", "1000"), + + ; + + private final Long id; // 내부 시드 ID ( 상대적 ) + private final String displayValue; // 화면에 띄울 값 + private final String rawDataValue; // 원천데이터 값rivate final String value; + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/enums/mode/QuestionMode.java b/src/main/java/DiffLens/back_end/domain/search/enums/mode/QuestionMode.java new file mode 100644 index 0000000..7d18485 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/enums/mode/QuestionMode.java @@ -0,0 +1,15 @@ +package DiffLens.back_end.domain.search.enums.mode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum QuestionMode { + + FLEXIBLE("유연모드"), + STRICT("엄격모드"); + + private final String kr; + +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/ChartRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/ChartRepository.java new file mode 100644 index 0000000..4a64d8a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/repository/ChartRepository.java @@ -0,0 +1,7 @@ +package DiffLens.back_end.domain.search.repository; + +import DiffLens.back_end.domain.search.entity.Chart; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChartRepository extends JpaRepository { +} diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java index 5b03604..05e9e3a 100644 --- a/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java +++ b/src/main/java/DiffLens/back_end/domain/search/repository/FilterRepository.java @@ -2,6 +2,9 @@ import DiffLens.back_end.domain.search.entity.Filter; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; public interface FilterRepository extends JpaRepository { } diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java new file mode 100644 index 0000000..3716149 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/repository/SearchFilterRepository.java @@ -0,0 +1,8 @@ +package DiffLens.back_end.domain.search.repository; + +import DiffLens.back_end.domain.search.entity.SearchFilter; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SearchFilterRepository extends JpaRepository { + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java b/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java new file mode 100644 index 0000000..df9e2f4 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/repository/SearchHistoryRepository.java @@ -0,0 +1,9 @@ +package DiffLens.back_end.domain.search.repository; + +import DiffLens.back_end.domain.search.entity.SearchHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SearchHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/ChartServiceImpl.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/ChartServiceImpl.java new file mode 100644 index 0000000..823124d --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/ChartServiceImpl.java @@ -0,0 +1,106 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.domain.rawData.dto.PanelDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.panel.util.ReflectionUtil; +import DiffLens.back_end.domain.search.entity.Chart; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.enums.chart.ChartType; +import DiffLens.back_end.domain.search.service.interfaces.ChartService; +import DiffLens.back_end.global.responses.code.status.error.SearchStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; + +import static java.util.Map.entry; + +@Service +@RequiredArgsConstructor +public class ChartServiceImpl implements ChartService { + + @Override + public List makeChart(FastNaturalSearchResponseDTO.SearchResult searchResult, SearchHistory searchHistory, List foundPanels) { + + List fastApiChartResponse = searchResult.getCharts(); + + // fast api 로부터 받은 응답값을 List로 변환 + List charts = fastApiChartResponse.stream() + .map(chart -> + Chart.builder() + .reason(chart.getReason()) + .chartType(ChartType.valueOf(chart.getChartType().toUpperCase())) + .panelColumn(chart.getPanelColumn()) + .title(chart.getTitle()) + .xAxis(chart.getXaxis()) + .yAxis(chart.getYaxis()) + .labels(new ArrayList<>()) + .values(new ArrayList<>()) + .build() + ).toList(); + + // 차트 데이터 생성 및 삽입 + charts.forEach(chart -> { + + Map dataSet = makeDataSet(foundPanels, chart.getPanelColumn()); + + // label 오름차순 정렬하며 label과 value 쌍 보장 + dataSet.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + chart.getLabels().add(entry.getKey()); + chart.getValues().add(entry.getValue()); + }); + + + }); + + + return charts; + } + + /** + * + * @param foundPanels 조회할 패널 + * @param panelColumn 패널의 어떤 데이터를 조회할 것인지 + * @return Key : Label, Value : 수치 를 담은 Map + */ + private Map makeDataSet(List foundPanels, String panelColumn) { + Map data = new LinkedHashMap<>(); + + String fieldName = PanelDTO.columnMapping.getOrDefault(panelColumn, null); + if(fieldName == null) { + throw new ErrorHandler(SearchStatus.INVALID_COLUMN); + } + +// if (fieldName == null) { +// return data; // 매핑 안 된 컬럼이면 빈 map 반환 +// } + + for (PanelWithRawDataDTO panel : foundPanels) { + try { + Object value = ReflectionUtil.getFieldValue(panel, fieldName); + + if (value instanceof List list) { + for (Object v : list) { + if (v != null) data.merge(v.toString(), 1, Integer::sum); + } + } else if (value != null) { + data.merge(value.toString(), 1, Integer::sum); + } + + } catch (Exception e) { + // DTO 필드 없으면 rawData Map에서 시도 + if (panel.getRawData() instanceof Map rawMap) { + Object val = rawMap.get(panelColumn); + if (val != null) data.merge(val.toString(), 1, Integer::sum); + } + } + } + + return data; + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/ExistingSearchService.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/ExistingSearchService.java new file mode 100644 index 0000000..d6d5b6d --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/ExistingSearchService.java @@ -0,0 +1,24 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.search.dto.SearchRequestDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.service.interfaces.SearchService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 재검색 Service --> SearchService 구현 + */ +@Service("existingSearchService") +@RequiredArgsConstructor +public class ExistingSearchService implements SearchService { + + private final PanelRepository panelRepository; + + @Override + public SearchResponseDTO.SearchResult search(SearchRequestDTO.ExistingSearchResult request) { + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/FilterServiceImpl.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/FilterServiceImpl.java new file mode 100644 index 0000000..f375249 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/FilterServiceImpl.java @@ -0,0 +1,42 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.search.entity.Filter; +import DiffLens.back_end.domain.search.entity.SearchFilter; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.repository.FilterRepository; +import DiffLens.back_end.domain.search.repository.SearchFilterRepository; +import DiffLens.back_end.domain.search.repository.SearchHistoryRepository; +import DiffLens.back_end.domain.search.service.interfaces.FilterService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FilterServiceImpl implements FilterService { + + private final SearchFilterRepository searchFilterRepository; + private final FilterRepository filterRepository; + private final SearchHistoryRepository searchHistoryRepository; + + @Transactional + public SearchFilter makeFilter(List filters, SearchHistory searchHistory) { + if (searchHistory.getId() == null) { + searchHistoryRepository.save(searchHistory); // 반드시 먼저 저장 + } + + SearchFilter searchFilter = SearchFilter.builder() + .searchHistory(searchHistory) + .filters(filters) + .build(); + + return searchFilterRepository.save(searchFilter); + } + + @Override + public List findFilters(List filterIdList) { + return filterRepository.findAllById(filterIdList); + } +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java new file mode 100644 index 0000000..a02fd02 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/NaturalSearchService.java @@ -0,0 +1,97 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.members.entity.Member; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; +import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.fastapi.FastApiService; +import DiffLens.back_end.global.fastapi.dto.request.FastPanelRequestDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.search.converter.SearchDtoConverter; +import DiffLens.back_end.domain.search.dto.ChartDTO; +import DiffLens.back_end.domain.search.dto.SearchRequestDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.entity.Chart; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.service.interfaces.ChartService; +import DiffLens.back_end.domain.search.service.interfaces.SearchHistoryService; +import DiffLens.back_end.domain.search.service.interfaces.SearchService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 자연어 Service --> SearchService 구현 + */ +@Service("naturalSearchService") +@RequiredArgsConstructor +public class NaturalSearchService implements SearchService { + + // service & repository + private final PanelRepository panelRepository; + private final SearchHistoryService searchHistoryService; + private final ChartService chartService; + private final FastApiService fastApiService; + + // converter들 + private final SearchDtoConverter> summaryConverter; + private final SearchDtoConverter, SearchHistory> filterConverter; + private final SearchDtoConverter, List> chartConverter; +// private final SearchDtoConverter> panelResponseConverter; + + private final CurrentUserService currentUserService; + + @Override + @Transactional(readOnly = false) + public SearchResponseDTO.SearchResult search(SearchRequestDTO.NaturalLanguage request) { + + // 현재 로그인한 유저 + Member currentUser = currentUserService.getCurrentUser(); + + // TODO : 추후 fast api 에서 불러오도록 수정해야 함 + FastNaturalSearchResponseDTO.SearchResult response = fastApiService.getNaturalSearch( + FastPanelRequestDTO.FastNaturalSearch.builder() + .question(request.getQuestion()) + .mode(request.getMode().getKr()) + .filters(new FastPanelRequestDTO.FastSearchFilters()) + .build() + ); +// FastNaturalSearchResponseDTO.SearchResult response = new FastNaturalSearchResponseDTO.SearchResult(); + + // id List 추출 + List panelIdList = response.getPanelList(); + + // DB 에서 Panel 목록 가져옴 + List foundPanels = panelRepository.findPanelsWithRawDataByIds(panelIdList); + + // SearchHistory 데이터 생성 및 저장 + SearchHistory searchHistory = searchHistoryService.makeSearchHistory(request, response); + searchHistory.setMember(currentUser); + + // SearchResult.Summary 생성 + SearchResponseDTO.SearchResult.Summary summary = summaryConverter.requestToDto(response, foundPanels); + // SearchResult.AppliedFilter 생성 + List appliedFiltersSummary = filterConverter.requestToDto(response, searchHistory); + + // 차트 생성 + List charts = chartService.makeChart(response, searchHistory, foundPanels); + charts.forEach(searchHistory::addChart); // 연관관계 편의 메서드 호출 + + // 차트 변환 + List graphs = chartConverter.requestToDto(null, charts); + + // 개별 응답 데이터 처리 및 반환 +// SearchPanelDTO.PanelData panelData = panelResponseConverter.requestToDto(response, foundPanels); + + return SearchResponseDTO.SearchResult.builder() + .searchId(searchHistory.getId()) + .summary(summary) + .appliedFiltersSummary(appliedFiltersSummary) + .charts(graphs) +// .panelData(panelData) // 개별 API로 분리 + .build(); + + } +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchHistoryServiceImpl.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchHistoryServiceImpl.java new file mode 100644 index 0000000..b888197 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchHistoryServiceImpl.java @@ -0,0 +1,126 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.rawData.service.RawDataService; +import DiffLens.back_end.domain.search.dto.SearchRequestDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.entity.SearchFilter; +import DiffLens.back_end.domain.search.entity.SearchHistory; +import DiffLens.back_end.domain.search.repository.SearchHistoryRepository; +import DiffLens.back_end.domain.search.service.interfaces.FilterService; +import DiffLens.back_end.domain.search.service.interfaces.SearchHistoryService; +import DiffLens.back_end.domain.search.service.interfaces.SearchPanelService; +import DiffLens.back_end.global.dto.ResponsePageDTO; +import DiffLens.back_end.global.responses.code.status.error.ErrorStatus; +import DiffLens.back_end.global.responses.code.status.error.SearchStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SearchHistoryServiceImpl implements SearchHistoryService { + + private final SearchHistoryRepository historyRepository; + private final SearchPanelService searchPanelService; + private final RawDataService rawDataService; + private final FilterService filterService; + + private final List keys = List.of("응답자ID-respondent_id", "성별-gender", "나이-age", "거주지-residence", "월소득-personal_income", "일치율-concordance_rate"); + + @Override + public SearchHistory makeSearchHistory(SearchRequestDTO.NaturalLanguage request, FastNaturalSearchResponseDTO.SearchResult fastApiResponse) { + + // SearchHistory 데이터 생성 및 저장 + SearchHistory searchHistory = historyRepository.save( + SearchHistory.builder() + .date(LocalDate.now()) + .content(request.getQuestion()) + .panelIds(fastApiResponse.getPanelList()) + .concordanceRate(fastApiResponse.getAccuracyList()) + .charts(new ArrayList<>()) + .build() + ); + + // SearchFilter 생성 + SearchFilter searchFilter = filterService.makeFilter(request.getFilters(), searchHistory); + // SearchHistory의 Filter를 지정 + searchHistory.setFilter(searchFilter); + + return searchHistory; + + } + + /** + * + * 개별 응답 데이터 조회입니다. + * 검색 결과에서 패널 정보와 일치율 정보를 가져와 페이징 처리하여 ( offset, limit 기반 ) 반환합니다. + * 현재 패널 정보 항목은 keys 변수와 같습니다. + * 항목이 바뀐다면 keys와 SearchResponseDTO.ResponseValues의 필드를 수정해야 합니다. + * + * @param searchHistoryId searchHistory의 식별자 + * @param pageNum 페이지 번호. 1 이상의 값 + * @param size 페이지 크기 + * @return EachResponses 객체 + */ + @Override + public SearchResponseDTO.EachResponses getEachResponses(Long searchHistoryId, Integer pageNum, Integer size) { + + // 페이지 번호 예외처리 + if(pageNum < 1){ + throw new ErrorHandler(ErrorStatus.PAGE_NO_INVALID); + } + // searchHistoryId로 검색기록 불러옴 + SearchHistory searchHistory = historyRepository.findById(searchHistoryId) + .orElseThrow(() -> new ErrorHandler(SearchStatus.SEARCH_HISTORY_NOT_FOUND)); + + // 검색기록에서 패널ID 목록이랑 일치율 목록 불러옴 + // 두 리스트의 순서쌍이 이루어짐 + List panelIds = searchHistory.getPanelIds(); + List concordanceRate = searchHistory.getConcordanceRate(); + + // 페이징을 위한 Pageable 객체 생성 + // 페이지는 1부터 시작 + Pageable pageable = PageRequest.of(pageNum - 1, size); + + // PanelId 목록을 이용해서 Panel 조회 + Page panelDtoList = searchPanelService.getPanelDtoList(panelIds, pageable); + + // 페이지 범위 초과 검사 추가 + if (pageNum > panelDtoList.getTotalPages() && panelDtoList.getTotalPages() > 0) { + throw new ErrorHandler(ErrorStatus.PAGE_NO_EXCEED); + } + + // Panel 목록 순회하며 응답보낼 데이터 파싱 + // panelIds와 concordanceRate의 순서쌍에 맞게 패널 정보와 일치율을 담음 + List values = panelDtoList.stream() + .map(panel -> { + int index = panelIds.indexOf(panel.getId()); // + String rate = (index != -1 && index < concordanceRate.size()) + ? String.format("%.2f", concordanceRate.get(index)) : null; + + return SearchResponseDTO.ResponseValues.fromPanelDTO(panel, rate); + }) + .toList(); + + //페이징 정보 생성 + ResponsePageDTO.OffsetLimitPageInfo pageInfo = ResponsePageDTO.OffsetLimitPageInfo.from(panelDtoList); + + return SearchResponseDTO.EachResponses.builder() + .keys(keys) + .values(values) + .pageInfo(pageInfo) + .build(); + } + + + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchPanelServiceImpl.java b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchPanelServiceImpl.java new file mode 100644 index 0000000..21d22e5 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/implement/SearchPanelServiceImpl.java @@ -0,0 +1,24 @@ +package DiffLens.back_end.domain.search.service.implement; + +import DiffLens.back_end.domain.panel.repository.PanelRepository; +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.domain.search.service.interfaces.SearchPanelService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SearchPanelServiceImpl implements SearchPanelService { + + private final PanelRepository panelRepository; + + @Override + public Page getPanelDtoList(List panelIds, Pageable pageable) { + return panelRepository.findPanelsWithRawDataByIdsInPage(panelIds, pageable); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/ChartService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/ChartService.java new file mode 100644 index 0000000..c2bc6d7 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/ChartService.java @@ -0,0 +1,13 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.panel.entity.Panel; +import DiffLens.back_end.domain.search.entity.Chart; +import DiffLens.back_end.domain.search.entity.SearchHistory; + +import java.util.List; + +public interface ChartService { + public List makeChart(FastNaturalSearchResponseDTO.SearchResult fastPanelResponseDTO, SearchHistory searchHistory, List foundPanels); +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/FilterService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/FilterService.java new file mode 100644 index 0000000..0c1bb0f --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/FilterService.java @@ -0,0 +1,12 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.search.entity.Filter; +import DiffLens.back_end.domain.search.entity.SearchFilter; +import DiffLens.back_end.domain.search.entity.SearchHistory; + +import java.util.List; + +public interface FilterService { + SearchFilter makeFilter(List filterIdList, SearchHistory searchHistory); + List findFilters(List filterIdList); +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchHistoryService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchHistoryService.java new file mode 100644 index 0000000..6d37988 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchHistoryService.java @@ -0,0 +1,11 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import DiffLens.back_end.domain.search.dto.SearchRequestDTO; +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; +import DiffLens.back_end.domain.search.entity.SearchHistory; + +public interface SearchHistoryService { + SearchHistory makeSearchHistory(SearchRequestDTO.NaturalLanguage request, FastNaturalSearchResponseDTO.SearchResult fastApiResponse); + SearchResponseDTO.EachResponses getEachResponses(Long searchHistoryId, Integer pageNum, Integer size); +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchPanelService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchPanelService.java new file mode 100644 index 0000000..4592322 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchPanelService.java @@ -0,0 +1,13 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.panel.repository.projection.PanelWithRawDataDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface SearchPanelService { + + Page getPanelDtoList(List panelIds, Pageable pageable); + +} diff --git a/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchService.java b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchService.java new file mode 100644 index 0000000..fecbf30 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/search/service/interfaces/SearchService.java @@ -0,0 +1,9 @@ +package DiffLens.back_end.domain.search.service.interfaces; + +import DiffLens.back_end.domain.search.dto.SearchResponseDTO; + +import java.util.List; + +public interface SearchService { + SearchResponseDTO.SearchResult search(T request); +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/global/dto/ResponsePageDTO.java b/src/main/java/DiffLens/back_end/global/dto/ResponsePageDTO.java index 8850626..341d283 100644 --- a/src/main/java/DiffLens/back_end/global/dto/ResponsePageDTO.java +++ b/src/main/java/DiffLens/back_end/global/dto/ResponsePageDTO.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; public class ResponsePageDTO { @@ -12,7 +13,7 @@ public class ResponsePageDTO { @Builder @AllArgsConstructor @NoArgsConstructor - public static class PageInfo { + public static class CursorPageInfo { @JsonProperty("next_cursor") private Integer nextCursor; @@ -25,4 +26,47 @@ public static class PageInfo { private Integer currentPageCount; } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class OffsetLimitPageInfo { + + private Integer offset; + + @JsonProperty("current_page") + private Integer currentPage; + + @JsonProperty("current_page_count") + private Integer currentPageCount; + + @JsonProperty("total_page_count") + private Integer totalPageCount; + + private Integer limit; + + @JsonProperty("total_count") + private Long totalCount; + + @JsonProperty("has_next") + private Boolean hasNext; + + @JsonProperty("has_previous") + private Boolean hasPrevious; + + public static OffsetLimitPageInfo from(Page page) { + return OffsetLimitPageInfo.builder() + .offset(page.getPageable().getPageNumber() * page.getPageable().getPageSize()) + .currentPage(page.getNumber() + 1) + .currentPageCount(page.getNumberOfElements()) + .totalPageCount(page.getTotalPages()) + .limit(page.getSize()) + .totalCount(page.getTotalElements()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } + + } + } diff --git a/src/main/java/DiffLens/back_end/global/fastapi/FastApiClient.java b/src/main/java/DiffLens/back_end/global/fastapi/FastApiClient.java new file mode 100644 index 0000000..5a386e1 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/fastapi/FastApiClient.java @@ -0,0 +1,42 @@ +package DiffLens.back_end.global.fastapi; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * FastAPI 서버에 실제 HTTP 요청을 보냄 + */ +@Component +@RequiredArgsConstructor +public class FastApiClient { + + private final WebClient webClient = WebClient.builder() + .baseUrl("https://ai.difflens.site") + .build(); + + /** + * + * @param type fast api에 보낼 요청을 관리하는 enum - FastApiRequestType + * @param requestBody fast api에 요청 보낼 클래스의 타입 + * @return fast api 로부터 응답받은 데이터 ( R ) + * @param request body의 클래스 + * @param response body의 클래스 + * + */ + public R sendRequest(FastApiRequestType type, T requestBody) { + + // 요청 타입이 맞지 않을 경우에 대한 예외 + if (!type.getRequestBody().isInstance(requestBody)) { + throw new IllegalArgumentException("올바르지 않은 요청 타입 " + type.name()); // TODO : 에외처리... + } + + return webClient.post() + .uri(type.getUri()) + .bodyValue(requestBody) + .retrieve() + .bodyToMono((Class) type.getResponseType()) + .block(); + } + +} diff --git a/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java b/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java new file mode 100644 index 0000000..1efcacc --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/fastapi/FastApiRequestType.java @@ -0,0 +1,20 @@ +package DiffLens.back_end.global.fastapi; + +import DiffLens.back_end.global.fastapi.dto.request.FastPanelRequestDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FastApiRequestType { + + NATURAL_SEARCH("/search/natural", FastPanelRequestDTO.FastNaturalSearch.class, FastNaturalSearchResponseDTO.SearchResult.class), +// REFINE_SEARCH("/search/refine", FastNaturalSearchResponseDTO.SearchResult.class), + ; + + private final String uri; + private final Class requestBody; // request body + private final Class responseType; // response body + +} diff --git a/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java b/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java new file mode 100644 index 0000000..bad2409 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/fastapi/FastApiService.java @@ -0,0 +1,22 @@ +package DiffLens.back_end.global.fastapi; + +import DiffLens.back_end.global.fastapi.dto.request.FastPanelRequestDTO; +import DiffLens.back_end.global.fastapi.dto.response.FastNaturalSearchResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Fast API 호출 관리 + */ +@Service +@RequiredArgsConstructor +public class FastApiService { + + private final FastApiClient fastApiClient; + + // 자연어 검색 + public FastNaturalSearchResponseDTO.SearchResult getNaturalSearch(FastPanelRequestDTO.FastNaturalSearch request) { + return fastApiClient.sendRequest(FastApiRequestType.NATURAL_SEARCH, request); + } + +} diff --git a/src/main/java/DiffLens/back_end/global/fastapi/dto/request/FastPanelRequestDTO.java b/src/main/java/DiffLens/back_end/global/fastapi/dto/request/FastPanelRequestDTO.java new file mode 100644 index 0000000..cfecc6c --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/fastapi/dto/request/FastPanelRequestDTO.java @@ -0,0 +1,94 @@ +package DiffLens.back_end.global.fastapi.dto.request; + +import lombok.*; + +import java.util.List; + +/** + * + * Spring Boot -> Fast API 요청 형태 + * + */ +public class FastPanelRequestDTO { + + // 자연어 검색 요청 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FastNaturalSearch{ + + private String question; + + private String mode; + + private FastSearchFilters filters; + + } + + // 재검색 요청 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FastRefineSearch{ + + private String question; + + private String mode; + + private FastSearchFilters filters; + + + } + + // 차트 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FastChart{ + + private String tempColumn; + + } + + // 추천 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FastRecommendation { + + private String tempColumn; + + } + + // 라이브러리 비교 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FastLibraryCompare { + + private String tempColumn; + + } + + // ------------------------------- + // 아 아래는 위에서 필요한 클래스들 + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class FastSearchFilters{ + + private Integer count; + private String gender; + private List filters; + + } + + +} diff --git a/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastNaturalSearchResponseDTO.java b/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastNaturalSearchResponseDTO.java new file mode 100644 index 0000000..82e79fe --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/fastapi/dto/response/FastNaturalSearchResponseDTO.java @@ -0,0 +1,56 @@ +package DiffLens.back_end.global.fastapi.dto.response; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +/** + * + * Fast API -> Spring Boot 응답 형태 + * 자연어 응답 + * + * + */ +public class FastNaturalSearchResponseDTO { + + /** + * fast api에서 패널 식별자 목록을 불러와 db에서 패널 데이터를 불러옵니다. + */ + @Getter + @Setter + @ToString + public static class SearchResult { + + // 일반적인 데이터 + private Float accuracy; + + private List panelList; + private List accuracyList; // 일치율 + + // 차트 관련 + private List charts; + + // 개별응답 종류 +// private List panelColumns; // SearchResponseDTO.EachResponses로 분리 + + } + + /** + * 차트 + */ + @Getter + @Setter + @ToString + public static class ChartFastResult { + private String chartType; // 차트 유형 + private String title; // 차트 제목 + private String reason; // 차트 선정 이유 + private String xaxis; // x축 항목 + private String yaxis; // y축 항목 + private String panelColumn; // 표에 표시할 정보 ex) 직업분포, 월평균 소득 등 + } + + +} diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java index 9879599..7d512ad 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java @@ -19,6 +19,7 @@ public enum AuthStatus implements BaseErrorCode { EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH405", "만료된 토큰입니다."), INVALID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 토큰입니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH407", "올바르지 않은 refresh token 입니다."), + NOT_SUPPLYING(HttpStatus.BAD_REQUEST, "AUTH408", "제공하지 않는 기능입니다."), ERROR_IN_SIGNUP(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH500", "회원가입 중 알 수 없는 오류가 발생했습니다."), diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/ErrorStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/ErrorStatus.java index f730c74..9eb922b 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/ErrorStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/ErrorStatus.java @@ -15,8 +15,11 @@ public enum ErrorStatus implements BaseErrorCode { BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON4001", "입력값이 유효하지 않습니다."), - INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "COMMON4002", "code 누락 또는 공백 등 잘못된 파라미터입니다."), + INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON404", "입력값이 유효하지 않습니다."), + INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "COMMON405", "code 누락 또는 공백 등 잘못된 파라미터입니다."), + PAGE_NO_INVALID(HttpStatus.BAD_REQUEST, "COMMON406", "페이지 번호는 1 이상 이어야 합니다."), + PAGE_NO_EXCEED(HttpStatus.BAD_REQUEST, "COMMON407", "최대 페이지를 벗어났습니다."), + ; diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/RawDataStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/RawDataStatus.java new file mode 100644 index 0000000..7b80cb3 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/RawDataStatus.java @@ -0,0 +1,38 @@ +package DiffLens.back_end.global.responses.code.status.error; + +import DiffLens.back_end.global.responses.code.BaseErrorCode; +import DiffLens.back_end.global.responses.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum RawDataStatus implements BaseErrorCode { + + RAW_DATA_ERROR(HttpStatus.BAD_REQUEST, "RAW400", "원천데이터 오류입니다. 관리자에게 문의하세요"), + RAW_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "RAW401", "존재하지 않는 원천데이터입니다."), + + RAW_DATA_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "RAW500", "원천데이터 오류입니다. 관리자에게 문의하세요."), + + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder().message(message).code(code).isSuccess(false).build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java new file mode 100644 index 0000000..088401e --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/SearchStatus.java @@ -0,0 +1,34 @@ +package DiffLens.back_end.global.responses.code.status.error; + +import DiffLens.back_end.global.responses.code.BaseErrorCode; +import DiffLens.back_end.global.responses.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SearchStatus implements BaseErrorCode { + + SEARCH_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH404", "검색기록을 찾을 수 없습니다."), + + PANEL_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH405", "패널을 찾을 수 없습니다."), + + INVALID_COLUMN(HttpStatus.INTERNAL_SERVER_ERROR, "SEARCH501", "Panel의 column을 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ErrorReasonDto getReason() { + return null; + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return null; + } +} diff --git a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java index 478c570..b8c5faf 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -29,26 +30,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = resolveToken(request); try { if (token != null) { - // token 이 BlackList에 있을 경우 if (blackListService.isContainToken(token)) { throw new ErrorHandler(AuthStatus.EXPIRED_TOKEN); } if (jwtTokenProvider.validateToken(token)) { - Authentication authentication = jwtTokenProvider.getAuthentication(token); + // 단일 Role 기반 Authentication 생성 + Authentication authentication = getAuthenticationFromToken(token); SecurityContextHolder.getContext().setAuthentication(authentication); -// System.out.println("authentication = " + authentication.getAuthorities()); } else { -// System.out.println("Invalid or expired token."); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid or expired token."); return; } + } else { Authentication anonymousAuth = new AnonymousAuthenticationToken( @@ -62,9 +63,29 @@ protected void doFilterInternal( response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("Internal server error occurred."); } + filterChain.doFilter(request, response); } + /** + * JWT에서 단일 Role 기반 Authentication 생성 + */ + private Authentication getAuthenticationFromToken(String token) { + var claims = jwtTokenProvider.parseClaims(token); + String email = (String) claims.get("email"); + if (email == null) email = (String) claims.get("sub"); + + var member = jwtTokenProvider.getMemberByEmail(email); + + String roleStr = (String) claims.get("role"); + var authority = new SimpleGrantedAuthority(roleStr); + + return new UsernamePasswordAuthenticationToken( + member, null, Collections.singletonList(authority) + ); + } + + private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { @@ -72,4 +93,5 @@ private String resolveToken(HttpServletRequest request) { } return null; } + } diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java index cd083ba..573d655 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java @@ -7,8 +7,8 @@ @AllArgsConstructor public enum JwtTokenExpirationTime { - ACCESS_TOKEN(1000L * 60 * 30), // 30분 - REFRESH_TOKEN(1000L * 60 * 60 * 24 * 14) + ACCESS_TOKEN(1000L * 60 * 60), // 60분 + REFRESH_TOKEN(1000L * 60 * 60 * 24 * 14), ; private final long expirationMillis; diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java index accc841..7c4abd3 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java @@ -133,6 +133,7 @@ public boolean validateToken(String token) { public String createAccessToken(Member member) { Claims claims = Jwts.claims().setSubject(member.getEmail()); + claims.put("role", member.getRole().name()); // 권한 설정 Date now = new Date(); return Jwts.builder() .setClaims(claims) @@ -235,6 +236,12 @@ public long getExpiration(String token) { } } + public Member getMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new ErrorHandler(AuthStatus.USER_NOT_FOUND)); + } + + public TokenWithRolesResponseDTO createTokenWithRoles(Member member) { // subject는 가급적 userId 기반 권장 (이메일 변경 이슈 방지). 기존 스타일을 그대로 쓰고 싶다면 이메일 유지도 가능. Claims claims = Jwts.claims().setSubject(String.valueOf(member.getId())); diff --git a/src/main/java/DiffLens/back_end/global/security/Role.java b/src/main/java/DiffLens/back_end/global/security/Role.java new file mode 100644 index 0000000..f00ea03 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/Role.java @@ -0,0 +1,20 @@ +package DiffLens.back_end.global.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; + +@Getter +@AllArgsConstructor +public enum Role implements GrantedAuthority { + + ROLE_USER, + ROLE_ADMIN, + ; + + @Override + public String getAuthority() { + return name(); + } + +} diff --git a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java index 026bb09..cbedc6d 100644 --- a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java +++ b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; @@ -26,6 +27,7 @@ @EnableWebSecurity @Configuration @RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { private final CustomLogoutHandler customLogoutHandler; @@ -53,7 +55,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/v3/api-docs/**", "/auth/signup/**", "/auth/login/**", + "/auth/reissue", "/oauth2/**" +// "/admin/**" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/DiffLens/back_end/global/util/data/FilterInitializer.java b/src/main/java/DiffLens/back_end/global/util/data/initializer/FilterInitializer.java similarity index 72% rename from src/main/java/DiffLens/back_end/global/util/data/FilterInitializer.java rename to src/main/java/DiffLens/back_end/global/util/data/initializer/FilterInitializer.java index 7735071..da37139 100644 --- a/src/main/java/DiffLens/back_end/global/util/data/FilterInitializer.java +++ b/src/main/java/DiffLens/back_end/global/util/data/initializer/FilterInitializer.java @@ -1,4 +1,4 @@ -package DiffLens.back_end.global.util.data; +package DiffLens.back_end.global.util.data.initializer; import DiffLens.back_end.domain.search.entity.Filter; import DiffLens.back_end.domain.search.enums.filters.FilterKey; @@ -20,13 +20,13 @@ @Slf4j @Component @RequiredArgsConstructor -public class FilterInitializer { +public class FilterInitializer implements Initializer { private final FilterRepository filterRepository; @PostConstruct @Transactional - public void init() { + public void initialize() { log.info("[초기 필터 데이터 저장] 시작"); @@ -55,7 +55,8 @@ private List convertEnumToFilters(FilterKey filterKey) { filters.add(Filter.builder() .id(filterKey.getBaseId() + getEnumId(enumValue)) .type(filterKey.getKeyKr()) - .value(getEnumValue(enumValue)) + .displayValue(getDisplayDataValue(enumValue)) + .rawDataValue(getRawDataValue(enumValue)) .build()); } @@ -87,14 +88,29 @@ private Long getEnumId(Enum enumValue) { } /** - * Enum에서 getValue() 호출 + * Enum에서 getDisplayDataValue() 호출 */ - private String getEnumValue(Enum enumValue) { + private String getDisplayDataValue(Enum enumValue) { try { - Method valueMethod = enumValue.getClass().getMethod("getValue"); + Method valueMethod = enumValue.getClass().getMethod("getDisplayValue"); return (String) valueMethod.invoke(enumValue); } catch (Exception e) { - throw new RuntimeException("Enum value 조회 실패: " + enumValue.name(), e); + throw new RuntimeException("Enum DisplayDataValue 조회 실패: " + enumValue.name(), e); } } + + /** + * Enum에서 getRawDataValue() 호출 + */ + private String getRawDataValue(Enum enumValue) { + try { + Method valueMethod = enumValue.getClass().getMethod("getRawDataValue"); + String rawDataValue = (String) valueMethod.invoke(enumValue); + // null 값인 경우 기본값 설정 + return rawDataValue != null ? rawDataValue : "UNKNOWN"; + } catch (Exception e) { + throw new RuntimeException("Enum RawDataValue 조회 실패: " + enumValue.name(), e); + } + } + } diff --git a/src/main/java/DiffLens/back_end/global/util/data/initializer/Initializer.java b/src/main/java/DiffLens/back_end/global/util/data/initializer/Initializer.java new file mode 100644 index 0000000..ebd9fc0 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/util/data/initializer/Initializer.java @@ -0,0 +1,5 @@ +package DiffLens.back_end.global.util.data.initializer; + +public interface Initializer { + void initialize(); +} diff --git a/src/main/java/DiffLens/back_end/global/util/data/initializer/MemberInitializer.java b/src/main/java/DiffLens/back_end/global/util/data/initializer/MemberInitializer.java new file mode 100644 index 0000000..19a67f6 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/util/data/initializer/MemberInitializer.java @@ -0,0 +1,46 @@ +package DiffLens.back_end.global.util.data.initializer; + +import DiffLens.back_end.domain.members.auth.strategy.implement.AuthAdminStrategy; +import DiffLens.back_end.domain.members.dto.auth.AuthRequestDTO; +import DiffLens.back_end.domain.members.enums.Industry; +import DiffLens.back_end.domain.members.enums.Job; +import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.domain.members.enums.PlanEnum; +import DiffLens.back_end.domain.members.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberInitializer implements Initializer { + + private final AuthAdminStrategy authStrategy; + private final MemberRepository memberRepository; + + @Value("${auth.admin_email}") + private String EMAIL; + + @Value("${auth.admin_pw}") + private String PASSWORD; + + @Override + public void initialize() { + + Boolean isExist = memberRepository.existsByEmail(EMAIL); + + if(!isExist){ + AuthRequestDTO.SignUp admin = new AuthRequestDTO.SignUp( + EMAIL, + "admin", + PASSWORD, + LoginType.GENERAL, + PlanEnum.BUSINESS, + Job.ETC_FREELANCER, + Industry.ETC + ); + authStrategy.signUp(admin); + } + + } +} diff --git a/src/main/java/DiffLens/back_end/global/util/data/initializer/PlanInitializer.java b/src/main/java/DiffLens/back_end/global/util/data/initializer/PlanInitializer.java new file mode 100644 index 0000000..9f4dd56 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/util/data/initializer/PlanInitializer.java @@ -0,0 +1,39 @@ +package DiffLens.back_end.global.util.data.initializer; + +import DiffLens.back_end.domain.members.entity.Plan; +import DiffLens.back_end.domain.members.enums.PlanEnum; +import DiffLens.back_end.domain.members.repository.PlanRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; + +/** + * PlanEnum에 있는 값들 DB에 적용 + */ +@Component +@RequiredArgsConstructor +public class PlanInitializer implements Initializer { + + private final PlanRepository planRepository; + + @Override + @Transactional + public void initialize() { + + Arrays.stream(PlanEnum.values()) + .forEach(planEnum -> + planRepository.save( + Plan.builder() + .id(planEnum.getId()) + .name(planEnum.getName()) + .price(planEnum.getPrice()) + .build() + ) + ); + + } + + +} diff --git a/src/main/java/DiffLens/back_end/global/util/data/initializer/ServerDataInitializer.java b/src/main/java/DiffLens/back_end/global/util/data/initializer/ServerDataInitializer.java new file mode 100644 index 0000000..bb8cbcd --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/util/data/initializer/ServerDataInitializer.java @@ -0,0 +1,34 @@ +package DiffLens.back_end.global.util.data.initializer; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * + * Initializer를 구현하는 모든 Bean을 찾아 List에 넣은 후 + * 각각 initialize() 메서드 실행 -> 기본 데이터 초기화 + * 서버 시작 시기에 초기화할 데이터가 있다면 Initializer 를 구현하면 됩니다. + * + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ServerDataInitializer { + + private final List initializers; + + @PostConstruct + @Transactional + public void initialize() { + log.info("[서버 초기 데이터 저장] 시작"); + for (Initializer initializer : initializers) { + initializer.initialize(); + } + log.info("[서버 초기 데이터 저장] 종료"); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 73eb48f..ff242d4 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -18,12 +18,10 @@ spring: data: redis: - host: ${REDIS_HOSTNAME} + host: ${REDIS_HOST} port: ${REDIS_PORT} timeout: 2000ms - - logging: level: root: info diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5972db9..f64c6d1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -17,7 +17,6 @@ spring: jpa: hibernate: ddl-auto: update - data: redis: host: localhost diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 56ded67..bdc92b8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,10 @@ spring: profiles: active: local + servlet: + multipart: + max-file-size: 100MB # panel json 넣기 위한 설정 + max-request-size: 100MB datasource: driver-class-name: org.postgresql.Driver @@ -17,4 +21,8 @@ spring: mode: never jwt: - secret: ${JWT_SECRET} \ No newline at end of file + secret: ${JWT_SECRET} + +auth: + admin_email: ${ADMIN_EMAIL} + admin_pw: ${ADMIN_EMAIL} \ No newline at end of file