From 6fe237c6161ca6487adf16b4f8115874e3d8d536 Mon Sep 17 00:00:00 2001 From: Kim Doo Hyeon Date: Tue, 18 Feb 2025 00:18:01 +0900 Subject: [PATCH] =?UTF-8?q?[FEATURE]=20=EB=A7=A4=EC=B9=AD=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20API=20#43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매칭하기 API --- .../young/blaybus/domain/address/Address.java | 9 +- .../repository/ListCertificateRepository.java | 30 ++++ .../repository/JobSeekRepository.java | 2 + .../repository/ListMatchingRepository.java | 28 ++++ .../young/blaybus/domain/member/Member.java | 6 +- .../request/CreateMemberRequest.java | 2 +- .../domain/member/service/MemberService.java | 6 +- .../senior/controller/SeniorController.java | 11 ++ .../controller/response/ListRecommendDto.java | 45 ++++++ .../response/ListRecommendResponse.java | 22 +++ .../repository/ListRecommendRepository.java | 44 ++++++ .../senior/service/RecommendService.java | 140 ++++++++++++++++++ .../young/blaybus/map/service/MapService.java | 14 +- src/test/java/young/blaybus/BlaybusTest.java | 4 +- 14 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 src/main/java/young/blaybus/domain/certificate/repository/ListCertificateRepository.java create mode 100644 src/main/java/young/blaybus/domain/matching/repository/ListMatchingRepository.java create mode 100644 src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendDto.java create mode 100644 src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendResponse.java create mode 100644 src/main/java/young/blaybus/domain/senior/repository/ListRecommendRepository.java create mode 100644 src/main/java/young/blaybus/domain/senior/service/RecommendService.java diff --git a/src/main/java/young/blaybus/domain/address/Address.java b/src/main/java/young/blaybus/domain/address/Address.java index bad336c..cd77a6f 100644 --- a/src/main/java/young/blaybus/domain/address/Address.java +++ b/src/main/java/young/blaybus/domain/address/Address.java @@ -3,11 +3,11 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.Comment; +import org.springframework.util.StringUtils; @Embeddable @Getter @Setter -@ToString @AllArgsConstructor @NoArgsConstructor public class Address { @@ -25,4 +25,11 @@ public class Address { @Comment("상세 주소") private String detail; + @Override + public String toString() { + String str = city + " " + district + " " + dong; + if (StringUtils.hasText(detail)) str += " " + detail; + + return str; + } } diff --git a/src/main/java/young/blaybus/domain/certificate/repository/ListCertificateRepository.java b/src/main/java/young/blaybus/domain/certificate/repository/ListCertificateRepository.java new file mode 100644 index 0000000..ae669ef --- /dev/null +++ b/src/main/java/young/blaybus/domain/certificate/repository/ListCertificateRepository.java @@ -0,0 +1,30 @@ +package young.blaybus.domain.certificate.repository; + +import static young.blaybus.domain.certificate.QCertificate.certificate; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import young.blaybus.domain.certificate.Certificate; +import young.blaybus.domain.certificate.enums.CertificateType; +import young.blaybus.domain.member.Member; + +@Repository +@RequiredArgsConstructor +public class ListCertificateRepository { + + private final JPAQueryFactory queryFactory; + + + public List getCertificateList(Member member) { + return queryFactory + .selectFrom(certificate) + .where( + certificate.member.eq(member), + certificate.type.ne(CertificateType.CARE) + ) + .fetch(); + + } +} diff --git a/src/main/java/young/blaybus/domain/job_seek/repository/JobSeekRepository.java b/src/main/java/young/blaybus/domain/job_seek/repository/JobSeekRepository.java index de4b8e3..1bf45d4 100644 --- a/src/main/java/young/blaybus/domain/job_seek/repository/JobSeekRepository.java +++ b/src/main/java/young/blaybus/domain/job_seek/repository/JobSeekRepository.java @@ -2,7 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import young.blaybus.domain.job_seek.JobSeek; +import young.blaybus.domain.senior.Senior; public interface JobSeekRepository extends JpaRepository { + JobSeek findBySenior(Senior senior); } diff --git a/src/main/java/young/blaybus/domain/matching/repository/ListMatchingRepository.java b/src/main/java/young/blaybus/domain/matching/repository/ListMatchingRepository.java new file mode 100644 index 0000000..9ccbcae --- /dev/null +++ b/src/main/java/young/blaybus/domain/matching/repository/ListMatchingRepository.java @@ -0,0 +1,28 @@ +package young.blaybus.domain.matching.repository; + +import static young.blaybus.domain.matching.QMatching.matching; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import young.blaybus.domain.matching.enums.MatchingStatus; +import young.blaybus.domain.member.Member; + +@Repository +@RequiredArgsConstructor +public class ListMatchingRepository { + + private final JPAQueryFactory queryFactory; + + + public long countAccepted(Member member) { + return queryFactory + .selectFrom(matching) + .where( + matching.member.eq(member), + matching.status.eq(MatchingStatus.ACCEPTED) + ) + .fetch().size(); + + } +} diff --git a/src/main/java/young/blaybus/domain/member/Member.java b/src/main/java/young/blaybus/domain/member/Member.java index f9f7b3e..6cd9566 100644 --- a/src/main/java/young/blaybus/domain/member/Member.java +++ b/src/main/java/young/blaybus/domain/member/Member.java @@ -17,6 +17,7 @@ import young.blaybus.domain.address.Address; import young.blaybus.domain.center.Center; import young.blaybus.domain.member.enums.MemberRole; +import young.blaybus.util.enums.CareStyle; @Entity @Builder @@ -70,8 +71,9 @@ public class Member { @Comment("경력 기간") private String careerPeriod; - @Comment("케어 스타일") - private String style; + @Enumerated(value = EnumType.STRING) + @Comment("돌봄 스타일") + private CareStyle careStyle; @CreatedDate @Column(updatable = false) diff --git a/src/main/java/young/blaybus/domain/member/controller/request/CreateMemberRequest.java b/src/main/java/young/blaybus/domain/member/controller/request/CreateMemberRequest.java index 2fc8e63..3d3bf36 100644 --- a/src/main/java/young/blaybus/domain/member/controller/request/CreateMemberRequest.java +++ b/src/main/java/young/blaybus/domain/member/controller/request/CreateMemberRequest.java @@ -51,6 +51,6 @@ public record CreateMemberRequest( @Schema(description = "한줄 소개") String introduction, - @Schema(description = "요양 스타일") + @Schema(description = "돌봄 스타일") CareStyle careStyle ) { } diff --git a/src/main/java/young/blaybus/domain/member/service/MemberService.java b/src/main/java/young/blaybus/domain/member/service/MemberService.java index cac2ab9..d799f10 100644 --- a/src/main/java/young/blaybus/domain/member/service/MemberService.java +++ b/src/main/java/young/blaybus/domain/member/service/MemberService.java @@ -6,7 +6,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; -import org.springframework.web.multipart.MultipartFile; import young.blaybus.api_response.exception.GeneralException; import young.blaybus.api_response.status.ErrorStatus; import young.blaybus.domain.address.Address; @@ -26,7 +25,6 @@ import young.blaybus.domain.member.enums.MemberRole; import young.blaybus.domain.member.repository.MemberRepository; import young.blaybus.domain.member.security.jwt.provider.JwtProvider; -import young.blaybus.domain.s3_file.service.S3FileService; import java.time.LocalDateTime; import java.util.*; @@ -89,7 +87,7 @@ public void workerRegisterMember(CreateMemberRequest memberRequest) { .introduction(memberRequest.introduction()) .careerPeriod(memberRequest.careerPeriod()) .createdTime(now) - .style(memberRequest.careStyle().getValue()) + .careStyle(memberRequest.careStyle()) .build(); memberRepository.save(member); @@ -162,7 +160,7 @@ public Object getMember(String memberId) { .dong(member.getAddress().getDong()) .certificate(getCertificate) .profileUrl(member.getProfileUrl()) - .style(member.getStyle()) + .style(member.getCareStyle().getValue()) .build(); return getMember; diff --git a/src/main/java/young/blaybus/domain/senior/controller/SeniorController.java b/src/main/java/young/blaybus/domain/senior/controller/SeniorController.java index b2ef916..11ff408 100644 --- a/src/main/java/young/blaybus/domain/senior/controller/SeniorController.java +++ b/src/main/java/young/blaybus/domain/senior/controller/SeniorController.java @@ -14,7 +14,9 @@ import young.blaybus.domain.senior.controller.request.CreateSeniorRequest; import young.blaybus.domain.senior.controller.request.UpdateSeniorRequest; import young.blaybus.domain.senior.controller.response.DetailSeniorResponse; +import young.blaybus.domain.senior.controller.response.ListRecommendResponse; import young.blaybus.domain.senior.controller.response.ListSeniorResponse; +import young.blaybus.domain.senior.service.RecommendService; import young.blaybus.domain.senior.service.SeniorService; @RestController @@ -24,6 +26,7 @@ public class SeniorController { private final SeniorService seniorService; + private final RecommendService recommendService; @GetMapping @Operation(summary = "어르신 목록 조회") @@ -58,4 +61,12 @@ public ApiResponse updateSenior( return ApiResponse.onSuccess(); } + @GetMapping("/{senior-id}/recommend") + @Operation(summary = "요양 보호사 추천 받기") + public ApiResponse getRecommendList( + @PathVariable("senior-id") Long seniorId + ) { + return ApiResponse.onSuccess(recommendService.getRecommendList(seniorId)); + } + } diff --git a/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendDto.java b/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendDto.java new file mode 100644 index 0000000..f1e3270 --- /dev/null +++ b/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendDto.java @@ -0,0 +1,45 @@ +package young.blaybus.domain.senior.controller.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import young.blaybus.util.enums.DayOfWeek; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "추천 요양 보호사 목록 조회 응답 DTO") +public class ListRecommendDto { + + @Schema(description = "요양 보호사 ID") + private String memberId; + + @Schema(description = "이름") + private String name; + + @Schema(description = "프로필 사진") + private String profileUrl; + + @Setter + @Schema(description = "희망 요일 목록") + private List dayList; + + @Schema(description = "희망 시작 시간") + private LocalTime startTime; + + @Schema(description = "희망 종료 시간") + private LocalTime endTime; + + @Schema(description = "돌봄 스타일") + private String careStyle; + + @Schema(description = "적합도 %") + private Integer fitness; + +} diff --git a/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendResponse.java b/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendResponse.java new file mode 100644 index 0000000..76299a2 --- /dev/null +++ b/src/main/java/young/blaybus/domain/senior/controller/response/ListRecommendResponse.java @@ -0,0 +1,22 @@ +package young.blaybus.domain.senior.controller.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Builder.Default; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "추천 요양 보호사 목록 조회 응답 객체") +public class ListRecommendResponse { + + @Default + @Schema(description = "요양 보호사 목록 조회 응답 객체") + private List recommendList = new ArrayList<>(); +} diff --git a/src/main/java/young/blaybus/domain/senior/repository/ListRecommendRepository.java b/src/main/java/young/blaybus/domain/senior/repository/ListRecommendRepository.java new file mode 100644 index 0000000..1ab8fc2 --- /dev/null +++ b/src/main/java/young/blaybus/domain/senior/repository/ListRecommendRepository.java @@ -0,0 +1,44 @@ +package young.blaybus.domain.senior.repository; + +import static young.blaybus.domain.certificate.QCertificate.certificate; +import static young.blaybus.domain.job_search.QJobSearch.jobSearch; +import static young.blaybus.domain.matching.QMatching.matching; +import static young.blaybus.domain.member.QMember.member; + +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import young.blaybus.domain.certificate.enums.CertificateType; +import young.blaybus.domain.matching.enums.MatchingStatus; +import young.blaybus.domain.member.Member; +import young.blaybus.domain.senior.Senior; + +@Repository +@RequiredArgsConstructor +public class ListRecommendRepository { + + private final JPAQueryFactory queryFactory; + + + public List getRecommendList(Senior senior) { + return queryFactory.selectFrom(member) + .innerJoin(jobSearch).on(jobSearch.member.eq(member)).fetchJoin() + .innerJoin(certificate).on(certificate.member.eq(member)).fetchJoin() + .where( + member.center.isNull(), // 요양 보호사만 + certificate.type.eq(CertificateType.CARE), // 요양 보호사 자격증 필수 + member.notIn( // 거절 이력 있으면 추천 X + JPAExpressions.select(matching.member) + .from(matching) + .where( + matching.member.eq(member), + matching.senior.eq(senior), + matching.status.eq(MatchingStatus.REJECTED)) + ) + ) + .groupBy(member) + .fetch(); + } +} diff --git a/src/main/java/young/blaybus/domain/senior/service/RecommendService.java b/src/main/java/young/blaybus/domain/senior/service/RecommendService.java new file mode 100644 index 0000000..b3b1019 --- /dev/null +++ b/src/main/java/young/blaybus/domain/senior/service/RecommendService.java @@ -0,0 +1,140 @@ +package young.blaybus.domain.senior.service; + +import java.time.Duration; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import young.blaybus.api_response.exception.GeneralException; +import young.blaybus.api_response.status.ErrorStatus; +import young.blaybus.domain.certificate.Certificate; +import young.blaybus.domain.certificate.enums.CertificateType; +import young.blaybus.domain.certificate.repository.ListCertificateRepository; +import young.blaybus.domain.job_search.JobSearch; +import young.blaybus.domain.job_search.JobSearchDay; +import young.blaybus.domain.job_search.repository.JobSearchRepository; +import young.blaybus.domain.job_seek.JobSeek; +import young.blaybus.domain.job_seek.repository.JobSeekRepository; +import young.blaybus.domain.matching.repository.ListMatchingRepository; +import young.blaybus.domain.member.Member; +import young.blaybus.domain.senior.Senior; +import young.blaybus.domain.senior.SeniorDay; +import young.blaybus.domain.senior.controller.response.ListRecommendDto; +import young.blaybus.domain.senior.controller.response.ListRecommendResponse; +import young.blaybus.domain.senior.repository.ListRecommendRepository; +import young.blaybus.domain.senior.repository.SeniorRepository; +import young.blaybus.map.controller.response.geocoding.Coordinate; +import young.blaybus.map.service.MapService; +import young.blaybus.util.enums.DayOfWeek; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecommendService { + + private final ListRecommendRepository listRecommendRepository; + private final SeniorRepository seniorRepository; + private final JobSearchRepository jobSearchRepository; + private final JobSeekRepository jobSeekRepository; + private final ListMatchingRepository listMatchingRepository; + private final ListCertificateRepository listCertificateRepository; + private final MapService mapService; + + public ListRecommendResponse getRecommendList(Long seniorId) { + Senior senior = seniorRepository.findById(seniorId) + .orElseThrow(() -> new GeneralException(ErrorStatus.KEY_NOT_EXIST)); + + List memberList = listRecommendRepository.getRecommendList(senior); + List recommendList = new ArrayList<>(); + for (Member member : memberList) { + + JobSearch jobSearch = jobSearchRepository.findByMemberId(member.getId()).orElse(null); + if (jobSearch == null) continue; + + recommendList.add( + ListRecommendDto.builder() + .memberId(member.getId()) + .name(member.getName()) + .profileUrl(member.getProfileUrl()) + .dayList(jobSearch.getDayList().stream().map(JobSearchDay::getDay).toList()) + .startTime(jobSearch.getStartTime()) + .endTime(jobSearch.getEndTime()) + .careStyle(member.getCareStyle().getValue()) + .fitness(calculateFitness(member, jobSearch, senior)) + .build() + ); + } + + // 적합도 기준 내림차순 정렬 + recommendList.sort(Comparator.comparingDouble(ListRecommendDto::getFitness).reversed()); + return ListRecommendResponse.builder() + .recommendList(recommendList) + .build(); + } + + private int calculateFitness(Member member, JobSearch jobSearch, Senior senior) { + double fitness = 0.0; + + JobSeek jobSeek = jobSeekRepository.findBySenior(senior); + + // 거리 : 0km 최고점, 350km 최하점 → 30점 만점 + fitness += 30; + Coordinate memberGeocoding = mapService.geocoding(member.getAddress().toString()); + Coordinate seniorGeocoding = mapService.geocoding(senior.getAddress()); + Double distance = mapService.getDistance(memberGeocoding, seniorGeocoding); + double maxDistance = 350_000; + + fitness -= Math.min(30, distance * 30 / maxDistance); + + // 요일 → (노인의 희망 요일이 보호사의 요일과 겹치는 개수) * 15 / (노인의 희망 요일 개수) 점 → 15점 만점 + List memberDayList = jobSearch.getDayList().stream().map(JobSearchDay::getDay).toList(); + List seniorDayList = senior.getDayList().stream().map(SeniorDay::getDay).toList(); + int intersectCount = memberDayList.stream() + .filter(seniorDayList::contains) + .collect(Collectors.toSet()).size(); + fitness += (double) (15 * intersectCount) / seniorDayList.size(); + + // 시간 → 겹치는 시간의 비율 * 15 (15 만점) + LocalTime memberStart = jobSearch.getStartTime(); + LocalTime memberEnd = jobSearch.getEndTime(); + LocalTime seniorStart = senior.getStartTime(); + LocalTime seniorEnd = senior.getEndTime(); + LocalTime overlapStart = memberStart.isBefore(seniorStart) ? seniorStart : memberStart; + LocalTime overlapEnd = memberEnd.isBefore(seniorEnd) ? memberEnd : seniorEnd; + if (overlapStart.isBefore(overlapEnd)) { // 겹치면 + long overlapMinutes = Duration.between(overlapStart, overlapEnd).toMinutes(); + long memberTotalMinutes = Duration.between(memberStart, memberEnd).toMinutes(); + fitness += (double) 15 * overlapMinutes / memberTotalMinutes; + } + + // 자격증 : 요양보호사 제외 개수당 3점 → 10점 만점, 사회복지사 자격증 번호 1로 시작(1급)하면 2점 추가 (10 만점) + List certificateList = listCertificateRepository.getCertificateList(member); + fitness += certificateList.size(); + for (Certificate certificate : certificateList) { + if (certificate.getType().equals(CertificateType.SOCIAL) + && certificate.getNumber().startsWith("1")) fitness += 2; + } + + // 노인 시급 < 보호사 시급일 때, 10 - 0.001 * (보호사 시급 - 노인 시급) → 1000원 차이당 1점 감소, 최소 0점 (10 만점) + fitness += 10; + if (jobSeek.getSalary() < jobSearch.getSalary()) + fitness -= Math.max(10, 0.001 * (jobSearch.getSalary() - jobSeek.getSalary())); + + // 요양 스타일 일치하면 5점 + if (member.getCareStyle().equals(senior.getCareStyle())) fitness += 5; + + // 매칭 횟수 → 5점 만점, 매칭 횟수 / 2 점 + long count = listMatchingRepository.countAccepted(member); + fitness += Math.max(5, count / 2); + + // 치매 교육 여부 → 5점, 차량 소유 여부 → 5점 + if (member.getCarYn()) fitness += 5; + if (member.getDementiaEducationYn()) fitness += 5; + + return (int) fitness; + } +} diff --git a/src/main/java/young/blaybus/map/service/MapService.java b/src/main/java/young/blaybus/map/service/MapService.java index a6c4cdf..1785748 100644 --- a/src/main/java/young/blaybus/map/service/MapService.java +++ b/src/main/java/young/blaybus/map/service/MapService.java @@ -25,7 +25,6 @@ import young.blaybus.map.controller.response.geocoding.GeocodingResponse; import young.blaybus.map.controller.response.poi.ListPoiResponse; import young.blaybus.map.controller.response.matrix.MatrixResponse; -import young.blaybus.map.controller.response.poi.PoiDto; import young.blaybus.map.repository.CityRepository; import young.blaybus.map.repository.DongRepository; import young.blaybus.map.repository.GuGunRepository; @@ -57,11 +56,9 @@ public class MapService { /** * 주소 -> 위도/경도 변환 */ - public Coordinate geocoding(String cityDo, String guGun, String dong) { - - // 상세주소 받게되면 dong 뒤에 맨 앞 공백과 함께 추가한 뒤 붙여서 요청 + public Coordinate geocoding(String address) { GeocodingResponse response = skClient.geocoding( - appKey, version, cityDo + " " + guGun + " " + dong, addressFlag, coordType, count + appKey, version, address, addressFlag, coordType, count ); return response.getCoordinateInfo().getCoordinate().get(0); @@ -82,12 +79,7 @@ public Double getDistance(Coordinate start, Coordinate end) { } public ListPoiResponse getPoiList(SearchPoiRequest request) { - - ListPoiResponse response = skClient.poi(appKey, version, request.address()); - for (PoiDto poi : response.getSearchPoiInfo().getPoiResponse().getPoiList()) { - System.out.println("poi = " + poi); - } - return response; + return skClient.poi(appKey, version, request.address()); } public void readMapExcel() { diff --git a/src/test/java/young/blaybus/BlaybusTest.java b/src/test/java/young/blaybus/BlaybusTest.java index 5c0f64e..0bec94b 100644 --- a/src/test/java/young/blaybus/BlaybusTest.java +++ b/src/test/java/young/blaybus/BlaybusTest.java @@ -26,8 +26,8 @@ void poiTest() { @Test void geocodingTest() { - Coordinate response1 = mapService.geocoding("서울시", "강남구", "역삼동"); - Coordinate response2 = mapService.geocoding("부산시", "해운대", "센텀"); + Coordinate response1 = mapService.geocoding("서울시"+" 강남구"+" 역삼동"); + Coordinate response2 = mapService.geocoding("부산시"+" 해운대"+" 센텀"); // Coordinate response3 = mapService.geocoding("대전시", "유성구", "어은동"); // Coordinate response4 = mapService.geocoding("인천", "남동", "구월"); // Coordinate response5 = mapService.geocoding("대구시", "동구", "신천동");