diff --git a/build.gradle b/build.gradle index c8b29f4..3c72b36 100644 --- a/build.gradle +++ b/build.gradle @@ -28,28 +28,44 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-devtools' //캐시 삭제, 개발 test 용이 + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - + + //JUnit4 추가 testImplementation("org.junit.vintage:junit-vintage-engine") { exclude group: "org.hamcrest", module: "hamcrest-core" } - + + // Spring Boot Starter Security implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - implementation 'org.springframework.boot:spring-boot-starter-web' + + // JSON 변환 implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.2' - implementation 'org.springframework.boot:spring-boot-starter-mail' - implementation 'org.springframework.boot:spring-boot-starter-web' + + // JavaMail + implementation 'org.springframework.boot:spring-boot-starter-mail' // JavaMail support in Spring Boot + implementation 'org.json:json:20230618' + + // Spring Security 테스트 의존성 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + // Swagger 의존성 추가 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } tasks.named('test') { diff --git a/src/main/java/com/chapter1/blueprint/PageSerializer.java b/src/main/java/com/chapter1/blueprint/PageSerializer.java new file mode 100644 index 0000000..ef4b24b --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/PageSerializer.java @@ -0,0 +1,29 @@ +package com.chapter1.blueprint; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.springframework.data.domain.Page; + +import java.io.IOException; + +public class PageSerializer extends StdSerializer> { + + @SuppressWarnings("unchecked") + public PageSerializer() { + super((Class>) (Class) Page.class); + } + + @Override + public void serialize(Page page, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeStartObject(); + gen.writeObjectField("content", page.getContent()); + gen.writeNumberField("totalPages", page.getTotalPages()); + gen.writeNumberField("totalElements", page.getTotalElements()); + gen.writeNumberField("number", page.getNumber()); + gen.writeNumberField("size", page.getSize()); + gen.writeBooleanField("first", page.isFirst()); + gen.writeBooleanField("last", page.isLast()); + gen.writeEndObject(); + } +} diff --git a/src/main/java/com/chapter1/blueprint/SwaggerConfig.java b/src/main/java/com/chapter1/blueprint/SwaggerConfig.java new file mode 100644 index 0000000..12dec80 --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/SwaggerConfig.java @@ -0,0 +1,55 @@ +package com.chapter1.blueprint; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + Info info = new Info() + .title("Project API Documentation") + .version("v1.0.0") + .description("API 명세서") + .contact(new Contact() + .name("Chapter 1") + .email("example@example.com") + .url("https://github.com/Chapter-1")) + .license(new License() + .name("Apache License Version 2.0") + .url("http://www.apache.org/licenses/LICENSE-2.0")); + + // Security 스키마 설정 + SecurityScheme bearerAuth = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + // Security 요청 설정 + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .openapi("3.0.1") + .info(info) + .servers(Arrays.asList( + new Server().url("http://localhost:8080").description("Local Server"), + new Server().url("http://localhost:5173/frontend").description("Production Server") + )) + .components(new Components() + .addSecuritySchemes("bearerAuth", bearerAuth)) + .addSecurityItem(securityRequirement); + } +} \ No newline at end of file diff --git a/src/main/java/com/chapter1/blueprint/WebMvcConfig.java b/src/main/java/com/chapter1/blueprint/WebMvcConfig.java new file mode 100644 index 0000000..515081b --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/WebMvcConfig.java @@ -0,0 +1,29 @@ +package com.chapter1.blueprint; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Page; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@EnableWebMvc +public class WebMvcConfig implements WebMvcConfigurer { + @Override + public void configureMessageConverters(List> converters) { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.getObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + + // Register the custom Page serializer + SimpleModule module = new SimpleModule(); + module.addSerializer((Class>) (Class) Page.class, new PageSerializer()); + converter.getObjectMapper().registerModule(module); + + converters.add(0, converter); + } +} diff --git a/src/main/java/com/chapter1/blueprint/exception/codes/ErrorCode.java b/src/main/java/com/chapter1/blueprint/exception/codes/ErrorCode.java index 85b605a..95b9127 100644 --- a/src/main/java/com/chapter1/blueprint/exception/codes/ErrorCode.java +++ b/src/main/java/com/chapter1/blueprint/exception/codes/ErrorCode.java @@ -19,18 +19,29 @@ public enum ErrorCode { // 서버(Server) INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_001", "서버 내부 에러가 발생했습니다."), - // Real Estamate Error + // 정책(Policy) + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "POLICY_001", "해당 정책을 찾을 수 없습니다."), + + // 부동산(Real Estate) REAL_ESTATE_NOT_FOUND(HttpStatus.NOT_FOUND, "ESTATE_001", "부동산 정보를 찾을 수 없습니다."), INVALID_REGION_PARAMETER(HttpStatus.BAD_REQUEST, "ESTATE_002", "잘못된 지역 정보가 입력되었습니다."), REAL_ESTATE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ESTATE_003", "부동산 정보 조회 중 오류가 발생했습니다."), - // 요청(Request 관련 에러 + // 요청(Request 관련 에러) BAD_REQUEST_ERROR(HttpStatus.BAD_REQUEST, "G001", "잘못된 요청입니다."), // 이메일(Email) INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "EMAIL_001", "유효하지 않은 인증 코드입니다."), VERIFICATION_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "EMAIL_002", "인증 코드가 만료되었습니다."), - EMAIL_SENDING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_003", "이메일 전송에 실패하였습니다."); + EMAIL_SENDING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_003", "이메일 전송에 실패하였습니다."), + RECOMMENDED_POLICY_EMAIL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_004", "추천된 정책 이메일 전송에 실패하였습니다."), + + // 알림(Notification) + NOTIFICATION_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_001", "알림 상태 업데이트에 실패했습니다."), + POLICY_ALARM_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_002", "해당 알림 설정을 찾을 수 없습니다."), + NOTIFICATION_DELETION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_003", "알림 삭제에 실패했습니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_004", "알림을 찾을 수 없습니다."); + private final HttpStatus status; private final String code; diff --git a/src/main/java/com/chapter1/blueprint/finance/controller/FinanceController.java b/src/main/java/com/chapter1/blueprint/finance/controller/FinanceController.java index 969a98c..a262a37 100644 --- a/src/main/java/com/chapter1/blueprint/finance/controller/FinanceController.java +++ b/src/main/java/com/chapter1/blueprint/finance/controller/FinanceController.java @@ -1,42 +1,146 @@ package com.chapter1.blueprint.finance.controller; import com.chapter1.blueprint.exception.dto.SuccessResponse; +import com.chapter1.blueprint.finance.domain.LoanList; +import com.chapter1.blueprint.finance.domain.SavingsList; import com.chapter1.blueprint.finance.service.FinanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; +import java.util.Map; + @Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/finance") +@Tag(name = "Finance", description = "금융 상품 관리 API") public class FinanceController { private final FinanceService financeService; + @Operation(summary = "예금 상품 업데이트", description = "예금 상품 정보를 최신 데이터로 업데이트합니다.") + @ApiResponse(responseCode = "200", description = "업데이트 성공") @GetMapping(value = "/update/deposit") public ResponseEntity updateDeposit() { String result = financeService.updateDeposit(); return ResponseEntity.ok(new SuccessResponse(result)); } + @Operation(summary = "적금 상품 업데이트", description = "적금 상품 정보를 최신 데이터로 업데이트합니다.") + @ApiResponse(responseCode = "200", description = "업데이트 성공") @GetMapping(value = "/update/saving") public ResponseEntity updateSaving() { String result = financeService.updateSaving(); return ResponseEntity.ok(new SuccessResponse(result)); } + @Operation(summary = "주택담보대출 상품 업데이트", description = "주택담보대출 상품 정보를 최신 데이터로 업데이트합니다.") + @ApiResponse(responseCode = "200", description = "업데이트 성공") @GetMapping(value = "/update/mortgage") public ResponseEntity updateMortgage() { String result = financeService.updateMortgageLoan(); return ResponseEntity.ok(new SuccessResponse(result)); } + @Operation(summary = "전세자금대출 상품 업데이트", description = "전세자금대출 상품 정보를 최신 데이터로 업데이트합니다.") + @ApiResponse(responseCode = "200", description = "업데이트 성공") @GetMapping(value = "/update/rentHouse") public ResponseEntity updateRentHouse() { String result = financeService.updateRenthouse(); return ResponseEntity.ok(new SuccessResponse(result)); } + + @Operation(summary = "적금 상품 필터 조회", description = "적금 상품 필터 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = SavingsList.class))) + @GetMapping("/filter/savings") + public ResponseEntity getSavingsFilter() { + + SavingsList savingsList = financeService.getSavingsFilter(); + return ResponseEntity.ok(new SuccessResponse(savingsList)); + } + + @Operation(summary = "대출 상품 필터 조회", description = "대출 상품 필터 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = LoanList.class))) + @GetMapping("/filter/loan") + public ResponseEntity getLoanFilter() { + + LoanList loanList = financeService.getLoanFilter(); + return ResponseEntity.ok(new SuccessResponse(loanList)); + } + + @Operation(summary = "대출 상품 목록 조회", description = "페이지네이션과 필터를 적용하여 대출 상품 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping("/loans") + public ResponseEntity getLoans( + @RequestParam int page, + @RequestParam int size, + @RequestParam(required = false, defaultValue = "") String mrtgTypeNm, + @RequestParam(required = false, defaultValue = "") String lendRateTypeNm, + @RequestParam(required = false, defaultValue = "lendRateMin") String sortBy, + @RequestParam(required = false, defaultValue = "asc") String direction + ) { + // Sort 객체 생성 + Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); + Pageable pageable = PageRequest.of(page, size, sort); + + // 서비스 호출 + Page result = financeService.getFilteredLoans(pageable, + mrtgTypeNm.isEmpty() ? null : mrtgTypeNm, + lendRateTypeNm.isEmpty() ? null : lendRateTypeNm); + + return ResponseEntity.ok(new SuccessResponse(result)); + } + + @Operation(summary = "저축 상품 목록 조회", description = "페이지네이션과 필터를 적용하여 저축 상품 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공") + @GetMapping("/savings") + public ResponseEntity getSavings( + @RequestParam int page, + @RequestParam int size, + @RequestParam(required = false, defaultValue = "") String intrRateNm, + @RequestParam(required = false, defaultValue = "") String prdCategory, + @RequestParam(required = false, defaultValue = "intrRate") String sortBy, + @RequestParam(required = false, defaultValue = "asc") String direction + ) { + // Sort 객체 생성 + Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); + Pageable pageable = PageRequest.of(page, size, sort); + + // 서비스 호출 + Page result = financeService.getFilteredSavings(pageable, + intrRateNm.isEmpty() ? null : intrRateNm, + prdCategory.isEmpty() ? null : prdCategory); + + return ResponseEntity.ok(new SuccessResponse(result)); + } + + @GetMapping("/getAllLoans") + public ResponseEntity getAllLoans() { + List loanList = financeService.getAllLoans(); + return ResponseEntity.ok(new SuccessResponse(loanList)); + } + + @GetMapping("/getAllSavings") + public ResponseEntity getAllSavings() { + List savingsList = financeService.getAllSavings(); + return ResponseEntity.ok(new SuccessResponse(savingsList)); + } } diff --git a/src/main/java/com/chapter1/blueprint/finance/domain/SavingsList.java b/src/main/java/com/chapter1/blueprint/finance/domain/SavingsList.java index e68f26d..331d266 100644 --- a/src/main/java/com/chapter1/blueprint/finance/domain/SavingsList.java +++ b/src/main/java/com/chapter1/blueprint/finance/domain/SavingsList.java @@ -48,4 +48,7 @@ public class SavingsList { @Column(name = "prd_category") private String prdCategory; + + @Column(name = "image_url") + private String imageUrl; } diff --git a/src/main/java/com/chapter1/blueprint/finance/repository/LoanListRepository.java b/src/main/java/com/chapter1/blueprint/finance/repository/LoanListRepository.java index 9a7b49e..7de49e9 100644 --- a/src/main/java/com/chapter1/blueprint/finance/repository/LoanListRepository.java +++ b/src/main/java/com/chapter1/blueprint/finance/repository/LoanListRepository.java @@ -1,9 +1,23 @@ package com.chapter1.blueprint.finance.repository; import com.chapter1.blueprint.finance.domain.LoanList; +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 org.springframework.stereotype.Repository; +import java.util.Map; + @Repository public interface LoanListRepository extends JpaRepository { + + @Query(value = "SELECT * FROM finance.loan_list ORDER BY lend_rate_avg LIMIT 1", nativeQuery = true) + LoanList getLoanFilter(); + + // @Query("SELECT l FROM LoanList l WHERE (:filter1 IS NULL OR l.mrtg_type_nm = :filter1) AND (:filter2 IS NULL OR l.lend_rate_type_nm = :filter2)") + @Query("SELECT l FROM LoanList l WHERE (:mrtgTypeNm is null or l.mrtgTypeNm = :mrtgTypeNm) AND (:lendRateTypeNm is null or l.lendRateTypeNm = :lendRateTypeNm)") + Page findLoansWithFilters(@Param("mrtgTypeNm") String mrtgTypeNm, @Param("lendRateTypeNm") String lendRateTypeNm, Pageable pageable); + } diff --git a/src/main/java/com/chapter1/blueprint/finance/repository/SavingsListRepository.java b/src/main/java/com/chapter1/blueprint/finance/repository/SavingsListRepository.java index 432d706..02a808e 100644 --- a/src/main/java/com/chapter1/blueprint/finance/repository/SavingsListRepository.java +++ b/src/main/java/com/chapter1/blueprint/finance/repository/SavingsListRepository.java @@ -1,9 +1,21 @@ package com.chapter1.blueprint.finance.repository; +import com.chapter1.blueprint.finance.domain.LoanList; import com.chapter1.blueprint.finance.domain.SavingsList; +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 org.springframework.stereotype.Repository; @Repository public interface SavingsListRepository extends JpaRepository { + + @Query(value = "SELECT * FROM finance.savings_list ORDER BY intr_rate2 DESC LIMIT 1", nativeQuery = true) + SavingsList getSavingsFilter(); + + @Query("SELECT s FROM SavingsList s WHERE (:intrRateNm is null or s.intrRateNm = :intrRateNm) AND (:prdCategory is null or s.prdCategory = :prdCategory)") + Page findSavingsWithFilters(@Param("intrRateNm") String intrRateNm, @Param("prdCategory") String prdCategory, Pageable pageable); + } diff --git a/src/main/java/com/chapter1/blueprint/finance/service/FinanceService.java b/src/main/java/com/chapter1/blueprint/finance/service/FinanceService.java index 548e8d6..1089c88 100644 --- a/src/main/java/com/chapter1/blueprint/finance/service/FinanceService.java +++ b/src/main/java/com/chapter1/blueprint/finance/service/FinanceService.java @@ -10,6 +10,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -278,4 +281,28 @@ public String updateRenthouse() { return "API 데이터 처리 중 오류 발생: " + e.getMessage(); } } + + public SavingsList getSavingsFilter() { + return savingsListRepository.getSavingsFilter(); + } + + public LoanList getLoanFilter() { + return loanListRepository.getLoanFilter(); + } + + public Page getFilteredLoans(Pageable pageable, String mrtgTypeNm, String lendRateTypeNm) { + return loanListRepository.findLoansWithFilters(mrtgTypeNm, lendRateTypeNm, pageable); + } + + public Page getFilteredSavings(Pageable pageable, String intrRateNm, String prdCategory) { + return savingsListRepository.findSavingsWithFilters(intrRateNm, prdCategory, pageable); + } + + public List getAllLoans() { + return loanListRepository.findAll(); + } + + public List getAllSavings() { + return savingsListRepository.findAll(); + } } \ No newline at end of file diff --git a/src/main/java/com/chapter1/blueprint/member/controller/EmailController.java b/src/main/java/com/chapter1/blueprint/member/controller/EmailController.java index 568abd0..9dbe33b 100644 --- a/src/main/java/com/chapter1/blueprint/member/controller/EmailController.java +++ b/src/main/java/com/chapter1/blueprint/member/controller/EmailController.java @@ -1,10 +1,8 @@ package com.chapter1.blueprint.member.controller; import com.chapter1.blueprint.exception.dto.SuccessResponse; -import com.chapter1.blueprint.member.dto.EmailDTO; -import com.chapter1.blueprint.member.dto.MemberDTO; +import com.chapter1.blueprint.member.domain.dto.EmailDTO; import com.chapter1.blueprint.member.service.EmailService; -import com.chapter1.blueprint.member.service.MemberService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/com/chapter1/blueprint/member/controller/MemberController.java b/src/main/java/com/chapter1/blueprint/member/controller/MemberController.java index e39bb80..4ebf09c 100644 --- a/src/main/java/com/chapter1/blueprint/member/controller/MemberController.java +++ b/src/main/java/com/chapter1/blueprint/member/controller/MemberController.java @@ -5,7 +5,7 @@ import com.chapter1.blueprint.member.domain.dto.FindPasswordDTO; import com.chapter1.blueprint.member.domain.dto.PasswordDTO; import com.chapter1.blueprint.member.domain.dto.ProfileInfoDTO; -import com.chapter1.blueprint.member.dto.MemberDTO; +import com.chapter1.blueprint.member.domain.dto.MemberDTO; import com.chapter1.blueprint.member.service.MemberService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,7 +17,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; - import java.util.Map; @Slf4j @@ -27,6 +26,7 @@ public class MemberController { private final MemberService memberService; + private static final Logger logger = LoggerFactory.getLogger(MemberController.class); @PostMapping("/register") @@ -99,7 +99,6 @@ public ResponseEntity updatePassword(@RequestBody PasswordDTO p return ResponseEntity.ok(new SuccessResponse("비밀번호 변경 성공")); } - //@GetMapping(value = "/members/new") //public String createForm() { // return "members/createMemberForm"; diff --git a/src/main/java/com/chapter1/blueprint/member/controller/NotificationController.java b/src/main/java/com/chapter1/blueprint/member/controller/NotificationController.java new file mode 100644 index 0000000..af17b29 --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/member/controller/NotificationController.java @@ -0,0 +1,249 @@ +package com.chapter1.blueprint.member.controller; + +import com.chapter1.blueprint.exception.dto.SuccessResponse; + +import com.chapter1.blueprint.member.domain.PolicyAlarm; +import com.chapter1.blueprint.member.repository.PolicyAlarmRepository; +import com.chapter1.blueprint.member.service.MemberService; +import com.chapter1.blueprint.member.service.NotificationService; +import com.chapter1.blueprint.policy.domain.PolicyDetailFilter; +import com.chapter1.blueprint.policy.domain.PolicyList; +import com.chapter1.blueprint.policy.repository.PolicyListRepository; +import com.chapter1.blueprint.exception.codes.ErrorCodeException; +import com.chapter1.blueprint.exception.codes.ErrorCode; + +import com.chapter1.blueprint.policy.service.PolicyDetailService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/member/notification") +public class NotificationController { + + private final NotificationService notificationService; + private final MemberService memberService; + private final PolicyListRepository policyListRepository; + + private static final Logger logger = LoggerFactory.getLogger(NotificationController.class); + + @GetMapping("/status") + public ResponseEntity getNotificationStatus() { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Fetching notification status for UID: {}", uid); + + boolean notificationStatus = notificationService.getNotificationStatus(uid); + + logger.info("Notification status fetched successfully for UID: {} with status: {}", uid, notificationStatus); + + return ResponseEntity.ok(new SuccessResponse(Map.of("notificationEnabled", notificationStatus))); + } + + @PutMapping("/status") + public ResponseEntity updateNotificationStatus(@RequestBody Map request) { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Retrieved UID: {}", uid); + logger.info("Request body: {}", request); + + boolean notificationEnabled = (Boolean) request.get("notificationEnabled"); + + logger.info("Calling notificationService.updateNotificationStatus with uid: {} and enabled: {}", uid, notificationEnabled); + + notificationService.updateNotificationStatus(uid, notificationEnabled); + + boolean updatedNotificationStatus = notificationService.getNotificationStatus(uid); + + logger.info("Notification status updated successfully for UID: {} with new status: {}", uid, updatedNotificationStatus); + + return ResponseEntity.ok(new SuccessResponse(Map.of("notificationEnabled", updatedNotificationStatus))); + } + + + @PutMapping("/{policyIdx}") + public ResponseEntity updateNotificationSettings( + + @PathVariable Long policyIdx, + @RequestBody Map request) { + Long uid = memberService.getAuthenticatedUid(); + boolean notificationEnabled = (Boolean) request.get("notificationEnabled"); + + logger.info("Calling notificationService.saveOrUpdateNotification with uid: {}, policyIdx: {}, enabled: {}", + uid, policyIdx, notificationEnabled); + + notificationService.saveOrUpdateNotification(uid, policyIdx, notificationEnabled); + + return ResponseEntity.ok(new SuccessResponse("Notification settings updated successfully.")); + } + + @DeleteMapping("/{policyIdx}") + public ResponseEntity deleteNotificationSettings(@PathVariable Long policyIdx) { + Long uid = memberService.getAuthenticatedUid(); + notificationService.deleteNotification(uid, policyIdx); + return ResponseEntity.ok("Notification settings deleted successfully."); + } + + @GetMapping("/list/member") + public ResponseEntity getMemberDefinedNotifications() { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Fetching member-defined notifications for UID: {}", uid); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + List> memberNotifications = notificationService.getMemberNotifications(uid).stream() + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()).orElse(null); + if (policy != null) { + Map map = new HashMap<>(); + map.put("policyName", policy.getName()); + map.put("applyEndDate", alarm.getApplyEndDate() != null + ? alarm.getApplyEndDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) + : "상시"); + + map.put("policyIdx", alarm.getPolicyIdx()); + map.put("notification_enabled", alarm.getNotificationEnabled()); + + return map; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return ResponseEntity.ok(new SuccessResponse(memberNotifications)); + } + + @GetMapping("/list/recommended") + public ResponseEntity getRecommendedNotifications() { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Fetching recommended notifications for UID: {}", uid); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + List> recommendedNotifications = notificationService.getRecommendedNotifications(uid).stream() + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()).orElse(null); + if (policy != null) { + Map map = new HashMap<>(); + map.put("policyName", policy.getName()); + map.put("applyEndDate", alarm.getApplyEndDate() != null + ? alarm.getApplyEndDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) + : "상시"); + + map.put("policyIdx", alarm.getPolicyIdx()); + map.put("notification_enabled", alarm.getNotificationEnabled()); + + return map; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return ResponseEntity.ok(new SuccessResponse(recommendedNotifications)); + } + + @GetMapping("/dashboard") + public ResponseEntity getNotificationDashboard() { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Fetching dashboard data for UID: {}", uid); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // 사용자 설정 알림 + List memberNotifications = notificationService.getMemberNotifications(uid); + List> formattedMemberNotifications = memberNotifications.stream() + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()).orElse(null); + if (policy != null) { + Map map = new HashMap<>(); + map.put("policyName", policy.getName()); + map.put("applyEndDate", alarm.getApplyEndDate() != null + ? alarm.getApplyEndDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) + : "상시"); + + map.put("policyIdx", alarm.getPolicyIdx()); + map.put("notification_enabled", alarm.getNotificationEnabled()); + + return map; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 추천된 정책 알림 + List recommendedNotifications = notificationService.getRecommendedNotifications(uid); + List> formattedRecommendedNotifications = recommendedNotifications.stream() + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()).orElse(null); + if (policy != null) { + Map map = new HashMap<>(); + map.put("policyName", policy.getName()); + map.put("applyEndDate", alarm.getApplyEndDate() != null + ? alarm.getApplyEndDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) + : "상시"); + + map.put("policyIdx", alarm.getPolicyIdx()); + map.put("notification_enabled", alarm.getNotificationEnabled()); + + return map; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Map dashboard = new HashMap<>(); + dashboard.put("memberNotifications", formattedMemberNotifications); + dashboard.put("recommendedNotifications", formattedRecommendedNotifications); + + return ResponseEntity.ok(new SuccessResponse(dashboard)); + } + + @PutMapping("/read/{policyIdx}") + public ResponseEntity markNotificationAsRead(@PathVariable Long policyIdx) { + Long uid = memberService.getAuthenticatedUid(); + logger.info("Marking notification as read for UID: {}, PolicyIdx: {}", uid, policyIdx); + + notificationService.markNotificationAsRead(uid, policyIdx); + + return ResponseEntity.ok(new SuccessResponse("Notification marked as read.")); + } + + @GetMapping("/push") + public ResponseEntity getPushNotifications() { + + Long uid = memberService.getAuthenticatedUid(); + logger.info("Fetching push notifications for UID: {}", uid); + + var pushMessages = notificationService.getPushNotifications(uid); + return ResponseEntity.ok(new SuccessResponse(pushMessages)); + } +} diff --git a/src/main/java/com/chapter1/blueprint/member/controller/PolicyDeadlineScheduler.java b/src/main/java/com/chapter1/blueprint/member/controller/PolicyDeadlineScheduler.java new file mode 100644 index 0000000..26c156c --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/member/controller/PolicyDeadlineScheduler.java @@ -0,0 +1,30 @@ +package com.chapter1.blueprint.member.controller; + +import com.chapter1.blueprint.member.service.NotificationService; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PolicyDeadlineScheduler { + + private final NotificationService notificationService; + + private static final Logger logger = LoggerFactory.getLogger(PolicyDeadlineScheduler.class); + + @Scheduled(cron = "0 0 9 * * ?") + public void checkPolicyDeadline() { + logger.info("Starting policy deadline check..."); + try { + notificationService.processAllPolicyAlarms(); + logger.info("Policy deadline check completed successfully."); + } catch (Exception e) { + logger.error("Error during policy deadline check", e); + } + } +} + diff --git a/src/main/java/com/chapter1/blueprint/member/domain/Member.java b/src/main/java/com/chapter1/blueprint/member/domain/Member.java index 898a61d..21e039c 100644 --- a/src/main/java/com/chapter1/blueprint/member/domain/Member.java +++ b/src/main/java/com/chapter1/blueprint/member/domain/Member.java @@ -94,4 +94,7 @@ public class Member { @Column(name = "expiration", nullable = true) private Timestamp expiration; + + @Column(name = "notification_status") + private Boolean notificationStatus = false; } diff --git a/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarm.java b/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarm.java index 7d0f16e..65a6a0b 100644 --- a/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarm.java +++ b/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarm.java @@ -1,17 +1,18 @@ package com.chapter1.blueprint.member.domain; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.util.Date; @Entity +@Builder @Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Table(name = "policy_alarm", catalog = "member") public class PolicyAlarm { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "idx") private Long idx; @@ -26,4 +27,16 @@ public class PolicyAlarm { @Column(name = "send_date") private Date sendDate; + + @Column(name = "policy_idx") + private Long policyIdx; + + @Column(name = "notification_enabled") + private Boolean notificationEnabled = false; + + @Column(name = "apply_end_date") + private Date applyEndDate; + + @Column(name = "is_read") + private Boolean isRead = false; } diff --git a/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarmType.java b/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarmType.java new file mode 100644 index 0000000..0da1a41 --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/member/domain/PolicyAlarmType.java @@ -0,0 +1,16 @@ +package com.chapter1.blueprint.member.domain; + +public enum PolicyAlarmType { + RECOMMENDED("RECOMMENDED"), + MEMBER_DEFINED("MEMBER_DEFINED"); + + private final String type; + + PolicyAlarmType(String type) { + this.type = type; + } + + public String getType() { + return type; + } +} diff --git a/src/main/java/com/chapter1/blueprint/member/dto/EmailDTO.java b/src/main/java/com/chapter1/blueprint/member/domain/dto/EmailDTO.java similarity index 67% rename from src/main/java/com/chapter1/blueprint/member/dto/EmailDTO.java rename to src/main/java/com/chapter1/blueprint/member/domain/dto/EmailDTO.java index e2d566b..420d8c0 100644 --- a/src/main/java/com/chapter1/blueprint/member/dto/EmailDTO.java +++ b/src/main/java/com/chapter1/blueprint/member/domain/dto/EmailDTO.java @@ -1,4 +1,4 @@ -package com.chapter1.blueprint.member.dto; +package com.chapter1.blueprint.member.domain.dto; import lombok.Data; diff --git a/src/main/java/com/chapter1/blueprint/member/dto/MemberDTO.java b/src/main/java/com/chapter1/blueprint/member/domain/dto/MemberDTO.java similarity index 84% rename from src/main/java/com/chapter1/blueprint/member/dto/MemberDTO.java rename to src/main/java/com/chapter1/blueprint/member/domain/dto/MemberDTO.java index 1822e35..e9fd26e 100644 --- a/src/main/java/com/chapter1/blueprint/member/dto/MemberDTO.java +++ b/src/main/java/com/chapter1/blueprint/member/domain/dto/MemberDTO.java @@ -1,6 +1,5 @@ -package com.chapter1.blueprint.member.dto; +package com.chapter1.blueprint.member.domain.dto; -import com.chapter1.blueprint.member.domain.Member; import lombok.*; @Data diff --git a/src/main/java/com/chapter1/blueprint/member/repository/MemberRepository.java b/src/main/java/com/chapter1/blueprint/member/repository/MemberRepository.java index 288ff09..337620f 100644 --- a/src/main/java/com/chapter1/blueprint/member/repository/MemberRepository.java +++ b/src/main/java/com/chapter1/blueprint/member/repository/MemberRepository.java @@ -21,5 +21,4 @@ public interface MemberRepository extends JpaRepository { @Query("SELECT m.password FROM Member m WHERE m.memberId = :memberId") String findPasswordByMemberId(@Param("memberId") String memberId); - } diff --git a/src/main/java/com/chapter1/blueprint/member/repository/PolicyAlarmRepository.java b/src/main/java/com/chapter1/blueprint/member/repository/PolicyAlarmRepository.java index 1461352..35f3fa4 100644 --- a/src/main/java/com/chapter1/blueprint/member/repository/PolicyAlarmRepository.java +++ b/src/main/java/com/chapter1/blueprint/member/repository/PolicyAlarmRepository.java @@ -3,8 +3,34 @@ import com.chapter1.blueprint.member.domain.FinanceRecommend; import com.chapter1.blueprint.member.domain.PolicyAlarm; 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 org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Date; +import java.util.List; @Repository public interface PolicyAlarmRepository extends JpaRepository { + + PolicyAlarm findByUidAndPolicyIdx(Long uid, Long policyIdx); + + List findByNotificationEnabled(Boolean notificationEnabled); + + List findByUid(Long uid); + + List findByUidAndAlarmType(Long uid, String alarmType); + + List findAllByNotificationEnabled(Boolean notificationEnabled); + + @Query("SELECT p FROM PolicyAlarm p WHERE p.notificationEnabled = true AND p.applyEndDate <= :applyEndDate") + List findEnabledNotificationsBeforeDeadline(@Param("applyEndDate") Date applyEndDate); + + @Modifying + @Transactional + @Query("DELETE FROM PolicyAlarm pa WHERE pa.policyIdx = :policyIdx") + void deleteByPolicyIdx(@Param("policyIdx") Long policyIdx); } + diff --git a/src/main/java/com/chapter1/blueprint/member/service/EmailService.java b/src/main/java/com/chapter1/blueprint/member/service/EmailService.java index 08c5854..228be20 100644 --- a/src/main/java/com/chapter1/blueprint/member/service/EmailService.java +++ b/src/main/java/com/chapter1/blueprint/member/service/EmailService.java @@ -2,11 +2,16 @@ import com.chapter1.blueprint.exception.codes.ErrorCode; import com.chapter1.blueprint.exception.codes.ErrorCodeException; +import com.chapter1.blueprint.member.controller.MemberController; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.mail.MailException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; + +import java.sql.Date; import java.util.concurrent.ConcurrentHashMap; import java.time.LocalDateTime; @@ -17,6 +22,7 @@ public class EmailService { private final JavaMailSender mailSender; + private static final Logger logger = LoggerFactory.getLogger(EmailService.class); private final Map verificationCodes = new ConcurrentHashMap<>(); private static final long CODE_EXPIRATION_MINUTES = 5; @@ -106,4 +112,32 @@ public void sendTemporaryPassword(String email, String temporaryPassword) { ); mailSender.send(message); } + + public void sendNotificationEmail(String to, String policyName, Date endDate, Long idx) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("[BluePrint] Policy Deadline Reminder"); + + String policyDetailLink = "http://localhost:5173/policy/detail/" + idx; // 정책 상세 페이지 링크 + + message.setText( + "안녕하세요,\n\n" + + "BluePrint 서비스를 이용해주셔서 감사합니다!\n\n" + + "알려드릴 사항: 정책 '" + policyName + "'의 신청 마감일이 3일 남았습니다.\n\n" + + "종료일: " + endDate + "\n\n" + + "자세한 정책 내용은 아래 링크를 통해 확인해 주세요:\n" + + policyDetailLink + "\n\n" + + "마감일을 놓치지 않도록 미리 준비해 주세요.\n\n" + + "감사합니다.\n" + + "BluePrint 팀 드림" + ); + + try { + mailSender.send(message); + logger.info("Email sent to: {}", to); + } catch (Exception e) { + logger.error("Failed to send email to: {}", to, e); + } + } + } diff --git a/src/main/java/com/chapter1/blueprint/member/service/MemberService.java b/src/main/java/com/chapter1/blueprint/member/service/MemberService.java index 065c638..5da4ba2 100644 --- a/src/main/java/com/chapter1/blueprint/member/service/MemberService.java +++ b/src/main/java/com/chapter1/blueprint/member/service/MemberService.java @@ -1,26 +1,30 @@ package com.chapter1.blueprint.member.service; + import com.chapter1.blueprint.exception.codes.ErrorCode; import com.chapter1.blueprint.exception.codes.ErrorCodeException; import com.chapter1.blueprint.member.domain.Member; import com.chapter1.blueprint.member.domain.dto.InputProfileDTO; import com.chapter1.blueprint.member.domain.dto.PasswordDTO; import com.chapter1.blueprint.member.domain.dto.ProfileInfoDTO; -import com.chapter1.blueprint.member.dto.MemberDTO; +import com.chapter1.blueprint.member.domain.dto.MemberDTO; import com.chapter1.blueprint.member.repository.MemberRepository; + import com.chapter1.blueprint.security.util.JwtProcessor; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.sql.Timestamp; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Random; +import java.util.*; +import java.time.LocalDate; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -30,8 +34,34 @@ public class MemberService { private final PasswordEncoder passwordEncoder; private final JwtProcessor jwtProcessor; private final EmailService emailService; + private static final Logger logger = LoggerFactory.getLogger(MemberService.class); + public Long getAuthenticatedUid() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + log.error("No authentication found in SecurityContextHolder"); + throw new IllegalArgumentException("No authentication found"); + } + + if (!(authentication.getCredentials() instanceof String)) { + log.error("Authentication credentials are not a String. Credentials: {}", authentication.getCredentials()); + throw new IllegalArgumentException("Invalid authentication credentials"); + } + + String token = (String) authentication.getCredentials(); + + if (token.startsWith("Bearer ")) { + token = token.substring(7); + } + return jwtProcessor.getUid(token); + } catch (Exception e) { + log.error("Exception in getAuthenticatedUid: ", e); + throw e; + } + } public Map register(MemberDTO memberDTO) { Member member = new Member(); @@ -170,4 +200,19 @@ public void updatePassword(String memberId, PasswordDTO passwordDTO) { memberRepository.save(member); } + public Integer calculateAge(Integer birthYear) { + Integer currentYear = LocalDate.now().getYear(); + return currentYear - birthYear; + } + + public Member getMemberByUid(Long uid) { + return memberRepository.findById(uid) + .orElseThrow(() -> new IllegalArgumentException("Member not found with UID: " + uid)); + } + + public Long getUidByMemberId(String memberId) { + Member member = memberRepository.findByMemberId(memberId) + .orElseThrow(() -> new IllegalArgumentException("Member not found with memberId: " + memberId)); + return member.getUid(); + } } diff --git a/src/main/java/com/chapter1/blueprint/member/service/NotificationService.java b/src/main/java/com/chapter1/blueprint/member/service/NotificationService.java new file mode 100644 index 0000000..e673c9f --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/member/service/NotificationService.java @@ -0,0 +1,382 @@ +package com.chapter1.blueprint.member.service; + +import com.chapter1.blueprint.exception.codes.ErrorCode; +import com.chapter1.blueprint.exception.codes.ErrorCodeException; +import com.chapter1.blueprint.member.domain.Member; +import com.chapter1.blueprint.member.domain.PolicyAlarm; +import com.chapter1.blueprint.member.domain.PolicyAlarmType; +import com.chapter1.blueprint.member.repository.MemberRepository; +import com.chapter1.blueprint.member.repository.PolicyAlarmRepository; +import com.chapter1.blueprint.policy.domain.PolicyList; +import com.chapter1.blueprint.policy.repository.PolicyListRepository; +import com.chapter1.blueprint.policy.service.PolicyDetailService; +import jakarta.persistence.EntityNotFoundException; + +import jakarta.validation.constraints.Email; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final PolicyAlarmRepository policyAlarmRepository; + private final MemberRepository memberRepository; + private final PolicyListRepository policyListRepository; + private final PolicyDetailService policyDetailService; + private final EmailService emailService; + + private static final Logger logger = LoggerFactory.getLogger(NotificationService.class); + + // 알림 상태 조회 + @Transactional(readOnly = true) + public boolean getNotificationStatus(Long uid) { + logger.info("Fetching notification status for UID: {}", uid); + Member member = memberRepository.findById(uid) + .orElseThrow(() -> new ErrorCodeException(ErrorCode.MEMBER_NOT_FOUND)); + + logger.info("Notification status for UID {}: {}", uid, member.getNotificationStatus()); + return member.getNotificationStatus(); + } + + @Transactional + public void updateNotificationStatus(Long uid, boolean enabled) { + logger.info("Updating notification status for UID: {}, Enabled: {}", uid, enabled); + + try { + // Member 테이블의 알림 상태 업데이트 + Member member = memberRepository.findById(uid) + .orElseThrow(() -> new ErrorCodeException(ErrorCode.MEMBER_NOT_FOUND)); + member.setNotificationStatus(enabled); + memberRepository.save(member); + logger.info("Updated Member.notificationStatus for UID: {}", uid); + + if (enabled) { + // 알림 활성화: 모든 알림을 다시 활성화 + logger.info("Enabling all notifications for UID: {}", uid); + restoreAllNotifications(uid); + + List recommendedPolicies = policyDetailService.recommendPolicy(uid); + logger.info("Enabling notifications for {} recommended policies for UID: {}", recommendedPolicies.size(), uid); + + for (PolicyList policy : recommendedPolicies) { + PolicyAlarm alarm = policyAlarmRepository.findByUidAndPolicyIdx(uid, policy.getIdx()); + if (alarm == null) { + alarm = PolicyAlarm.builder() + .uid(uid) + .policyIdx(policy.getIdx()) + .notificationEnabled(true) + .alarmType(PolicyAlarmType.RECOMMENDED.getType()) + .applyEndDate(policy.getApplyEndDate()) + .isRead(false) + .build(); + policyAlarmRepository.save(alarm); + } else { + alarm.setNotificationEnabled(true); + alarm.setApplyEndDate(policy.getApplyEndDate()); + policyAlarmRepository.save(alarm); + } + } + } else { + logger.info("Disabling all notifications for UID: {}", uid); + disableAllNotifications(uid); + } + + logger.info("Notification status updated successfully for UID: {}", uid); + + } catch (ErrorCodeException e) { + logger.error("Error while updating notification status for UID: {}", uid, e); + throw e; + } catch (Exception e) { + logger.error("Unexpected error while updating notification status for UID: {}", uid, e); + throw new ErrorCodeException(ErrorCode.NOTIFICATION_UPDATE_FAILED); + } + } + + public void restoreAllNotifications(Long uid) { + List alarms = policyAlarmRepository.findByUid(uid); + if (alarms.isEmpty()) { + logger.info("No notifications found for UID: {}", uid); + return; + } + alarms.forEach(alarm -> alarm.setNotificationEnabled(true)); + policyAlarmRepository.saveAll(alarms); + logger.info("All notifications restored for UID: {}", uid); + } + + public void disableAllNotifications(Long uid) { + List alarms = policyAlarmRepository.findByUid(uid); + if (alarms.isEmpty()) { + logger.info("No notifications found for UID: {}", uid); + return; + } + alarms.forEach(alarm -> alarm.setNotificationEnabled(false)); + policyAlarmRepository.saveAll(alarms); + logger.info("All notifications disabled for UID: {}", uid); + } + + // 알림 저장 또는 업데이트 + @Transactional + public void saveOrUpdateNotification(Long uid, Long policyIdx, boolean notificationEnabled) { + try { + PolicyList policy = policyListRepository.findById(policyIdx) + .orElseThrow(() -> new ErrorCodeException(ErrorCode.POLICY_NOT_FOUND)); + + PolicyAlarm existingAlarm = policyAlarmRepository.findByUidAndPolicyIdx(uid, policyIdx); + + if (existingAlarm == null) { + logger.info("Creating new MEMBER_DEFINED PolicyAlarm for UID: {}, PolicyIdx: {}", uid, policyIdx); + PolicyAlarm newAlarm = PolicyAlarm.builder() + .uid(uid) + .policyIdx(policyIdx) + .notificationEnabled(notificationEnabled) + .alarmType(PolicyAlarmType.MEMBER_DEFINED.getType()) + .applyEndDate(policy.getApplyEndDate()) + .isRead(false) + .build(); + policyAlarmRepository.save(newAlarm); + } else { + logger.info("Updating MEMBER_DEFINED PolicyAlarm for UID: {}, PolicyIdx: {}", uid, policyIdx); + existingAlarm.setNotificationEnabled(notificationEnabled); + existingAlarm.setAlarmType(PolicyAlarmType.MEMBER_DEFINED.getType()); + existingAlarm.setApplyEndDate(policy.getApplyEndDate()); + + if (!notificationEnabled) { + existingAlarm.setIsRead(false); + } + + policyAlarmRepository.save(existingAlarm); + } + + } catch (ErrorCodeException e) { + logger.error("Error while saving or updating MEMBER_DEFINED notification for UID: {}, PolicyIdx: {}", uid, policyIdx, e); + throw e; + } catch (Exception e) { + logger.error("Unexpected error while saving or updating MEMBER_DEFINED notification for UID: {}, PolicyIdx: {}", uid, policyIdx, e); + throw new ErrorCodeException(ErrorCode.NOTIFICATION_UPDATE_FAILED); + } + } + + // 알림 삭제 + @Transactional + public void deleteNotification(Long uid, Long policyIdx) { + try { + PolicyAlarm alarm = policyAlarmRepository.findByUidAndPolicyIdx(uid, policyIdx); + if (alarm == null) { + throw new ErrorCodeException(ErrorCode.POLICY_ALARM_NOT_FOUND); + } + + policyAlarmRepository.delete(alarm); + logger.info("Notification deleted for UID: {}, PolicyIdx: {}", uid, policyIdx); + } catch (ErrorCodeException e) { + logger.error("Error during deletion of notification for UID: {}, PolicyIdx: {}", uid, policyIdx, e); + throw e; + } catch (Exception e) { + logger.error("Unexpected error during notification deletion for UID: {}, PolicyIdx: {}", uid, policyIdx, e); + throw new ErrorCodeException(ErrorCode.NOTIFICATION_DELETION_FAILED); + } + } + + // 사용자 정의 알림 가져오기 + public List getMemberNotifications(Long uid) { + return policyAlarmRepository.findByUidAndAlarmType(uid, "MEMBER_DEFINED") + .stream() + .filter(alarm -> Boolean.TRUE.equals(alarm.getNotificationEnabled())) + .collect(Collectors.toList()); + } + + // 추천 알림 가져오기 + public List getRecommendedNotifications(Long uid) { + return policyAlarmRepository.findByUidAndAlarmType(uid, "RECOMMENDED") + .stream() + .filter(alarm -> alarm.getApplyEndDate() != null) + .collect(Collectors.toList()); + } + + // 알림 읽음 상태 업데이트 + @Transactional + public void markNotificationAsRead(Long uid, Long policyIdx) { + PolicyAlarm alarm = policyAlarmRepository.findByUidAndPolicyIdx(uid, policyIdx); + if (alarm == null) { + logger.error("Notification not found for UID: {}, PolicyIdx: {}", uid, policyIdx); + throw new ErrorCodeException(ErrorCode.NOTIFICATION_NOT_FOUND); + } + logger.info("Fetched PolicyAlarm: {}", alarm); + + alarm.setIsRead(true); + policyAlarmRepository.save(alarm); + } + + public List> formatNotifications(List alarms, DateTimeFormatter formatter) { + return alarms.stream() + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()).orElse(null); + if (policy != null) { + Map map = new HashMap<>(); + map.put("policyName", policy.getName()); + map.put("applyEndDate", alarm.getApplyEndDate() != null + ? alarm.getApplyEndDate().toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(formatter) + : "상시"); + map.put("isRead", alarm.getIsRead()); + map.put("policyIdx", alarm.getPolicyIdx()); + return map; + } + return null; + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + // Push 알림 조회 + @Transactional(readOnly = true) + public List> getPushNotifications(Long uid) { + log.info("Fetching push notifications for UID: {}", uid); + + List alarms = policyAlarmRepository.findByUid(uid); + if (alarms.isEmpty()) { + log.info("No notifications found for UID: {}", uid); + return Collections.emptyList(); + } + + log.info("Total alarms fetched: {}", alarms.size()); + + return alarms.stream() + .filter(alarm -> { + Date applyEndDate = alarm.getApplyEndDate(); + if (applyEndDate == null) { + log.info("Alarm ID: {}, ApplyEndDate is null, skipping...", alarm.getPolicyIdx()); + return false; + } + + boolean isEmailNotification = isThreeDaysBefore(applyEndDate); + boolean isDayBeforeNotification = isOneDayBefore(applyEndDate); + + log.info("Alarm ID: {}, ApplyEndDate: {}, AlarmType: {}, IsEmail: {}, IsDayBefore: {}", + alarm.getPolicyIdx(), applyEndDate, alarm.getAlarmType(), isEmailNotification, isDayBeforeNotification); + + return isEmailNotification || isDayBeforeNotification; + }) + .map(alarm -> { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()) + .orElseThrow(() -> { + log.error("Policy not found for PolicyIdx: {}", alarm.getPolicyIdx()); + throw new ErrorCodeException(ErrorCode.POLICY_NOT_FOUND); + }); + + Map message = new HashMap<>(); + message.put("policyIdx", alarm.getPolicyIdx()); + message.put("policyName", policy.getName()); + message.put("applyEndDate", formatDate(policy.getApplyEndDate())); + message.put("pushDate", formatDate(new Date())); + + if (isOneDayBefore(alarm.getApplyEndDate())) { + message.put("message", "마감 하루 전"); + } else if (isThreeDaysBefore(alarm.getApplyEndDate())) { + message.put("message", "이메일 발송"); + } + + return message; + }) + .toList(); + } + + // 알림 처리 + public void processAllPolicyAlarms() { + List alarms = policyAlarmRepository.findByNotificationEnabled(true); + for (PolicyAlarm alarm : alarms) { + processPolicyAlarm(alarm); + } + } + + private void processPolicyAlarm(PolicyAlarm alarm) { + try { + PolicyList policy = policyListRepository.findById(alarm.getPolicyIdx()) + .orElseThrow(() -> new ErrorCodeException(ErrorCode.POLICY_NOT_FOUND)); + + if (isThreeDaysBefore(policy.getApplyEndDate()) && alarm.getSendDate() == null) { + sendEmailNotification(policy, alarm); + } + + if (isOneDayBefore(policy.getApplyEndDate())) { + createPushNotification(alarm, "마감 하루 전"); + } + } catch (Exception e) { + logger.error("Error processing PolicyAlarm for UID: {}, PolicyIdx: {}", alarm.getUid(), alarm.getPolicyIdx(), e); + } + } + + // 이메일 발송 + private void sendEmailNotification(PolicyList policy, PolicyAlarm alarm) { + Member member = memberRepository.findById(alarm.getUid()) + .orElseThrow(() -> new ErrorCodeException(ErrorCode.MEMBER_NOT_FOUND)); + + java.sql.Timestamp applyEndDateTimestamp = (Timestamp) policy.getApplyEndDate(); + java.sql.Date applyEndDate = null; + + if (applyEndDateTimestamp != null) { + applyEndDate = new java.sql.Date(applyEndDateTimestamp.getTime()); + } + + emailService.sendNotificationEmail( + member.getEmail(), + policy.getName(), + applyEndDate, + policy.getIdx() + ); + + alarm.setSendDate(new Date()); + policyAlarmRepository.save(alarm); + + createPushNotification(alarm, "이메일 발송"); + } + + // Push 알림 생성 + private void createPushNotification(PolicyAlarm alarm, String label) { + logger.info("Push notification created: Label: {}, Policy Name: {}, Push Date: {}", + label, + alarm.getPolicyIdx(), + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + } + + private String formatDate(Date date) { + return date != null + ? date.toInstant().atZone(ZoneId.systemDefault()) + .toLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + : "상시"; + } + + private boolean isThreeDaysBefore(Date date) { + if (date == null) { + return false; + } + long daysDiff = ChronoUnit.DAYS.between(LocalDate.now(), date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + return daysDiff == 3; + } + + private boolean isOneDayBefore(Date date) { + if (date == null) { + return false; + } + long daysDiff = ChronoUnit.DAYS.between(LocalDate.now(), date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + return daysDiff == 1; + } + +} diff --git a/src/main/java/com/chapter1/blueprint/policy/controller/PolicyController.java b/src/main/java/com/chapter1/blueprint/policy/controller/PolicyController.java index 360692d..fab94d8 100644 --- a/src/main/java/com/chapter1/blueprint/policy/controller/PolicyController.java +++ b/src/main/java/com/chapter1/blueprint/policy/controller/PolicyController.java @@ -1,6 +1,7 @@ package com.chapter1.blueprint.policy.controller; import com.chapter1.blueprint.exception.dto.SuccessResponse; +import com.chapter1.blueprint.member.service.MemberService; import com.chapter1.blueprint.policy.domain.PolicyList; import com.chapter1.blueprint.policy.domain.dto.FilterDTO; import com.chapter1.blueprint.policy.domain.dto.PolicyDetailDTO; @@ -10,6 +11,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -22,6 +26,7 @@ public class PolicyController { private final PolicyService policyService; private final PolicyDetailService policyDetailService; + private final MemberService memberService; @GetMapping(value = "/update/TK") public ResponseEntity updatePolicyTK() { @@ -29,6 +34,12 @@ public ResponseEntity updatePolicyTK() { return ResponseEntity.ok(new SuccessResponse(result)); } + @GetMapping(value = "/update/company") + public ResponseEntity updatePolicyCompany() { + String result = policyService.updatePolicyCompany(); + return ResponseEntity.ok(new SuccessResponse(result)); + } + @GetMapping("/list") public ResponseEntity getPolicyList() { List policyList = policyDetailService.getPolicyList(); @@ -46,4 +57,24 @@ public ResponseEntity getPolicyListByFiltering(@RequestBody Fil List policyListByFiltering = policyDetailService.getPolicyListByFiltering(filterDTO); return ResponseEntity.ok(new SuccessResponse(policyListByFiltering)); } + + @GetMapping("/deadline") + public ResponseEntity checkPolicyDeadline() { + List policies = policyService.findPoliciesWithApproachingDeadline(); + return ResponseEntity.ok(new SuccessResponse(policies)); + } + + @PostMapping("/manual-check-deadline") + public ResponseEntity manualCheckPolicyDeadline() { + checkPolicyDeadline(); + return ResponseEntity.ok("Policy deadline check triggered manually."); + } + + @GetMapping("/recommendation") + public ResponseEntity recommendPolicy() { + Long uid = memberService.getAuthenticatedUid(); + + List recommendedPolicy = policyDetailService.recommendPolicy(uid); + return ResponseEntity.ok(new SuccessResponse(recommendedPolicy)); + } } diff --git a/src/main/java/com/chapter1/blueprint/policy/domain/PolicyDetailFilter.java b/src/main/java/com/chapter1/blueprint/policy/domain/PolicyDetailFilter.java new file mode 100644 index 0000000..1f99a7d --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/policy/domain/PolicyDetailFilter.java @@ -0,0 +1,41 @@ +package com.chapter1.blueprint.policy.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.sql.Date; + +@Entity +@Getter @Setter +@Table(name = "policy_detail_filter", catalog = "policy") +public class PolicyDetailFilter { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "idx") + private Long idx; + + @Column(name = "target") + private String target; + + @Column(name = "condition") + private String condition; + + @Column(name = "content") + private String content; + + @Column(name = "min_age") + private Integer minAge; + + @Column(name = "max_Age") + private Integer maxAge; + + @Column(name = "region") + private String region; + + @Column(name = "job") + private String job; + + @Column(name = "apply_end_date") + private Date applyEndDate; +} diff --git a/src/main/java/com/chapter1/blueprint/policy/domain/dto/FilterDTO.java b/src/main/java/com/chapter1/blueprint/policy/domain/dto/FilterDTO.java index 18e98d5..f4d0528 100644 --- a/src/main/java/com/chapter1/blueprint/policy/domain/dto/FilterDTO.java +++ b/src/main/java/com/chapter1/blueprint/policy/domain/dto/FilterDTO.java @@ -9,4 +9,7 @@ public class FilterDTO { private String city; private String district; private String type; + private Integer age; + private String job; + private String name; } diff --git a/src/main/java/com/chapter1/blueprint/policy/domain/dto/PolicyDetailDTO.java b/src/main/java/com/chapter1/blueprint/policy/domain/dto/PolicyDetailDTO.java index 8b772c2..7b36512 100644 --- a/src/main/java/com/chapter1/blueprint/policy/domain/dto/PolicyDetailDTO.java +++ b/src/main/java/com/chapter1/blueprint/policy/domain/dto/PolicyDetailDTO.java @@ -1,5 +1,6 @@ package com.chapter1.blueprint.policy.domain.dto; +import com.chapter1.blueprint.policy.domain.PolicyDetail; import lombok.Getter; import lombok.Setter; @@ -15,4 +16,25 @@ public class PolicyDetailDTO { private String document; private String url; private String way; + private Integer minAge; + private Integer maxAge; + private String job; + + public PolicyDetailDTO(Long idx, String subject, String condition, String content, String scale, + String enquiry, String document, String url, String way, + Integer minAge, Integer maxAge, String job) { + this.idx = idx; + this.subject = subject; + this.condition = condition; + this.content = content; + this.scale = scale; + this.enquiry = enquiry; + this.document = document; + this.url = url; + this.way = way; + this.minAge = minAge; + this.maxAge = maxAge; + this.job = job; + } + } diff --git a/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailFilterRepository.java b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailFilterRepository.java new file mode 100644 index 0000000..b0e1817 --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailFilterRepository.java @@ -0,0 +1,17 @@ +package com.chapter1.blueprint.policy.repository; + +import com.chapter1.blueprint.policy.domain.PolicyDetailFilter; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PolicyDetailFilterRepository extends JpaRepository { + @Query("SELECT p FROM PolicyDetailFilter p WHERE p.region = :region AND (p.minAge IS NULL OR :age >= p.minAge) AND (p.maxAge IS NULL OR :age <= p.maxAge) AND (p.job IS NULL OR p.job = :job)") + List findRecommendedPoliciesByUid(@Param("region") String region, + @Param("age") Integer age, + @Param("job") String job); +} diff --git a/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailRepository.java b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailRepository.java index 5af7db9..aec8779 100644 --- a/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailRepository.java +++ b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyDetailRepository.java @@ -1,10 +1,20 @@ package com.chapter1.blueprint.policy.repository; import com.chapter1.blueprint.policy.domain.PolicyDetail; +import com.chapter1.blueprint.policy.domain.dto.PolicyDetailDTO; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface PolicyDetailRepository extends JpaRepository { + @Query("SELECT new com.chapter1.blueprint.policy.domain.dto.PolicyDetailDTO(" + + "d.idx, d.subject, d.condition, d.content, d.scale, d.enquiry, d.document, d.url, d.way, " + + "f.minAge, f.maxAge, f.job) " + + "FROM PolicyDetail d JOIN PolicyDetailFilter f ON d.idx = f.idx " + + "WHERE d.idx = :idx") + PolicyDetailDTO findPolicyDetailByIdx(@Param("idx") Long idx); + } diff --git a/src/main/java/com/chapter1/blueprint/policy/repository/PolicyListRepository.java b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyListRepository.java index ab221d6..3995cbf 100644 --- a/src/main/java/com/chapter1/blueprint/policy/repository/PolicyListRepository.java +++ b/src/main/java/com/chapter1/blueprint/policy/repository/PolicyListRepository.java @@ -1,5 +1,6 @@ package com.chapter1.blueprint.policy.repository; +import com.chapter1.blueprint.policy.domain.dto.PolicyListDTO; import org.springframework.data.jpa.repository.JpaRepository; import com.chapter1.blueprint.policy.domain.PolicyList; import org.springframework.data.jpa.repository.Query; @@ -12,9 +13,35 @@ public interface PolicyListRepository extends JpaRepository { @Query("SELECT p FROM PolicyList p " + - "WHERE (:district IS NULL OR p.district = :district) " + + "JOIN PolicyDetailFilter f ON p.idx = f.idx " + + "WHERE (:city IS NULL OR p.city = :city) " + + "AND (:district IS NULL OR p.district = :district) " + "AND (:type IS NULL OR p.type = :type) " + - "AND (:city IS NULL OR p.city = :city)") - List findByDistrictAndType(@Param("city") String city, @Param("district") String district, @Param("type") String type); + "AND (:age IS NULL OR (f.minAge <= :age AND f.maxAge >= :age)) " + + "AND (:job IS NULL OR f.job = :job)" + + "AND (:name IS NULL OR p.name LIKE %:name%)" + + "ORDER BY p.applyEndDate DESC") + List findByCityDistrictTypeAgeJob( + @Param("city") String city, + @Param("district") String district, + @Param("type") String type, + @Param("age") Integer age, + @Param("job") String job, + @Param("name") String name); + @Query("SELECT p FROM PolicyList p WHERE DATEDIFF(p.applyEndDate, CURRENT_DATE) = 3") + List findPoliciesWithApproachingDeadline(); + + @Query("SELECT p FROM PolicyList p " + + "JOIN PolicyDetailFilter f ON p.idx = f.idx " + + "WHERE (:city IS NULL OR p.city = :city) " + + "AND (:district IS NULL OR p.district = :district OR p.district LIKE CONCAT('%', :district, '%')) " + + "AND (:age IS NULL OR ((f.minAge <= :age AND f.maxAge >= :age) OR (f.minAge = 0 AND f.maxAge = 0))) " + + "AND (:job IS NULL OR f.job = :job OR f.job = '전체') " + + "AND (p.applyEndDate >= CURRENT_DATE)") + List findByCityDistrictAgeJob( + @Param("city") String city, + @Param("district") String district, + @Param("age") Integer age, + @Param("job") String job); } diff --git a/src/main/java/com/chapter1/blueprint/policy/service/PolicyDetailService.java b/src/main/java/com/chapter1/blueprint/policy/service/PolicyDetailService.java index 9d8779f..cbfbe02 100644 --- a/src/main/java/com/chapter1/blueprint/policy/service/PolicyDetailService.java +++ b/src/main/java/com/chapter1/blueprint/policy/service/PolicyDetailService.java @@ -1,5 +1,8 @@ package com.chapter1.blueprint.policy.service; +import com.chapter1.blueprint.member.domain.Member; +import com.chapter1.blueprint.member.repository.MemberRepository; +import com.chapter1.blueprint.member.service.MemberService; import com.chapter1.blueprint.policy.domain.PolicyDetail; import com.chapter1.blueprint.policy.domain.PolicyList; import com.chapter1.blueprint.policy.domain.dto.FilterDTO; @@ -21,6 +24,8 @@ public class PolicyDetailService { private final PolicyDetailRepository policyDetailRepository; private final PolicyListRepository policyListRepository; + private final MemberRepository memberRepository; + private final MemberService memberService; public List getPolicyList() { List policyList = policyListRepository.findAll(); @@ -43,24 +48,18 @@ public List getPolicyList() { } public PolicyDetailDTO getPolicyDetail(Long idx) { - PolicyDetail policyDetail = policyDetailRepository.findById(idx) - .orElseThrow(() -> new IllegalArgumentException("해당 idx의 PolicyDetail을 찾을 수 없습니다.")); - - PolicyDetailDTO policyDetailDTO = new PolicyDetailDTO(); - policyDetailDTO.setIdx(policyDetail.getIdx()); - policyDetailDTO.setSubject(policyDetail.getSubject()); - policyDetailDTO.setCondition(policyDetail.getCondition()); - policyDetailDTO.setContent(policyDetail.getContent()); - policyDetailDTO.setScale(policyDetail.getScale()); - policyDetailDTO.setEnquiry(policyDetail.getEnquiry()); - policyDetailDTO.setDocument(policyDetail.getDocument()); - policyDetailDTO.setUrl(policyDetail.getUrl()); - policyDetailDTO.setWay(policyDetail.getWay()); - - return policyDetailDTO; + return policyDetailRepository.findPolicyDetailByIdx(idx); } public List getPolicyListByFiltering(FilterDTO filterDTO) { - return policyListRepository.findByDistrictAndType(filterDTO.getCity(), filterDTO.getDistrict(), filterDTO.getType()); + return policyListRepository.findByCityDistrictTypeAgeJob(filterDTO.getCity(), filterDTO.getDistrict(), filterDTO.getType(), filterDTO.getAge(), filterDTO.getJob(), filterDTO.getName()); + } + + public List recommendPolicy(Long uid) { + Member member = memberRepository.findById(uid) + .orElseThrow(() -> new RuntimeException("Member not found with uid (recommendPolicy): " + uid)); + + int age = memberService.calculateAge(member.getBirthYear()); + return policyListRepository.findByCityDistrictAgeJob(member.getRegion(), member.getDistrict(), age, member.getOccupation()); } } diff --git a/src/main/java/com/chapter1/blueprint/policy/service/PolicyRecommendationService.java b/src/main/java/com/chapter1/blueprint/policy/service/PolicyRecommendationService.java new file mode 100644 index 0000000..e7fbb48 --- /dev/null +++ b/src/main/java/com/chapter1/blueprint/policy/service/PolicyRecommendationService.java @@ -0,0 +1,28 @@ +package com.chapter1.blueprint.policy.service; + +import com.chapter1.blueprint.member.domain.Member; +import com.chapter1.blueprint.member.repository.MemberRepository; +import com.chapter1.blueprint.member.service.MemberService; +import com.chapter1.blueprint.policy.domain.PolicyDetailFilter; +import com.chapter1.blueprint.policy.repository.PolicyDetailFilterRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PolicyRecommendationService { + + private final PolicyDetailFilterRepository policyDetailFilterRepository; + private final MemberService memberService; + + public List getRecommendedPolicies(Long uid) { + Member member = memberService.getMemberByUid(uid); + + Integer age = memberService.calculateAge(member.getBirthYear()); + + return policyDetailFilterRepository.findRecommendedPoliciesByUid(member.getRegion(), age, member.getOccupation()); + } +} diff --git a/src/main/java/com/chapter1/blueprint/policy/service/PolicyService.java b/src/main/java/com/chapter1/blueprint/policy/service/PolicyService.java index b663418..bc08ead 100644 --- a/src/main/java/com/chapter1/blueprint/policy/service/PolicyService.java +++ b/src/main/java/com/chapter1/blueprint/policy/service/PolicyService.java @@ -1,7 +1,9 @@ package com.chapter1.blueprint.policy.service; +import com.chapter1.blueprint.member.repository.PolicyAlarmRepository; import com.chapter1.blueprint.policy.domain.PolicyDetail; import com.chapter1.blueprint.policy.domain.PolicyList; +import com.chapter1.blueprint.policy.domain.dto.PolicyListDTO; import com.chapter1.blueprint.policy.repository.PolicyDetailRepository; import com.chapter1.blueprint.policy.repository.PolicyListRepository; import com.fasterxml.jackson.databind.JsonNode; @@ -16,8 +18,11 @@ import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; +import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.Date; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @Transactional @@ -26,6 +31,7 @@ public class PolicyService { private final PolicyListRepository policyListRepository; private final PolicyDetailRepository policyDetailRepositpry; + private final PolicyAlarmRepository policyAlarmRepository; @Value("${tk.policy.api.url}") private String policyApiUrlTK; @@ -33,9 +39,17 @@ public class PolicyService { @Value("${tk.policy.api.key}") private String policyApiKeyTK; + @Value("${company.policy.api.key}") + private String companyPolicyApiKey; + + @Value("${company.policy.api.url}") + private String companyPolicyApiUrl; + + @Value("${company.policy.api.hahtag}") + private String companyPolicyApiHahtag; public String updatePolicyTK() { - String requestUrl = policyApiUrlTK + "?apiKey=" + policyApiKeyTK + "&searchYear=" + 2024+"&recordCount="+100; + String requestUrl = policyApiUrlTK + "?apiKey=" + policyApiKeyTK + "&searchYear=" + 2024+"&recordCount="+500; Integer numOfPolicy = 0; try { @@ -105,4 +119,200 @@ private Date parseDate(String dateStr, SimpleDateFormat dateFormat) { return null; } } + + public List findPoliciesWithApproachingDeadline() { + return policyListRepository.findPoliciesWithApproachingDeadline(); + } + + @Transactional + public void deletePolicy(Long policyIdx) { + policyListRepository.deleteById(policyIdx); + + policyAlarmRepository.deleteByPolicyIdx(policyIdx); + } + + public String updatePolicyCompany() { + String requestUrl = companyPolicyApiUrl + "?crtfcKey=" + companyPolicyApiKey +"&dataType=json&hashtags="+ companyPolicyApiHahtag +"&searchCnt=100"; + Integer numOfPolicy = 0; + + try { + URL url = new URL(requestUrl); + log.info("API 요청 URL: {}", requestUrl); + + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + + int responseCode = conn.getResponseCode(); + log.info("API 응답 코드: {}", responseCode); + + if (responseCode != HttpURLConnection.HTTP_OK) { + BufferedReader errorReader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), "UTF-8")); + StringBuilder errorResponse = new StringBuilder(); + String line; + while ((line = errorReader.readLine()) != null) { + errorResponse.append(line); + } + errorReader.close(); + log.error("API 오류 응답: {}", errorResponse.toString()); + throw new RuntimeException("API 호출 실패. 응답 코드: " + responseCode); + } + + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8")); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + log.info("API 응답 데이터: {}", response.toString()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(response.toString()); + JsonNode items = rootNode.path("jsonArray"); + + if (items.isMissingNode() || items.isEmpty()) { + log.warn("API 응답에 정책 데이터가 없습니다."); + return "API 응답에 정책 데이터가 없습니다."; + } + + numOfPolicy = items.size(); + log.info("처리할 정책 수: {}", numOfPolicy); + + for (JsonNode item : items) { + try { + PolicyList policyList = new PolicyList(); + String policyName = item.path("pblancNm").asText(); + + policyList.setName(policyName); + policyList.setCity(extractAndMapCity(policyName)); + policyList.setDistrict(extractAndMapCity(policyName)); + policyList.setType(item.path("pldirSportRealmLclasCodeNm").asText()); + policyList.setOfferInst(item.path("excInsttNm").asText()); + policyList.setManageInst(item.path("jrsdInsttNm").asText()); + + processDateRange(item.path("reqstBeginEndDe").asText(), policyList); + + PolicyDetail policyDetail = new PolicyDetail(); + policyDetail.setSubject(item.path("trgetNm").asText()); + policyDetail.setCondition(item.path("bsnsSumryCn").asText()); + policyDetail.setContent(item.path("bsnsSumryCn").asText()); + policyDetail.setEnquiry(item.path("refrncNm").asText()); + policyDetail.setWay(item.path("reqstMthPapersCn").asText()); + + String fileNm = item.path("fileNm").asText(); + if (!fileNm.equals("null") && !fileNm.isEmpty()) { + policyDetail.setDocument(fileNm); + } + + String pblancUrl = item.path("pblancUrl").asText(); + if (!pblancUrl.equals("null") && !pblancUrl.isEmpty()) { + policyDetail.setUrl(pblancUrl); + } + + // 각 엔티티 독립적으로 저장 + policyListRepository.save(policyList); + policyDetailRepositpry.save(policyDetail); + + numOfPolicy++; + + } catch (Exception e) { + log.error("정책 데이터 처리 중 오류 발생. 정책명: {}, 오류: {}", + item.path("pblancNm").asText(), e.getMessage()); + // 개별 정책 처리 실패 시 다음 정책 처리 계속 + continue; + } + } + + } catch (Exception e) { + log.error("정책 정보 업데이트 중 오류 발생: ", e); + throw new RuntimeException("정책 정보 업데이트 실패: " + e.getMessage()); + } + + String result = String.format("성공, 불러온 정책 수는 %d개", numOfPolicy); + log.info(result); + return result; + } + + private void processDateRange(String dateRange, PolicyList policyList) { + try { + if (dateRange == null || dateRange.isEmpty()) { + return; + } + + // 특수 케이스 처리: "예산 소진시까지" + if (dateRange.contains("예산 소진시까지")) { + Date endDate = createDateForYear(2099, 12, 31); + Date startDate = new Date(); // 현재 날짜를 시작일로 설정 + policyList.setApplyStartDate(startDate); + policyList.setApplyEndDate(endDate); + policyList.setStartDate(startDate); + policyList.setEndDate(endDate); + return; + } + + // 일반적인 날짜 범위 처리 ("20241113 ~ 20241120" 형식) + String[] dates = dateRange.split("~"); + if (dates.length == 2) { + String startDateStr = dates[0].trim(); + String endDateStr = dates[1].trim(); + + SimpleDateFormat apiDateFormat = new SimpleDateFormat("yyyyMMdd"); + policyList.setApplyStartDate(apiDateFormat.parse(startDateStr)); + policyList.setApplyEndDate(apiDateFormat.parse(endDateStr)); + policyList.setStartDate(apiDateFormat.parse(startDateStr)); + policyList.setEndDate(apiDateFormat.parse(endDateStr)); + } + } catch (ParseException e) { + log.error("날짜 파싱 중 오류 발생: {}", dateRange, e); + // 파싱 실패 시 기본값 설정 + setDefaultDates(policyList); + } + } + private Date createDateForYear(int year, int month, int day) { + Calendar calendar = Calendar.getInstance(); + calendar.set(year, month - 1, day, 23, 59, 59); + return calendar.getTime(); + } + + private void setDefaultDates(PolicyList policyList) { + // 파싱 실패 시 현재 날짜를 시작일로, 2099-12-31을 종료일로 설정 + policyList.setApplyStartDate(new Date()); + policyList.setApplyEndDate(createDateForYear(2099, 12, 31)); + policyList.setStartDate(new Date()); + policyList.setEndDate(createDateForYear(2099, 12, 31)); + } + + /** + * 정책명에서 지역 정보를 추출하고 매핑된 도시명을 반환 + */ + private String extractAndMapCity(String policyName) { + try { + // 정책명에서 [] 안의 내용 추출 + Pattern pattern = Pattern.compile("\\[(.*?)\\]"); + Matcher matcher = pattern.matcher(policyName); + + if (matcher.find()) { + String cityCode = matcher.group(1); + // 매핑된 도시명 반환, 매핑이 없는 경우 원본 값 사용 + return CITY_MAPPING.getOrDefault(cityCode, cityCode); + } + } catch (Exception e) { + log.error("지역 정보 추출 중 오류 발생: {}", policyName, e); + } + + // [] 안에 지역 정보가 없는 경우 "전국" 반환 + return "전국"; + } + + // 지역 매핑을 위한 Map 선언 + private static final Map CITY_MAPPING = new HashMap<>() {{ + put("경기", "경기도"); + put("서울", "서울특별시"); + put("세종", "세종특별자치시"); + put("전북", "전북특별자치도"); + put("울산", "울산광역시"); + put("전남", "전라남도"); + }}; } diff --git a/src/main/java/com/chapter1/blueprint/security/config/SecurityConfig.java b/src/main/java/com/chapter1/blueprint/security/config/SecurityConfig.java index 887ec6e..0349e2c 100644 --- a/src/main/java/com/chapter1/blueprint/security/config/SecurityConfig.java +++ b/src/main/java/com/chapter1/blueprint/security/config/SecurityConfig.java @@ -62,13 +62,25 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS).permitAll() - .requestMatchers("/member/login", "/member/register").permitAll() - .requestMatchers("/member/checkMemberId/**", "/member/checkEmail/**").permitAll() - .requestMatchers("/member/find/memberId", "/member/find/password").permitAll() - .requestMatchers("/member/email/sendVerification", "/member/email/verifyEmailCode").permitAll() + .requestMatchers( + "/member/login", + "/member/register", + "/member/checkMemberId/**", + "/member/checkEmail/**", + "/member/find/memberId", + "/member/find/password", + "/member/email/sendVerification", + "/member/email/verifyEmailCode" + ).permitAll() .requestMatchers("/member/**").authenticated() - .requestMatchers("/policy/list/**", "/policy/detail/**", "policy/filter").permitAll() - .requestMatchers("/subscription/city", "/subscription/district", "/subscription/local").permitAll() + .requestMatchers("/finance/filter/**").authenticated() + .requestMatchers("/policy/recommendation").authenticated() + .requestMatchers("/policy/list/**", "/policy/detail/**", "/policy/filter", "/policy/update/TK","/policy/update/company").permitAll() + .requestMatchers("/finance/**", "/finance/filter/**").permitAll() + + .requestMatchers("/subscription/recommendation").authenticated() + .requestMatchers("/subscription/city", "/subscription/district", "/subscription/local", "/subscription/update").permitAll() + .requestMatchers("/swagger-ui.html","/swagger-ui/**","/v3/api-docs/**","/swagger-resources/**","/webjars/**").permitAll() .anyRequest().permitAll() ) .exceptionHandling(exception -> exception @@ -76,8 +88,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication .accessDeniedHandler(accessDeniedHandler) ) .addFilterBefore(authenticationErrorFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtAuthenticationFilter, JwtUsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAt(jwtUsernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/chapter1/blueprint/security/controller/AuthController.java b/src/main/java/com/chapter1/blueprint/security/controller/AuthController.java index c8d0e37..98fcb3f 100644 --- a/src/main/java/com/chapter1/blueprint/security/controller/AuthController.java +++ b/src/main/java/com/chapter1/blueprint/security/controller/AuthController.java @@ -27,7 +27,7 @@ public ResponseEntity refreshAccessToken(@RequestBody AuthDTO authDTO) if (jwtProcessor.validateRefreshToken(refreshToken)) { Member member = memberRepository.findByMemberId(memberId) - .orElseThrow(() -> new RuntimeException("Invalid User")); + .orElseThrow(() -> new RuntimeException("Invalid Member")); String newAccessToken = jwtProcessor.generateAccessToken( member.getMemberId(), diff --git a/src/main/java/com/chapter1/blueprint/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/chapter1/blueprint/security/filter/JwtAuthenticationFilter.java index ea6558b..475a5b5 100644 --- a/src/main/java/com/chapter1/blueprint/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/chapter1/blueprint/security/filter/JwtAuthenticationFilter.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ import java.io.IOException; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -24,22 +26,57 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 1. Authorization 헤더 추출 String header = request.getHeader("Authorization"); + log.info("Authorization Header: {}", header); if (header != null && header.startsWith("Bearer ")) { + // 2. JWT 토큰 추출 String token = header.substring(7); + log.info("Extracted Token: {}", token); - if (jwtProcessor.validateToken(token)) { - var authentication = jwtProcessor.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } else { - jsonResponseUtil.sendErrorResponse(response, HttpStatus.UNAUTHORIZED, - "Invalid or expired token in authorization header.", - "The provided token is invalid or expired."); + try { + // 3. 토큰 검증 + if (jwtProcessor.validateToken(token)) { + log.info("Token is valid: {}", token); + + // 4. 인증 객체 생성 및 SecurityContextHolder 설정 + var authentication = jwtProcessor.getAuthentication(token); + if (authentication != null) { + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Authentication set in SecurityContextHolder: {}", authentication); + } else { + log.warn("Failed to create authentication from token: {}", token); + } + } else { + log.warn("Invalid or expired token: {}", token); + jsonResponseUtil.sendErrorResponse( + response, + HttpStatus.UNAUTHORIZED, + "Invalid or expired token in authorization header.", + "The provided token is invalid or expired." + ); + return; + } + } catch (Exception e) { + log.error("Error processing JWT token: {}", token, e); + jsonResponseUtil.sendErrorResponse( + response, + HttpStatus.UNAUTHORIZED, + "Error processing token.", + e.getMessage() + ); return; } + } else { + if (header == null) { + log.warn("Authorization header is missing."); + } else { + log.warn("Authorization header does not start with 'Bearer '. Header: {}", header); + } } + // 5. 필터 체인으로 요청 전달 filterChain.doFilter(request, response); } } diff --git a/src/main/java/com/chapter1/blueprint/security/util/JwtProcessor.java b/src/main/java/com/chapter1/blueprint/security/util/JwtProcessor.java index abb418b..3692d43 100644 --- a/src/main/java/com/chapter1/blueprint/security/util/JwtProcessor.java +++ b/src/main/java/com/chapter1/blueprint/security/util/JwtProcessor.java @@ -85,7 +85,20 @@ private Claims parseTokenClaims(String token) { public Long getUid(String token) { String encryptedUid = parseTokenClaims(token).get("uid", String.class); - return Long.parseLong(AESUtil.decrypt(encryptedUid, encryptionSecret)); + if (encryptedUid == null) { + log.error("UID not found in token"); + throw new IllegalArgumentException("UID not found in token"); + } + + try { + log.debug("Encrypted UID: {}", encryptedUid); + String decryptedUid = AESUtil.decrypt(encryptedUid, encryptionSecret); + log.debug("Decrypted UID: {}", decryptedUid); + return Long.parseLong(decryptedUid); + } catch (Exception e) { + log.error("Failed to decrypt UID from token: " + e.getMessage(), e); + throw new IllegalArgumentException("Invalid UID in token", e); + } } public String getAuth(String token) { @@ -127,6 +140,10 @@ public boolean validateRefreshToken(String refreshToken) { public Authentication getAuthentication(String token) { String memberId = getSubject(token); UserDetails userDetails = userDetailsService.loadUserByUsername(memberId); - return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + return new UsernamePasswordAuthenticationToken( + userDetails, // principal + token, // credentials - 여기를 null에서 token으로 변경 + userDetails.getAuthorities() + ); } } diff --git a/src/main/java/com/chapter1/blueprint/subscription/controller/RealEstateController.java b/src/main/java/com/chapter1/blueprint/subscription/controller/RealEstateController.java index 7c88681..659c6c1 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/controller/RealEstateController.java +++ b/src/main/java/com/chapter1/blueprint/subscription/controller/RealEstateController.java @@ -2,6 +2,7 @@ import com.chapter1.blueprint.exception.dto.SuccessResponse; import com.chapter1.blueprint.subscription.domain.DTO.RealEstatePriceSummaryDTO; +import com.chapter1.blueprint.subscription.repository.SsgcodeRepository; import com.chapter1.blueprint.subscription.service.RealEstateService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,10 +19,18 @@ @RequestMapping("/realestate") public class RealEstateController { private final RealEstateService realEstateService; + private final SsgcodeRepository ssgcodeRepository; @GetMapping(value = "/get") public ResponseEntity getRealEstatePrice() { String result = realEstateService.getRealEstatePrice(); + + return ResponseEntity.ok(new SuccessResponse(result)); + } + + @GetMapping(value = "/test") + public ResponseEntity test() { + String result = String.valueOf(ssgcodeRepository.findAllSsgcodes().size()); return ResponseEntity.ok(new SuccessResponse(result)); } diff --git a/src/main/java/com/chapter1/blueprint/subscription/controller/SubscriptionController.java b/src/main/java/com/chapter1/blueprint/subscription/controller/SubscriptionController.java index d8025be..c1de81a 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/controller/SubscriptionController.java +++ b/src/main/java/com/chapter1/blueprint/subscription/controller/SubscriptionController.java @@ -1,13 +1,19 @@ package com.chapter1.blueprint.subscription.controller; import com.chapter1.blueprint.exception.dto.SuccessResponse; +import com.chapter1.blueprint.member.service.MemberService; +import com.chapter1.blueprint.policy.domain.PolicyList; import com.chapter1.blueprint.subscription.domain.SubscriptionList; import com.chapter1.blueprint.subscription.domain.DTO.ResidenceDTO; import com.chapter1.blueprint.subscription.service.ResidenceService; import com.chapter1.blueprint.subscription.service.SubscriptionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -19,16 +25,25 @@ public class SubscriptionController { private final SubscriptionService subscriptionService; private final ResidenceService residenceService; + private final MemberService memberService; @GetMapping(value = "/update") public ResponseEntity updateSubscription() { - String result = subscriptionService.updateSub(); + String result = subscriptionService.updateSubAPT()+subscriptionService.updateSubAPT2()+subscriptionService.updateSubOther(); return ResponseEntity.ok(new SuccessResponse(result)); } @GetMapping(value = "/get") - public ResponseEntity getSubscription() { - List subscriptionLists = subscriptionService.getAllSubscription(); + public ResponseEntity getSubscription( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page subscriptionLists = subscriptionService.getAllSubscription(PageRequest.of(page, size)); + return ResponseEntity.ok(new SuccessResponse(subscriptionLists)); + } + + @GetMapping("/getAll") + public ResponseEntity getAllSubscriptions() { + List subscriptionLists = subscriptionService.getAllSubscriptions(); return ResponseEntity.ok(new SuccessResponse(subscriptionLists)); } @@ -50,4 +65,12 @@ public ResponseEntity getLocal(@RequestBody ResidenceDTO reside return ResponseEntity.ok(new SuccessResponse(localList)); } + @GetMapping("/recommendation") + public ResponseEntity recommendSubscription() { + Long uid = memberService.getAuthenticatedUid(); + + List recommendedSubscription = subscriptionService.recommendSubscription(uid); + return ResponseEntity.ok(new SuccessResponse(recommendedSubscription)); + } + } diff --git a/src/main/java/com/chapter1/blueprint/subscription/domain/DTO/SsgcodeDTO.java b/src/main/java/com/chapter1/blueprint/subscription/domain/DTO/SsgcodeDTO.java index 5878e4c..c17845d 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/domain/DTO/SsgcodeDTO.java +++ b/src/main/java/com/chapter1/blueprint/subscription/domain/DTO/SsgcodeDTO.java @@ -5,7 +5,6 @@ @Getter @Setter public class SsgcodeDTO { - private Integer ssgCd; private String ssgCdNm; diff --git a/src/main/java/com/chapter1/blueprint/subscription/domain/Ssgcode.java b/src/main/java/com/chapter1/blueprint/subscription/domain/Ssgcode.java index 417c3c5..35409e7 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/domain/Ssgcode.java +++ b/src/main/java/com/chapter1/blueprint/subscription/domain/Ssgcode.java @@ -8,13 +8,11 @@ @Getter @Setter @Table(name = "ssgcode",catalog = "subscription") public class Ssgcode { - @Id - @Column(name = "ssg_cd") - private Integer ssgCd; @Column(name = "ssg_cd_nm") private String ssgCdNm; + @Id @Column(name = "ssg_cd_5") private String ssgCd5; @@ -23,4 +21,5 @@ public class Ssgcode { @Column(name = "ssg_cd_nm_city") private String ssgCdNmCity; + } diff --git a/src/main/java/com/chapter1/blueprint/subscription/domain/SubscriptionList.java b/src/main/java/com/chapter1/blueprint/subscription/domain/SubscriptionList.java index fde80ac..bbfa9ec 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/domain/SubscriptionList.java +++ b/src/main/java/com/chapter1/blueprint/subscription/domain/SubscriptionList.java @@ -33,6 +33,9 @@ public class SubscriptionList { @Column(name = "house_manage_no") private Integer houseManageNo; + @Column(name="house_dtl_secd_nm") + private String houseDtlSecdNm; + @Column(name = "rent_secd") private String rentSecd; @@ -47,5 +50,4 @@ public class SubscriptionList { @Column(name = "pblanc_url") private String pblancUrl; - } diff --git a/src/main/java/com/chapter1/blueprint/subscription/repository/RealEstatePriceRepository.java b/src/main/java/com/chapter1/blueprint/subscription/repository/RealEstatePriceRepository.java index 62155cb..d63115a 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/repository/RealEstatePriceRepository.java +++ b/src/main/java/com/chapter1/blueprint/subscription/repository/RealEstatePriceRepository.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Set; @Repository public interface RealEstatePriceRepository extends JpaRepository { @@ -39,4 +40,19 @@ public interface RealEstatePriceRepository extends JpaRepository getLocal(@Param("city") String city, @Param("district") String district); + @Query("SELECT COUNT(DISTINCT r.ssgCd) FROM RealEstatePrice r") + long countDistinctSsgCd(); + + @Query("SELECT COUNT(r) FROM RealEstatePrice r WHERE r.ssgCd = :ssgCd") + long countBySsgCd(@Param("ssgCd") String ssgCd); + + @Query("SELECT DISTINCT r.ssgCd FROM RealEstatePrice r") + List findDistinctSsgCds(); + + @Query("SELECT COUNT(DISTINCT r.ssgCd) FROM RealEstatePrice r WHERE r.dealYear = :year AND r.dealMonth = :month") + long countBySsgCdAndYearMonth(@Param("year") String year, @Param("month") Integer month); + + @Query("SELECT DISTINCT r.ssgCd FROM RealEstatePrice r WHERE r.dealYear = :year AND r.dealMonth = :month") + Set findBySsgCdAndDealYearAndDealMonth(String year, int month); + } diff --git a/src/main/java/com/chapter1/blueprint/subscription/repository/SsgcodeRepository.java b/src/main/java/com/chapter1/blueprint/subscription/repository/SsgcodeRepository.java index de2dd42..4414648 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/repository/SsgcodeRepository.java +++ b/src/main/java/com/chapter1/blueprint/subscription/repository/SsgcodeRepository.java @@ -6,9 +6,15 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface SsgcodeRepository extends JpaRepository { + //@Query("SELECT s FROM Ssgcode s") + @Query("SELECT s FROM Ssgcode s GROUP BY s.ssgCd5") + List findAllSsgcodes(); + @Query("SELECT s FROM Ssgcode s WHERE s.ssgCd5 = :number") Ssgcode findBySsgCd5(@Param("number") String number); diff --git a/src/main/java/com/chapter1/blueprint/subscription/repository/SubscriptionListRepository.java b/src/main/java/com/chapter1/blueprint/subscription/repository/SubscriptionListRepository.java index bae864a..441fb90 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/repository/SubscriptionListRepository.java +++ b/src/main/java/com/chapter1/blueprint/subscription/repository/SubscriptionListRepository.java @@ -7,7 +7,15 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface SubscriptionListRepository extends JpaRepository { + @Query("SELECT s FROM SubscriptionList s " + + "WHERE s.region = :region " + + "AND s.city LIKE %:city% " + + "AND s.rceptEndde >= CURRENT_DATE") + List findByRegionAndCityContaining(String region, String city); + } diff --git a/src/main/java/com/chapter1/blueprint/subscription/service/RealEstateService.java b/src/main/java/com/chapter1/blueprint/subscription/service/RealEstateService.java index 1522336..e9f8277 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/service/RealEstateService.java +++ b/src/main/java/com/chapter1/blueprint/subscription/service/RealEstateService.java @@ -31,17 +31,22 @@ import java.io.StringReader; import java.math.BigDecimal; import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; import java.net.URL; import java.time.LocalDate; import java.time.ZoneId; -import java.util.Date; -import java.util.List; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Collections; +import java.util.Set; @Slf4j @Service @@ -51,7 +56,17 @@ public class RealEstateService { private final RealEstatePriceRepository realEstatePriceRepository; private final SsgcodeRepository ssgcodeRepository; private final RealEstatePriceSummaryRepository realEstatePriceSummaryRepository; - private final ExecutorService executorService = Executors.newFixedThreadPool(20); + + // 카운터 추가 + private final AtomicInteger processedCount = new AtomicInteger(0); + private final AtomicInteger savedCount = new AtomicInteger(0); + + private final Set globalProcessedCodes = createConcurrentSet(); + + // 스레드 풀 크기를 시스템 리소스에 맞게 조정 + private final ExecutorService executorService = Executors.newFixedThreadPool( + Runtime.getRuntime().availableProcessors() * 2 + ); @Value("${public.api.key}") private String apiKey; @@ -59,151 +74,159 @@ public class RealEstateService { @Value("${realestate.api.url}") private String realestateUrl; - private final XmlMapper xmlMapper = new XmlMapper(); - public String getRealEstatePrice() { - String callDate = "202407"; - - List ssgcds = ssgcodeRepository.findAll(); - List distinctSsgcds = ssgcds.stream() - .collect(Collectors.toMap( - Ssgcode::getSsgCd5, // 중복 기준 필드 - Function.identity(), - (existing, replacement) -> existing // 중복 시 기존 값 유지 - )) - .values() - .stream() - .collect(Collectors.toList()); + String callDate = "202305"; + List ssgcds = ssgcodeRepository.findAllSsgcodes(); + Set failedSsgCodes = createConcurrentSet(); - try { - List> futures = distinctSsgcds.stream() - .map(ssgcd -> CompletableFuture.runAsync(() -> processSsgcode(ssgcd, callDate), executorService)) - .toList(); + log.info("Starting to process {} ssgcodes for period: {}", ssgcds.size(), callDate); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - executorService.shutdown(); - if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { - executorService.shutdownNow(); - } - realEstatePriceRepository.updateRealEstatePriceFromSsgcode(); - realEstatePriceRepository.insertSummary(); - return "API 불러오기 및 DB저장 성공"; - } catch (Exception e) { - log.error("Error processing real estate data: ", e); - return "API 처리 중 오류 발생: " + e.getMessage(); - } - } + // 카운터 초기화 + processedCount.set(0); + savedCount.set(0); - private void processSsgcode(Ssgcode ssgcd, String callDate) { try { - Thread.sleep(500); // 요청 간격 0.5초 대기 - String response = fetchApiData(ssgcd.getSsgCd5(), callDate); - log.info("XML Response: {}", response); - - // XML Fault 응답일 경우 로그만 남기고 종료 - if (isXMLFaultResponse(response)) { - log.error("API returned XML fault for Ssgcode {}: {}", ssgcd.getSsgCd5(), getFaultString(response)); - return; + // [1단계] 전체 데이터 처리 시도 + int chunkSize = 10; + List> chunks = splitListIntoChunks(ssgcds, chunkSize); + + for (int i = 0; i < chunks.size(); i++) { + List currentChunk = chunks.get(i); + processChunk(currentChunk, callDate, failedSsgCodes, i + 1, chunks.size()); + Thread.sleep(2000); // 청크 간 딜레이 } - processApiResponse(response, ssgcd.getSsgCd5()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Thread was interrupted for Ssgcode {}", ssgcd.getSsgCd5(), e); - } catch (Exception e) { - log.error("Error processing Ssgcode {}: {}", ssgcd.getSsgCd5(), e.getMessage()); - } - } + // [2단계] 실패한 것들만 재시도 + if (!failedSsgCodes.isEmpty()) { + log.info("Retrying {} failed ssgcodes", failedSsgCodes.size()); + int maxRetries = 3; - private boolean isXMLFaultResponse(String response) { - return response != null && response.contains("XMLFault"); - } + for (int retryCount = 1; retryCount <= maxRetries && !failedSsgCodes.isEmpty(); retryCount++) { + // 현재 실패 목록 복사 + Set currentFailed = new HashSet<>(failedSsgCodes); + failedSsgCodes.clear(); - // XML Fault 응답의 상세 내용 로깅 - private String getFaultString(String response) { - try { - // 기본 XmlMapper 설정으로 진행 - XmlMapper tempMapper = new XmlMapper(); - JsonNode rootNode = tempMapper.readTree(response); + // 실패한 코드들만 필터링 + List retryList = ssgcds.stream() + .filter(ssgcd -> currentFailed.contains(ssgcd.getSsgCd5())) + .collect(Collectors.toList()); - // namespace를 포함한 경로와 포함하지 않은 경로 모두 시도 - JsonNode faultString = null; + List> retryChunks = splitListIntoChunks(retryList, 5); // 더 작은 청크로 재시도 - // 가능한 모든 경로 시도 - String[] paths = { - "/ns1:XMLFault/ns1:faultstring", - "/XMLFault/faultstring", - "/faultstring" - }; + log.info("Retry attempt {}/{} - Processing {} ssgcodes", + retryCount, maxRetries, retryList.size()); - for (String path : paths) { - faultString = rootNode.at(path); - if (!faultString.isMissingNode()) { - return faultString.asText(); + for (int i = 0; i < retryChunks.size(); i++) { + processChunk(retryChunks.get(i), callDate, failedSsgCodes, + i + 1, retryChunks.size()); + Thread.sleep(5000); // 재시도 시 더 긴 딜레이 + } + + if (failedSsgCodes.isEmpty()) { + log.info("All failed ssgcodes successfully processed on retry {}", retryCount); + break; + } + + // 다음 재시도 전 대기 + if (retryCount < maxRetries) { + Thread.sleep(10000 * retryCount); + } } } - // findValue로 마지막 시도 - faultString = rootNode.findValue("faultstring"); - if (faultString != null) { - return faultString.asText(); - } + // 최종 결과 확인 + long uniqueSsgCodes = realEstatePriceRepository.countBySsgCdAndYearMonth( + callDate.substring(0, 4), + Integer.parseInt(callDate.substring(4)) + ); - // 디버깅을 위한 XML 구조 출력 - log.debug("XML Structure: {}", rootNode.toPrettyString()); - log.error("Could not find fault string in response: {}", response); - return "Error: Could not parse fault string"; + String result = generateResultSummary(ssgcds.size(), processedCount.get(), + savedCount.get(), uniqueSsgCodes, failedSsgCodes); + realEstatePriceRepository.updateRealEstatePriceFromSsgcode(); + realEstatePriceRepository.insertSummary(); + + return result; } catch (Exception e) { - log.error("Error parsing XML fault: ", e); - return "Error parsing XML fault: " + e.getMessage(); + log.error("Error in main processing loop: ", e); + return String.format("API 처리 중 오류 발생: %s (처리된 개수: %d, 실패: %d)", + e.getMessage(), processedCount.get(), failedSsgCodes.size()); + } finally { + shutdownExecutorService(); + } + } + + // 리스트를 청크 단위로 분할하는 유틸리티 메서드 + private List> splitListIntoChunks(List list, int chunkSize) { + List> chunks = new ArrayList<>(); + for (int i = 0; i < list.size(); i += chunkSize) { + chunks.add(list.subList( + i, + Math.min(i + chunkSize, list.size()) + )); } + return chunks; + } + + private boolean isXMLFaultResponse(String response) { + return response != null && response.contains("XMLFault"); } private String fetchApiData(String ssgCd5, String callDate) throws Exception { - // URL을 있는 그대로 사용 (이미 인코딩된 serviceKey 사용) - String requestUrl = String.format("%s?serviceKey=%s&LAWD_CD=%s&DEAL_YMD=%s&numOfRows=50", + String requestUrl = String.format("%s?serviceKey=%s&LAWD_CD=%s&DEAL_YMD=%s&numOfRows=1000", realestateUrl, apiKey, ssgCd5, callDate); - log.info("Requesting URL: {}", requestUrl); - + log.debug("Requesting URL for ssgCd5 {}: {}", ssgCd5, requestUrl); HttpURLConnection connection = null; + try { URL url = new URL(requestUrl); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); - - // 브라우저와 유사한 헤더 설정 connection.setRequestProperty("Accept", "*/*"); connection.setRequestProperty("User-Agent", "Mozilla/5.0"); - connection.setConnectTimeout(10000); - connection.setReadTimeout(10000); - - // 응답 읽기 - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(connection.getInputStream(), "UTF-8"))) { - StringBuilder response = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - response.append(line).append("\n"); - } - String responseStr = response.toString(); - log.info("Raw API Response: {}", responseStr); + connection.setConnectTimeout(30000); // 30초로 증가 + connection.setReadTimeout(100000); // 60초 유지 + + int responseCode = connection.getResponseCode(); + log.info("Response code for ssgCd5 {}: {}", ssgCd5, responseCode); + + String responseStr; + if (responseCode == HttpURLConnection.HTTP_OK) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), "UTF-8"))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line).append("\n"); + } + responseStr = response.toString(); - // XML 응답 구조 검증 - if (responseStr.contains("")) { - return responseStr; - } else if (responseStr.contains("XMLFault")) { - log.error("Received XMLFault response: {}", responseStr); - // XMLFault 응답을 처리하되, 실제 오류 내용 확인 + // 기본적인 XML 구조 확인 + if (!responseStr.contains("")) { + log.error("Invalid XML response for ssgCd5 {}: {}", ssgCd5, responseStr); + return null; + } + + log.debug("Received valid response for ssgCd5 {} ({} bytes)", + ssgCd5, responseStr.length()); return responseStr; - } else { - log.error("Unexpected response format: {}", responseStr); - throw new RuntimeException("Unexpected API response format"); + } + } else { + try (BufferedReader errorReader = new BufferedReader( + new InputStreamReader(connection.getErrorStream(), "UTF-8"))) { + StringBuilder errorResponse = new StringBuilder(); + String line; + while ((line = errorReader.readLine()) != null) { + errorResponse.append(line).append("\n"); + } + log.error("Error response for ssgCd5 {} (code {}): {}", + ssgCd5, responseCode, errorResponse.toString()); + return null; } } } catch (Exception e) { - log.error("Error fetching API data for Ssgcode {}: {}", ssgCd5, e.getMessage()); + log.error("Error fetching data for ssgCd5 {}: {}", ssgCd5, e.getMessage()); throw e; } finally { if (connection != null) { @@ -213,8 +236,14 @@ private String fetchApiData(String ssgCd5, String callDate) throws Exception { } private void processApiResponse(String response, String ssgCd5) throws Exception { + if (response == null) { + log.error("Null response received for Ssgcode {}", ssgCd5); + return; + } + if (response.contains("XMLFault")) { - log.error("API returned XMLFault for Ssgcode {}: {}", ssgCd5, response); + String faultString = getFaultString(response); + log.error("API returned XMLFault for Ssgcode {}: {}", ssgCd5, faultString); return; } @@ -224,14 +253,38 @@ private void processApiResponse(String response, String ssgCd5) throws Exception Document document = builder.parse(new InputSource(new StringReader(response))); XPath xPath = XPathFactory.newInstance().newXPath(); + // API 응답 검증 + String resultCode = (String) xPath.evaluate("//resultCode", document, XPathConstants.STRING); + String resultMsg = (String) xPath.evaluate("//resultMsg", document, XPathConstants.STRING); + String totalCount = (String) xPath.evaluate("//totalCount", document, XPathConstants.STRING); + + log.info("API Response for ssgCd5 {}: resultCode={}, resultMsg={}, totalCount={}", + ssgCd5, resultCode, resultMsg, totalCount); + + // resultCode 체크 수정: "000"이 성공 코드 + if (!"000".equals(resultCode)) { // "00" 에서 "000"으로 수정 + log.error("API error for ssgCd5 {}: {}", ssgCd5, resultMsg); + return; + } + + // totalCount가 0인 경우 조기 반환 추가 + if ("0".equals(totalCount)) { + log.info("No data available for ssgCd5 {}", ssgCd5); + return; + } + // 모든 item 노드 가져오기 String expression = "//item"; - org.w3c.dom.NodeList nodeList = (org.w3c.dom.NodeList) xPath.evaluate( - expression, document, XPathConstants.NODESET - ); + NodeList nodeList = (NodeList) xPath.evaluate(expression, document, XPathConstants.NODESET); - log.info("Found {} items in response", nodeList.getLength()); + if (nodeList.getLength() == 0) { + log.warn("No items found in response for ssgCd5 {}", ssgCd5); + return; + } + + log.info("Processing {} items for ssgCd5 {}", nodeList.getLength(), ssgCd5); + int savedItems = 0; for (int i = 0; i < nodeList.getLength(); i++) { try { Node itemNode = nodeList.item(i); @@ -266,14 +319,27 @@ private void processApiResponse(String response, String ssgCd5) throws Exception } realEstatePrice.setExcluUseAr(getNodeBigDecimalValue(itemNode, "excluUseAr")); realEstatePriceRepository.save(realEstatePrice); - log.debug("Saved real estate price data for item {}/{}: {}", - i + 1, nodeList.getLength(), realEstatePrice.getAptNm()); + savedItems++; + + if (i % 10 == 0 || i == nodeList.getLength() - 1) { + log.info("Progress for ssgCd5 {}: {}/{} items processed", + ssgCd5, i + 1, nodeList.getLength()); + } } catch (Exception e) { - log.error("Error processing item {}: {}", i, e.getMessage()); + log.error("Error processing item {} for ssgCd5 {}: {}", i, ssgCd5, e.getMessage()); } } + + if (savedItems > 0) { + log.info("Successfully processed ssgCd5 {}: {} out of {} items saved", + ssgCd5, savedItems, nodeList.getLength()); + } else { + log.error("Failed to save any items for ssgCd5 {}", ssgCd5); + throw new Exception("No items were saved"); + } + } catch (Exception e) { - log.error("Error parsing XML response: {}", e.getMessage()); + log.error("Error parsing XML response for ssgCd5 {}: {}", ssgCd5, e.getMessage()); throw e; } } @@ -323,7 +389,7 @@ private Date formatDate(int year, int month, int day) { .toInstant()); } - public List getRealEstateSummary(String region, String sggCdNm, String umdNm){ + public List getRealEstateSummary(String region, String sggCdNm, String umdNm) { validateInput(region, sggCdNm, umdNm); try { List results = realEstatePriceSummaryRepository @@ -413,4 +479,234 @@ public List getUmdList(String region, String sggCdNm) { throw new ErrorCodeException(ErrorCode.REAL_ESTATE_SERVER_ERROR); } } + + private boolean processSsgcode(Ssgcode ssgcd, String callDate) { + int maxRetries = 3; + int retryDelayMs = 1000; + boolean success = false; + + for (int retry = 0; retry < maxRetries; retry++) { + try { + if (retry > 0) { + Thread.sleep(retryDelayMs * (long) Math.pow(2, retry - 1)); + } + + String response = fetchApiData(ssgcd.getSsgCd5(), callDate); + + if (response == null || isXMLFaultResponse(response)) { + log.error("Invalid response for Ssgcode {}", ssgcd.getSsgCd5()); + continue; + } + + // 데이터 처리 전 기존 카운트 + long beforeCount = realEstatePriceRepository.countBySsgCd(ssgcd.getSsgCd5()); + + // 데이터 처리 + processApiResponse(response, ssgcd.getSsgCd5()); + + // 처리 후 카운트 확인 + long afterCount = realEstatePriceRepository.countBySsgCd(ssgcd.getSsgCd5()); + + if (afterCount > beforeCount) { + success = true; + savedCount.incrementAndGet(); + log.info("Successfully saved data for ssgCd5 {}: {} records", + ssgcd.getSsgCd5(), (afterCount - beforeCount)); + break; + } + + } catch (Exception e) { + if (retry == maxRetries - 1) { + log.error("Final retry failed for Ssgcode {}", ssgcd.getSsgCd5(), e); + } else { + log.warn("Retry {}/{} failed for Ssgcode {}", + retry + 1, maxRetries, ssgcd.getSsgCd5()); + } + } + } + return success; + } + + private String getFaultString(String response) { + try { + // 기본 XmlMapper 설정으로 진행 + XmlMapper tempMapper = new XmlMapper(); + JsonNode rootNode = tempMapper.readTree(response); + + // namespace를 포함한 경로와 포함하지 않은 경로 모두 시도 + JsonNode faultString = null; + + String[] paths = { + "/ns1:XMLFault/ns1:faultstring", + "/XMLFault/faultstring", + "/faultstring" + }; + + for (String path : paths) { + faultString = rootNode.at(path); + if (!faultString.isMissingNode()) { + return faultString.asText(); + } + } + + return "Could not parse fault string"; + } catch (Exception e) { + return "Error parsing XML fault: " + e.getMessage(); + } + } + private void processAllSsgcodes(List ssgcds, String callDate, + Set failedSsgCodes) throws InterruptedException { + int chunkSize = 10; + List> chunks = splitListIntoChunks(ssgcds, chunkSize); + + for (int i = 0; i < chunks.size(); i++) { + processChunk(chunks.get(i), callDate, failedSsgCodes, i + 1, chunks.size()); + Thread.sleep(2000); // 청크 간 딜레이 + } + } + + private void retryFailedSsgcodes(List allSsgcds, String callDate, + Set failedSsgCodes) throws InterruptedException { + int maxRetries = 3; + + for (int retryCount = 1; retryCount <= maxRetries && !failedSsgCodes.isEmpty(); retryCount++) { + log.info("Retry attempt {}/{} for {} failed ssgcodes", + retryCount, maxRetries, failedSsgCodes.size()); + + // 현재 실패 목록의 스냅샷 생성 + Set currentFailedCodes = new HashSet<>(failedSsgCodes); + + // 실패한 ssg 코드에 해당하는 Ssgcode 객체들을 찾아서 재시도 + List retryList = allSsgcds.stream() + .filter(ssgcd -> currentFailedCodes.contains(ssgcd.getSsgCd5())) + .distinct() // 중복 제거 + .collect(Collectors.toList()); + + log.info("Attempting to retry {} unique ssgcodes", retryList.size()); + + Set stillFailed = createConcurrentSet(); + + // 더 작은 청크 사이즈로 재처리 + List> retryChunks = splitListIntoChunks(retryList, 3); // 청크 크기를 더 작게 조정 + + for (int i = 0; i < retryChunks.size(); i++) { + List uniqueChunk = retryChunks.get(i).stream() + .distinct() + .collect(Collectors.toList()); + + processChunk(uniqueChunk, callDate, stillFailed, + i + 1, retryChunks.size()); + Thread.sleep(5000); // 재시도 간격 증가 + } + + // 실패 목록 업데이트 + failedSsgCodes.clear(); + failedSsgCodes.addAll(stillFailed); + + if (failedSsgCodes.isEmpty()) { + log.info("All failed ssgcodes successfully processed on retry {}", retryCount); + break; + } + + // 다음 재시도 전 대기 시간 증가 + if (retryCount < maxRetries) { + long waitTime = 10000L * retryCount; // 10초씩 증가 + log.info("Waiting {} seconds before next retry...", waitTime/1000); + Thread.sleep(waitTime); + } + } + + if (!failedSsgCodes.isEmpty()) { + log.error("Still failed after all retries: {} ssgcodes", failedSsgCodes.size()); + log.error("Failed ssgcodes: {}", failedSsgCodes); + } + } + + private void processChunk(List chunk, String callDate, + Set failedSsgCodes, int currentChunk, int totalChunks) { + List> futures = chunk.stream() + .map(ssgcd -> CompletableFuture.supplyAsync(() -> { + try { + boolean result = processSsgcode(ssgcd, callDate); + int completed = processedCount.incrementAndGet(); + + if (result) { + log.info("Successfully processed ssgcode: {}, Progress: {}/{}", + ssgcd.getSsgCd5(), currentChunk, totalChunks); + failedSsgCodes.remove(ssgcd.getSsgCd5()); + } else { + log.error("Failed to process ssgcode: {}", ssgcd.getSsgCd5()); + failedSsgCodes.add(ssgcd.getSsgCd5()); + } + return result; + } catch (Exception e) { + log.error("Error processing ssgcode {}: {}", + ssgcd.getSsgCd5(), e.getMessage()); + failedSsgCodes.add(ssgcd.getSsgCd5()); + return false; + } + }, executorService)) + .collect(Collectors.toList()); + + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(15, TimeUnit.MINUTES); + + long successCount = futures.stream() + .map(CompletableFuture::join) + .filter(success -> success) + .count(); + + log.info("Chunk {}/{} completed: {} successful out of {} attempts", + currentChunk, totalChunks, successCount, chunk.size()); + } catch (Exception e) { + log.error("Error processing chunk {}/{}: {}", + currentChunk, totalChunks, e.getMessage()); + chunk.stream() + .map(Ssgcode::getSsgCd5) + .forEach(failedSsgCodes::add); + } + } + + private String generateResultSummary(int totalSsgcodes, int processed, + int saved, long uniqueSsgCodes, Set failedSsgCodes) { + StringBuilder summary = new StringBuilder(); + summary.append(String.format( + "API 처리 완료.\n전체: %d개\n처리됨: %d개\n저장 성공: %d개\nDB 저장 수: %d개", + totalSsgcodes, processed, saved, uniqueSsgCodes + )); + + if (!failedSsgCodes.isEmpty()) { + summary.append(String.format("\n실패: %d개", failedSsgCodes.size())); + summary.append("\n실패한 코드: ").append(String.join(", ", failedSsgCodes)); + } + + double successRate = ((double) uniqueSsgCodes / totalSsgcodes) * 100; + if (successRate < 90) { + summary.append(String.format("\n경고: 전체 중 %.2f%%만 성공적으로 처리됨", successRate)); + } + + return summary.toString(); + } + + private void shutdownExecutorService() { + try { + executorService.shutdown(); + if (!executorService.awaitTermination(30, TimeUnit.MINUTES)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private Set createConcurrentSet() { + return Collections.newSetFromMap(new ConcurrentHashMap<>()); + } + + private static Predicate distinctByKey(Function keyExtractor) { + Set seen = ConcurrentHashMap.newKeySet(); + return t -> seen.add(keyExtractor.apply(t)); + } } diff --git a/src/main/java/com/chapter1/blueprint/subscription/service/SubscriptionService.java b/src/main/java/com/chapter1/blueprint/subscription/service/SubscriptionService.java index 798f8cb..408eedb 100644 --- a/src/main/java/com/chapter1/blueprint/subscription/service/SubscriptionService.java +++ b/src/main/java/com/chapter1/blueprint/subscription/service/SubscriptionService.java @@ -1,14 +1,17 @@ package com.chapter1.blueprint.subscription.service; +import com.chapter1.blueprint.member.domain.Member; +import com.chapter1.blueprint.member.repository.MemberRepository; import com.chapter1.blueprint.subscription.domain.SubscriptionList; import com.chapter1.blueprint.subscription.repository.RealEstatePriceRepository; import com.chapter1.blueprint.subscription.repository.SubscriptionListRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.Column; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,17 +32,24 @@ public class SubscriptionService { private final RealEstatePriceRepository realEstatePriceRepository; private final SubscriptionListRepository subscriptionListRepository; + private final MemberRepository memberRepository; @Value("${public.api.key}") private String apiKey; - @Value("${sub.api.url}") - private String subApiUrl; + @Value("${sub.apt.api.url}") + private String subAptApiUrl; - public String updateSub() { - String requestUrl = subApiUrl +"?"+"serviceKey="+apiKey; + @Value("${sub.apt2.api.url}") + private String subApt2ApiUrl; - try{ + @Value("${sub.other.api.url}") + private String subOtherApiUrl; + + public String updateSubAPT() { + String requestUrl = subAptApiUrl + "?page=1&perPage=50&" + "serviceKey=" + apiKey; + + try { URL url = new URL(requestUrl); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); @@ -77,15 +87,128 @@ public String updateSub() { } subscriptionList.setName(item.path("HOUSE_NM").asText()); subscriptionList.setHouseManageNo(item.path("HOUSE_MANAGE_NO").asInt()); + subscriptionList.setHouseDtlSecdNm(item.path("HOUSE_DTL_SECD_NM").asText()); subscriptionList.setRentSecd(item.path("RENT_SECD_NM").asText()); subscriptionList.setHouseDtlSecd(item.path("HOUSE_DTL_SECD_NM").asText()); - subscriptionList.setRceptBgnde(parseDate(item.path("RCEPT_BGNDE").asText(),dateFormat)); - subscriptionList.setRceptEndde(parseDate(item.path("RCEPT_ENDDE").asText(),dateFormat)); + subscriptionList.setRceptBgnde(parseDate(item.path("RCEPT_BGNDE").asText(), dateFormat)); + subscriptionList.setRceptEndde(parseDate(item.path("RCEPT_ENDDE").asText(), dateFormat)); subscriptionList.setPblancUrl(item.path("PBLANC_URL").asText()); subscriptionListRepository.save(subscriptionList); } - } catch (Exception e){ + } catch (Exception e) { + log.error(e.getMessage()); + } + return "API 불러오기 및 DB저장 성공"; + } + + public String updateSubAPT2() { + String requestUrl = subApt2ApiUrl + "?page=1&perPage=50&" + "serviceKey=" + apiKey; + + try { + URL url = new URL(requestUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8")); + StringBuilder response = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + log.info("API Response: {}", response.toString()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(response.toString()); + JsonNode items = rootNode.path("data"); + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + for (JsonNode item : items) { + SubscriptionList subscriptionList = new SubscriptionList(); + + //subscriptionList.setRegion(item.path("SUBSCRPT_AREA_CODE_NM").asText()); + + String hssplyAdres = item.path("HSSPLY_ADRES").asText(); + String[] addressParts = parseAddress(hssplyAdres); + + if (addressParts != null) { + subscriptionList.setRegion(addressParts[0]); // 예: "울산광역시" + subscriptionList.setCity(addressParts[1]); // 예: "중구" + subscriptionList.setDistrict(addressParts[2]); // 예: "우정동" + subscriptionList.setDetail(addressParts[3]); // 예: "286-1번지" + } + subscriptionList.setName(item.path("HOUSE_NM").asText()); + subscriptionList.setHouseManageNo(item.path("HOUSE_MANAGE_NO").asInt()); + subscriptionList.setHouseDtlSecdNm(item.path("HOUSE_SECD_NM").asText()); + subscriptionList.setRentSecd(item.path("HOUSE_SECD_NM").asText()); + subscriptionList.setHouseDtlSecd(item.path("HOUSE_SECD_NM").asText()); + subscriptionList.setRceptBgnde(parseDate(item.path("SUBSCRPT_RCEPT_BGNDE").asText(), dateFormat)); + subscriptionList.setRceptEndde(parseDate(item.path("SUBSCRPT_RCEPT_ENDDE").asText(), dateFormat)); + subscriptionList.setPblancUrl(item.path("PBLANC_URL").asText()); + + subscriptionListRepository.save(subscriptionList); + } + } catch (Exception e) { + log.error(e.getMessage()); + } + return "API 불러오기 및 DB저장 성공"; + } + + public String updateSubOther() { + String requestUrl = subOtherApiUrl + "?page=1&perPage=50&" + "serviceKey=" + apiKey; + + try { + URL url = new URL(requestUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + + BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8")); + StringBuilder response = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + log.info("API Response: {}", response.toString()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(response.toString()); + JsonNode items = rootNode.path("data"); + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + + for (JsonNode item : items) { + SubscriptionList subscriptionList = new SubscriptionList(); + + //subscriptionList.setRegion(item.path("SUBSCRPT_AREA_CODE_NM").asText()); + + String hssplyAdres = item.path("HSSPLY_ADRES").asText(); + String[] addressParts = parseAddress(hssplyAdres); + + if (addressParts != null) { + subscriptionList.setRegion(addressParts[0]); // 예: "울산광역시" + subscriptionList.setCity(addressParts[1]); // 예: "중구" + subscriptionList.setDistrict(addressParts[2]); // 예: "우정동" + subscriptionList.setDetail(addressParts[3]); // 예: "286-1번지" + } + subscriptionList.setName(item.path("HOUSE_NM").asText()); + subscriptionList.setHouseManageNo(item.path("HOUSE_MANAGE_NO").asInt()); + subscriptionList.setHouseDtlSecdNm(item.path("HOUSE_DTL_SECD_NM").asText()); + subscriptionList.setRentSecd(item.path("HOUSE_SECD_NM").asText()); + subscriptionList.setHouseDtlSecd(item.path("HOUSE_DTL_SECD_NM").asText()); + subscriptionList.setRceptBgnde(parseDate(item.path("SUBSCRPT_RCEPT_BGNDE").asText(), dateFormat)); + subscriptionList.setRceptEndde(parseDate(item.path("SUBSCRPT_RCEPT_ENDDE").asText(), dateFormat)); + subscriptionList.setPblancUrl(item.path("PBLANC_URL").asText()); + + subscriptionListRepository.save(subscriptionList); + } + } catch (Exception e) { log.error(e.getMessage()); } return "API 불러오기 및 DB저장 성공"; @@ -101,17 +224,39 @@ private Date parseDate(String dateStr, SimpleDateFormat dateFormat) { } private String[] parseAddress(String address) { - // 주소를 "시도 시군구 읍면동 나머지주소" 형태로 파싱하기 위한 정규식 사용 - Pattern pattern = Pattern.compile("^(\\S+시|\\S+도)\\s(\\S+구|\\S+군|\\S+시)\\s(\\S+동|\\S+읍|\\S+면)\\s(.+)$"); + // 시도 (특별시, 광역시, 도) + (구/군/시) + (동/가/도로명 등) + 나머지 주소를 파싱 + Pattern pattern = Pattern.compile( + "^(\\S+시|\\S+도|\\S+특별자치시)\\s?" + // 시도: 서울특별시, 경기도, 세종특별자치시 등 + "(\\S+구|\\S+군|\\S+시)?\\s?" + // 시군구: 영등포구, 아산시 등 (선택적) + "((?:\\S+동(?:\\d*가)?|\\S+읍|\\S+면|.+로|.+길)\\s?(?:\\d+번지)?)?\\s?" + // 읍면동/도로명, 번지 포함 + "(.+)?$" // 나머지 주소 + ); Matcher matcher = pattern.matcher(address); if (matcher.find()) { - return new String[]{matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(4)}; + // 각 그룹을 확인하며 null인 경우를 빈 문자열로 처리 + String region1 = matcher.group(1) != null ? matcher.group(1) : ""; // 시도 + String region2 = matcher.group(2) != null ? matcher.group(2) : ""; // 시군구 + String region3 = matcher.group(3) != null ? matcher.group(3) : ""; // 읍면동/도로명 + String restAddress = matcher.group(4) != null ? matcher.group(4) : ""; // 나머지 주소 + + return new String[]{region1, region2, region3, restAddress}; } return null; // 주소 형식이 맞지 않으면 null 반환 } - public List getAllSubscription(){ + public Page getAllSubscription(Pageable pageable) { + return subscriptionListRepository.findAll(pageable); + } + + public List getAllSubscriptions() { return subscriptionListRepository.findAll(); } + + public List recommendSubscription(Long uid) { + Member member = memberRepository.findById(uid) + .orElseThrow(() -> new RuntimeException("Member not found with uid (recommendSubscription): " + uid)); + + return subscriptionListRepository.findByRegionAndCityContaining(member.getRegion(), member.getDistrict()); + } } diff --git a/src/test/java/com/chapter1/blueprint/SwaggerConfigTest.java b/src/test/java/com/chapter1/blueprint/SwaggerConfigTest.java new file mode 100644 index 0000000..def1a44 --- /dev/null +++ b/src/test/java/com/chapter1/blueprint/SwaggerConfigTest.java @@ -0,0 +1,7 @@ +package com.chapter1.blueprint; + +import static org.junit.jupiter.api.Assertions.*; + +class SwaggerConfigTest { + +} \ No newline at end of file diff --git a/src/test/java/com/chapter1/blueprint/member/controller/NotificationControllerTest.java b/src/test/java/com/chapter1/blueprint/member/controller/NotificationControllerTest.java new file mode 100644 index 0000000..e23f3ef --- /dev/null +++ b/src/test/java/com/chapter1/blueprint/member/controller/NotificationControllerTest.java @@ -0,0 +1,187 @@ +package com.chapter1.blueprint.member.controller; + +import com.chapter1.blueprint.exception.dto.SuccessResponse; +import com.chapter1.blueprint.member.domain.PolicyAlarm; +import com.chapter1.blueprint.member.service.MemberService; +import com.chapter1.blueprint.member.service.NotificationService; +import com.chapter1.blueprint.policy.domain.PolicyDetailFilter; +import com.chapter1.blueprint.policy.domain.PolicyList; +import com.chapter1.blueprint.policy.repository.PolicyListRepository; +import com.chapter1.blueprint.policy.service.PolicyRecommendationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class NotificationControllerTest { + + @Mock + private NotificationService notificationService; + + @Mock + private MemberService memberService; + + @Mock + private PolicyRecommendationService policyRecommendationService; + + @Mock + private PolicyListRepository policyListRepository; + + @InjectMocks + private NotificationController notificationController; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testUpdateNotificationStatus_EnableNotifications() { + // 알림을 ON으로 설정하는 테스트 + Long mockUid = 123L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + + Map request = new HashMap<>(); + request.put("notificationEnabled", true); + + ResponseEntity response = notificationController.updateNotificationStatus(request); + + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Notification status updated successfully.", response.getBody()); + verify(notificationService, times(1)).updateNotificationStatus(mockUid, true); + } + + @Test + void testUpdateNotificationStatus_DisableNotifications() { + // 알림을 OFF로 설정하는 테스트 + Long mockUid = 123L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + + Map request = new HashMap<>(); + request.put("notificationEnabled", false); + + ResponseEntity response = notificationController.updateNotificationStatus(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Notification status updated successfully.", response.getBody()); + verify(notificationService, times(1)).updateNotificationStatus(mockUid, false); + } + + @Test + void testUpdateNotificationSettingsForPolicy() { + // 특정 정책에 대해 알림을 설정하는 테스트 + Long mockUid = 123L; + Long policyIdx = 1L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + + Map request = new HashMap<>(); + request.put("notificationEnabled", true); + request.put("applyEndDate", new Date()); + + ResponseEntity response = notificationController.updateNotificationSettings(policyIdx, request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Notification settings updated successfully.", response.getBody()); + verify(notificationService, times(1)).saveOrUpdateNotification(mockUid, policyIdx, true); + } + + @Test + void testDeleteNotificationSettings() { + // 특정 정책에 대한 알림 설정을 삭제하는 테스트 + Long mockUid = 123L; + Long policyIdx = 1L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + + ResponseEntity response = notificationController.deleteNotificationSettings(policyIdx); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Notification settings deleted successfully.", response.getBody()); + verify(notificationService, times(1)).deleteNotification(mockUid, policyIdx); + } + + @Test + void testGetMemberDefinedNotifications() { + // 사용자가 직접 설정한 알림 목록을 가져오는 테스트 + Long mockUid = 123L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + List mockNotifications = List.of(new PolicyAlarm()); + + when(notificationService.getMemberNotifications(mockUid)).thenReturn(mockNotifications); + + ResponseEntity response = notificationController.getMemberDefinedNotifications(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + SuccessResponse successResponse = (SuccessResponse) response.getBody(); + assertEquals(mockNotifications, successResponse.getResponse().getData()); + } + + @Test + void testGetRecommendedNotifications() { + // 시스템에서 추천하는 알림 목록을 가져오는 테스트 + Long mockUid = 123L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + List mockRecommendedNotifications = List.of(new PolicyAlarm()); + + when(notificationService.getRecommendedNotifications(mockUid)).thenReturn(mockRecommendedNotifications); + + ResponseEntity response = notificationController.getRecommendedNotifications(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + SuccessResponse successResponse = (SuccessResponse) response.getBody(); + assertEquals(mockRecommendedNotifications, successResponse.getResponse().getData()); + } + + @Test + void testGetNotificationDashboard() { + Long mockUid = 123L; + when(memberService.getAuthenticatedUid()).thenReturn(mockUid); + + PolicyAlarm memberAlarm = new PolicyAlarm(); + memberAlarm.setPolicyIdx(1L); + PolicyAlarm recommendedAlarm = new PolicyAlarm(); + recommendedAlarm.setPolicyIdx(2L); + + List mockMemberNotifications = List.of(memberAlarm); + List mockRecommendedNotifications = List.of(recommendedAlarm); + List mockRecommendedPolicies = List.of(new PolicyDetailFilter()); + + PolicyList mockPolicy = new PolicyList(); + mockPolicy.setName("Test Policy"); + mockPolicy.setApplyEndDate(new Date()); + + when(notificationService.getMemberNotifications(mockUid)).thenReturn(mockMemberNotifications); + when(notificationService.getRecommendedNotifications(mockUid)).thenReturn(mockRecommendedNotifications); + when(policyRecommendationService.getRecommendedPolicies(mockUid)).thenReturn(mockRecommendedPolicies); + + when(policyListRepository.findById(1L)).thenReturn(Optional.of(mockPolicy)); + when(policyListRepository.findById(2L)).thenReturn(Optional.of(mockPolicy)); + + ResponseEntity response = notificationController.getNotificationDashboard(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + Map dashboard = (Map) response.getBody(); + + System.out.println("Dashboard: " + dashboard); + + // 대시보드의 "memberNotifications" 값 확인 + List> memberNotifications = (List>) dashboard.get("memberNotifications"); + assertEquals(1, memberNotifications.size()); + assertEquals("Test Policy", memberNotifications.get(0).get("policyName")); + assertEquals(mockPolicy.getApplyEndDate(), memberNotifications.get(0).get("applyEndDate")); + + // 대시보드의 "recommendedNotifications" 값 확인 + List> recommendedNotifications = (List>) dashboard.get("recommendedNotifications"); + assertEquals(1, recommendedNotifications.size()); + assertEquals("Test Policy", recommendedNotifications.get(0).get("policyName")); + assertEquals(mockPolicy.getApplyEndDate(), recommendedNotifications.get(0).get("applyEndDate")); + } +} diff --git a/src/test/java/com/chapter1/blueprint/member/service/MemberServiceTest.java b/src/test/java/com/chapter1/blueprint/member/service/MemberServiceTest.java new file mode 100644 index 0000000..20ae700 --- /dev/null +++ b/src/test/java/com/chapter1/blueprint/member/service/MemberServiceTest.java @@ -0,0 +1,76 @@ +package com.chapter1.blueprint.member.service; + +import com.chapter1.blueprint.security.util.JwtProcessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MemberServiceTest { + + @Mock + private JwtProcessor jwtProcessor; + + @InjectMocks + private MemberService memberService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testGetAuthenticatedUid_Success() { + SecurityContext securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + + String token = "mockToken"; + when(securityContext.getAuthentication()) + .thenReturn(new UsernamePasswordAuthenticationToken(null, token)); + + Long mockUid = 123L; + when(jwtProcessor.getUid(token)).thenReturn(mockUid); + + Long uid = memberService.getAuthenticatedUid(); + + assertNotNull(uid); + assertEquals(mockUid, uid); + verify(jwtProcessor, times(1)).getUid(token); + } + + @Test + void testGetAuthenticatedUid_NoAuthentication() { + SecurityContext securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + + when(securityContext.getAuthentication()).thenReturn(null); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + memberService.getAuthenticatedUid(); + }); + assertEquals("No authentication found", exception.getMessage()); + } + + @Test + void testGetAuthenticatedUid_InvalidCredentials() { + + SecurityContext securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + + when(securityContext.getAuthentication()) + .thenReturn(new UsernamePasswordAuthenticationToken(null, 123)); // Invalid credentials + + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + memberService.getAuthenticatedUid(); + }); + assertEquals("Invalid authentication credentials", exception.getMessage()); + } +}