diff --git a/build.gradle b/build.gradle index b59b961..75b3615 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'com.h2database:h2' + // batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + } tasks.named('test') { diff --git a/src/main/java/NextLevel/demo/config/BatchConfig.java b/src/main/java/NextLevel/demo/config/BatchConfig.java new file mode 100644 index 0000000..79e39b0 --- /dev/null +++ b/src/main/java/NextLevel/demo/config/BatchConfig.java @@ -0,0 +1,17 @@ +package NextLevel.demo.config; + +import jakarta.persistence.EntityManagerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class BatchConfig { + + @Bean + public PlatformTransactionManager transactionManager(EntityManagerFactory emf) { + return new JpaTransactionManager(emf); + } + +} diff --git a/src/main/java/NextLevel/demo/project/batch/BatchController.java b/src/main/java/NextLevel/demo/project/batch/BatchController.java new file mode 100644 index 0000000..47641d8 --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/BatchController.java @@ -0,0 +1,20 @@ +package NextLevel.demo.project.batch; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@RequiredArgsConstructor +public class BatchController { + + private final ProjectBatchService projectBatchService; + + @GetMapping("/admin/batch") + public ResponseEntity doBatch() { + projectBatchService.runProjectStatusJob(); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectAndFundingPriceDto.java b/src/main/java/NextLevel/demo/project/batch/ProjectAndFundingPriceDto.java new file mode 100644 index 0000000..56a4997 --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/ProjectAndFundingPriceDto.java @@ -0,0 +1,23 @@ +package NextLevel.demo.project.batch; + +import NextLevel.demo.project.ProjectStatus; +import NextLevel.demo.project.project.entity.ProjectEntity; +import java.io.Serializable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +public class ProjectAndFundingPriceDto implements Serializable { + + private ProjectSerializableDto projectSerializableDto; + private Integer fundingPrice; + private ProjectStatus projectStatus; + + public ProjectAndFundingPriceDto(ProjectSerializableDto projectSerializableDto, Integer fundingPrice) { + this.projectSerializableDto = projectSerializableDto; + this.fundingPrice = fundingPrice; + } +} diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java b/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java new file mode 100644 index 0000000..7421ca6 --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java @@ -0,0 +1,37 @@ +package NextLevel.demo.project.batch; + +import NextLevel.demo.exception.CustomException; +import NextLevel.demo.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ProjectBatchService { + + private final JobLauncher jobLauncher; + private final Job projectStatusJob; + + @Scheduled(cron = "${scheduler.day}") + public void runProjectStatusJob() { + try{ + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", 1L) + .toJobParameters(); + + jobLauncher.run(projectStatusJob, jobParameters); + log.info("Project status job finished"); + } catch (Exception e){ + e.printStackTrace(); + throw new CustomException(ErrorCode.SIBAL_WHAT_IS_IT, e.getMessage()); + } + } + +} diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectSerializableDto.java b/src/main/java/NextLevel/demo/project/batch/ProjectSerializableDto.java new file mode 100644 index 0000000..e4c51c9 --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/ProjectSerializableDto.java @@ -0,0 +1,24 @@ +package NextLevel.demo.project.batch; + +import NextLevel.demo.project.project.entity.ProjectEntity; +import java.io.Serializable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +public class ProjectSerializableDto implements Serializable { + + private Long projectId; + private Long projectGoal; + + public static ProjectSerializableDto of(ProjectEntity projectEntity) { + ProjectSerializableDto projectSerializableDto = new ProjectSerializableDto(); + projectSerializableDto.setProjectId(projectEntity.getId()); + projectSerializableDto.setProjectGoal(projectEntity.getGoal()); + return projectSerializableDto; + } + +} diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java b/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java new file mode 100644 index 0000000..3294cfb --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java @@ -0,0 +1,182 @@ +package NextLevel.demo.project.batch; + +import NextLevel.demo.project.ProjectStatus; +import NextLevel.demo.project.project.entity.ProjectEntity; +import jakarta.persistence.EntityManagerFactory; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.sql.DataSource; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.configuration.annotation.JobScope; +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.scope.context.StepSynchronizationManager; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.ItemPreparedStatementSetter; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class ProjectStatusBatchService { + + private final DataSource dataSource; + private final JobRepository jobRepository; + private final EntityManagerFactory entityManagerFactory; + + @Bean + public JpaPagingItemReader projectReader() { + return new JpaPagingItemReaderBuilder() + .name("expiredBoardReader") + .queryString( + """ + select p + from ProjectEntity p + where p.projectStatus = :projectStatus and p.expiredAt <= :date + """) + .parameterValues(Map.of( + "projectStatus", ProjectStatus.PROGRESS, + "date", LocalDate.now() + )) + .pageSize(10) + .entityManagerFactory(entityManagerFactory) + .build(); + } + + @Bean + @JobScope + public Step projectSelectStep( + PlatformTransactionManager transactionManager + ) { + return new StepBuilder("checkProjectStatus", jobRepository) + . chunk(10, transactionManager) + .reader(projectReader()) + .processor((p)->p) // nothing to do + .writer(projectChunk->{ + StepExecution stepExecution = StepSynchronizationManager.getContext().getStepExecution(); + List dtoList = projectChunk.getItems().stream().map(ProjectSerializableDto::of).toList(); + stepExecution.getJobExecution().getExecutionContext().put("projectDtoList", dtoList); + }) + .allowStartIfComplete(true) + .build(); + } + + @Bean + @JobScope + public JdbcCursorItemReader projectFundingPriceReader( + @Value("#{jobExecutionContext['projectDtoList']}") List projectChunk + ) { +// StepExecution stepExecution = StepSynchronizationManager.getContext().getStepExecution(); +// Chunk projectChunk = (Chunk)stepExecution.getJobExecution().getExecutionContext().get("projectChunk"); + Map projectMap = projectChunk.stream().collect(Collectors.toMap(ProjectSerializableDto::getProjectId, p -> p)); + return new JdbcCursorItemReaderBuilder() + .name("projectFundingPriceReader") + .sql(""" + SELECT + p.id AS projectId, + + COALESCE(( + SELECT SUM(ff.price) + FROM free_funding ff + WHERE ff.project_id = p.id + ), 0) + + + COALESCE(( + SELECT SUM(ofd.count * o.price) + FROM option_funding ofd + JOIN `option` o ON ofd.option_id = o.id + WHERE o.project_id = p.id + ), 0) AS fundingPrice + FROM project p + WHERE p.id IN (?) + """) + .queryArguments(projectChunk.stream().map(ProjectSerializableDto::getProjectId).toList()) + .rowMapper(new RowMapper() { + @Override + public Object mapRow(ResultSet rs, int rowNum) throws SQLException { + long projectId = rs.getLong("projectId"); + int fundingPrice = rs.getInt("fundingPrice"); + return new ProjectAndFundingPriceDto(projectMap.get(projectId), fundingPrice); + } + }) + .dataSource(dataSource) + .build(); + } + + @Bean + public ItemProcessor projectProcessor() { + return (dto)-> { + ProjectStatus status = dto.getFundingPrice() >= dto.getProjectSerializableDto().getProjectGoal() ? ProjectStatus.SUCCESS : ProjectStatus.FAIL; + dto.setProjectStatus(status); + return dto; + }; + } + + @Bean + public JdbcBatchItemWriter projectWriter() { + return new JdbcBatchItemWriterBuilder() + .sql(""" + update project + set project_status = ? + where project.id = ? + """) + .dataSource(dataSource) + .itemPreparedStatementSetter(new ItemPreparedStatementSetter() { + @Override + public void setValues(ProjectAndFundingPriceDto projectDto, PreparedStatement ps) throws SQLException { + ps.setObject(1, projectDto.getProjectStatus().name()); + ps.setLong(2, projectDto.getProjectSerializableDto().getProjectId()); + } + }) + .beanMapped() + .build(); + } + + @Bean + public Step expiredProjectStep( + PlatformTransactionManager transactionManager, + JdbcCursorItemReader projectFundingPriceReader + ) { + return new StepBuilder("expiredProjectStep", jobRepository) + . chunk(10, transactionManager) + .reader(projectFundingPriceReader) + .processor(projectProcessor()) + .writer(projectWriter()) + .allowStartIfComplete(true) + .build(); + } + + @Bean + public Job projectStatusJob( + JobRepository jobRepository, + Step projectSelectStep, + Step expiredProjectStep + ) { + return new JobBuilder("projectStatusJob", jobRepository) + .start(projectSelectStep) + .next(expiredProjectStep) + .build(); + } +} diff --git a/src/main/java/NextLevel/demo/project/project/service/ProjectStatusService.java b/src/main/java/NextLevel/demo/project/project/service/ProjectStatusService.java index 0786078..3f690c4 100644 --- a/src/main/java/NextLevel/demo/project/project/service/ProjectStatusService.java +++ b/src/main/java/NextLevel/demo/project/project/service/ProjectStatusService.java @@ -5,9 +5,19 @@ import NextLevel.demo.project.project.repository.ProjectRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; @Service @Slf4j diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 818cfda..543c534 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -79,6 +79,10 @@ spring: user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo user-name-attribute: id # Google의 사용자 식별자 (고유 ID) + batch: + jdbc: + initialize-schema: always + jwt: secret: ${JWT_SECRET} @@ -108,6 +112,9 @@ email: EMAIL: ${EMAIL} EMAIL_PASSWORD: "${EMAIL_PASSWORD}" +scheduler: + day: 0 0 3 * * * # 메일 세벽 3시 작동 + --- spring: config: