diff --git a/build.gradle b/build.gradle index a5f0705..f95332a 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,15 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // Spring Batch + implementation 'org.apache.poi:poi-ooxml:5.3.0' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'com.hierynomus:sshj:0.37.0' // SFTP 프로토콜 지원 + implementation 'org.springframework.retry:spring-retry' + + // Spring Quartz + implementation 'org.springframework.boot:spring-boot-starter-quartz' + // JWT implementation 'io.jsonwebtoken:jjwt:0.12.6' diff --git a/src/main/java/com/fisa/pg/batch/dto/MerchantSummary.java b/src/main/java/com/fisa/pg/batch/dto/MerchantSummary.java new file mode 100644 index 0000000..210fa2c --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/dto/MerchantSummary.java @@ -0,0 +1,18 @@ +package com.fisa.pg.batch.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 가맹점별 일일 정산 요약 정보를 담는 불변 객체 + */ +public record MerchantSummary( + Long merchantId, + String merchantName, + BigDecimal creditAmount, + BigDecimal debitAmount, + BigDecimal totalAmount, + String currency, + LocalDateTime settlementDate +) { +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/batch/job_config/GenerateAndSendPurchaseCsvJob.java b/src/main/java/com/fisa/pg/batch/job_config/GenerateAndSendPurchaseCsvJob.java new file mode 100644 index 0000000..123e528 --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/job_config/GenerateAndSendPurchaseCsvJob.java @@ -0,0 +1,128 @@ +package com.fisa.pg.batch.job_config; + +import com.fisa.pg.batch.dto.MerchantSummary; +import com.fisa.pg.batch.utils.CsvGenerator; +import com.fisa.pg.batch.utils.SftpUploader; +import com.fisa.pg.entity.card.CardType; +import com.fisa.pg.entity.payment.Payment; +import com.fisa.pg.entity.payment.PaymentStatus; +import com.fisa.pg.entity.user.Merchant; +import com.fisa.pg.repository.MerchantRepository; +import com.fisa.pg.repository.PaymentRepository; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.io.File; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Configuration +@EnableBatchProcessing +public class GenerateAndSendPurchaseCsvJob { + + private final PaymentRepository paymentRepository; + private final MerchantRepository merchantRepository; + private final CsvGenerator csvGenerator; + private final SftpUploader sftpUploader; + + public GenerateAndSendPurchaseCsvJob( + PaymentRepository paymentRepository, + MerchantRepository merchantRepository, + CsvGenerator csvGenerator, + SftpUploader sftpUploader + ) { + this.paymentRepository = paymentRepository; + this.merchantRepository = merchantRepository; + this.csvGenerator = csvGenerator; + this.sftpUploader = sftpUploader; + } + + @Bean + public Job generateAndSendPurchaseCsvJob(JobRepository jobRepository, + Step settlementStep) { + return new JobBuilder("GenerateAndSendPurchaseCsvJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(settlementStep) + .build(); + } + + @Bean + public Step settlementStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + org.springframework.retry.support.RetryTemplate retryTemplate) { + + return new StepBuilder("SettlementSummaryStep", jobRepository) + .tasklet((contrib, ctx) -> + retryTemplate.execute(retryCtx -> { + // 1) 어제 00:00:00 ~ 23:59:59 + LocalDate yesterday = LocalDate.now().minusDays(1); + LocalDateTime start = yesterday.atStartOfDay(); + LocalDateTime end = yesterday.atTime(23, 59, 59); + + // 2) 정산 시각: 오늘 02:00 + LocalDateTime settlementTime = LocalDateTime.now() + .withHour(2).withMinute(0).withSecond(0).withNano(0); + + // 3) 활성 가맹점 조회 + List merchants = merchantRepository.findByIsActiveTrue(); + + // 4) 일일 매출 합계 계산 및 요약 DTO 생성 + List summaries = merchantRepository.findByIsActiveTrue().stream() + .map(m -> { + List payments = paymentRepository + .findByMerchantAndPaymentStatusAndApprovedAtBetween( + m, PaymentStatus.SUCCEEDED, start, end); + if (payments.isEmpty()) return null; + + // 신용카드 합계 + BigDecimal creditSum = payments.stream() + .filter(p -> p.getUserCard() != null + && p.getUserCard().getCardType() == CardType.CREDIT) + .map(p -> BigDecimal.valueOf(p.getAmount())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 체크카드 합계 + BigDecimal debitSum = payments.stream() + .filter(p -> p.getUserCard() != null + && p.getUserCard().getCardType() == CardType.DEBIT) + .map(p -> BigDecimal.valueOf(p.getAmount())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 전체 합계 + BigDecimal totalSum = creditSum.add(debitSum); + + return new MerchantSummary( + m.getId(), + m.getName(), + creditSum, + debitSum, + totalSum, + payments.get(0).getCurrency(), + settlementTime + ); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + // 5) CSV 생성 & SFTP 전송 + File csv = csvGenerator.generateSummary(summaries); + sftpUploader.upload(csv); + + return RepeatStatus.FINISHED; + }) + , transactionManager) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/batch/job_config/RetryTemplateConfig.java b/src/main/java/com/fisa/pg/batch/job_config/RetryTemplateConfig.java new file mode 100644 index 0000000..3ada765 --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/job_config/RetryTemplateConfig.java @@ -0,0 +1,26 @@ +package com.fisa.pg.batch.job_config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; + +@Configuration +public class RetryTemplateConfig { + + // Tasklet 모델 재시도 전략(Chunk 모델과 달리 프레임워크에서 재시도 기능을 제공해주지 않음) + @Bean + public org.springframework.retry.support.RetryTemplate retryTemplate() { + org.springframework.retry.support.RetryTemplate template = new org.springframework.retry.support.RetryTemplate(); + + SimpleRetryPolicy policy = new SimpleRetryPolicy(); + policy.setMaxAttempts(3); // 총 3회 시도 + + FixedBackOffPolicy backOff = new FixedBackOffPolicy(); + backOff.setBackOffPeriod(2000); // 시도 간격 2초 + + template.setRetryPolicy(policy); + template.setBackOffPolicy(backOff); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/batch/quartz/QuartzConfig.java b/src/main/java/com/fisa/pg/batch/quartz/QuartzConfig.java new file mode 100644 index 0000000..2fc8cf2 --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/quartz/QuartzConfig.java @@ -0,0 +1,27 @@ +package com.fisa.pg.batch.quartz; + +import org.quartz.*; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuartzConfig { + + @Bean + public JobDetail settlementJobDetail() { + return JobBuilder.newJob(QuartzJobLauncher.class) + .withIdentity("dailySettlementJob") + .usingJobData("jobName", "GenerateAndSendPurchaseCsvJob") + .storeDurably() + .build(); + } + + @Bean + public Trigger settlementJobTrigger(JobDetail jobDetail) { + return TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity("dailySettlementTrigger") + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) // 매일 02:00 + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/batch/quartz/QuartzJobLauncher.java b/src/main/java/com/fisa/pg/batch/quartz/QuartzJobLauncher.java new file mode 100644 index 0000000..a7dad6e --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/quartz/QuartzJobLauncher.java @@ -0,0 +1,36 @@ +package com.fisa.pg.batch.quartz; + +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.quartz.QuartzJobBean; + +public class QuartzJobLauncher extends QuartzJobBean { + + @Autowired + private JobLauncher jobLauncher; + + // Bean 이름(메서드명)으로 등록된 Spring Batch Job을 주입 + @Autowired + @Qualifier("generateAndSendPurchaseCsvJob") + private Job generateAndSendPurchaseCsvJob; + + @Override + protected void executeInternal(JobExecutionContext context) throws JobExecutionException { + try { + jobLauncher.run( + generateAndSendPurchaseCsvJob, + new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters() + ); + } catch (Exception ex) { + // Quartz의 JobExecutionException을 던져야 Quartz가 재시도/오류 로깅을 합니다. + throw new JobExecutionException(ex); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/batch/utils/CsvGenerator.java b/src/main/java/com/fisa/pg/batch/utils/CsvGenerator.java new file mode 100644 index 0000000..a35683a --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/utils/CsvGenerator.java @@ -0,0 +1,55 @@ +package com.fisa.pg.batch.utils; + +import com.fisa.pg.batch.dto.MerchantSummary; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +public class CsvGenerator { + + @Value("${batch.output.directory}") + private String outputDirectory; + + /** + * 전체 요약 리스트로 단일 CSV 파일 생성 + */ + public File generateSummary(List summaries) throws IOException { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmm")); + String fileName = String.format("pg_daily_settlement_summary_to_card_%s.csv", timestamp); + + File dir = new File(outputDirectory); + if (!dir.exists()) dir.mkdirs(); + File csvFile = new File(dir, fileName); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(csvFile))) { + // 헤더 + writer.write("merchant_id,merchant_name,credit_card_amount,check_card_amount,total_amount,currency,settlement_date"); + writer.newLine(); + + // 데이터 + DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + for (MerchantSummary ms : summaries) { + writer.write(String.join(",", + ms.merchantId(), + ms.merchantName(), + ms.creditAmount().toPlainString(), + ms.debitAmount().toPlainString(), + ms.totalAmount().toPlainString(), + ms.currency(), + ms.settlementDate().format(dtf) + )); + writer.newLine(); + } + } + return csvFile; + } +} diff --git a/src/main/java/com/fisa/pg/batch/utils/SftpUploader.java b/src/main/java/com/fisa/pg/batch/utils/SftpUploader.java new file mode 100644 index 0000000..6637d0e --- /dev/null +++ b/src/main/java/com/fisa/pg/batch/utils/SftpUploader.java @@ -0,0 +1,67 @@ +package com.fisa.pg.batch.utils; + +import lombok.extern.slf4j.Slf4j; +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.transport.verification.PromiscuousVerifier; +import net.schmizz.sshj.xfer.FileSystemFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; + +@Slf4j +@Component +public class SftpUploader { + + @Value("${batch.sftp.host}") + private String sftpHost; + + @Value("${batch.sftp.port:22}") + private int sftpPort; + + @Value("${batch.sftp.username}") + private String sftpUsername; + + @Value("${batch.sftp.password}") + private String sftpPassword; + + @Value("${batch.sftp.remote-dir}") + private String sftpRemoteDir; + + private final RetryTemplate sftpRetryTemplate; + + public SftpUploader(RetryTemplate sftpRetryTemplate) { + this.sftpRetryTemplate = sftpRetryTemplate; + } + + public void upload(File file) { + if (file == null || !file.exists()) { + throw new IllegalArgumentException(">>>>>>>>>>>>>[Error]: 전송할 파일이 존재하지 않습니다: " + file); + } + + try { + sftpRetryTemplate.execute(context -> { + // 기존 로직 + try (SSHClient ssh = new SSHClient()) { + ssh.addHostKeyVerifier(new PromiscuousVerifier()); + ssh.connect(sftpHost, sftpPort); + ssh.authPassword(sftpUsername, sftpPassword); + + try (SFTPClient sftp = ssh.newSFTPClient()) { + sftp.put(new FileSystemFile(file), sftpRemoteDir + "/"); + log.info(">>>>>>>>>>>>>>SFTP 전송 성공: {}", file.getName()); + } + } catch (IOException e) { + log.warn(">>>>>>>>>>>>>>SFTP 전송 재시도 중 오류 발생: {}", e.getMessage()); + throw e; + } + return null; + }); + } catch (IOException e) { + throw new RuntimeException(">>>>>>>>>>>>>>SFTP 최종 실패: " + file.getName(), e); + } + } +} diff --git a/src/main/java/com/fisa/pg/config/db/DataDBConfig.java b/src/main/java/com/fisa/pg/config/db/DataDBConfig.java new file mode 100644 index 0000000..648d4cb --- /dev/null +++ b/src/main/java/com/fisa/pg/config/db/DataDBConfig.java @@ -0,0 +1,59 @@ +package com.fisa.pg.config.db; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; +import java.util.HashMap; + +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories( + basePackages = "com.fisa.pg.repository", + entityManagerFactoryRef = "dataEntityManager", + transactionManagerRef = "dataTransactionManager" +) +public class DataDBConfig { + + @Bean + @ConfigurationProperties(prefix = "spring.datasource-data") + public DataSource dataDBSource() { + + return DataSourceBuilder.create().build(); + } + + @Bean + public LocalContainerEntityManagerFactoryBean dataEntityManager() { // 엔티티들을 관리할 Manager + + LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); + + em.setDataSource(dataDBSource()); + em.setPackagesToScan(new String[]{"com.fisa.pg.entity"}); + em.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + + HashMap properties = new HashMap<>(); + properties.put("hibernate.hbm2ddl.auto", "update"); + properties.put("hibernate.show_sql", "true"); + em.setJpaPropertyMap(properties); + + return em; + } + + @Bean + public PlatformTransactionManager dataTransactionManager() { + + JpaTransactionManager transactionManager = new JpaTransactionManager(); + + transactionManager.setEntityManagerFactory(dataEntityManager().getObject()); + + return transactionManager; + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/config/db/MetaDBConfig.java b/src/main/java/com/fisa/pg/config/db/MetaDBConfig.java new file mode 100644 index 0000000..457503c --- /dev/null +++ b/src/main/java/com/fisa/pg/config/db/MetaDBConfig.java @@ -0,0 +1,30 @@ +package com.fisa.pg.config.db; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; + +@Configuration +public class MetaDBConfig { + + @Primary // Meta DB와 Data DB의 충돌 방지 + @Bean(name = "dataSource") + @ConfigurationProperties(prefix = "spring.datasource-meta") + public DataSource metaDBSource() { + + return DataSourceBuilder.create().build(); + } + + @Primary + @Bean + public PlatformTransactionManager metaTransactionManager() { + + return new DataSourceTransactionManager(metaDBSource()); + } +} \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/repository/MerchantRepository.java b/src/main/java/com/fisa/pg/repository/MerchantRepository.java index 6668e1e..344f629 100644 --- a/src/main/java/com/fisa/pg/repository/MerchantRepository.java +++ b/src/main/java/com/fisa/pg/repository/MerchantRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface MerchantRepository extends JpaRepository { @@ -22,4 +23,5 @@ public interface MerchantRepository extends JpaRepository { */ Page findByWebhookUrlIsNotNullAndIsWebhookEnabledTrue(Pageable pageable); + List findByIsActiveTrue(); } \ No newline at end of file diff --git a/src/main/java/com/fisa/pg/repository/PaymentRepository.java b/src/main/java/com/fisa/pg/repository/PaymentRepository.java index 5cea1db..70fd889 100644 --- a/src/main/java/com/fisa/pg/repository/PaymentRepository.java +++ b/src/main/java/com/fisa/pg/repository/PaymentRepository.java @@ -1,9 +1,12 @@ package com.fisa.pg.repository; import com.fisa.pg.entity.payment.Payment; +import com.fisa.pg.entity.payment.PaymentStatus; import com.fisa.pg.entity.user.Merchant; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface PaymentRepository extends JpaRepository { @@ -16,4 +19,11 @@ public interface PaymentRepository extends JpaRepository { * @return 결제 정보 */ Optional findByOrderIdAndMerchant(String orderId, Merchant merchant); + + List findByMerchantAndPaymentStatusAndApprovedAtBetween( + Merchant merchant, + PaymentStatus paymentStatus, + LocalDateTime start, + LocalDateTime end + ); }