Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Feature/#46 - 각 가게별 Event 스케줄링 로직 구현 #50

Merged
merged 1 commit into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ dependencies {
implementation 'org.springframework.cloud:spring-cloud-aws-context:2.2.6.RELEASE'
implementation 'org.springframework.cloud:spring-cloud-aws-autoconfigure:2.2.6.RELEASE'

// Secheduler
implementation 'org.springframework.boot:spring-boot-starter-quartz'

// Testing Dependencies
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
1 change: 1 addition & 0 deletions http/onjung/OnjungControllerHttpRequest.http
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ Authorization: Bearer {{access_token}}
Content-Type: application/json

{
"event_id": {{onjung.API_4_7.event_id}},
"donation_amount": {{onjung.API_4_7.donation_amount}}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ public static StoreInfoDto fromEntity(Store store) {
@Getter
public static class EventInfoDto {

@NotNull(message = "id는 null일 수 없습니다.")
@JsonProperty("id")
private final Long id;

@NotNull(message = "total_amount는 null일 수 없습니다.")
@JsonProperty("total_amount")
private final Integer totalAmount;
Expand All @@ -140,13 +144,15 @@ public static class EventInfoDto {
private final Integer restOfDate;

@Builder
public EventInfoDto(Integer totalAmount, Integer restOfDate) {
public EventInfoDto(Integer totalAmount, Integer restOfDate, Long id) {
this.id = id;
this.totalAmount = totalAmount;
this.restOfDate = restOfDate;
}

public static EventInfoDto fromEntity(Integer totalAmount, Integer restOfDate) {
public static EventInfoDto of(Long id, Integer totalAmount, Integer restOfDate) {
return EventInfoDto.builder()
.id(id)
.totalAmount(totalAmount)
.restOfDate(restOfDate)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public ReadStoreDetailResponseDto execute(Long id) {
// event 정보
Integer totalAmount = getTotalAmount(id);

ReadStoreDetailResponseDto.EventInfoDto eventInfoDto = ReadStoreDetailResponseDto.EventInfoDto.fromEntity(totalAmount, eventService.getRestOfDate(event));
ReadStoreDetailResponseDto.EventInfoDto eventInfoDto = ReadStoreDetailResponseDto.EventInfoDto.of(event.getId(), totalAmount, eventService.getRestOfDate(event));

// onjung 정보 (null 값 대신 0으로
Integer totalOnjungCount = Optional.ofNullable(storeRepository.countUsersByStoreId(id)).orElse(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@

import com.daon.onjung.account.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface UserRepository extends JpaRepository <User, UUID> {
Optional<User> findBySerialId(String serialId);

@Query("SELECT u.id FROM User u")
List<UUID> findAllUserIds();

}
39 changes: 39 additions & 0 deletions src/main/java/com/daon/onjung/core/config/SchedulerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.daon.onjung.core.config;

import lombok.Setter;
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;

@Configuration
public class SchedulerConfig {

@Bean
public SchedulerFactoryBean schedulerFactoryBean(AutowiringSpringBeanJobFactory jobFactory) {
SchedulerFactoryBean factoryBean = new SchedulerFactoryBean();
factoryBean.setJobFactory(jobFactory); // Spring 빈으로 관리되는 JobFactory 주입
return factoryBean;
}

@Bean
public AutowiringSpringBeanJobFactory jobFactory(AutowireCapableBeanFactory beanFactory) {
AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
jobFactory.setBeanFactory(beanFactory); // Spring의 BeanFactory 주입
return jobFactory;
}

@Setter
public static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory {
private AutowireCapableBeanFactory beanFactory;

@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
Object job = super.createJobInstance(bundle);
beanFactory.autowireBean(job); // Spring 의존성 주입
return job;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public enum ErrorCode {
INTERNAL_SERVER_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 에러입니다."),
INTERNAL_DATA_ERROR(50001, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 데이터 에러입니다."),
UPLOAD_FILE_ERROR(50002, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패하였습니다."),
SCHEDULER_ERROR(50003, HttpStatus.INTERNAL_SERVER_ERROR, "스케줄러 등록에 실패하였습니다."),

// External Server Error
EXTERNAL_SERVER_ERROR(50200, HttpStatus.BAD_GATEWAY, "서버 외부 에러입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.daon.onjung.core.listener;

import com.daon.onjung.core.exception.error.ErrorCode;
import com.daon.onjung.core.exception.type.CommonException;
import com.daon.onjung.event.application.controller.consumer.EventSchedulerConsumerV1Controller;
import com.daon.onjung.event.domain.event.EventScheduled;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import java.sql.Timestamp;

@Component
@RequiredArgsConstructor
@Slf4j
public class AppEventListener {

private final Scheduler scheduler;

@EventListener
public void handleEventScheduled(EventScheduled eventScheduled) {
JobDetail jobDetail = JobBuilder.newJob(EventSchedulerConsumerV1Controller.class)
.withIdentity("eventJob-" + eventScheduled.eventId(), "eventGroup")
.usingJobData("eventId", eventScheduled.eventId())
.build();
log.info("Job 등록 완료. JobKey: {}", jobDetail.getKey());

Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("eventTrigger-" + eventScheduled.eventId(), "eventGroup")
.startAt(Timestamp.valueOf(eventScheduled.scheduledTime()))
.build();
log.info("Trigger 등록 완료. TriggerKey: {}", trigger.getKey());
log.info("Trigger 시작 시간: {}", trigger.getStartTime());

try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
throw new CommonException(ErrorCode.SCHEDULER_ERROR);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.daon.onjung.event.application.controller.consumer;

import com.daon.onjung.event.application.usecase.ProcessCompletedEventUseCase;
import lombok.RequiredArgsConstructor;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

@RequiredArgsConstructor
public class EventSchedulerConsumerV1Controller implements Job {

private final ProcessCompletedEventUseCase processCompletedEventUseCase;

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
Long eventId = context.getJobDetail().getJobDataMap().getLong("eventId");

processCompletedEventUseCase.execute(eventId);
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.daon.onjung.event.application.service;

import com.daon.onjung.account.domain.Store;
import com.daon.onjung.account.domain.type.EBankName;
import com.daon.onjung.account.repository.mysql.UserRepository;
import com.daon.onjung.core.dto.CreateVirtualAccountResponseDto;
import com.daon.onjung.core.exception.error.ErrorCode;
import com.daon.onjung.core.exception.type.CommonException;
import com.daon.onjung.core.utility.BankUtil;
import com.daon.onjung.core.utility.RestClientUtil;
import com.daon.onjung.event.application.usecase.ProcessCompletedEventUseCase;
import com.daon.onjung.event.domain.Event;
import com.daon.onjung.event.domain.Ticket;
import com.daon.onjung.event.domain.event.EventScheduled;
import com.daon.onjung.event.domain.service.EventService;
import com.daon.onjung.event.domain.service.TicketService;
import com.daon.onjung.event.domain.type.EStatus;
import com.daon.onjung.event.repository.mysql.EventRepository;
import com.daon.onjung.event.repository.mysql.TicketRepository;
import com.daon.onjung.onjung.repository.mysql.DonationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;

@Service
@RequiredArgsConstructor
@Slf4j
public class ProcessCompletedEventService implements ProcessCompletedEventUseCase {

private final EventRepository eventRepository;
private final UserRepository userRepository;
private final TicketRepository ticketRepository;
private final DonationRepository donationRepository;

private final EventService eventService;
private final TicketService ticketService;

private final RestClientUtil restClientUtil;
private final BankUtil bankUtil;

private final ApplicationEventPublisher applicationEventPublisher;

@Override
@Transactional
public void execute(Long eventId) {

// 이벤트 조회
Event currentEvent = eventRepository.findById(eventId)
.orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_RESOURCE));

// 가게 조회
Store store = currentEvent.getStore();

// 현재 진행중인 이벤트의 상태를 모금완료로 변경
currentEvent = eventService.completeEvent(currentEvent);
eventRepository.save(currentEvent);

// 새로운 이벤트 생성
Event newEvent = eventService.createEvent(
LocalDate.now(),
LocalDate.now().plusDays(13),
store
);
newEvent = eventRepository.save(newEvent);

// 가상 계좌 생성
String url = bankUtil.createCreateVirtualAccountRequestUrl();
HttpHeaders headers = bankUtil.createVirtualAccountRequestHeaders();
String body = bankUtil.createCreateVirtualAccountRequestBody(newEvent.getId(), EBankName.KAKAO.toString());

CreateVirtualAccountResponseDto createVirtualAccountResponseDto =
bankUtil.mapToCreateVirtualAccountResponseDto(restClientUtil.sendPostMethod(url, headers, body));

// 이벤트에 은행 정보 업데이트
newEvent.updateBankInfo(
EBankName.fromString(createVirtualAccountResponseDto.data().bankName()),
createVirtualAccountResponseDto.data().bankId()
);
eventRepository.save(newEvent);

// 새롭게 생성된 이벤트에 대한 종료일자에 맞춘 이벤트 발행. 발행한 이벤트는 이벤트 리스너에 의해 스케줄러에 등록됨
applicationEventPublisher.publishEvent(
EventScheduled.builder()
.eventId(newEvent.getId())
.scheduledTime(newEvent.getEndDate().plusDays(1).atStartOfDay())
// .scheduledTime(LocalDateTime.now().plusMinutes(1)) // 테스트용 1분 뒤
.build()
);

// 종료된 이벤트와 연결된 가상계좌에 모급된 금액을 조회
headers = bankUtil.createVirtualAccountRequestHeaders();
url = bankUtil.createReadVirtualAccountRequestUrl(currentEvent.getBankId());
Integer totalBalance = bankUtil.mapToReadVirtualAccountResponseDto(restClientUtil.sendGetMethod(url, headers)).data().balance();

// 종료된 이벤트와 연결된 가상계좌에 모금된 금액을 고용주 계좌에 이체
headers = bankUtil.createVirtualAccountRequestHeaders();
url = bankUtil.createTransferVirtualAccountRequestUrl(currentEvent.getBankId());
String requestBody = bankUtil.createTransferVirtualAccountRequestBody(totalBalance, store.getOwner().getBankAccountNumber());
bankUtil.mapToDepositOrTransferVirtualAccountResponseDto(restClientUtil.sendPostMethod(url, headers, requestBody));

// 발행 가능한 식권
int ticketNumber = totalBalance / 10000;

// 발행 가능한 식권만큼 랜덤한 유저 선택
List<UUID> userIds = userRepository.findAllUserIds();
Random random = new Random();

// 이미 티켓을 발급받은 유저를 저장
Set<UUID> issuedUserIds = new HashSet<>();
int issuedTickets = 0; // 발급된 티켓 개수

// 발급 가능한 티켓 수만큼 루프
while (issuedTickets < ticketNumber) {
if (issuedUserIds.size() == userIds.size()) {
// 모든 유저가 티켓을 발급받은 경우 루프 종료
break;
}

// 랜덤하게 유저 선택
UUID userId = userIds.get(random.nextInt(userIds.size()));
int userSize = userIds.size();
int cursor = 0;
// 해당 이벤트에 대해 동참하기를 한 유저인지 확인. 동참하기를 한 유저라면 티켓 발급 안하고 다음 유저를 뽑음
while(cursor < userSize) {
if (donationRepository.findByUserIdAndEventId(userId, eventId).isEmpty())
break;
userId = userIds.get(random.nextInt(userIds.size()));
cursor++;
}

if (cursor == userSize) {
// 더 이상 동참하기 안한 유저를 찾을 수 없는 경우 티켓 발급 종료
log.info("-----------------더 이상 동참하기 안한 유저를 찾을 수 없음. 티켓 발급 종료--------------------");
break;
}

// 이미 티켓을 발급받은 유저인지 확인
if (!issuedUserIds.contains(userId)) {
// 티켓 발급
Ticket ticket = ticketService.createTicket(
LocalDate.now().plusDays(30),
10000,
true,
store,
userRepository.findById(userId).orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_RESOURCE)),
currentEvent
);
ticketRepository.save(ticket);

// 발급된 유저 기록 및 발급된 티켓 수 증가
issuedUserIds.add(userId);
issuedTickets++;
log.info("유저 {}에게 티켓 발급 완료", userId);

}
}
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.daon.onjung.event.application.usecase;

import com.daon.onjung.core.annotation.bean.UseCase;

@UseCase
public interface ProcessCompletedEventUseCase {
void execute(Long eventId);
}
14 changes: 8 additions & 6 deletions src/main/java/com/daon/onjung/event/domain/Event.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,21 @@ public class Event {
/* Methods ------------------------------------ */
/* -------------------------------------------- */
@Builder
public Event(EStatus status, LocalDate startDate, LocalDate endDate, LocalDate storeDeliveryDate, LocalDate ticketIssueDate, LocalDate reportDate, EBankName bankName, Store store) {
this.status = status;
public Event(LocalDate startDate, LocalDate endDate, Store store) {
this.status = EStatus.IN_PROGRESS;
this.startDate = startDate;
this.endDate = endDate;
this.storeDeliveryDate = storeDeliveryDate;
this.ticketIssueDate = ticketIssueDate;
this.reportDate = reportDate;
this.bankName = bankName;
this.store = store;
}

public void updateBankInfo(EBankName bankName, Long bankId) {
this.bankName = bankName;
this.bankId = bankId;
}

public void completeEvent() {
this.status = EStatus.TICKET_ISSUE;
this.storeDeliveryDate = LocalDate.now();
this.ticketIssueDate = LocalDate.now();
}
}
2 changes: 1 addition & 1 deletion src/main/java/com/daon/onjung/event/domain/Ticket.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class Ticket {
/* Methods ------------------------------------ */
/* -------------------------------------------- */
@Builder
public Ticket(LocalDate expirationDate, int ticketPrice, boolean isValidate, Store store, User user, Event event) {
public Ticket(LocalDate expirationDate, Integer ticketPrice, Boolean isValidate, Store store, User user, Event event) {
this.expirationDate = expirationDate;
this.ticketPrice = ticketPrice;
this.isValidate = isValidate;
Expand Down
Loading
Loading