diff --git a/README.md b/README.md index a4cb146..619cae5 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ LLM 기반 챗봇과의 대화를 통해 사용자는 자신에게 알맞는 요금제를 비교, 추천 받을 수 있습니다. -### [프론트엔드 레포지토리 바로가기](https://github.com/U-plait/u-plait-fe) -### [AI 레포지토리 바로가기](https://github.com/U-plait/u-plait-ai) +### [U-plait : 프론트엔드 레포지토리 바로가기](https://github.com/U-plait/u-plait-fe) +### [U-plait : AI 레포지토리 바로가기](https://github.com/U-plait/u-plait-ai)

# 1. 프로젝트의 배경 ### 1.1 문제인식 @@ -158,7 +158,7 @@ LLM 기반 챗봇과의 대화를 통해 사용자는 자신에게 알맞는 요 - ERD
![Image](https://github.com/user-attachments/assets/271fbd65-50ba-4528-a96c-3ae0b16a7d34) - +

# 6. 기대 효과 diff --git a/build.gradle b/build.gradle index 35911fd..bcc56fb 100644 --- a/build.gradle +++ b/build.gradle @@ -69,8 +69,18 @@ dependencies { //validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + //smtp + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // spring batch + implementation 'org.springframework.boot:spring-boot-starter-batch' } tasks.named('test') { useJUnitPlatform() } + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs << "-parameters" +} diff --git a/src/main/java/com/ureca/uplait/domain/admin/service/AdminPlanService.java b/src/main/java/com/ureca/uplait/domain/admin/service/AdminPlanService.java index 5204300..1d539d6 100644 --- a/src/main/java/com/ureca/uplait/domain/admin/service/AdminPlanService.java +++ b/src/main/java/com/ureca/uplait/domain/admin/service/AdminPlanService.java @@ -1,33 +1,20 @@ package com.ureca.uplait.domain.admin.service; -import static com.ureca.uplait.domain.plan.util.DescriptionUtil.createDescription; - import com.ureca.uplait.domain.admin.api.FastAPIClient; -import com.ureca.uplait.domain.admin.dto.request.AdminIPTVPlanCreateRequest; -import com.ureca.uplait.domain.admin.dto.request.AdminIPTVPlanUpdateRequest; -import com.ureca.uplait.domain.admin.dto.request.AdminInternetPlanCreateRequest; -import com.ureca.uplait.domain.admin.dto.request.AdminInternetPlanUpdateRequest; -import com.ureca.uplait.domain.admin.dto.request.AdminMobileCreateRequest; -import com.ureca.uplait.domain.admin.dto.request.AdminMobilePlanUpdateRequest; +import com.ureca.uplait.domain.admin.dto.request.*; import com.ureca.uplait.domain.admin.dto.response.AdminPlanCreateResponse; import com.ureca.uplait.domain.admin.dto.response.AdminPlanDeleteResponse; import com.ureca.uplait.domain.admin.dto.response.AdminPlanDetailResponse; import com.ureca.uplait.domain.admin.dto.response.AdminUpdateAllVectorResponse; import com.ureca.uplait.domain.admin.repository.PlanVectorJdbcRepository; +import com.ureca.uplait.domain.email.batch.EmailBatchRunner; import com.ureca.uplait.domain.community.entity.CommunityBenefit; import com.ureca.uplait.domain.community.entity.CommunityBenefitPrice; import com.ureca.uplait.domain.community.entity.PlanCommunity; import com.ureca.uplait.domain.community.repository.CommunityBenefitPriceRepository; import com.ureca.uplait.domain.community.repository.CommunityBenefitRepository; import com.ureca.uplait.domain.community.repository.PlanCommunityRepository; -import com.ureca.uplait.domain.plan.dto.response.CommunityBenefitResponse; -import com.ureca.uplait.domain.plan.dto.response.IPTVPlanDetailResponse; -import com.ureca.uplait.domain.plan.dto.response.InternetPlanDetailResponse; -import com.ureca.uplait.domain.plan.dto.response.MobilePlanDetailResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanCreationInfoResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanDetailResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanResponseFactory; -import com.ureca.uplait.domain.plan.dto.response.TagResponse; +import com.ureca.uplait.domain.plan.dto.response.*; import com.ureca.uplait.domain.plan.entity.IPTVPlan; import com.ureca.uplait.domain.plan.entity.InternetPlan; import com.ureca.uplait.domain.plan.entity.MobilePlan; @@ -40,16 +27,17 @@ import com.ureca.uplait.domain.user.repository.TagRepository; import com.ureca.uplait.global.exception.GlobalException; import com.ureca.uplait.global.response.ResultCode; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.stream.Collectors; + +import static com.ureca.uplait.domain.plan.util.DescriptionUtil.createDescription; + @Service @RequiredArgsConstructor @Transactional @@ -63,6 +51,7 @@ public class AdminPlanService { private final CommunityBenefitPriceRepository communityBenefitPriceRepository; private final PlanVectorJdbcRepository planVectorJdbcRepository; private final FastAPIClient fastAPIClient; + private final EmailBatchRunner emailBatchRunner; @Transactional public AdminPlanCreateResponse createMobilePlan(AdminMobileCreateRequest request) { @@ -82,6 +71,12 @@ public AdminPlanCreateResponse createMobilePlan(AdminMobileCreateRequest request getPricesGroupedByBenefit(communityBenefitList)); fastAPIClient.saveVector(savedPlan, description); + // Batch 실행 + String tagIdStr = tagList.stream() + .map(t -> String.valueOf(t.getId())) + .collect(Collectors.joining(",")); + emailBatchRunner.runEmailBatchAsync(plan.getId(), tagIdStr); + return new AdminPlanCreateResponse(savedPlan.getId()); } @@ -103,6 +98,12 @@ public AdminPlanCreateResponse createInternetPlan(AdminInternetPlanCreateRequest getPricesGroupedByBenefit(communityBenefitList)); fastAPIClient.saveVector(savedPlan, description); + // Batch 실행 + String tagIdStr = tagList.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + emailBatchRunner.runEmailBatchAsync(plan.getId(), tagIdStr); + return new AdminPlanCreateResponse(savedPlan.getId()); } @@ -124,6 +125,12 @@ public AdminPlanCreateResponse createIptvPlan(AdminIPTVPlanCreateRequest request getPricesGroupedByBenefit(communityBenefitList)); fastAPIClient.saveVector(savedPlan, description); + // Batch 실행 + String tagIdStr = tagList.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + emailBatchRunner.runEmailBatchAsync(plan.getId(), tagIdStr); + return new AdminPlanCreateResponse(savedPlan.getId()); } @@ -231,7 +238,7 @@ public AdminPlanDetailResponse getPlanDetail(Long planId) { public PlanDetailResponse getTypedPlanDetail(String type, Long planId) { Plan plan = getPlan(planId); - return PlanResponseFactory.from(plan, false); + return PlanResponseFactory.from(plan, null,false); } @Transactional diff --git a/src/main/java/com/ureca/uplait/domain/auth/service/AuthService.java b/src/main/java/com/ureca/uplait/domain/auth/service/AuthService.java index 4467d6b..e5ea123 100644 --- a/src/main/java/com/ureca/uplait/domain/auth/service/AuthService.java +++ b/src/main/java/com/ureca/uplait/domain/auth/service/AuthService.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import jakarta.transaction.Transactional; import org.springframework.stereotype.Service; import com.ureca.uplait.domain.auth.api.KakaoOauthClient; @@ -81,6 +82,7 @@ public void reissue(String refreshToken, HttpServletResponse response){ jwtProvider.addRefreshTokenCookie(response, newRefreshToken); } + @Transactional public void logout(String refreshToken, HttpServletResponse response){ if (!jwtValidator.validateToken(refreshToken)) { diff --git a/src/main/java/com/ureca/uplait/domain/email/batch/EmailBatchRunner.java b/src/main/java/com/ureca/uplait/domain/email/batch/EmailBatchRunner.java new file mode 100644 index 0000000..32523af --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/email/batch/EmailBatchRunner.java @@ -0,0 +1,35 @@ +package com.ureca.uplait.domain.email.batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailBatchRunner { + + private final JobLauncher jobLauncher; + private final Job emailSendJob; + + @Async + public void runEmailBatchAsync(Long planId, String tagIdsStr) { + try { + log.info("runEmailBatchRunner 실행"); + jobLauncher.run( + emailSendJob, + new JobParametersBuilder() + .addString("timestamp", String.valueOf(System.currentTimeMillis())) + .addString("planId", planId.toString()) + .addString("tagIds", tagIdsStr) + .toJobParameters() + ); + } catch (Exception e) { + log.error("이메일 배치 실행 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/ureca/uplait/domain/email/batch/JdbcPagingUserReader.java b/src/main/java/com/ureca/uplait/domain/email/batch/JdbcPagingUserReader.java new file mode 100644 index 0000000..122d0cc --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/email/batch/JdbcPagingUserReader.java @@ -0,0 +1,34 @@ +package com.ureca.uplait.domain.email.batch; + +import com.ureca.uplait.domain.user.entity.User; +import com.ureca.uplait.domain.user.repository.UserJdbcRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.ItemReader; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Iterator; +import java.util.List; + +@RequiredArgsConstructor +public class JdbcPagingUserReader implements ItemReader { + + private final UserJdbcRepository userJdbcRepository; + private final Long planId; + private final int pageSize; + + private int currentPage = 0; + private Iterator currentIterator; + + @Override + public User read() { + if (currentIterator == null || !currentIterator.hasNext()) { + Pageable pageable = PageRequest.of(currentPage++, pageSize); + List users = userJdbcRepository.findUsersWithMatchingTopTagsByPlanId(planId, pageable); + if (users.isEmpty()) return null; // 배치 종료 조건 + currentIterator = users.iterator(); + } + + return currentIterator.next(); + } +} diff --git a/src/main/java/com/ureca/uplait/domain/email/entity/Email.java b/src/main/java/com/ureca/uplait/domain/email/entity/Email.java new file mode 100644 index 0000000..81ba829 --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/email/entity/Email.java @@ -0,0 +1,11 @@ +package com.ureca.uplait.domain.email.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Email { + private String title; + private String content; +} diff --git a/src/main/java/com/ureca/uplait/domain/email/util/EmailTemplateUtil.java b/src/main/java/com/ureca/uplait/domain/email/util/EmailTemplateUtil.java new file mode 100644 index 0000000..37eca5b --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/email/util/EmailTemplateUtil.java @@ -0,0 +1,83 @@ +package com.ureca.uplait.domain.email.util; + +import com.ureca.uplait.domain.email.entity.Email; +import com.ureca.uplait.domain.plan.entity.IPTVPlan; +import com.ureca.uplait.domain.plan.entity.InternetPlan; +import com.ureca.uplait.domain.plan.entity.MobilePlan; +import com.ureca.uplait.domain.plan.entity.Plan; +import com.ureca.uplait.domain.user.entity.User; +import com.ureca.uplait.global.exception.GlobalException; + +import static com.ureca.uplait.global.response.ResultCode.INVALID_PLAN; + +public class EmailTemplateUtil { + + public static Email buildEmail(User user, Plan plan) { + String title = String.format("[Uplait] 신규 요금제 '%s' 출시! 특별 혜택 확인하세요!", plan.getPlanName()); + String content = String.format( + "안녕하세요 %s님.\n\n" + + "Uplait에서 %s님이 관심있어하시는 주제와 관련된 새로운 요금제 '%s'가 출시되었습니다!\n" + + "요금: %,d원\n" + + buildDetail(plan) + + "아래 링크에서 자세한 혜택을 확인해보세요.\n\n" + + "자세히 보기: https://uplait.site/%s/plan/%s" + + "\n감사합니다,\nUplait 드림", + user.getName() != null ? user.getName() : "고객", + user.getName() != null ? user.getName() : "고객", + plan.getPlanName(), + plan.getPlanPrice(), + getType(plan), + plan.getId() + ); + return new Email(title, content); + } + + private static String buildDetail(Plan plan) { + if(plan instanceof MobilePlan mp) { + return buildMobile(mp); + } else if(plan instanceof InternetPlan ip) { + return buildInternet(ip); + } else if(plan instanceof IPTVPlan ip) { + return buildIptv(ip); + } else { + throw new GlobalException(INVALID_PLAN); + } + } + + private static String getType(Plan plan) { + if(plan instanceof MobilePlan) { + return "mobile"; + } else if(plan instanceof InternetPlan) { + return "internet"; + } else if(plan instanceof IPTVPlan) { + return "iptv"; + } else { + throw new GlobalException(INVALID_PLAN); + } + } + + private static String buildMobile(MobilePlan mp) { + return String.format( + "데이터: %s\n" + + "음성통화: %s\n" + + "문자: %s\n\n", + mp.getData(), + mp.getVoiceCall(), + mp.getMessage() + ); + } + + private static String buildInternet(InternetPlan ip) { + return String.format( + "인터넷 속도: %s\n", + ip.getVelocity() + ); + } + + private static String buildIptv(IPTVPlan ip) { + return String.format( + "채널 수: %s\n", + ip.getChannel() + ); + } +} diff --git a/src/main/java/com/ureca/uplait/domain/plan/dto/response/IPTVPlanDetailResponse.java b/src/main/java/com/ureca/uplait/domain/plan/dto/response/IPTVPlanDetailResponse.java index a98b9f2..c33a485 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/dto/response/IPTVPlanDetailResponse.java +++ b/src/main/java/com/ureca/uplait/domain/plan/dto/response/IPTVPlanDetailResponse.java @@ -2,9 +2,10 @@ import com.ureca.uplait.domain.plan.entity.IPTVPlan; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; import lombok.Getter; +import java.util.List; + @Getter @Schema(description = "IPTV 요금제 상세") public class IPTVPlanDetailResponse extends PlanDetailResponse { @@ -22,8 +23,8 @@ public class IPTVPlanDetailResponse extends PlanDetailResponse { @Schema(description = "결합 혜택", example = "가족결합") private List communityBenefitList; - public IPTVPlanDetailResponse(IPTVPlan plan, boolean inUse) { - super(plan, inUse); + public IPTVPlanDetailResponse(IPTVPlan plan, List communityIdList, boolean inUse) { + super(plan, communityIdList, inUse); this.channel = plan.getChannel(); this.iptvDiscount = plan.getPlanPrice() * (100 - plan.getIptvDiscountRate()) / 100; } diff --git a/src/main/java/com/ureca/uplait/domain/plan/dto/response/InternetPlanDetailResponse.java b/src/main/java/com/ureca/uplait/domain/plan/dto/response/InternetPlanDetailResponse.java index 8545ad5..6cafbdb 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/dto/response/InternetPlanDetailResponse.java +++ b/src/main/java/com/ureca/uplait/domain/plan/dto/response/InternetPlanDetailResponse.java @@ -2,9 +2,10 @@ import com.ureca.uplait.domain.plan.entity.InternetPlan; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; import lombok.Getter; +import java.util.List; + @Getter @Schema(description = "인터넷 요금제 상세") public class InternetPlanDetailResponse extends PlanDetailResponse { @@ -21,8 +22,8 @@ public class InternetPlanDetailResponse extends PlanDetailResponse { @Schema(description = "결합 혜택", example = "가족결합") private List communityBenefitList; - public InternetPlanDetailResponse(InternetPlan plan, boolean inUse) { - super(plan, inUse); + public InternetPlanDetailResponse(InternetPlan plan, List communityIdList, boolean inUse) { + super(plan, communityIdList, inUse); this.velocity = plan.getVelocity(); this.internetDiscount = plan.getInternetDiscountRate(); } diff --git a/src/main/java/com/ureca/uplait/domain/plan/dto/response/MobilePlanDetailResponse.java b/src/main/java/com/ureca/uplait/domain/plan/dto/response/MobilePlanDetailResponse.java index 7fdc808..6d6efed 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/dto/response/MobilePlanDetailResponse.java +++ b/src/main/java/com/ureca/uplait/domain/plan/dto/response/MobilePlanDetailResponse.java @@ -3,9 +3,10 @@ import com.ureca.uplait.domain.plan.entity.MediaBenefit; import com.ureca.uplait.domain.plan.entity.MobilePlan; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; import lombok.Getter; +import java.util.List; + @Getter @Schema(description = "모바일 요금제 상세") public class MobilePlanDetailResponse extends PlanDetailResponse { @@ -40,8 +41,8 @@ public class MobilePlanDetailResponse extends PlanDetailResponse { @Schema(description = "결합 혜택", example = "가족결합") private List communityBenefitList; - public MobilePlanDetailResponse(MobilePlan plan, boolean inUse) { - super(plan, inUse); + public MobilePlanDetailResponse(MobilePlan plan, List communityIdList, boolean inUse) { + super(plan, communityIdList, inUse); this.data = plan.getData(); this.sharedData = plan.getSharedData(); this.voiceCall = plan.getVoiceCall(); diff --git a/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanDetailResponse.java b/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanDetailResponse.java index 3ee0f0e..433a0b6 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanDetailResponse.java +++ b/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanDetailResponse.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import java.util.List; + @Getter @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = "요금제 상세 조회 결과") @@ -37,14 +39,18 @@ public abstract class PlanDetailResponse { @Schema(description = "플랜 설명", example = "너무 좋은 요금제") private String description; + @Schema(description = "요금제의 결합 상품", example = "1, 2, 3") + private List communityIdList; + - protected PlanDetailResponse(Plan plan, boolean inUse) { + protected PlanDetailResponse(Plan plan, List communityIdList, boolean inUse) { this.planId = plan.getId(); this.planName = plan.getPlanName(); this.planPrice = plan.getPlanPrice(); this.planBenefit = plan.getPlanBenefit(); this.availability = plan.getAvailability(); this.description = plan.getDescription(); + this.communityIdList = communityIdList; this.inUse = inUse; } diff --git a/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanResponseFactory.java b/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanResponseFactory.java index eab3449..73fd838 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanResponseFactory.java +++ b/src/main/java/com/ureca/uplait/domain/plan/dto/response/PlanResponseFactory.java @@ -6,17 +6,19 @@ import com.ureca.uplait.domain.plan.entity.Plan; import com.ureca.uplait.global.exception.GlobalException; +import java.util.List; + import static com.ureca.uplait.global.response.ResultCode.INVALID_PLAN; public class PlanResponseFactory { - public static PlanDetailResponse from(Plan plan, boolean inUse) { + public static PlanDetailResponse from(Plan plan, List communityIdList, boolean inUse) { if (plan instanceof IPTVPlan iptv) { - return new IPTVPlanDetailResponse(iptv, inUse); + return new IPTVPlanDetailResponse(iptv, communityIdList, inUse); } else if (plan instanceof InternetPlan internet) { - return new InternetPlanDetailResponse(internet, inUse); + return new InternetPlanDetailResponse(internet, communityIdList, inUse); } else if (plan instanceof MobilePlan mobile) { - return new MobilePlanDetailResponse(mobile, inUse); + return new MobilePlanDetailResponse(mobile, communityIdList, inUse); } else { throw new GlobalException(INVALID_PLAN); } diff --git a/src/main/java/com/ureca/uplait/domain/plan/service/PlanService.java b/src/main/java/com/ureca/uplait/domain/plan/service/PlanService.java index 5afd440..44d2596 100644 --- a/src/main/java/com/ureca/uplait/domain/plan/service/PlanService.java +++ b/src/main/java/com/ureca/uplait/domain/plan/service/PlanService.java @@ -1,14 +1,8 @@ package com.ureca.uplait.domain.plan.service; -import static com.ureca.uplait.global.response.ResultCode.INVALID_INPUT; -import static com.ureca.uplait.global.response.ResultCode.PLAN_NOT_FOUND; - +import com.ureca.uplait.domain.community.repository.PlanCommunityRepository; import com.ureca.uplait.domain.contract.repository.ContractRepository; -import com.ureca.uplait.domain.plan.dto.response.PlanCompareFactory; -import com.ureca.uplait.domain.plan.dto.response.PlanCompareResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanDetailResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanListResponse; -import com.ureca.uplait.domain.plan.dto.response.PlanResponseFactory; +import com.ureca.uplait.domain.plan.dto.response.*; import com.ureca.uplait.domain.plan.entity.Plan; import com.ureca.uplait.domain.plan.repository.IPTVPlanRepository; import com.ureca.uplait.domain.plan.repository.InternetPlanRepository; @@ -16,13 +10,17 @@ import com.ureca.uplait.domain.plan.repository.PlanRepository; import com.ureca.uplait.domain.user.entity.User; import com.ureca.uplait.global.exception.GlobalException; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static com.ureca.uplait.global.response.ResultCode.INVALID_INPUT; +import static com.ureca.uplait.global.response.ResultCode.PLAN_NOT_FOUND; + @Service @RequiredArgsConstructor public class PlanService { @@ -32,12 +30,15 @@ public class PlanService { private final MobilePlanRepository mobilePlanRepository; private final InternetPlanRepository internetPlanRepository; private final IPTVPlanRepository iptvPlanRepository; + private final PlanCommunityRepository planCommunityRepository; @Transactional(readOnly = true) public PlanDetailResponse getPlanDetail(User user, Long planId) { Plan plan = findPlan(planId); + List communityIdList = planCommunityRepository.findAllByPlan(plan).stream() + .map(pc -> pc.getCommunityBenefit().getId()).toList(); boolean inUse = contractRepository.existsByUserIdAndPlanId(user.getId(), planId); - return PlanResponseFactory.from(plan, inUse); + return PlanResponseFactory.from(plan, communityIdList, inUse); } private Plan findPlan(Long planId) { diff --git a/src/main/java/com/ureca/uplait/domain/user/controller/UserController.java b/src/main/java/com/ureca/uplait/domain/user/controller/UserController.java index 4e07291..283f458 100644 --- a/src/main/java/com/ureca/uplait/domain/user/controller/UserController.java +++ b/src/main/java/com/ureca/uplait/domain/user/controller/UserController.java @@ -44,24 +44,24 @@ public CommonResponse addTag(@RequestBody AddTagRequest request, @Authenti } @GetMapping("/duplicate/phone") - @Operation(summary="전화번호 중복검사 API", description = "추가정보 입력 시 전화번호 중복 검사를 수행한다.") + @Operation(summary = "전화번호 중복검사 API", description = "추가정보 입력 시 전화번호 중복 검사를 수행한다.") public CommonResponse duplicatePhone( - @RequestParam("value") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다.") - String phoneNumber) { - boolean duplicated = userService.isPhoneNumberDuplicated(phoneNumber); + @RequestParam("value") + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다.") String phoneNumber, + @RequestParam(value = "current", required = false) String currentPhoneNumber + ) { + boolean duplicated = userService.isPhoneNumberDuplicated(phoneNumber, currentPhoneNumber); DuplicateResponse result = new DuplicateResponse(duplicated); return CommonResponse.success(result); } @GetMapping("/duplicate/email") - @Operation(summary="이메일 중복검사 API", description = "추가정보 입력 시 이메일 중복 검사를 수행한다.") + @Operation(summary = "이메일 중복검사 API", description = "추가정보 입력 시 이메일 중복 검사를 수행한다.") public CommonResponse duplicateEmail( - @RequestParam("value") - @Email(message = "올바른 이메일 형식이 아닙니다.") - String email - ){ - boolean duplicated = userService.isEmailDuplicated(email); + @RequestParam("value") @Email(message = "올바른 이메일 형식이 아닙니다.") String email, + @RequestParam(value = "current", required = false) String currentEmail + ) { + boolean duplicated = userService.isEmailDuplicated(email, currentEmail); DuplicateResponse result = new DuplicateResponse(duplicated); return CommonResponse.success(result); } diff --git a/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepository.java b/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepository.java new file mode 100644 index 0000000..d98f1f4 --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepository.java @@ -0,0 +1,10 @@ +package com.ureca.uplait.domain.user.repository; + +import com.ureca.uplait.domain.user.entity.User; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface UserJdbcRepository { + List findUsersWithMatchingTopTagsByPlanId(Long planId, Pageable pageable); +} diff --git a/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepositoryImpl.java b/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepositoryImpl.java new file mode 100644 index 0000000..a8ebf0b --- /dev/null +++ b/src/main/java/com/ureca/uplait/domain/user/repository/UserJdbcRepositoryImpl.java @@ -0,0 +1,82 @@ +package com.ureca.uplait.domain.user.repository; + +import com.ureca.uplait.domain.user.entity.User; +import com.ureca.uplait.domain.user.enums.Gender; +import com.ureca.uplait.domain.user.enums.Role; +import com.ureca.uplait.domain.user.enums.Status; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class UserJdbcRepositoryImpl implements UserJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public List findUsersWithMatchingTopTagsByPlanId(Long planId, Pageable pageable) { + String sql = """ + WITH target_plan_tags AS ( + SELECT tag_id + FROM plan_tag + WHERE plan_id = ? + ), + user_top_tags AS ( + SELECT + user_id, + tag_id, + tag_count, + RANK() OVER (PARTITION BY user_id ORDER BY tag_count DESC) AS rank + FROM user_tag + ), + user_top2_tags AS ( + SELECT * + FROM user_top_tags + WHERE rank <= 2 + ), + matched_users AS ( + SELECT DISTINCT ut.user_id + FROM user_top2_tags ut + JOIN target_plan_tags tp ON ut.tag_id = tp.tag_id + ) + SELECT * + FROM users u + JOIN matched_users mu ON u.id = mu.user_id + WHERE u.ad_agree = true + ORDER BY u.id + LIMIT ? OFFSET ? + """; + + return jdbcTemplate.query( + sql, + new UserRowMapper(), + planId, + pageable.getPageSize(), + pageable.getOffset() + ); + } + + private static class UserRowMapper implements RowMapper { + @Override + public User mapRow(ResultSet rs, int rowNum) throws SQLException { + return User.builder() + .name(rs.getString("name")) + .kakaoId(rs.getString("kakao_id")) + .phoneNumber(rs.getString("phone_number")) + .email(rs.getString("email")) + .age(rs.getInt("age")) + .gender(Gender.valueOf(rs.getString("gender"))) + .role(Role.valueOf(rs.getString("role"))) + .status(Status.valueOf(rs.getString("status"))) + .adAgree(rs.getBoolean("ad_agree")) + .build(); + } + } +} diff --git a/src/main/java/com/ureca/uplait/domain/user/repository/UserRepository.java b/src/main/java/com/ureca/uplait/domain/user/repository/UserRepository.java index e3f89ae..53f6df1 100644 --- a/src/main/java/com/ureca/uplait/domain/user/repository/UserRepository.java +++ b/src/main/java/com/ureca/uplait/domain/user/repository/UserRepository.java @@ -1,13 +1,18 @@ package com.ureca.uplait.domain.user.repository; -import java.util.Optional; - +import com.ureca.uplait.domain.user.entity.User; +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 com.ureca.uplait.domain.user.entity.User; +import java.util.List; +import java.util.Optional; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, UserJdbcRepository{ Optional findByKakaoId(String kakaoId); boolean existsByPhoneNumber(String phoneNumber); boolean existsByEmail(String email); + Page findAllByAdAgreeTrue(Pageable pageable); } diff --git a/src/main/java/com/ureca/uplait/domain/user/service/UserService.java b/src/main/java/com/ureca/uplait/domain/user/service/UserService.java index de3cf00..5c4ab6c 100644 --- a/src/main/java/com/ureca/uplait/domain/user/service/UserService.java +++ b/src/main/java/com/ureca/uplait/domain/user/service/UserService.java @@ -51,12 +51,17 @@ public void addUserTag(AddTagRequest request, User user) { } } - public boolean isPhoneNumberDuplicated( - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호는 010-xxxx-xxxx 형식이어야 합니다.") String phoneNumber) { + public boolean isPhoneNumberDuplicated(String phoneNumber, String currentPhoneNumber) { + if (phoneNumber.equals(currentPhoneNumber)) { + return false; + } return userRepository.existsByPhoneNumber(phoneNumber); } - public boolean isEmailDuplicated(@Email(message = "올바른 이메일 형식이 아닙니다.") String email) { + public boolean isEmailDuplicated(String email, String currentEmail) { + if (email.equals(currentEmail)) { + return false; + } return userRepository.existsByEmail(email); } } diff --git a/src/main/java/com/ureca/uplait/global/config/BatchConfig.java b/src/main/java/com/ureca/uplait/global/config/BatchConfig.java new file mode 100644 index 0000000..2c69863 --- /dev/null +++ b/src/main/java/com/ureca/uplait/global/config/BatchConfig.java @@ -0,0 +1,132 @@ +package com.ureca.uplait.global.config; + +import com.ureca.uplait.domain.email.batch.JdbcPagingUserReader; +import com.ureca.uplait.domain.email.entity.Email; +import com.ureca.uplait.domain.email.util.EmailTemplateUtil; +import com.ureca.uplait.domain.plan.entity.Plan; +import com.ureca.uplait.domain.plan.repository.PlanRepository; +import com.ureca.uplait.domain.user.entity.User; +import com.ureca.uplait.domain.user.repository.UserRepository; +import com.ureca.uplait.global.exception.GlobalException; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Arrays; +import java.util.List; + +import static com.ureca.uplait.global.response.ResultCode.PLAN_NOT_FOUND; + +@Slf4j +@EnableAsync +@Configuration +public class BatchConfig { + + @Bean(name = "batchTaskExecutor") + public TaskExecutor batchTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("EmailBatch-"); + executor.initialize(); + return executor; + } + + @Bean + public Job emailSendJob(JobRepository jobRepository, Step sendStep) { + return new JobBuilder("emailSendJob", jobRepository) + .start(sendStep) + .build(); + } + + @Bean + public Step sendStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ItemReader userReader, + ItemProcessor emailProcessor, + ItemWriter emailWriter) { + + return new StepBuilder("sendStep", jobRepository) + .chunk(100, transactionManager) + .reader(userReader) + .processor(emailProcessor) + .writer(emailWriter) + .build(); + } + + @Bean + @StepScope + public ItemReader userReader( + UserRepository userRepository, + @Value("#{jobParameters['planId']}") Long planId + ) { + int pageSize = 100; // 원하는 페이지 사이즈로 설정 + return new JdbcPagingUserReader(userRepository, planId, pageSize); + } + + @Bean + @StepScope + public ItemProcessor emailProcessor( + JavaMailSender mailSender, + @Value("#{jobParameters['planId']}") String planIdStr, + @Value("#{jobParameters['tagIds']}") String tagIdsStr, + PlanRepository planRepository + ) { + return user -> { + // Plan 조회 + Long planId = Long.parseLong(planIdStr); + Plan plan = planRepository.findById(planId) + .orElseThrow(() -> new GlobalException(PLAN_NOT_FOUND)); + + // tagIds 파싱 및 조회 + List tagIds = Arrays.stream(tagIdsStr.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Long::parseLong) + .toList(); + + // 이메일 생성 + Email email = EmailTemplateUtil.buildEmail(user, plan); + + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); + helper.setTo(user.getEmail()); + helper.setSubject(email.getTitle()); + helper.setText(email.getContent()); + + return message; + }; + } + + @Bean + public ItemWriter emailWriter(JavaMailSender mailSender) { + return messages -> { + for (MimeMessage msg : messages) { + try { + mailSender.send(msg); + log.info("[이메일 발송 성공] to={}", (Object) msg.getAllRecipients()); + } catch (Exception e) { + log.error("[이메일 발송 실패] to={}, reason={}", msg.getAllRecipients(), e.getMessage(), e); + } + } + }; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1f9d617..3a10a53 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,23 @@ spring: liquibase: change-log: classpath:db/changelog/changelog-master.yaml enabled: true + batch: + job: + enabled: false + jdbc: + initialize-schema: always + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + timeout: 5000 + starttls: + enable: true kakao: client-id: ${KAKAO_CLIENT_ID} diff --git a/src/test/java/com/ureca/uplait/domain/admin/service/AdminPlanServiceTest.java b/src/test/java/com/ureca/uplait/domain/admin/service/AdminPlanServiceTest.java index 2e3adc0..a5d0a38 100644 --- a/src/test/java/com/ureca/uplait/domain/admin/service/AdminPlanServiceTest.java +++ b/src/test/java/com/ureca/uplait/domain/admin/service/AdminPlanServiceTest.java @@ -31,6 +31,8 @@ import com.ureca.uplait.domain.user.repository.TagRepository; import com.ureca.uplait.global.exception.GlobalException; import com.ureca.uplait.global.response.ResultCode; + +import java.util.HashSet; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -231,6 +233,8 @@ void getAllMobilePlans_success() { .mediaBenefit(MediaBenefit.PREMIUM) .durationDiscountRate(10) .premierDiscountRate(5) + .planTags(new HashSet<>()) + .communityBenefitList(new HashSet<>()) .build(); MobilePlan plan2 = MobilePlan.builder() @@ -247,6 +251,8 @@ void getAllMobilePlans_success() { .mediaBenefit(MediaBenefit.PREMIUM) .durationDiscountRate(5) .premierDiscountRate(2) + .planTags(new HashSet<>()) + .communityBenefitList(new HashSet<>()) .build(); List mockResponses = List.of( diff --git a/src/test/java/com/ureca/uplait/domain/admin/service/AdminReviewServiceTest.java b/src/test/java/com/ureca/uplait/domain/admin/service/AdminReviewServiceTest.java index 522b391..961e705 100644 --- a/src/test/java/com/ureca/uplait/domain/admin/service/AdminReviewServiceTest.java +++ b/src/test/java/com/ureca/uplait/domain/admin/service/AdminReviewServiceTest.java @@ -1,5 +1,6 @@ package com.ureca.uplait.domain.admin.service; +import com.ureca.uplait.domain.admin.dto.response.AdminDetailReviewResponse; import com.ureca.uplait.domain.admin.dto.response.AdminReviewDeleteResponse; import com.ureca.uplait.domain.admin.dto.response.AdminReviewResponse; import com.ureca.uplait.domain.plan.entity.MobilePlan; @@ -103,16 +104,14 @@ void getAllReviewsForAdmin() { review1.getUser().getName(), review1.getTitle(), review1.getRating(), - "25.06.08", - review1.getContent() + LocalDateTime.now() ); AdminReviewResponse response2 = new AdminReviewResponse( review2.getId(), review2.getUser().getName(), review2.getTitle(), review2.getRating(), - "25.06.09", - review2.getContent() + LocalDateTime.now() ); Page page = new PageImpl<>(List.of(response1, response2)); @@ -138,7 +137,7 @@ void getReviewDetailForAdmin() { when(reviewRepository.findById(user.getId())).thenReturn(Optional.of(review1)); //when - AdminReviewResponse response = adminReviewService.getReviewDetailForAdmin(user.getId()); + AdminDetailReviewResponse response = adminReviewService.getReviewDetailForAdmin(user.getId(), user); //then assertEquals(1L, response.getReviewId()); @@ -157,7 +156,7 @@ void getReviewDetailForAdmin_Exception() { //when & then GlobalException exception = assertThrows(GlobalException.class, () -> { - adminReviewService.getReviewDetailForAdmin(review1.getId()); + adminReviewService.getReviewDetailForAdmin(review1.getId(), user); }); assertEquals(ResultCode.REVIEW_NOT_FOUND, exception.getResultCode()); diff --git a/src/test/java/com/ureca/uplait/domain/review/service/ReviewServiceTest.java b/src/test/java/com/ureca/uplait/domain/review/service/ReviewServiceTest.java index 8e16905..00d1305 100644 --- a/src/test/java/com/ureca/uplait/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/ureca/uplait/domain/review/service/ReviewServiceTest.java @@ -1,5 +1,6 @@ package com.ureca.uplait.domain.review.service; +import com.ureca.uplait.domain.common.filter.BanWordFilter; import com.ureca.uplait.domain.plan.entity.MobilePlan; import com.ureca.uplait.domain.plan.repository.PlanRepository; import com.ureca.uplait.domain.review.dto.request.ReviewCreateRequest; @@ -35,6 +36,9 @@ class ReviewServiceTest { @Mock private PlanRepository planRepository; + @Mock + private BanWordFilter banWordFilter; + @InjectMocks private ReviewService reviewService; diff --git a/src/test/java/com/ureca/uplait/domain/user/service/UserServiceTest.java b/src/test/java/com/ureca/uplait/domain/user/service/UserServiceTest.java index 8366233..dc36e08 100644 --- a/src/test/java/com/ureca/uplait/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/ureca/uplait/domain/user/service/UserServiceTest.java @@ -2,8 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; import com.ureca.uplait.domain.user.dto.request.ExtraInfoRequest; import com.ureca.uplait.domain.user.entity.User; @@ -73,9 +72,10 @@ void updateUserExtraInfo_success() { @DisplayName("전화번호 중복 - x") void isPhoneNumberDuplicated_returnsFalse() { String uniquePhoneNumber = "010-1234-5678"; + String currentPhoneNumber = null; given(userRepository.existsByPhoneNumber(uniquePhoneNumber)).willReturn(false); - boolean isDuplicated = userService.isPhoneNumberDuplicated(uniquePhoneNumber); + boolean isDuplicated = userService.isPhoneNumberDuplicated(uniquePhoneNumber, currentPhoneNumber); assertThat(isDuplicated).isFalse(); verify(userRepository, times(1)).existsByPhoneNumber(uniquePhoneNumber); @@ -85,21 +85,35 @@ void isPhoneNumberDuplicated_returnsFalse() { @DisplayName("전화번호 중복 - o") void isPhoneNumberDuplicated_returnsTrue() { String duplicatedPhoneNumber = "010-9876-5432"; + String currentPhoneNumber = null; given(userRepository.existsByPhoneNumber(duplicatedPhoneNumber)).willReturn(true); - boolean isDuplicated = userService.isPhoneNumberDuplicated(duplicatedPhoneNumber); + boolean isDuplicated = userService.isPhoneNumberDuplicated(duplicatedPhoneNumber, currentPhoneNumber); assertThat(isDuplicated).isTrue(); verify(userRepository, times(1)).existsByPhoneNumber(duplicatedPhoneNumber); } + @Test + @DisplayName("전화번호 중복 - 전화번호 변경이 없는 경우") + void isPhoneNumberDuplicated_samePhoneNumber() { + String uniquePhoneNumber = "010-1234-5678"; + String currentPhoneNumber = "010-1234-5678"; + + boolean isDuplicated = userService.isPhoneNumberDuplicated(uniquePhoneNumber, currentPhoneNumber); + + assertThat(isDuplicated).isFalse(); + verify(userRepository, never()).existsByPhoneNumber(uniquePhoneNumber); + } + @Test @DisplayName("이메일 중복 - x") void isEmailDuplicated_returnsFalse() { String uniqueEmail = "unique@example.com"; + String currentEmail = null; given(userRepository.existsByEmail(uniqueEmail)).willReturn(false); - boolean isDuplicated = userService.isEmailDuplicated(uniqueEmail); + boolean isDuplicated = userService.isEmailDuplicated(uniqueEmail, currentEmail); assertThat(isDuplicated).isFalse(); verify(userRepository, times(1)).existsByEmail(uniqueEmail); @@ -109,11 +123,24 @@ void isEmailDuplicated_returnsFalse() { @DisplayName("이메일 중복 - o") void isEmailDuplicated_returnsTrue() { String duplicatedEmail = "duplicated@example.com"; + String currentEmail = null; given(userRepository.existsByEmail(duplicatedEmail)).willReturn(true); - boolean isDuplicated = userService.isEmailDuplicated(duplicatedEmail); + boolean isDuplicated = userService.isEmailDuplicated(duplicatedEmail, currentEmail); assertThat(isDuplicated).isTrue(); verify(userRepository, times(1)).existsByEmail(duplicatedEmail); } + + @Test + @DisplayName("이메일 중복 - 이메일 변경이 없는 경우") + void isEmailDuplicated_sameEmail() { + String uniqueEmail = "unique@example.com"; + String currentEmail = "unique@example.com"; + + boolean isDuplicated = userService.isEmailDuplicated(uniqueEmail, currentEmail); + + assertThat(isDuplicated).isFalse(); + verify(userRepository, never()).existsByEmail(uniqueEmail); + } } \ No newline at end of file