Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dc58900
Chore: Spring Batch 의존성 라이브러리 추가(latest 버전)
yxhwxn May 27, 2025
83d0862
Feat: Meta_DB(배치 메타 데이터 DB), Data_DB 복수 DB Config 클래스 작성
yxhwxn May 27, 2025
1af26b5
chore: sftp 프로토콜 지원용 의존성 라이브러리 추가
yxhwxn May 27, 2025
eafff73
Feat: SFTP 파일 전송용 유틸 클래스 작성
yxhwxn May 27, 2025
ecd0c03
Feat: CSV 가맹점 매입 전표 생성용 유틸 클래스 추가
yxhwxn May 27, 2025
0f1b150
Feat: 가맹점 일일 정산(매입 전표 생성 및 csv 파일 전송) Batch Job 스텁 추가
yxhwxn May 27, 2025
953a62b
Fix: 일일 정산 시 approvedAt Datetime을 기반으로 값을 조회할 수 있도록 수정
yxhwxn May 27, 2025
e13dc53
Fix: HostKeyVerifier 인터페이스 필수 메서드("findExistingAlgorithms()") 추가
yxhwxn May 27, 2025
c1b9692
Fix: LocalSourceFile 메서드 시그니처 추가
yxhwxn May 27, 2025
5804944
Fix: sftp 송수신 간 파일명 중복 방지 로직 추가
yxhwxn May 27, 2025
c331562
Chore: Spring Batch 프로세서가 결제 내역 조회 시 사용하는 chunk size 수정(10 -> 5000)
yxhwxn May 27, 2025
4ae58ce
Chore: Batch Job Retry 전략을 위한 라이브러리 추가
yxhwxn May 27, 2025
c387284
Feat: 실패 시 SFTP 전송을 자동 재시도(retry) 하도록 RetryTemplate 적용
yxhwxn May 27, 2025
18e8d82
Feat: Spring Batch chunk 기반 재시도 설정 추가
yxhwxn May 27, 2025
49c4190
Refactor: 매입 전표 csv 형식 변경에 따른 record 불변 객체 추가
yxhwxn May 28, 2025
52ccc41
Fix: DTO 타입 수정으로 인한 CSV 생성 코드 수정
yxhwxn May 28, 2025
f54eadb
Chore: Quartz 스케줄러 의존성 라이브러리 추가
yxhwxn May 28, 2025
073f294
Feat: Quartz 기반 Batch Job 트리거 설정 및 Batch 빈 주입
yxhwxn May 28, 2025
b676125
Feat: 활성 가맹점 조회 메서드 추가
yxhwxn May 28, 2025
0a523a1
Fix: Spring Batch 5.x 부터는 tasklet(Tasklet, PlatformTransactionManager…
yxhwxn May 28, 2025
d587b0b
Refactor: Chunk 모델 -> Tasklet 단일 모델 변경으로 인한 재시도 전략 수정
yxhwxn May 28, 2025
17bd430
Refactor: 불필요한 LocalSourceFile 메서드 제거, FileSystemFile을 사용하여 로컬 파일을 래핑…
yxhwxn May 28, 2025
157e44c
Chore: csv 파일명 수정
yxhwxn May 28, 2025
02e04a2
Fix: 가맹점별 일일 정산 요약 시 신용카드, 체크카드 결제액 구분(각 수수료율을 다르게 정산해줘야 하기 때문)
yxhwxn May 29, 2025
0d99f3f
Fix: 카드 타입에 따른 차별적인 우대 수수료 적용을 위한 csv 생성 코드 수정
yxhwxn May 29, 2025
d98f81a
Chore: 응답 객체 필드 수정
yxhwxn May 30, 2025
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
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/fisa/pg/batch/dto/MerchantSummary.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<Merchant> merchants = merchantRepository.findByIsActiveTrue();

// 4) 일일 매출 합계 계산 및 요약 DTO 생성
List<MerchantSummary> summaries = merchantRepository.findByIsActiveTrue().stream()
.map(m -> {
List<Payment> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
27 changes: 27 additions & 0 deletions src/main/java/com/fisa/pg/batch/quartz/QuartzConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
36 changes: 36 additions & 0 deletions src/main/java/com/fisa/pg/batch/quartz/QuartzJobLauncher.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
55 changes: 55 additions & 0 deletions src/main/java/com/fisa/pg/batch/utils/CsvGenerator.java
Original file line number Diff line number Diff line change
@@ -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<MerchantSummary> 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;
}
}
67 changes: 67 additions & 0 deletions src/main/java/com/fisa/pg/batch/utils/SftpUploader.java
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +41 to +43
Copy link

Copilot AI May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider separating the null check from the exists() check to provide a clearer error message when file is null.

Suggested change
if (file == null || !file.exists()) {
throw new IllegalArgumentException(">>>>>>>>>>>>>[Error]: 전송할 파일이 존재하지 않습니다: " + file);
}
if (file == null) {
throw new IllegalArgumentException(">>>>>>>>>>>>>[Error]: 전송할 파일이 null입니다.");
}
if (!file.exists()) {
throw new IllegalArgumentException(">>>>>>>>>>>>>[Error]: 전송할 파일이 존재하지 않습니다: " + file.getAbsolutePath());
}

Copilot uses AI. Check for mistakes.

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);
}
}
}
Loading
Loading