Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.18'
id 'org.springframework.boot' version '3.5.14'
id 'io.spring.dependency-management' version '1.1.7'
}

Expand All @@ -23,6 +23,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.kafka:spring-kafka'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.redisson:redisson:3.27.2'
implementation 'io.github.cdimascio:dotenv-java:3.0.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.rocketcrew.pocatbatch.client;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
@Slf4j
public class MainAppBuyoutClient {

private final RestTemplate restTemplate;

@Value("${pocat.main-app.base-url}")
private String baseUrl;

@Value("${pocat.main-app.internal-token}")
private String internalToken;

/**
* 경매 구매 확정 실패 복구 요청
* POST {baseUrl}/internal/auctions/{id}/recover-buyout
* 최대 3회 재시도 (지수 백오프)
* 4xx 오류는 SkipException 발생
*/
public void recoverBuyout(Long auctionId, Long jobExecutionId) {
String url = String.format("%s/internal/auctions/%d/recover-buyout", baseUrl, auctionId);

HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.set("Idempotency-Key", String.format("buyout-%d-%d", auctionId, jobExecutionId));

HttpEntity<String> request = new HttpEntity<>(headers);

int maxRetries = 3;
long delayMs = 1000;

for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
restTemplate.exchange(url, HttpMethod.POST, request, String.class);
log.info("경매 구매 확정 복구 성공: auctionId={}", auctionId);
return;
} catch (HttpClientErrorException e) {
if (e.getStatusCode().is4xxClientError()) {
log.warn("경매 구매 확정 복구 4xx 오류: auctionId={}, status={}", auctionId, e.getStatusCode());
throw new RuntimeException("4xx 오류로 스킵", e);
}
// 5xx 오류는 재시도
if (attempt == maxRetries) {
log.error("경매 구매 확정 복구 최대 재시도 초과: auctionId={}", auctionId, e);
throw e;
}
try {
Thread.sleep(delayMs);
delayMs *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
} catch (Exception e) {
if (attempt == maxRetries) {
log.error("경매 구매 확정 복구 실패: auctionId={}", auctionId, e);
throw e;
}
try {
Thread.sleep(delayMs);
delayMs *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.rocketcrew.pocatbatch.client;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
@Slf4j
public class MainAppRefundClient {

private final RestTemplate restTemplate;

@Value("${pocat.main-app.base-url}")
private String baseUrl;

@Value("${pocat.main-app.internal-token}")
private String internalToken;

/**
* 환불 재시도 요청
* POST {baseUrl}/internal/refunds/{id}/retry
* 최대 3회 재시도 (지수 백오프)
* 4xx 오류는 SkipException 발생
*/
public void retryRefund(Long refundId, Long jobExecutionId) {
String url = String.format("%s/internal/refunds/%d/retry", baseUrl, refundId);

HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.set("Idempotency-Key", String.format("refund-%d-%d", refundId, jobExecutionId));

HttpEntity<String> request = new HttpEntity<>(headers);

int maxRetries = 3;
long delayMs = 1000;

for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
restTemplate.exchange(url, HttpMethod.POST, request, String.class);
log.info("환불 재시도 성공: refundId={}", refundId);
return;
} catch (HttpClientErrorException e) {
if (e.getStatusCode().is4xxClientError()) {
log.warn("환불 재시도 4xx 오류: refundId={}, status={}", refundId, e.getStatusCode());
throw new RuntimeException("4xx 오류로 스킵", e);
}
// 5xx 오류는 재시도
if (attempt == maxRetries) {
log.error("환불 재시도 최대 재시도 초과: refundId={}", refundId, e);
throw e;
}
try {
Thread.sleep(delayMs);
delayMs *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
} catch (Exception e) {
if (attempt == maxRetries) {
log.error("환불 재시도 실패: refundId={}", refundId, e);
throw e;
}
try {
Thread.sleep(delayMs);
delayMs *= 2;
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.rocketcrew.pocatbatch.config;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

@Configuration
@Profile("!test")
@RequiredArgsConstructor
public class KafkaProducerConfig {

private final KafkaProperties kafkaProperties;

public static final Set<String> FINANCIAL_TOPICS = Set.of("payment", "refund", "settlement");

/**
* 기본 KafkaTemplate (acks=1)
* 일반 이벤트 발행용
*/
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties(null));
props.put("acks", "1");
ProducerFactory<String, String> factory = new DefaultKafkaProducerFactory<>(props);
return new KafkaTemplate<>(factory);
}

/**
* 금융 관련 KafkaTemplate (acks=all, enable.idempotence=true)
* 환불, 결제, 정산 이벤트 발행용 (중복 방지 + 높은 안정성)
*/
@Bean
public KafkaTemplate<String, String> paymentKafkaTemplate() {
Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties(null));
props.put("acks", "all");
props.put("enable.idempotence", true);
ProducerFactory<String, String> factory = new DefaultKafkaProducerFactory<>(props);
return new KafkaTemplate<>(factory);
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/rocketcrew/pocatbatch/config/RedissonConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.rocketcrew.pocatbatch.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Profile("!test")
@Configuration
public class RedissonConfig {

@Value("${spring.data.redis.host:localhost}")
private String redisHost;

@Value("${spring.data.redis.port:6379}")
private int redisPort;

@Value("${spring.data.redis.password:}")
private String redisPassword;

/**
* Redisson Client Bean
* 경매 락(auction activation, expiration) 및 일반적인 Redis 분산 락 사용
*/
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String redisUrl = (redisPassword == null || redisPassword.isEmpty())
? String.format("redis://%s:%d", redisHost, redisPort)
: String.format("redis://:%s@%s:%d", redisPassword, redisHost, redisPort);
config.useSingleServer().setAddress(redisUrl);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return Redisson.create(config);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.rocketcrew.pocatbatch.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;

@Configuration
@Profile("!test")
public class RestClientConfig {

/**
* RestTemplate Bean
* Main App 내부 API 호출용 (buyout recovery, refund retry)
*/
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10))
.build();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.rocketcrew.pocatbatch.domain.ai.entity;

import com.rocketcrew.pocatbatch.domain.freepost.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

import java.time.LocalDateTime;

/**
* AI 채팅 세션 엔티티.
* 사용자별 멀티턴 대화 세션 관리.
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
@Entity
@Table(name = "ai_chat_sessions",
indexes = {
@Index(name = "idx_ai_chat_session_user_id", columnList = "user_id"),
@Index(name = "idx_ai_chat_session_uuid", columnList = "session_uuid"),
@Index(name = "idx_ai_chat_session_last_active", columnList = "last_active_at")
}
)
@SQLDelete(sql = "UPDATE ai_chat_sessions SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class AiChatSession extends BaseEntity {

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "session_uuid", nullable = false, length = 36, unique = true)
private String sessionUuid;

@Column(name = "total_tokens", nullable = false)
private Integer totalTokens;

@Column(name = "is_expired", nullable = false)
private Boolean isExpired;

@Column(name = "last_active_at", nullable = false)
private LocalDateTime lastActiveAt;

/**
* 토큰 추가.
*/
public void addTokens(int tokens) {
this.totalTokens += tokens;
}

/**
* 마지막 활동 시간 업데이트.
*/
public void updateLastActiveAt(LocalDateTime now) {
this.lastActiveAt = now;
}

/**
* 만료된 세션 재활성화.
*/
public void reactivate(LocalDateTime now) {
this.isExpired = false;
this.lastActiveAt = now;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.rocketcrew.pocatbatch.domain.ai.repository;

import com.rocketcrew.pocatbatch.domain.ai.entity.AiChatSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;

@Repository
public interface AiChatSessionRepository extends JpaRepository<AiChatSession, Long> {

/**
* 지정된 시간 이전의 비활성 세션을 만료 처리.
*
* @param threshold 기준 시간
* @return 만료된 세션 수
*/
@Modifying
@Query("UPDATE AiChatSession s SET s.isExpired = true WHERE s.lastActiveAt < :threshold AND s.isExpired = false")
int expireSessionsBeforeTime(@Param("threshold") LocalDateTime threshold);
}
Loading