From ef3da4df705617d54eeab432ec6bc306a9561012 Mon Sep 17 00:00:00 2001 From: kwonhee1 Date: Wed, 22 Oct 2025 13:44:27 +0900 Subject: [PATCH 1/2] feat batch --- build.gradle | 3 + .../NextLevel/demo/config/BatchConfig.java | 17 ++ .../demo/project/batch/BatchController.java | 20 +++ .../batch/ProjectAndFundingPriceDto.java | 23 +++ .../project/batch/ProjectBatchService.java | 37 ++++ .../project/batch/ProjectSerializableDto.java | 24 +++ .../batch/ProjectStatusBatchService.java | 170 ++++++++++++++++++ .../project/service/ProjectStatusService.java | 10 ++ src/main/resources/application.yml | 4 + 9 files changed, 308 insertions(+) create mode 100644 src/main/java/NextLevel/demo/config/BatchConfig.java create mode 100644 src/main/java/NextLevel/demo/project/batch/BatchController.java create mode 100644 src/main/java/NextLevel/demo/project/batch/ProjectAndFundingPriceDto.java create mode 100644 src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java create mode 100644 src/main/java/NextLevel/demo/project/batch/ProjectSerializableDto.java create mode 100644 src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java diff --git a/build.gradle b/build.gradle index d6f01fd..a76fc70 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,9 @@ dependencies { // test h2 db 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..e752653 --- /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("/public/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..8474cbb --- /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 = "") + public void runProjectStatusJob() { + try{ + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) + .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..a76251f --- /dev/null +++ b/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java @@ -0,0 +1,170 @@ +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.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); + }) + .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 FreeFundingEntity ff where ff.project.id = p.id), 0)\s + + + COALESCE( (select sum(of.count * of.option.price) from OptionFundingEntity of where of.option.project.id = p.id), 0) + as fundingPrice + from ProjectEntity p + where p.id in :projectIdList + """) + .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 projectStatus = :projectStatus + where project.id = :projectId + """) + .dataSource(dataSource) + .itemPreparedStatementSetter(new ItemPreparedStatementSetter() { + @Override + public void setValues(ProjectAndFundingPriceDto projectDto, PreparedStatement ps) throws SQLException { + ps.setObject(1, projectDto.getProjectStatus()); + ps.setLong(1, 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()) + .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 749817d..6dfa36b 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} From 5e82f2c8562fc9e28c40d00dddde7343831dd74f Mon Sep 17 00:00:00 2001 From: kwonhee1 Date: Thu, 30 Oct 2025 14:23:26 +0900 Subject: [PATCH 2/2] feat batch --- .../demo/project/batch/BatchController.java | 2 +- .../project/batch/ProjectBatchService.java | 4 +- .../batch/ProjectStatusBatchService.java | 38 ++++++++++++------- src/main/resources/application.yml | 3 ++ 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/main/java/NextLevel/demo/project/batch/BatchController.java b/src/main/java/NextLevel/demo/project/batch/BatchController.java index e752653..47641d8 100644 --- a/src/main/java/NextLevel/demo/project/batch/BatchController.java +++ b/src/main/java/NextLevel/demo/project/batch/BatchController.java @@ -11,7 +11,7 @@ public class BatchController { private final ProjectBatchService projectBatchService; - @GetMapping("/public/batch") + @GetMapping("/admin/batch") public ResponseEntity doBatch() { projectBatchService.runProjectStatusJob(); return ResponseEntity.ok().build(); diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java b/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java index 8474cbb..7421ca6 100644 --- a/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java +++ b/src/main/java/NextLevel/demo/project/batch/ProjectBatchService.java @@ -19,11 +19,11 @@ public class ProjectBatchService { private final JobLauncher jobLauncher; private final Job projectStatusJob; - @Scheduled(cron = "") + @Scheduled(cron = "${scheduler.day}") public void runProjectStatusJob() { try{ JobParameters jobParameters = new JobParametersBuilder() - .addLong("time", System.currentTimeMillis()) + .addLong("time", 1L) .toJobParameters(); jobLauncher.run(projectStatusJob, jobParameters); diff --git a/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java b/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java index a76251f..3294cfb 100644 --- a/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java +++ b/src/main/java/NextLevel/demo/project/batch/ProjectStatusBatchService.java @@ -7,6 +7,7 @@ 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; @@ -78,6 +79,7 @@ public Step projectSelectStep( List dtoList = projectChunk.getItems().stream().map(ProjectSerializableDto::of).toList(); stepExecution.getJobExecution().getExecutionContext().put("projectDtoList", dtoList); }) + .allowStartIfComplete(true) .build(); } @@ -92,15 +94,24 @@ public JdbcCursorItemReader projectFundingPriceReader return new JdbcCursorItemReaderBuilder() .name("projectFundingPriceReader") .sql(""" - select - p.id as projectId, - COALESCE( (select sum(ff.price) from FreeFundingEntity ff where ff.project.id = p.id), 0)\s - + - COALESCE( (select sum(of.count * of.option.price) from OptionFundingEntity of where of.option.project.id = p.id), 0) - as fundingPrice - from ProjectEntity p - where p.id in :projectIdList - """) + 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 @@ -128,15 +139,15 @@ public JdbcBatchItemWriter projectWriter() { return new JdbcBatchItemWriterBuilder() .sql(""" update project - set projectStatus = :projectStatus - where project.id = :projectId + 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()); - ps.setLong(1, projectDto.getProjectSerializableDto().getProjectId()); + ps.setObject(1, projectDto.getProjectStatus().name()); + ps.setLong(2, projectDto.getProjectSerializableDto().getProjectId()); } }) .beanMapped() @@ -153,6 +164,7 @@ public Step expiredProjectStep( .reader(projectFundingPriceReader) .processor(projectProcessor()) .writer(projectWriter()) + .allowStartIfComplete(true) .build(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75b4e80..543c534 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -112,6 +112,9 @@ email: EMAIL: ${EMAIL} EMAIL_PASSWORD: "${EMAIL_PASSWORD}" +scheduler: + day: 0 0 3 * * * # 메일 세벽 3시 작동 + --- spring: config: